整合Laravel与Swoole,Shadowfax是这样做的

2020/06/08    PHP Laravel Swoole Shadowfax

前面向大家推荐了Shadowfax这个拓展包,现在来聊聊Shadowfax是如何整合Laravel与Swoole的。

PHP为什么“慢”

众所周知,PHP是一门解释型语言,解释型语言的特点就是运行时才编译。PHP脚本在执行时先由Zend引擎解析并构建语法树,然后将语法树编译成opcode,最后执行opcode。并且每次执行都会重复上述步骤,这是其性能低下的原因之一。不过PHP早在5.5版本的时候就引入了opcache技术,解析和编译过后便将opcode缓存下来,使性能得到了质的提升。但由于PHP每次都会分配新的内存来执行opcode,这也使得其无法复用资源。而Swoole可以改变这一切,它使程序常驻内存,不仅让程序代码只解析和编译一次,还可以实现资源复用,从而大幅提升程序运行的效率。

简易版整合

让Laravel运行在Swoole之上的思路其实不难。熟悉Swoole的朋友应该知道使用Swoole创建一个HTTP服务器只需要设置一个request回调即可,那么我们将Laravel搬到request回调里面来执行不就好了吗?的确如此,我们来尝试一下,首先创建一个新的Laravel项目:

composer create-project --prefer-dist laravel/laravel

然后在Laravel项目的根目录创建一个swoole.php脚本,代码如下:

<?php

require __DIR__.'/vendor/autoload.php';

use HuangYi\Shadowfax\Http\Request;
use HuangYi\Shadowfax\Http\Response;
use Illuminate\Contracts\Http\Kernel;
use Swoole\Http\Server;

$server = new Server('127.0.0.1', 9501);

$server->set([
    'worker_num' => 1,
    'enable_coroutine' => false,
]);

$server->on('request', function ($request, $response) {
    $app = require __DIR__.'/bootstrap/app.php';

    $kernel = $app->make(Kernel::class);

    $illuminateResponse = $kernel->handle(
        $illuminateRequest = Request::make($request)->toIlluminate()
    );

    Response::make($illuminateResponse)->send($response);

    $kernel->terminate($illuminateRequest, $illuminateResponse);
});

$server->start();

有阅读过Laravel源码经验的朋友就会发现,request回调中的代码其实就是public/index.php中的代码,只是多了两个陌生的类:HuangYi\Shadowfax\Http\RequestHuangYi\Shadowfax\Http\Response,这两个类都来自huang-yi/shadowfax包。由于Swoole的request/response对象和Laravel的request/response对象是不兼容的,所以需要进行转换,而这两个类就是负责兼容工作的,我们不比关心它们的具体实现,只需要将huang-yi/shadowfax包require到当前项目中供我们使用即可(composer require huang-yi/shadowfax)。接下来运行脚本:

php swoole.php

然后打开浏览器,访问http://127.0.0.1:9501,是不是看到了熟悉的Laravel欢迎页。到这儿我们已经完成了一版最简易的整合,如果做一下benchmark测试,你会发现它的性能已经比运行在PHP-FPM之上的Laravel好了不少。

复用容器

熟悉Laravel的朋友都知道IoC容器是整个框架的核心,几乎所有Laravel提供的服务都被注册在IoC容器中。每当容器启动时,Laravel就会将大部分服务注册到容器中来,有些服务还会去加载文件,比如配置、路由等,可以说启动容器是比较“耗时”的。我们再次观察上面的脚本,可以看到request回调的第一行就是创建IoC容器($app),这也意味着每次在处理请求时都会创建一次容器,这样不仅重复执行了许多代码,还造成不小的IO开销,所以上述脚本显然不是最优的做法。

那我们试试只创建一个容器,再让所有的请求都复用这个容器。我们可以在worker进程启动时(也就是workerStart回调中)创建并启动容器,这样在request回调中就能复用了。现在将swoole.php调整一下:

<?php

require __DIR__.'/vendor/autoload.php';

use HuangYi\Shadowfax\Http\Request;
use HuangYi\Shadowfax\Http\Response;
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Http\Request as IlluminateRequest;
use Swoole\Http\Server;

$server = new Server('127.0.0.1', 9501);

$server->set([
    'worker_num' => 1,
    'enable_coroutine' => false,
]);

$app = null;

$server->on('workerStart', function () use (&$app) {
    $app = require __DIR__.'/bootstrap/app.php';

    $app->instance('request', IlluminateRequest::create('http://localhost'));

    $app->make(Kernel::class)->bootstrap();
});

$server->on('request', function ($request, $response) use (&$app) {
    $kernel = $app->make(Kernel::class);

    $illuminateResponse = $kernel->handle(
        $illuminateRequest = Request::make($request)->toIlluminate()
    );

    Response::make($illuminateResponse)->send($response);

    $kernel->terminate($illuminateRequest, $illuminateResponse);
});

$server->start();

