Laravel Octane 使用依賴注入的問題

使用 Laravel Octane 已經快兩年了,最近遇到了一個依賴注入相關的問題。以下將會詳細說明問題的核心原因,與解決的概念和方法。

問題的現象

因專案需求,需要自定義新的 log driver,因此參考之前曾經寫過的文章:Laravel 使用 Slack 配合 Proxy 設定方法後,將會寫出類似下面註冊自定義 log driver 的程式:

$logManager->extend('custom_driver', function (Container $app, array $config) {
return (new CustomDriverFactory($app))($config);
});

註冊好 custom_driver 後,接著就可以在 config/logging.php 寫類似下面的設定:

'custom_logger' => [
'driver' => 'custom_driver',
// Other config
],

因為 custom_driver 需要記錄 Request 相關的內容,因此在 Factory 建立 Logger 的時候,會依賴 Container 來取得 Request:

class CustomDriverFactory
{
public function __construct(private ContainerInterface $app)
{
$this->app = $app;
}

public function __invoke($config)
{
return new CustomLogger($config, $this->app->make('request'));
}
}

以上這些配置,在一般 Laravel 的場景下,並不會有什麼問題,但是換到 Laravel Octane 的場景時,將會發生一件事:CustomLogger 所依賴的 Request 不會隨著請求不同而變動,而是真的變成了「單例」物件了。

實際情境範例會像是:第一次請求 /foo 時,CustomLogger 所記錄的路徑會是 /foo,第二次之後的請求,例如 /bar 時,CustomLogger 所記錄的路徑會保持在前一個 /foo 而不會變動。

問題的核心原因

這個問題正是 Laravel Octane 官方文件裡面提到的依賴注入章節所提到的問題:

In general, you should avoid injecting the application service container or HTTP request instance into the constructors of other objects.

我們應該要避免注入 Container 或 HTTP Request 實例到其他物件裡(除了文中所說的建構子注入外,Setter 注入則要看使用方法,其他注入方法可以參考淺談依賴注入)。文件後面還有提到,如果有依賴 Config 也要小心。

