本篇文章分享一个和 Nginx 以及 PHP 有关的“黑魔法”:NGX-PHP 模块。通过这个方式,我们可以低成本的实现高性能应用,以及适合在服务器资源有限的情况下,同时体验到 Nginx 的高效以及 PHP 的灵活。

如果你对 PHP 的印象还停留在“慢”,那么或许这篇文章可以帮助你打开新世界。

写在前面

提到 “NGX 和 PHP”,使用过 Nginx 和 PHP 的同学第一反映可能是 Nginx + PHP-FPM 这种架构。不过,这篇文章中,我们要提到的技术架构更简单高效一些:直接使用 Nginx 和三方模块(NGX-PHP),调用 PHP Embedded 库,来实现原本需要跨进程实现的功能,从而明显提升应用性能。

之所以能够这样玩,需要感谢下面两个项目的相关实现:

  • PHP 提供了一种有趣的调用方式:让其他的程序能够通过支持 C Bindings 的符号绑定的方式来调用它的核心引擎,Zend。这种接口调用方式,被称作 PHP SAPI 或者 PHP-Embeded,项目地址:https://github.com/php/php-src/tree/master/sapi/embed
  • 2016 年,有一位来自搜狐的工程师 rryqszq4,开始在 GitHub 上尝试开源一个项目,把 “Nginx” 和 “PHP-Embeded Library” 桥接到一起,这个项目经过多年发展,陆续支持了 PHP5、PHP7,以及最新的 PHP8。项目地址:https://github.com/rryqszq4/ngx-php

在 Techem Power 的测试中,自 2020 年开始,“NGX-PHP” 这个技术选型出现之后,便取得了不错的成绩,比如:2020 年的Round 19,以及 2022 年的Round 21

2020 年和 2022 年的两轮框架评分测试

2020 年和 2022 年的两轮框架评分测试

在最近的 2022 年测试中,框架开销非常低,位于排行榜第五和第六名。

2022 年测试中,框架开销排行

2022 年测试中,框架开销排行

如果用我们熟悉的 Node.js + MongoDB 作为基准,那么这套方案开销比它少 300%:跑的快,吃草少。换个角度来看,这个方案非常贴合 “Nginx” 和 “PHP” 的特性:快糙猛。

好了,关于这个项目的概况就介绍到这里,我们先来使用 Docker 快速、实际的感受下它的性能。

快速体验

执行下面的命令:

docker run --rm -it -v `pwd`/data:/usr/share/nginx/html/data:rw -p 8090:80 soulteary/ngx-php:8-microblog

当 Docker 镜像下载完毕之后,我们将看到一个和普通 Nginx 镜像启动无异的日志输出:

/docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
/docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
/docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
10-listen-on-ipv6-by-default.sh: info: Getting the checksum of /etc/nginx/conf.d/default.conf
10-listen-on-ipv6-by-default.sh: info: Enabled listen on IPv6 in /etc/nginx/conf.d/default.conf
/docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
/docker-entrypoint.sh: Launching /docker-entrypoint.d/30-tune-worker-processes.sh
/docker-entrypoint.sh: Configuration complete; ready for start up
2022/10/05 10:25:39 [notice] 1#1: using the "epoll" event method
2022/10/05 10:25:39 [notice] 1#1: nginx/1.23.1
2022/10/05 10:25:39 [notice] 1#1: built by gcc 11.2.1 20220219 (Alpine 11.2.1_git20220219) 
2022/10/05 10:25:39 [notice] 1#1: OS: Linux 5.10.76-linuxkit
...

打开浏览器,输入 http://localhost:8090 ,就能够看到效果啦。

2022 年测试中,框架开销排行

2022 年测试中,框架开销排行

随手输入一些内容,能够看到程序“跑的”还是挺快的。

发一些只有自己看的到的“微博”

发一些只有自己看的到的“微博”

在不进行应用优化、Nginx 优化的前提下,我们能够看到处理一个请求不过 2ms 左右。

单次请求服务端处理时间2ms左右

单次请求服务端处理时间2ms左右

接下来,我们来聊聊如何使用 NGX-PHP,学习了解这种开源方案背后的一些细节。完整的应用代码,我上传到了 soulteary/ngx-php-micro-blog,有需要可以自取。

准备工作