重新运行swoole.php后,打开浏览器调试工具再次请求首页,你会发现页面响应速度更快了。如果使用benchmark工具进行测试,也会发现比第一版脚本的性能又提升了不少。

资源污染问题

说起资源复用就不得不面对资源污染的问题。传统的PHP程序每次执行完毕后就会被销毁,不会对下一次执行造成任何影响,所以PHP程序员很少去操心变量污染的问题。Laravel出于对性能的考虑,大量的服务都是以单例的形式注册在IoC容器之中的,而这些单例在常驻内存的程序中很容易引起副作用。举个简单的例子,Laravel的auth组件就是一个典型的单例服务,在用户完成登录后会将当前的User对象保存在一个成员变量中,那么下一个请求在调用auth组件时,获得的User对象还是上一个请求保存的,这样就会引起用户身份错乱,从而导致数据异常,这是非常可怕的。

解决资源污染问题,我们只需要在请求结束后清理掉或者还原那些已经“污染了的资源”即可。针对Laravel容器里面的服务,我们可以这样清理:

<?php

/** @var \Illuminate\Contracts\Container\Container $app */

$abstract = 'auth';
$abstract = $app->getAlias($abstract);
$binding = $app->getBindings()[$abstract] ?? null;

unset($app[$abstract]);

if ($binding) {
    $app->bind($abstract, $binding['concrete'], $binding['shared']);
}

可以看到,如果abstract存在binding关系的话,会被重新绑定到容器中去,这样就能保证服务持续可用。这段代码可以在Shadowfax的源码中找到,位于src/Laravel/RebindsAbstracts.php。在Shadowfax的配置文件里提供了一个名为abstracts的数组来帮助开发者清理容器中被污染的服务。

当然,有些开发者会使用全局变量或者静态变量来存储数据,这些也属于容易被污染的资源,不过需要开发者自行处理。Shadowfax在程序执行的各个阶段都提供了事件接口,开发者可以通过监听事件来注入自己的代码。其中HuangYi\Shadowfax\Events\AppPushingEvent事件可以帮助开发者注入自定义的清理代码,这个事件会在Shadowfax回收容器之前触发,可以这样定义一个Listener:

<?php

namespace App\Listeners;

use Illuminate\Contracts\Container\Container;

class CleanPollutedData
{
    public function handle(Container $app)
    {
        // Clean polluted data here...
    }
}

然后在bootstrap/shadowfax.php文件中将自定义的Listener注册到事件监听中去:

<?php // File 'bootstrap/shadowfax.php'

use App\Listeners\CleanPollutedData;
use HuangYi\Shadowfax\Events\AppPushingEvent;

$shadowfax->make('events')->listen(AppPushingEvent::class, new CleanPollutedData);

return $shadowfax;

启用协程

协程是Swoole的最强武器,也是实现高并发的精髓所在。那么在Laravel中使用协程会有问题吗?我们来做个简单的实验,首先启动Swoole的协程特性,将enable_coroutine设置为true即可,然后在routes/web.php里面添加两个测试路由:

<?php

use Swoole\Coroutine;

app()->singleton('counter', function () {
    $counter = new stdClass;
    $counter->number = 0;

    return $counter;
});

Route::get('one', function () {
    app('counter')->number = 1;

    Coroutine::sleep(5);

    echo sprintf("one: %d\n", app('counter')->number);
});

Route::get('two', function () {
    app('counter')->number = 2;

    Coroutine::sleep(5);

    echo sprintf("two: %d\n", app('counter')->number);
});

上述代码首先在容器里面注册了一个counter单例,路由onecounter单例的number属性设置为1,然后模拟协程被挂起5秒,恢复后打印出number属性的值。路由two也类似,只是将number属性设置为了2。启动服务器后,我们先访问one,然后立马访问two(间隔不要超过5秒)。我们可以观察到Console输出的信息为:

one: 2
two: 2

结果并没有符合我们的预期。这是因为容器是共享的,两个请求访问的是同一个counter单例,当请求one被挂起后,请求twonumber属性修改为了2,所以导致请求one打印出来的值也是2。

那我们能不能用解决资源污染的方案来解决这个问题呢?当然是不行的,并且结果还会变的更诡异。请求one打印出来的数值依然是2,而请求two打印出来的数值是0。因为当请求one结束时,清理程序会将counter单例重置,此时number的值又变为了0。

所以在协程环境下我们不能共享IoC容器,我们应该为每个协程提供一个容器,这样才能保证程序的正常执行。那么问题又来了,当我们的应用并发量很大时,意味着同时运行的协程数也非常多,如果为每个协程都提供一个容器的话,内存岂不爆炸?这里我们就要用到“池”技术来解决这个问题,在worker进程启动的时候,利用Swoole的Channel创建一个容器池,当请求过来时从容器池里面取出一个容器供当前协程环境使用,结束后再归还到容器池里去,而那些取不到容器的协程就一直等待,直到取到容器再执行。

