Laravel Octane 使用依賴注入的問題
使用 Laravel Octane 已經快兩年了,最近遇到了一個依賴注入相關的問題。以下將會詳細說明問題的核心原因,與解決的概念和方法。
問題的現象
因專案需求,需要自定義新的 log driver,因此參考之前曾經寫過的文章:Laravel 使用 Slack 配合 Proxy 設定方法後,將會寫出類似下面註冊自定義 log driver 的程式:
$logManager->extend('custom_driver', function (Container $app, array $config) { |
註冊好 custom_driver
後,接著就可以在 config/logging.php
寫類似下面的設定:
'custom_logger' => [ |
因為 custom_driver
需要記錄 Request 相關的內容,因此在 Factory 建立 Logger 的時候,會依賴 Container 來取得 Request:
class CustomDriverFactory |
以上這些配置,在一般 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 像 CacheManager 或 AuthManager,甚至是自己繼承 Laravel Manager 也會有一樣的問題(Laravel Manager 用法可參考之前寫的文章:使用 Laravel Manager 類別)
原因是發生在 Laravel 在設計這些 Manager 的時候,有設計了 Registry of Singleton Pattern,這是指同個 Manager 的實例裡,同個 Driver 只會建立單一實例。例如:
$logger = $logManager->driver('custom_logger'); |
問題會發生在,$logManager
的生命週期會在 Octane 整個 Process 裡共享,因此雖然 Service Provider 裡面沒有直接依賴,但 Manager 產生出來的 Driver 有依賴的話,還是會踩到這個雷。
最快速的解法
正確的解法是需要好好設計 class,在下個段落會說明。只是當問題突然發生的時候,還是會希望有快速解決的方法。
這個問題是有速解法的,只要在 config/octane.php
這個檔案設定的 flush
裡,加上 log
確保 LogManager 的實例會在每次請求都清除即可:
return [ |
這個解法在本機測試可行,但因為有發現 LogManager 在 Application Bootstrap 的時候就會初始化,不確定對底層機制會不會有什麼影響。實際這個做法真的要上線的話,請多加測試。
實驗過後,確定上述的做法是不可行的,後來確認可行的做法如下:
在 config/octane.php
這個檔案設定的 listeners
裡,在 RequestReceived 事件裡註冊 listener:
return [ |
FlushLogChannel 的實作如下:
class FlushLogChannel |
這跟速解法一開始的想法是一樣的,只是這個做法是在處理 Request 之前把 logger channel 清除。
正確的解法
正確的解法,要參考官方文件所說的:不要把 Container 與 HTTP Request 注入到單例物件裡。
因此在物件裡出現下面的寫法(或有類似概念),全都要改寫:
$this->app = $app // 把 Container 或 Application 存入 property |
相對的,下面這些的寫法都是可以的:
// 使用 Helper function |
另一個方法是改用參數注入。但過去寫 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 |
這個設計方法,會需要為這個 class 寫 Service Provider:
class CustomLoggerServiceProvider extends ServiceProvider |
而這個寫法,剛好就跟官網的範例是一樣的:
use App\Service; |
不過我們目前遇到的是 LogManager 的 custom driver 功能有問題,因此是另外一個場景,調整方法為:先調整 Factory,讓 invoker 在建物件的時候,傳入 Resolver:
class CustomDriverFactory |
最後就是調整呼叫 Factory 的地方,如下:
$logManager->extend('custom_driver', function (Container $app, array $config) { |
後記
這個問題一開始出現的時候,就有發現是注入的問題。只是實驗起來有點困難,花了很多時間才確定是 custom driver 上實作的 Registry of Singleton Pattern 造成的。而會想要實驗的原因是,一來是確認 bug 的過程,本來就必須要確實地驗證,而不是胡亂猜測;另外就是,剛好有遇到 Octane 長連線的問題,因此覺得需要理解 Octane 處理 Container 的機制,而在這過程中,也去追了一下 Octane 運作的程式,接而發現短解的方法--想辦法在對應的流程裡 flush 實例。
之後有機會再來寫處理長連線問題或追 Octane 程式相關的筆記了。