想要愉快的阅读和跟着本文游玩,只需要 Docker 环境,可以参考《在笔记本上搭建高性价比的 Linux 学习环境:基础篇》文章完成基础环境的准备,就不过多赘述了。

实现简单的微博应用

我们来使用“最好的语言:PHP”,实现一个简单的“微博/推特”程序。

简单实现模版类

使用 PHP “画一个”页面出来,可以用的方式非常多,最具可维护性的方式是使用”“模版”。为了不过多引入复杂性,就不使用 PHP 包管理器来为项目添加“模版引擎”了,我们来实现一个简单的模版类(不到 30 行):

<?php

class Template
{
    protected $dir = TEMPLATE_DIR . DIRECTORY_SEPARATOR;
    protected $vars = array();
    public function __construct($dir = null)
    {
        if ($dir !== null) {
            $this->dir = $dir;
        }
    }
    public function render($file)
    {
        if (file_exists($this->dir . $file)) {
            include $this->dir . $file;
        } else {
            throw new Exception('no template file ' . $file . ' present in directory ' . $this->dir);
        }
    }
    public function __set($name, $value)
    {
        $this->vars[$name] = $value;
    }
    public function __get($name)
    {
        return $this->vars[$name];
    }
}

在完成简单的模版功能之后,我们就能够在应用中使用 new Template, template->render('template.name.html') 来进行页面结果的渲染了。

简单实现主要逻辑

接下来,我们来实现“微博”的主要流程逻辑,大概 130 行左右的代码就能够搞定:

<?php

class Whisper
{
    public function __construct()
    {
        if (empty($_POST['content'])) {
            $start_time = microtime(true);
            $page = 1;
            if (!empty($_GET['p'])) {
                $page = (int) filter_var($_GET['p'], FILTER_SANITIZE_NUMBER_INT);
                if ($page < 1) {
                    $page = 1;
                }
            }

            $tpl = new Template();
            $tpl->data = $this->loadData($page);

            ob_start();
            $tpl->render('main.html');
            ob_end_flush();

            $end_time = microtime(true);
            echo "\n<!-- " . round($end_time - $start_time, 3) . "s -->";
        } else {
            $content = trim($_POST['content']);
            if (strlen($content) == 0) {
                echo ERROR_IS_EMPTY;
                exit;
            }
            $content = (string) filter_var($content, FILTER_SANITIZE_SPECIAL_CHARS);
            $this->postWhisper($content);
        }
    }

    private function postWhisper($content)
    {
        $date = date('Y-m-d g:i:s A');
        $filename = DATA_DIR . DIRECTORY_SEPARATOR . date('YmdHis') . ".txt";
        $file = fopen($filename, "w+");
        $content = $date . "\n" . $content . "\n";
        fwrite($file, $content);
        fclose($file);
        header("location: /");
    }

    private function loadData($page)
    {
        $result = [
            'whispers' => [],
            'pagination' => ['hide' => true],
        ];

        $files = [];
        if ($handle = @opendir(DATA_DIR)) {
            while ($file = readdir($handle)) {
                if (!is_dir($file)) {
                    $files[] = $file;
                }
            }
        }
        rsort($files);

        $total = sizeof($files);
        if ($total == 0) {
            return $result;
        }

        $page = $page - 1;
        $start = $page * WHISPER_PER_PAGE;
        if (($start + WHISPER_PER_PAGE) > $total) {
            $last = $total;
        } else {
            $last = $start + WHISPER_PER_PAGE;
        }

        for ($i = $start; $i < $last; $i++) {
            $raw = file(DATA_DIR . DIRECTORY_SEPARATOR . $files[$i]);
            $date = trim($raw[0]);
            unset($raw[0]);

            $content = "";
            foreach ($raw as $value) {
                $content .= $value;
            }
            $data = array(
                'date' => $date,
                'content' => $content,
            );
            $result['whispers'][] = $data;
        }

        $result['pagination'] = $this->getPagination($start, $last, $page, $total);
        return $result;
    }

