数据说话

从图中可以看出RoadRunner对比Nginx+FPM,运行效率是有数量级上的提升。

一般PHP服务器

传统CGI协议服务器

客户端访问某个URL地址之后,通过 GET/POST/PUT等方式提交数据,并通过HTTP协议向Web服务器发出请求,服务器端将HTTP请求里描述的信息通过标准输入(stdin)和环境变量(environment variable)传递给新建的CGI进程。处理完成后,进程立即关闭。

Nginx + PHP-FPM模式

现在流行的PHP web程序一般都是运行在Nginx + PHP-FPM模式下的。PHP-FPM就是PHP对FastCGI的实现。
master创建并监听多个worker进程,通过共享内存获取worker的状态,进而通过信号控制worker进程。每一个worker进程就类似一个CGI进程,收到CGI请求后会执行相应的PHP文件,并把请求内容作为PHP进程状态的一部分(_GET, _POST, _SERVER等等)。结束请求后,worker不会立刻结束,而是继续留在worker pool. 这就节省了频繁创建结束子进程的开支。

RoadRunner

为什么

现在很多PHP的企业级框架都要求你加载至少十几个文件,构造多个类并解析一些配置,以便处理简单的用户请求或查询数据库。每个任务完成后,你不得不抛弃这些代码。收到下一个HTTP请求时,PHP-FPM会创建一个新的PHP子进程来处理这个请求,所有的文件都要重新加载一遍,即便文件可以有缓存,所有的代码也要重新运行。

如果我们可以避免对每个请求都重启一次PHP子进程,我们就可以节约很多的资源。

基本原理

RoadRunner可以看作一个升级版的Nginx + PHP-FPM. 它直接把长时运行的PHP进程作为worker, 直接对PHP worker进行监控和维护,每次收到http请求时,就发给php worker来处理。这样,我们就不再需要对每个请求重启一遍PHP了。

一些实现细节

整个项目都是开源的,RoadRunner的代码在这里

RoaderRunner 通过 Socket/Pipe 上的二进制流完成和PHP子进程之间的通信。为此,他们创建了一个轻量的二进制协议,这个协议的包头长这样

不同的通信方式,创建worker时就会有所区别。RoadRunner实现了两套factory和relay:

当使用pipe时,work是从pipe_factory.go创建的。PHP方面对应的Class是StreamRelay.php
其中读取包头的部分代码是这样的

$prefixBody = fread($this->in, 17);
if ($prefixBody === false) {
    throw new Exceptions\PrefixException("unable to read prefix from the stream");
}

$result = unpack("Cflags/Psize/Jrevs", $prefixBody);
if (!is_array($result)) {
    throw new Exceptions\PrefixException("invalid prefix");
}

if ($result['size'] != $result['revs']) {
    throw new Exceptions\PrefixException("invalid prefix (checksum)");

当使用socket时,对应的PHP Class是SocketRelay.php, 代码是类似的。只不过PHP读取的部分从stdin/out变成了socket.

RoadRunner实现了StaticPoolWorker来对所有的PHP worker进行管理。本质上就是一个进程池。当需要PHP对数据进行处理时,StaticPool的allocateWorker()方法会从进程池的free worker中分配一个进程,把数据发送给这个PHP进程。StaticPool不会在PHP进程返回数据后关闭进程,而是把这个进程放回进程池。如果某个PHP进程意外关闭,staticPool会主动丢弃并在需要的时候创建新的进程。

举个栗子

下面是一个实际应用RoadRunner的例子。我们创建了一个最多拥有4个PHP进程的StaticPool,Golang会把0到9的数字依次传给PHP处理,PHP把数字加1并返回。

Golang部分:

//创建RoadRunner server
srv := roadrunner.NewServer(
    &roadrunner.ServerConfig{
        Command: "php " + phpScriptName,
        Relay:   "pipes", // 选择用pipe通信
        Pool: &roadrunner.Config{
            NumWorkers:      4, // worker的数量是4
            AllocateTimeout: time.Second,
            DestroyTimeout:  time.Second,
        },
    })
defer srv.Stop()

err := srv.Start()
if err != nil {
    panic(err)
}

for i := 0; i < 10; i++ {
    // 把i作为payload传给PHP进程
    res, err = srv.Exec(&roadrunner.Payload{Body: i})
    if err != nil {
        panic(err)
    }

    fmt.Print(res)
}

PHP部分

<?php

use Spiral\Goridge;
use Spiral\RoadRunner;

// 创建Worker实例,选择用pipe通信
$rr = new RoadRunner\Worker(new Spiral\Goridge\StreamRelay(STDIN, STDOUT));

// 长时运行,阻塞式接收go传来的数据
while ($i = $this->rr->receive($context)) {
    try {
        $i = $i + 1;

        $rr->send($i, (string) $context);
    } catch (\Throwable $e) {
        $rr->error((string) $e);
    }
}

为什么要go + php?

“有些人仍然坚持认为 PHP 是一种缓慢,笨重的语言,只能用来编写 WordPress 插件。他们甚至可能会说 PHP 有一个限制:一旦你的应用程序变得比较大,你就必须切换到更“成熟”的语言并取代之前的 PHP 代码。”

基本上php脚本也都可以用go来写,那为什么不直接用100%的Go来开发项目呢?
我会认为这种模式还是有一些存在的意义的:

  1. 开发效率高。尤其是PHP对html渲染有很好的支持,这又正是Go的痛点。
  2. 用工成本的考虑。让大牛用Go写出框架,每个模块再由php分别实现,也许是个好主意。(这也算PHP的优势吗-。-?)
  3. PHP作为弱类型的脚本语言,用来实现一些小模块,真的很爽~!

参考文献

blog.spiralscout.com/ph

GO+PHP, 让全宇宙最好的两种语言合体的神器——RoadRunner
标签: