Laravel Container - 情境綁定詳解

情境綁定是 Laravel 針對不同情境要綁定不同實作的需求,所設計的好用功能。

最近剛好有需求使用到它,發現對它的設計有誤解造成不斷踩雷。本篇文章會以我自己使用經驗來說明這個功能的應用方法。

情境綁定:原文為 contextual binding,contextual 可解釋為上下文,也就是依賴注入當下的情境。本篇文章中文譯名暫定為「情境綁定」。

以下參考 Laravel Framework 8.79.0 的程式碼,然後以我自己主觀的想法來分析使用方法與設計意圖。

基本用法

官網提供的範例如下:

$container->when(PhotoController::class)
->needs(Filesystem::class)
->give(function () {
return Storage::disk('local');
});

$container->when([VideoController::class, UploadController::class])
->needs(Filesystem::class)
->give(function () {
return Storage::disk('s3');
});

Container 設計上非常語意:當(when)PhotoController 需要(needs)Filesystem 的時候,給(give)local 的 Storage 物件,而 VideoController 和 UploadController 需要的時候,則是給 s3 的 Storage 物件。

註冊單例類型的物件

先離題講 Container 另外一個功能:註冊單例類型的物件。

Container 提供 singleton() 方法可以定義物件生成的規則,是在 Container 裡的生命週期保持唯一。如下面的寫法都是在註冊單例類型:

// 綁定自定義建置方法
$container->singleton(FooInterface::class, function() {
return new Foo();
})

// 綁定實作類別
$container->singleton(FooInterface::class, Foo::class);

// 註冊特定 class 產生物件會是單例
$container->singleton(Foo::class);

singleton() 方法其實就是 bind() 方法加上 $shared 參數設定為 true。因此 Container 設計意圖上,單例指的是產生一個物件,並共享給其他依賴方使用。

Container 產生物件的流程(指 make() 方法)中,若發現註冊是單例類型,且過去曾產生過物件就會直接回傳,達到 Container 內的生命週期唯一。

產生物件並放入共用空間的程式片段

if ($this->isShared($abstract) && ! $needsContextualBuild) {
$this->instances[$abstract] = $object;
}

檢查共用空間是否有物件的程式片段

if (isset($this->instances[$abstract]) && ! $needsContextualBuild) {
return $this->instances[$abstract];
}

設計意圖

回到情境綁定,當在不同情境需要相同型別 + 不同實作的物件時,適合使用情境綁定功能。但有個前提需要先理解:在 Container 的設計裡,這不屬於單例類型的綁定,也就是屬於 $shared = false 的綁定方法。

從上面的程式碼可以看到,符合單例類型的綁定的重要條件之一是,非 contextual build。(指 ! $needsContextualBuild 條件)

接著我們來看一下 $needsContextualBuild 指的是什麼:

$concrete = $this->getContextualConcrete($abstract);

$needsContextualBuild = ! empty($parameters) || ! is_null($concrete);

兩個條件,只要有一個條件達成就會歸類為 contextual build:

  1. 有提供 parameter
  2. 有註冊情境綁定

從程式也可以知道,有另一個類似意圖,但不同的功能是「make() 方法搭配 parameter」,也算是 contextual build。

總結來說,Container 的設計是:因不同參數或使用情境綁定的前提下,產生同型但不同狀態的物件,不能算是單例

踩雷

在 PHP 常見的開發方法,通常都是採用 process 執行完即結束的流程。因此是不是單例,其實問題不大,只要 DB 或對外連線處理好即可,這些 Laravel 內建程式都處理得很好。

而我最近遇到的麻煩在於寫單元測試。

單元測試會需要注入 mock 或 fake 以方便建立對應的測試情境。注入的方法會使用 instance(),但關鍵把實例放進 Container 裡的程式碼如下:

$this->instances[$abstract] = $instance;

剛好對應到上面提到,單例類型在要取得已產生物件的判斷裡的程式:

if (isset($this->instances[$abstract]) && ! $needsContextualBuild) {
return $this->instances[$abstract];
}

上面兩件事合併起來,簡單來說就是:當使用情境綁定的時候,是無法用 instance() 方法注入 mock 物件的。

解決方法

注意:以下是針對情境綁定的注入方法說明,parameter 我還沒找到好解法

網路上可以查到兩種解法,一個是使用 extend() 方法:

$container->extend(FooInterface::class, function() {
$mock = Mockery::mock(FooInterface::class);
// 設定 mock 物件 ...

return $mock;
});

另一個是使用 addContextualBinding() 方法:

$container->addContextualBinding(SomeController::classs, FooInterface::class, function() {
$mock Mockery::mock(FooInterface::class);
// 設定 mock 物件 ...

return $mock;
});

就程式的行為來說,addContextualBinding() 會比 extend() 來的適合。原因是 extend() 會多執行到 provider 產生物件的流程。若我們要使用 mock 的理由之一,是想避開產生物件的流程(如建構子),那 extend() 就顯得不適合,因此相較適合的會是使用 addContextualBinding()。

另外還有一個方法是,改寫註冊情境綁定的方法如下:

$this->app->when(SomeController::class)
->needs(FooInterface::class)
->give(function () {
return $this->app->make(Foo::class);
});

單元測試要 mock 的時候,就可以改寫成下面這樣:

$this->mock(Foo::class);

結論

情境綁定雖然在適用的場景下,會非常好用,但單元測試真的比想像中的困難超級多。因此我個人的建議,還是盡量避免情境綁定的設計會比較好。