    private function getPagination($start, $last, $page, $total)
    {
        if ($total <= WHISPER_PER_PAGE) {
            return ['hide' => true];
        }

        $page = $page + 1;
        $next = 0;
        $prev = 0;

        if ($start == 0) {
            if ($last < $total) {
                $next = $page + 1;
            }
        } else {
            if ($last < $total) {
                $next = $page + 1;
                $prev = $page - 1;
            } else {
                $prev = $page - 1;
            }
        }

        return [
            'hide' => false,
            'prev' => $prev,
            'next' => $next,
            'page' => $page,
            'last' => ceil($total / 5),
        ];
    }
}

new Whisper();

简单实现页面模版

完成主要程序实现之后,我们来实现页面模版,大概 120 行就能够搞定:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" href="assets/css/bootstrap.min.css">
  <link rel="stylesheet" href="assets/css/style.css">
  <title>Whisper</title>
</head>
<body>
  <div id="root">

    <div class="container" id="brand-container">
      <div class="row">
        <div class="py-4 text-center">
          <h2>
            <span>Whisper</span>
            <img class="logo" src="assets/img/logo.svg" alt="" width="72" height="57" />
          </h2>
          <p class="lead">a simplest example.</p>
        </div>
      </div>
    </div>

    <div class="container" id="post-container">
      <div class="row">
        <div class="col-xs-12">
          <h4 class="mb-3"># Post a Whisper</h4>
          <form class="mb-3" action="/" method="post" novalidate >
            <div class="row g-3">
              <div class="col-12">
                <textarea class="form-control mb-2" placeholder="enter content here..." name="content" rows="4" required></textarea>
                <button class="w-100 btn btn-primary" type="submit">Post</button>
              </div>
            </div>
          </form>
        </div>
      </div>
    </div>

    <div class="container" id="whispers-container">
      <?php if(!$this->data['pagination']['hide']):?>
      <div class="row row-cols-sm-auto">
        <div class="col-sm-9">
          <hr class="my-3 w-100" />
        </div>
        <div class="col-sm-3">
          <div aria-label="Page navigation" id="pagination">
            <ul class="pagination justify-content-center pagination-sm">
              <?php if($this->data['pagination']['prev']==0):?>
              <li class="page-item disabled">
                <a class="page-link">Previous</a>
              </li>
              <?php else:?>
              <li class="page-item">
                <a class="page-link" href="?p=<?=$this->data['pagination']['prev']?>">Previous</a>
              </li>
              <?php endif;?>
              <?php if($this->data['pagination']['next']==0):?>
              <li class="page-item disabled">
                <a class="page-link">Next</a>
              </li>
              <?php else:?>
              <li class="page-item">
                <a class="page-link" href="?p=<?=$this->data['pagination']['next']?>">Next</a>
              </li>
              <?php endif;?>
            </ul>
          </div>
        </div>        
      </div>
      <?php endif;?>

      <?php if(sizeof($this->data['whispers'])>0):?>      
      <div class="row row-cols-sm-auto">
        <h4 class="col-sm-12">
          <span># List</span>
          <?php if(!$this->data['pagination']['hide']):?>
          <span class="text-muted" id="page-info">Page #<?=$this->data['pagination']['page']?> / <?=$this->data['pagination']['last']?></span>
          <?php endif;?>
        </h4>
      </div>

      <div class="row">
        <div class="col-xs-12">
          <ul class="list-group w-auto">
            <?php foreach ($this->data['whispers'] as $whisper): ?>
            <li class="list-group-item d-flex gap-3 py-3" aria-current="true">
              <img src="assets/img/icon.svg" width="32" height="32" class="rounded-circle flex-shrink-0">
              <div class="d-flex gap-2 w-100 justify-content-between">
                <div>
                  <h6 class="mb-0 whisper-content"><?=$whisper['content']?></h6>
                  <p class="mb-0 opacity-75"><?=$whisper['date']?></p>
                </div>
                <small class="opacity-50 text-nowrap timeago" data-value="<?=$whisper['date']?>"></small>
              </div>
            </li>
            <?php endforeach; ?>
          </ul>
        </div>
      </div>
      <?php endif;?>
    </div>

    <div class="container">
      <div class="row">
        <div class="my-3 pt-3 text-muted text-center text-small">
          <ul class="list-inline mb-1">
            <li class="list-inline-item">&copy; 2007–<?=date('Y');?> @soulteary: <a href="https://soulteary.com/" target="_blank">Blog</a></li>
            <li class="list-inline-item"><a href="https://www.zhihu.com/people/soulteary" target="_blank">Zhihu</a></li>
            <li class="list-inline-item"><a href="https://weibo.com/u/1220149481" target="_blank">Weibo</a></li>
          </ul>
        </div>
      </div>
    </div>

  </div>
  <script src="assets/js/main.js"></script>
