前面向大家推荐了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
脚本,代码如下:
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\Request
和HuangYi\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
调整一下:
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容器里面的服务,我们可以这样清理:
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:
然后在bootstrap/shadowfax.php
文件中将自定义的Listener注册到事件监听中去:
make('events')->listen(AppPushingEvent::class, new CleanPollutedData);
return $shadowfax;
启用协程
协程是Swoole的最强武器,也是实现高并发的精髓所在。那么在Laravel中使用协程会有问题吗?我们来做个简单的实验,首先启动Swoole的协程特性,将enable_coroutine
设置为true
即可,然后在routes/web.php
里面添加两个测试路由:
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
单例,路由one
将counter
单例的number
属性设置为1,然后模拟协程被挂起5秒,恢复后打印出number
属性的值。路由two
也类似,只是将number
属性设置为了2。启动服务器后,我们先访问one
,然后立马访问two
(间隔不要超过5秒)。我们可以观察到Console输出的信息为:
one: 2
two: 2
结果并没有符合我们的预期。这是因为容器是共享的,两个请求访问的是同一个counter
单例,当请求one
被挂起后,请求two
将number
属性修改为了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并未提供coroutineYied
和coroutineResume
这类事件,不然我们可以通过监听事件来切换,真是令人头疼。
最后,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。开源项目需要大家一起贡献力量。