整合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。开源项目需要大家一起贡献力量。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,539评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,911评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,337评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,723评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,795评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,762评论 1 294
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,742评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,508评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,954评论 1 308
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,247评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,404评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,104评论 5 340
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,736评论 3 324
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,352评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,557评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,371评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,292评论 2 352