</body>
</html>

使用 PHP 官方镜像验证程序

为了方便后续的演示和性能对比,这里我们直接声明一些路径为 Nginx 容器的地址,所以当你看到后续 Apache 镜像中使用的路径,不必惊讶:

<?php
date_default_timezone_set('Asia/shanghai');

define('TEMPLATE_DIR', '/usr/share/nginx/html/templates');
define('DATA_DIR', '/usr/share/nginx/html/data');
define('WHISPER_PER_PAGE', 5);
...

在完成程序调整之后,我们简单编写一个 compose 配置,来使用 PHP 官方提供的 Docker 镜像来验证程序是否能够正常运行:

version: '3'

services:

  talk:
    image: php:8.1.10-apache-buster
    restart: always
    ports:
      - 8090:80
    volumes:
      - ./app:/var/www/html/
      - ./app/templates:/usr/share/nginx/html/templates:rw
      - ./app/data:/usr/share/nginx/html/data:rw

使用 docker-compose up 启动程序之后,我们访问 http://localhost:8090 就能够看到文章一开头的界面了。随便输入点内容,然后点击“发布” 按钮,能够看到一切符合预期,功能可以正常工作。

验证是否能够完成核心功能:发微博

验证是否能够完成核心功能:发微博

确认程序能够正常工作后,我们来将程序迁移到 NGX-PHP 环境中。

这部分的代码,可以参考项目的提交记录:soulteary/ngx-php-micro-blog/commit/d97385b945c998385cbc7dee813529f05b4f15d3

构建 NGX PHP 容器镜像

这里,我们借助很早之前提到过的一个项目 https://github.com/nginx-with-docker/nginx-docker-playground 来快速完成 NGX PHP 这个 Nginx 模块的构建,关于“如何在容器时代高效使用 Nginx 三方模块”,可以参考这篇文章

# Nginx Docker Playground (https://github.com/nginx-with-docker/nginx-docker-playground)
FROM soulteary/prebuilt-nginx-modules:base-1.23.1-alpine AS Builder
RUN sed -i -E "s/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g" /etc/apk/repositories
RUN apk update && apk --no-cache add curl gcc g++ make musl-dev linux-headers gd-dev geoip-dev libxml2-dev libxslt-dev openssl-dev  pcre-dev perl-dev pkgconf zlib-dev libedit-dev ncurses-dev php8-dev php8-embed git unzip argon2-dev
ENV PHP_LIB=/usr/lib
WORKDIR /usr/src

# Nginx Development Kit (https://github.com/vision5/ngx_devel_kit)
ARG DEVEL_KIT_MODULE_CHECKSUM=e15316e13a7b19a3d2502becbb26043a464a135a
ARG DEVEL_KIT_VERSION=0.3.1
ARG DEVEL_KIT_NAME=ngx_devel_kit
RUN curl -L "https://github.com/vision5/ngx_devel_kit/archive/v${DEVEL_KIT_VERSION}.tar.gz" -o "v${DEVEL_KIT_VERSION}.tar.gz" && \
    echo "${DEVEL_KIT_MODULE_CHECKSUM}  v${DEVEL_KIT_VERSION}.tar.gz" | shasum -c && \
    tar -zxC /usr/src -f v${DEVEL_KIT_VERSION}.tar.gz && \
    mv ${DEVEL_KIT_NAME}-${DEVEL_KIT_VERSION}/ ${DEVEL_KIT_NAME}

# Nginx PHP Module (Mirror https://github.com/nginx-with-docker/ngx_http_php_module-src)
ARG MODULE_CHECKSUM=aeef775b2beb8378cb295a4da29b80d98274e1fa
ARG MODULE_VERSION=master
ARG MODULE_NAME=ngx_http_php_module-src
ARG MODULE_SOURCE=https://github.com/nginx-with-docker/ngx_http_php_module-src
RUN curl -L "${MODULE_SOURCE}/archive/refs/heads/${MODULE_VERSION}.zip" -o "v${MODULE_VERSION}.zip" && \
    echo "${MODULE_CHECKSUM}  v${MODULE_VERSION}.zip" | shasum -c && \
    unzip "v${MODULE_VERSION}.zip" && \
    mv "$MODULE_NAME-$MODULE_VERSION" "$MODULE_NAME"