Shadowfax在启动worker进程时会判断服务器是否启用了协程特性,如果启用则创建容器池,否则复用一个容器,以达到最优的性能。

Shadowfax只会为每个request分配一个容器,如果有子协程,会使用父协程中的容器。

协程环境下的app()方法

Laravel的容器使用了单例模式,在它的构造函数里会调用static::setInstance($this),这步操作会将创建的容器保存到一个静态变量里(Container::$instance),这样就可以通过Container::getInstance()方法获取到容器单例。此外Laravel还提供了一个助手函数app()来获取容器单例,并且这个函数被广泛使用。正是因为这个单例特性,在协程环境下如果我们使用app()函数时,获得的始终是同一个容器,这就导致容器池失去了作用。

最开始想到的解决方案是,每次从池中取出容器后,就立刻调用Container::setInstance()将其设置为全局容器(即覆盖Container::$instance的值)。但是这个方案存在一个问题,如果A协程在挂起期间执行了B协程,此时全局容器会被B协程的容器覆盖,那么当A协程恢复后再调用app()方法获得的将是B协程的容器。可惜Swoole并未提供coroutineYiedcoroutineResume这类事件,不然我们可以通过监听事件来切换,真是令人头疼。

最后,Shadowfax使用了一种比较hack的解决方案。既然我们无法在恢复协程的时候切换,那就在Container::getInstance()方法里面切换吧。为了实现这个方案,首先需要将取出来的容器保存到当前协程的Context中,方便协程resume时直接从Context中取出。然后在Container::getInstance()方法中添加切换的逻辑,判断当前协程Context中的容器与全局容器是否为同一个,如果不是,则将当前协程的容器替换为全局容器即可。具体的实现可参考Shadowfax源码,位于src/helpers.php文件中的shadowfax_correct_container()函数。

接下来的难题就是如何将这段切换容器的代码注入到Container::getInstance()方法中去。最先想到的方案是通过类继承的方式,然后覆盖getInstance方法来实现注入。但这种方法需要将bootstrap/app.php里的Illuminate\Foundation\Application修改为继承后的类名,侵入性太强了,假如有一天我想切回PHP-FPM的模式,还需要将类名修改回去,所以果断放弃这个方案。

Shadowfax的做法是这样的,在程序启动时先读取vendor/laravel/framework/src/Illuminate/Container/Container.php的文本内容,然后使用字符串替换的方式将shadowfax_correct_container()函数写到getInstance方法里面去,再保存为一个新的coroutine_container.php文件,最后我们只需要将coroutine_container.php文件require到程序中来即可。需要明白的一点是,由于coroutine_container.php文件提供的也是Illuminate\Containe\Container类,一旦被require到程序中后,便不会再通过autoload去加载Laravel框架里面的Container类了,从而达到替换的功效。

现在,你可以放心地在程序里使用app()函数了。虽然这个方案很粗暴,但的确很有效,既能保障Shadowfax的功能,且程序脱离Shadowfax运行时依然是正常的。感兴趣的朋友可以阅读Shadowfax的源码,这段逻辑位于src/Bootstrap/CreateCoroutineContainer.php

数据库连接池

现代Web应用几乎离不开数据库的使用,在协程环境下使用数据库如果不配合连接池,就会造成连接异常。当然,使用Swoole的Channel来创建连接池非常简单,但是如果直接在业务代码中使用连接池,程序员需要自行控制何时取何时回收,而且还不能使用Laravel的Model了,这点我是绝对不能接受的。还有一点,由于在业务代码中使用了Swoole的接口,这意味着你的程序必须运行在Swoole之上,再也无法切回PHP-FPM了。

Shadowfax做到了无感知的使用连接池,开发者依然像平时那样用Model来查询或者更新数据,唯一需要做的就是将程序中使用到的数据库连接名配置到db_pools当中即可。Shadowfax是如何做到的呢?我们只需要搞清楚一点就能明白原理了,Laravel中的数据库连接都是通过Illuminate\Database\DatabaseManager::connection()方法来获取的,我们可以继承这个类并改造connection()方法,如果取的是db_pools中配置的连接,那么就从对应的连接池中获取。最后使用这个改造后的类注覆盖原来的db服务即可。具体的实现就请阅读源码吧,文件为src/Laravel/DatabaseManager.php

当然,Shadowfax也支持redis连接池,只需要将程序中使用到的连接名配置到redis_pools当中即可。

结束语

相信使用这个拓展包的人和我一样都非常喜欢Laravel,Laravel的开发体验让我们爱不释手,所以Shadowfax在整个设计过程中都会去避免破坏这种体验,尽量让开发者以最小的成本将Laravel应用运行到Swoole之上来,以获得性能的提升。

Shadowfax是一个开源项目,它的诞生也花费了作者不少的时间和精力。如果你觉得好用,请贡献一个star以示支持。如果你在使用过程中遇到了问题,请提交issue。如果你能改进程序,欢迎提交PR。开源项目需要大家一起贡献力量。