Laravel Container - 情境綁定詳解
情境綁定是 Laravel 針對不同情境要綁定不同實作的需求,所設計的好用功能。
最近剛好有需求使用到它,發現對它的設計有誤解造成不斷踩雷。本篇文章會以我自己使用經驗來說明這個功能的應用方法。
情境綁定:原文為 contextual binding,contextual 可解釋為上下文,也就是依賴注入當下的情境。本篇文章中文譯名暫定為「情境綁定」。
以下參考 Laravel Framework 8.79.0 的程式碼,然後以我自己主觀的想法來分析使用方法與設計意圖。
基本用法
官網提供的範例如下:
$container->when(PhotoController::class) |
Container 設計上非常語意:當(when)PhotoController 需要(needs)Filesystem 的時候,給(give)local 的 Storage 物件,而 VideoController 和 UploadController 需要的時候,則是給 s3 的 Storage 物件。
註冊單例類型的物件
先離題講 Container 另外一個功能:註冊單例類型的物件。
Container 提供 singleton() 方法可以定義物件生成的規則,是在 Container 裡的生命週期保持唯一。如下面的寫法都是在註冊單例類型:
// 綁定自定義建置方法 |
singleton() 方法其實就是 bind() 方法加上 $shared 參數設定為 true。因此 Container 設計意圖上,單例指的是產生一個物件,並共享給其他依賴方使用。
Container 產生物件的流程(指 make() 方法)中,若發現註冊是單例類型,且過去曾產生過物件就會直接回傳,達到 Container 內的生命週期唯一。
產生物件並放入共用空間的程式片段:
if ($this->isShared($abstract) && ! $needsContextualBuild) { |
檢查共用空間是否有物件的程式片段:
if (isset($this->instances[$abstract]) && ! $needsContextualBuild) { |
設計意圖
回到情境綁定,當在不同情境需要相同型別 + 不同實作的物件時,適合使用情境綁定功能。但有個前提需要先理解:在 Container 的設計裡,這不屬於單例類型的綁定,也就是屬於 $shared = false
的綁定方法。
從上面的程式碼可以看到,符合單例類型的綁定的重要條件之一是,非 contextual build。(指 ! $needsContextualBuild
條件)
接著我們來看一下 $needsContextualBuild
指的是什麼:
$concrete = $this->getContextualConcrete($abstract); |
兩個條件,只要有一個條件達成就會歸類為 contextual build:
- 有提供 parameter
- 有註冊情境綁定
從程式也可以知道,有另一個類似意圖,但不同的功能是「make() 方法搭配 parameter」,也算是 contextual build。
總結來說,Container 的設計是:因不同參數或使用情境綁定的前提下,產生同型但不同狀態的物件,不能算是單例。
踩雷
在 PHP 常見的開發方法,通常都是採用 process 執行完即結束的流程。因此是不是單例,其實問題不大,只要 DB 或對外連線處理好即可,這些 Laravel 內建程式都處理得很好。
而我最近遇到的麻煩在於寫單元測試。
單元測試會需要注入 mock 或 fake 以方便建立對應的測試情境。注入的方法會使用 instance(),但關鍵把實例放進 Container 裡的程式碼如下:
$this->instances[$abstract] = $instance; |
剛好對應到上面提到,單例類型在要取得已產生物件的判斷裡的程式:
if (isset($this->instances[$abstract]) && ! $needsContextualBuild) { |
上面兩件事合併起來,簡單來說就是:當使用情境綁定的時候,是無法用 instance() 方法注入 mock 物件的。
解決方法
注意:以下是針對情境綁定的注入方法說明,parameter 我還沒找到好解法
網路上可以查到兩種解法,一個是使用 extend() 方法:
$container->extend(FooInterface::class, function() { |
另一個是使用 addContextualBinding() 方法:
$container->addContextualBinding(SomeController::classs, FooInterface::class, function() { |
就程式的行為來說,addContextualBinding() 會比 extend() 來的適合。原因是 extend() 會多執行到 provider 產生物件的流程。若我們要使用 mock 的理由之一,是想避開產生物件的流程(如建構子),那 extend() 就顯得不適合,因此相較適合的會是使用 addContextualBinding()。
另外還有一個方法是,改寫註冊情境綁定的方法如下:
$this->app->when(SomeController::class) |
單元測試要 mock 的時候,就可以改寫成下面這樣:
$this->mock(Foo::class); |
結論
情境綁定雖然在適用的場景下,會非常好用,但單元測試真的比想像中的困難超級多。因此我個人的建議,還是盡量避免情境綁定的設計會比較好。