# Nginx
RUN cd /usr/src/nginx && \
    CONFARGS=$(nginx -V 2>&1 | sed -n -e 's/^.*arguments: //p') \
    CONFARGS=${CONFARGS/-Os -fomit-frame-pointer -g/-Os} && \
    echo $CONFARGS && \
    ./configure --with-compat $CONFARGS --with-ld-opt="-Wl,-rpath,${PHP_LIB}" --add-dynamic-module=../${DEVEL_KIT_NAME} --add-dynamic-module=../${MODULE_NAME} && \
    make modules

完成构建之后,我们使用多阶段构建,制作最终的应用镜像就好了:

FROM nginx:1.23.1-alpine
LABEL MAINTAINER=soulteary@gmail.com
COPY --from=Builder /usr/lib/libphp.so          /usr/lib/
COPY --from=Builder /usr/lib/libargon2.so.1     /usr/lib/
COPY --from=Builder /lib/libz.so.1              /lib/
COPY --from=Builder /etc/php8/php.ini           /etc/php8/
COPY --from=Builder /usr/src/nginx/objs/ndk_http_module.so      /etc/nginx/modules/
COPY --from=Builder /usr/src/nginx/objs/ngx_http_php_module.so  /etc/nginx/modules/
ENV PHP_LIB=/usr/lib
COPY conf/nginx.conf /etc/nginx/
COPY app/index.php  /usr/share/nginx/html/
COPY app/assets     /usr/share/nginx/html/assets
COPY app/templates  /usr/share/nginx/html/templates
RUN mkdir -p /usr/share/nginx/html/data && \
    chown nginx:nginx /usr/share/nginx/html/data && \
    chmod 777 /usr/share/nginx/html/data

这个小节的完整代码,在这里可以找到: soulteary/ngx-php-micro-blog/Dockerfile。使用 docker build -t soulteary/ngx-php:8-microblog .,完成基础镜像构建,我们将得到一个 12MB 左右的小巧的、包含了 Nginx PHP 模块的镜像。

小巧可爱的容器镜像

小巧可爱的容器镜像

在完成了基础镜像构建之后,我们来进行程序的“改造”。

将 PHP 程序适配 NGX PHP 环境

如果我们不修改任何代码,通过调整 docker compose 配置文件,切换容器镜像和挂载的文件,是可以让程序在我们新构建的 NGX-PHP 镜像中运行的。

version: '3'

services:

  talk:
    image: soulteary/ngx-php:8-microblog
    restart: always
    ports:
      - 8090:80
    volumes:
      - ./app/data:/usr/share/nginx/html/data:rw
      - ./app/index.php:/usr/share/nginx/html/index.php:ro
      - ./app/templates:/usr/share/nginx/html/templates:ro
      - ./app/assets:/usr/share/nginx/html/assets:ro

但是我们会得到一些报错,导致程序不能正常运行。

解决变量、函数重复定义的问题

我们首先可能遇到的问题就是类似下面的报错,告诉我们重复声明了“某些内容”,比如常量:

Warning: Constant TEMPLATE_DIR already defined in /usr/share/nginx/html/index.php on line 4

或者重复声明了“某些类”:

Fatal error: Cannot declare class Template, because the name is already in use in /usr/share/nginx/html/index.php on line 18

出现这两个问题的原因,是因为 NGX PHP 模块中,“全局变量和静态变量”都是不安全的。

解决第一个问题,我们可以有两个方案,降低声明的作用域,或者加上一些防御性判断:

defined('TEMPLATE_DIR') or define('TEMPLATE_DIR', '/usr/share/nginx/html/templates');

解决第二个问题,我们只能够依赖添加判断来避免重复声明:

if (!class_exists('Template')) {
    class Template
    {
        // ...
    }
}

解决完毕上面两个问题,程序就能够正常展示界面了。

解决参数获取不到的问题

虽然解决了上面的问题,程序能够正常展示,但是我们会发现提交任何内容,程序都不会有“正确的反应”,而 Nginx 日志中也没有任何错误信息。