這個問題從一開始使用 Octane 就有注意到了,在寫 Service Provider 的時候都有避開這類的寫法,但是沒想到這次的雷區是發生在 LogManager 上。不只 LogManager 會有這個問題,包含其他 Manager 像 CacheManagerAuthManager,甚至是自己繼承 Laravel Manager 也會有一樣的問題(Laravel Manager 用法可參考之前寫的文章:使用 Laravel Manager 類別

原因是發生在 Laravel 在設計這些 Manager 的時候,有設計了 Registry of Singleton Pattern,這是指同個 Manager 的實例裡,同個 Driver 只會建立單一實例。例如:

$logger = $logManager->driver('custom_logger'); 
$logger = $logManager->driver('custom_logger'); // 會拿到跟前一個一模一樣的 物件

問題會發生在,$logManager 的生命週期會在 Octane 整個 Process 裡共享,因此雖然 Service Provider 裡面沒有直接依賴,但 Manager 產生出來的 Driver 有依賴的話,還是會踩到這個雷。

最快速的解法

正確的解法是需要好好設計 class,在下個段落會說明。只是當問題突然發生的時候,還是會希望有快速解決的方法。

這個問題是有速解法的,只要在 config/octane.php 這個檔案設定的 flush 裡,加上 log 確保 LogManager 的實例會在每次請求都清除即可:

return [
// ...

'flush' => [
'log',
],

// ...
];

這個解法在本機測試可行,但因為有發現 LogManager 在 Application Bootstrap 的時候就會初始化,不確定對底層機制會不會有什麼影響。實際這個做法真的要上線的話,請多加測試。

實驗過後,確定上述的做法是不可行的,後來確認可行的做法如下:

config/octane.php 這個檔案設定的 listeners 裡,在 RequestReceived 事件裡註冊 listener:

return [
// ...
'listeners' => [

// ...

RequestReceived::class => [
...Octane::prepareApplicationForNextOperation(),
...Octane::prepareApplicationForNextRequest(),
//
App\Listeners\FlushLogChannel::class,
],

// ...
],

// ...
];

FlushLogChannel 的實作如下:

class FlushLogChannel
{
public function handle(RequestReceived $event): void
{
if (!$event->sandbox->bound('log')) {
return;
}

// 清除自定義的 logger
$event->sandbox['log']->forgetChannel('custom_logger');

// 如果有用 stack driver 的話,stack channel 也需要清
$event->sandbox['log']->forgetChannel('custom_stack');
}
}

這跟速解法一開始的想法是一樣的,只是這個做法是在處理 Request 之前把 logger channel 清除。

正確的解法

正確的解法,要參考官方文件所說的:不要把 Container 與 HTTP Request 注入到單例物件裡。

因此在物件裡出現下面的寫法(或有類似概念),全都要改寫:

$this->app = $app // 把 Container 或 Application 存入 property
$this->request = $request // 把 Request 存入 property

相對的,下面這些的寫法都是可以的:

// 使用 Helper function
$app = app();
$request = request();

// 使用 Facade
$instance = App::make(SomeClass::class);
$method = Request::method();

另一個方法是改用參數注入。但過去寫 PHP 的場景通常是 Response 回傳後就會結束 Process,因此有人會把 Request 當成單例物件,所以會注入到某個單例物件裡,這樣就會對這種寫法不大適應,但至少,它是一種解法:

$logManager->info('somelog', ['request' => request()]);

使用 Resolver

直接使用 Laravel Helper 或 Laravel Facade 可以很輕易的解決,但缺點是這段程式直接依賴了 Laravel 框架所提供的功能。如果是直接寫在 Laravel Project 裡的話,當然沒有問題,但是如果是要寫一個 Library,還是會建議盡可能不要依賴框架。以這個考慮為基礎的話,程式會調整成依賴 PSR-7 Request,這樣就還是得靠注入才能解決,可是我們又不想使用參數注入的話,該怎麼辦呢?

這時就可以使用 Resolver 的寫法,這個名詞是來自於 Laravel 框架裡面的變數命名,例如 UserResolver,代表著「取得目前登入的使用者」的解析器。

而現在要取得的是 Request,因此可以取一個叫 RequestResolver 的 Closure 變數放入物件的 property,而在需要使用 Request 的時候再來呼叫這個 Closure:

class CustomLogger
{
public function __construct(private $config, private Closure $requestResolver)
{
}

public function handle()
{
// 取得 PSR-7 Request
/** @var ServerRequestInterface $request */
$request = ($this->requestResolver)();

// 使用 Request
$log = ['method' => $request->getMethod()];

// ...
}
}

這個設計方法,會需要為這個 class 寫 Service Provider:

class CustomLoggerServiceProvider extends ServiceProvider
{
public function register()
{
$this->app->singleton(CustomLogger::class, function (Container $app) {
return new CustomLogger(fn() => $app['request']);
});
}
}

而這個寫法,剛好就跟官網的範例是一樣的:

use App\Service;
use Illuminate\Contracts\Foundation\Application;

$this->app->singleton(Service::class, function (Application $app) {
return new Service(fn () => $app['request']);
});

不過我們目前遇到的是 LogManager 的 custom driver 功能有問題,因此是另外一個場景,調整方法為:先調整 Factory,讓 invoker 在建物件的時候,傳入 Resolver:

class CustomDriverFactory
{
public function __invoke($config, Closure $requestResolver)
{
return new CustomLogger($config, $requestResolver);
}
}

最後就是調整呼叫 Factory 的地方,如下:

$logManager->extend('custom_driver', function (Container $app, array $config) {
return (new CustomDriverFactory())($config, fn() => $app['request']);
});

後記

這個問題一開始出現的時候,就有發現是注入的問題。只是實驗起來有點困難,花了很多時間才確定是 custom driver 上實作的 Registry of Singleton Pattern 造成的。而會想要實驗的原因是,一來是確認 bug 的過程,本來就必須要確實地驗證,而不是胡亂猜測;另外就是,剛好有遇到 Octane 長連線的問題,因此覺得需要理解 Octane 處理 Container 的機制,而在這過程中,也去追了一下 Octane 運作的程式,接而發現短解的方法--想辦法在對應的流程裡 flush 實例。

之後有機會再來寫處理長連線問題或追 Octane 程式相關的筆記了。