整合 Laravel 與 Swoole,Shadowfax 是這樣做的

斯坦毅發表於2020-06-15

前面向大家推薦了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。開源專案需要大家一起貢獻力量。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

我是黃毅,歡迎關注我的 Github部落格

相關文章