出现这个问题的原因是,在 NGX PHP 环境下,PHP 获取用户提交数据的方式由 $_GET 和 $_POST 改为了 ngx_query_args() 和 ngx_post_args()

为了解决这个问题,并且保持我们的程序依旧能够在官方 PHP 环境中运行、调试,可以实现一个简单的 getArgs 方法,让程序兼容不同的环境:

private function getArgs($key, $method)
{
    $dataSource = null;
    $isNginxEnv = false;

    if ($method == 'GET') {
        if (function_exists('ngx_query_args')) {
            $dataSource = ngx_query_args();
            $isNginxEnv = true;
        } else {
            $dataSource = $_GET;
        }
    } else {
        if (function_exists('ngx_post_args')) {
            $dataSource = ngx_post_args();
            $isNginxEnv = true;
        } else {
            $dataSource = $_POST;
        }
    }

    if (!isset($dataSource[$key])) {
        return "";
    }

    return $isNginxEnv ? trim(urldecode($dataSource[$key])) : trim($dataSource[$key]);
}

对应的,调整上文中程序获取用户输入数据的方法,就能够让程序正常的在 NGX PHP 容器中运行啦。

最终应用程序

最终的应用程序,算上换行大概 220 行左右:

<?php
date_default_timezone_set('Asia/shanghai');

defined('TEMPLATE_DIR') or define('TEMPLATE_DIR', '/usr/share/nginx/html/templates');
defined('DATA_DIR') or define('DATA_DIR', '/usr/share/nginx/html/data');
defined('WHISPER_PER_PAGE') or define('WHISPER_PER_PAGE', 5);
defined('ERROR_IS_EMPTY') or define('ERROR_IS_EMPTY', '内容不能为空');

if (defined('DATA_DIR')) {
    if (!file_exists(DATA_DIR)) {
        mkdir(DATA_DIR);
    }
} else {
    echo "需要定义数据目录";
    exit;
}

if (!class_exists('Template')) {
    class Template
    {
        protected $dir = TEMPLATE_DIR . DIRECTORY_SEPARATOR;
        protected $vars = array();
        public function __construct($dir = null)
        {
            if ($dir !== null) {
                $this->dir = $dir;
            }
        }
        public function render($file)
        {
            if (file_exists($this->dir . $file)) {
                include $this->dir . $file;
            } else {
                throw new Exception('no template file ' . $file . ' present in directory ' . $this->dir);
            }
        }
        public function __set($name, $value)
        {
            $this->vars[$name] = $value;
        }
        public function __get($name)
        {
            return $this->vars[$name];
        }
    }
}

if (!class_exists('Whisper')) {
    class Whisper
    {
        private function getArgs($key, $method)
        {
            $dataSource = null;
            $isNginxEnv = false;

            if ($method == 'GET') {
                if (function_exists('ngx_query_args')) {
                    $dataSource = ngx_query_args();
                    $isNginxEnv = true;
                } else {
                    $dataSource = $_GET;
                }
            } else {
                if (function_exists('ngx_post_args')) {
                    $dataSource = ngx_post_args();
                    $isNginxEnv = true;
                } else {
                    $dataSource = $_POST;
                }
            }

            if (!isset($dataSource[$key])) {
                return "";
            }

            return $isNginxEnv ? trim(urldecode($dataSource[$key])) : trim($dataSource[$key]);
        }

        private function redir($url)
        {
            if (function_exists('ngx_header_set')) {
                ngx_header_set("Location", $url);
                ngx_exit(NGX_HTTP_MOVED_TEMPORARILY);
            } else {
                header("Location: " . $url);
            }
        }

        public function __construct()
        {
            $content = $this->getArgs('content', 'POST');
            if (empty($content)) {
                $start_time = microtime(true);
                $page = 1;

                $page = $this->getArgs('p', 'GET');
                if (!empty($page)) {
                    $page = (int) filter_var($page, FILTER_SANITIZE_NUMBER_INT);
                    if ($page < 1) {
                        $page = 1;
                    }
                } else {
                    $page = 1;
                }

                $tpl = new Template();
                $tpl->data = $this->loadData($page);

                ob_start();
                $tpl->render('main.html');
                ob_end_flush();

                $end_time = microtime(true);
                echo "\n<!-- program processing time: " . round($end_time - $start_time, 3) . "s -->";
            } else {
                $content = htmlentities((string) filter_var($content, FILTER_SANITIZE_SPECIAL_CHARS));
                $this->postWhisper($content);
            }
        }

        private function postWhisper($content)
        {
            $date = date('Y-m-d g:i:s A');
            $filename = DATA_DIR . DIRECTORY_SEPARATOR . date('YmdHis') . ".txt";
            $file = fopen($filename, "w+");
            $content = $date . "\n" . $content . "\n";
            fwrite($file, $content);
            fclose($file);
            $this->redir("/");
        }

        private function loadData($page)
        {
            $result = [
                'whispers' => [],
                'pagination' => ['hide' => true],
            ];

            $files = [];
            if ($handle = @opendir(DATA_DIR)) {
                while ($file = readdir($handle)) {
                    if (!is_dir($file)) {
                        $files[] = $file;
                    }
                }
            }
            rsort($files);

            $total = sizeof($files);
            if ($total == 0) {
                return $result;
            }

            $page = $page - 1;
            $start = $page * WHISPER_PER_PAGE;
            if (($start + WHISPER_PER_PAGE) > $total) {
                $last = $total;
            } else {
                $last = $start + WHISPER_PER_PAGE;
            }

            for ($i = $start; $i < $last; $i++) {
                $raw = file(DATA_DIR . DIRECTORY_SEPARATOR . $files[$i]);

                $date = trim($raw[0]);
                unset($raw[0]);

                $content = "";
                foreach ($raw as $value) {
                    $content .= $value;
                }
                $data = array(
                    'date' => $date,
                    'content' => html_entity_decode($content),
                );
                $result['whispers'][] = $data;
            }

            $result['pagination'] = $this->getPagination($start, $last, $page, $total);
            return $result;
        }

        private function getPagination($start, $last, $page, $total)
        {
            if ($total <= WHISPER_PER_PAGE) {
                return ['hide' => true];
            }

            $page = $page + 1;
            $next = 0;
            $prev = 0;

            if ($start == 0) {
                if ($last < $total) {
                    $next = $page + 1;
                }
            } else {
                if ($last < $total) {
                    $next = $page + 1;
                    $prev = $page - 1;
                } else {
                    $prev = $page - 1;
                }
            }

            return [
                'hide' => false,
                'prev' => $prev,
                'next' => $next,
                'page' => $page,
                'last' => ceil($total / 5),
            ];
        }
    }
}

new Whisper();

简单的性能比较

除了相信相对中立的机构的测试结果之外,我们也可以自己进行应用性能测试,来验证 NGX-PHP 是否真的能够“降本增效”。

下面我们就用上面最终实现好的程序,分别在我们构建的 soulteary/ngx-php:8 镜像和 PHP 官方镜像 php:8.1.10-apache-buster 中进行简单的请求性能测试:

先使用开启 OPCACHE 之后的官方镜像(php:8.1.10-apache-buster),完成30s 的压力测试:

wrk -t16 -c 100 -d 30s http://127.0.0.1:8090
Running 30s test @ http://127.0.0.1:8090
  16 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   132.92ms  158.49ms   1.98s    86.67%
    Req/Sec    54.38     56.83   670.00     94.88%
  22603 requests in 30.08s, 49.40MB read
  Socket errors: connect 0, read 0, write 0, timeout 112
Requests/sec:    751.53
Transfer/sec:      1.64MB

接着,使用我们构建好的 NGX PHP 镜像,在不开启缓存的情况下进行测试:

wrk -t16 -c 100 -d 30s http://127.0.0.1:8090
Running 30s test @ http://127.0.0.1:8090
  16 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    94.01ms   15.94ms 431.01ms   81.62%
    Req/Sec    64.02     11.33   148.00     74.26%
  30715 requests in 30.09s, 65.03MB read
Requests/sec:   1020.65
Transfer/sec:      2.16MB

可以看到,性能提升还是比较明显的。

最后

好了,关于 NGX PHP 的第一篇文章就聊到这里吧。关于更多的细节,或许后面有机会,我会再写一两篇文章进行分享。

–EOF

Nginx 黑魔法:使用 NGX-PHP 模块低成本实现高性能应用
标签: