從可測試性角度探討依賴注入

依賴反轉原則是 SOLID 中最難理解的原則,而依賴注入則是單元測試的基石,本文將從可測試性角度探討依賴反轉與依賴注入,並將 Laravel 的 service container、constructor injection 與 method injection 應用在實務上。

Version


PHP 7.0.0
Laravel 5.2.29

實際案例


假設我們想要計算運費,目前有黑貓新竹客運郵局三家可以選擇,每家針對不同的重量有其相對應的計算公式,而我們希望能寫出高內聚、低耦合,符合 SOLID 原則的程式碼,方便日後維護。1 1此範例並非我原創,靈感來自於 Joey Chen 的 30天快速上手TDD : Refactoring Legacy Code 簡介之範例,因為此範例非常容易懂,而且很適合介紹重構。

貨運商 計費規則
黑貓 基本運費 100 元,每公斤加收 10
新竹貨運 基本運費 80 元,每公斤加收 15
郵局 基本運費 70 元,每公斤加收 20

傳統寫法


傳統我們會使用 if elsenew 來建立物件。

BlackCat.php2 2GitHub Commit : 新增BlackCat.php

app/Services/BlackCat.php
1
2
3
4
5
6
7
8
9
10
11
12
13
namespace App\Services;

class BlackCat
{

/**
* @param int $weight
* @return int
*/

public function calculateFee($weight)
{

return 100 + $weight * 10;
}
}

黑貓的計費方式。

Hsinchu.php3 3GitHub Commit : 新增Hsinchu.php

app/Services/Hsinchu.php
1
2
3
4
5
6
7
8
9
10
11
12
13
namespace App\Services;

class Hsinchu
{

/**
* @param int $weight
* @return int
*/

public function calculateFee($weight)
{

return 80 + $weight * 15;
}
}

新竹貨運的計費方式。

PostOffice.php4 4GitHub Commit : 新增PostOffice.php

app/Services/PostOffice.php
1
2
3
4
5
6
7
8
9
10
11
12
13
namespace App\Services;

class PostOffice
{

/**
* @param int $weight
* @return int
*/

public function calculateFee($weight)
{

return 70 + $weight * 20;
}
}

郵局的計費方式。

ShippingService.php5 5GitHub Commit : 新增ShippingService.php

app/Services/ShippingService.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
namespace App\Services;

use Exception;

class ShippingService
{

/**
* @param string $companyName
* @param int $weight
* @return int
* @throws Exception
*/

public function calculateFee($companyName, $weight)
{

if ($companyName == 'BlackCat') {
$blackCat = new BlackCat();
return $blackCat->calculateFee($weight);
}
elseif ($companyName == 'Hsinchu') {
$hsinchu = new Hsinchu();
return $hsinchu->calculateFee($weight);
}
elseif ($companyName == 'PostOffice') {
$postOffice = new PostOffice();
return $postOffice->calculateFee($weight);
}
else {
throw new Exception('No company exception');
}
}
}

calculateFee() 傳入 2 個參數 : $companyName$weight

使用者可自行由 $companyName 挑選貨運公司,並傳入 $weight 計算運費。

使用 if else 判斷 $companyName 字串,並 new出相對應物件,這是初學者學習物件導向時的寫法。

ShippingService.php6 6GitHub Commit : 將if else重構成switch

app/Services/ShippingService.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* @param string $companyName
* @param int $weight
* @return int
* @throws Exception
*/

public function calculateFee($companyName, $weight)
{

switch ($companyName) {
case 'BlackCat':
$blackCat = new BlackCat();
return $blackCat->calculateFee($weight);
case 'Hsinchu':
$hsinchu = new Hsinchu();
return $hsinchu->calculateFee($weight);
case 'PostOffice':
$postOffice = new PostOffice();
return $postOffice->calculateFee($weight);
default:
throw new Exception('No company exception');
}
}

if else 重構成 switch,可稍微改善程式碼的可讀性。7 7if else 重構成 switch,請參考如何在PhpStorm將if else重構成switch case?

使用 Interface


目前的寫法,執行上沒有什麼問題,若以 TDD 開發,我們將得到第一個 綠燈

我們將繼續重構成更好的程式。

目前我們是實際去 new Blackcat()new Hsinchu()new PostOffice(),也就是說ShippingService將直接相依BlackCatHshinchuPostOffice 3 個 class。

物件導向就是希望達到高內聚,低耦合的設計。所謂的低耦合,就是希望能減少相依於外部的 class 的數量。

何謂相依?

簡單的說,當你去使用一個物件的 property 或 method 時,就是相依了該物件。

由於 PHP 不用編譯,所以可能較無法體會相依的嚴重性,但若是需要編譯的程式語言,若你相依的 class 的 property 或 method 改變,可能導致你的程式無法編譯成功,也就是你必須配合相依的 class 做相對應的修改才能通過編譯,因此我們希望降低對其他 class 的相依程度與數量。

GoF 四人幫在設計模式曾說 : Program to an Interface, not an Implementation。其中的 implementation 指的就是 class,也就是程式應該只相依於 interface,而不是相依於實際 class,目的就是要藉由 interface,降低對於實際 class 的相依程度。

若我們能將 BlackCatHshinchuPostOffice 3 個 class抽象化為 1 個 interface,則 ShippingService將從相依 3 個 class,降低成只相依於 1 個interface,將大大降低 ShippingService 與其他 class 的相依程度。

若以編譯的角度,由於 ShippingService 只相依於 interface,因此 BlackCatHshinchuPostOffice 做任何修改都不會影響我 ShippingService 的編譯。

LogisticsInterface.php8 8GitHub Commit : 抽取出LogisticsInterface

app/Services/LogisticsInterface.php
1
2
3
4
5
6
7
8
9
10
namespace App\Services;

interface LogisticsInterface
{

/**
* @param int $weight
* @return int
*/

public function calculateFee($weight);
}

BlackCat 抽取出 LogisticsInterface,將 BlackCatHsinchuPostOffice 抽象化成 LogisticsInterface

BlackCat.php9 9GitHub Commit : BlackCat實現LogisticsInterface

app/Services/BlackCat.php
1
2
3
4
5
6
7
8
9
10
11
12
13
namespace App\Services;

class BlackCat implements LogisticsInterface
{

/**
* @param int $weight
* @return int
*/

public function calculateFee($weight)
{

return 100 * $weight * 10;
}
}

BlackCat 實現 LogisticsInterface

Hsinchu.php10 10GitHub Commit : Hsinchu實現LogisticsInterface

app/Services/Hsinchu.php
1
2
3
4
5
6
7
8
9
10
11
12
13
namespace App\Services;

class Hsinchu implements LogisticsInterface
{

/**
* @param int $weight
* @return int
*/

public function calculateFee($weight)
{

return 80 * $weight * 15;
}
}

Hsinchu 實現 LogisticsInterface

PostOffice.php11 11GitHub Commit : PostOffice實現LogisticsInterface

app/Services/PostOffice.php
1
2
3
4
5
6
7
8
9
10
11
12
13
namespace App\Services;

class PostOffice implements LogisticsInterface
{

/**
* @param int $weight
* @return int
*/

public function calculateFee($weight)
{

return 70 * $weight * 20;
}
}

PostOffice 實現 LogisticsInterface

ShippingService.php12 12GitHub Commit : ShippingService只相依於LogisticsInterface

app/Services/ShippingService.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
namespace App\Services;

use Exception;

class ShippingService
{

/**
* @param string $companyName
* @param int $weight
* @return int
* @throws Exception
*/

public function calculateFee($companyName, $weight)
{

switch ($companyName) {
case 'BlackCat':
$logistics = new BlackCat();
return $logistics->calculateFee($weight);
case 'Hsinchu':
$logistics = new Hsinchu();
return $logistics->calculateFee($weight);
case 'PostOffice':
$logistics = new PostOffice();
return $logistics->calculateFee($weight);
default:
throw new Exception('No company exception');
}
}
}

$logistics 的型別都是 LogisticsInterface,目前 PHP 7 對於變數還沒有支援 type hint,所以程式碼看起來差異不大,但藉由 PHPDoc,在 PhpStorm 打 $logistics->,已經可以得到語法提示: calculateFee(),表示 PhpStorm 已經知道BlackCatHsinchuPostOffice 都是 LogisticsInterface 型別的物件,也就是對於 ShippingService 來說,BlackCatHsinchuPostOffice 都已經抽象化成 LogisticsInterface

工廠模式


雖然已經將 BlackCatHsinchuPostOffice 抽象化成 LogisticsInterface,但是在 ShoppingService 中,仍看到 new Blackcat()new Hsinchu()new PostOffice(),對於 ShoppingService 而言,我們看到了 3 個問題 :

  1. 違反單一職責原則 : calculateFee() 原本應該只負責計算運費,現在卻還要負責建立貨運公司物件。
  2. 違反開放封閉原則 : 將來若有新的貨運公司供使用者選擇,勢必修改 switch
  3. 實質相依數為 3 : 雖然已經重構出 interface,但實際上卻還必須 new 3 個class。

比較好的方式是將 new 封裝在 LogisticsFactory 中。

LogisticsFactory.php13 13GitHub Commit : 新增LogisticsFactory.php

app/Services/LogisticsFactory.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
namespace App\Services;

use Exception;

class LogisticsFactory
{

/**
* @param string $companyName
* @return LogisticsInterface
* @throws Exception
*/

public static function create(string $companyName)
{

switch ($companyName) {
case 'BlackCat':
return new BlackCat();
case 'Hsinchu':
return new Hsinchu();
case 'PostOffice':
return new PostOffice();
default:
throw new Exception('No company exception');
}
}
}

Simple Factory模式使用了static create(),專門負責建立貨運公司物件:

  1. 專門負責建立貨運公司的邏輯,符合單一職責原則。

ShippingService.php14 14GitHub Commit : ShippingService只相依於LogisticsFactory

app/Services/ShippingService.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
namespace App\Services;

use Exception;

class ShippingService
{

/**
* @param string $companyName
* @param int $weight
* @return int
* @throws Exception
*/

public function calculateFee($companyName, $weight)
{

$logistics = LogisticsFactory::create($companyName);
return $logistics->calculateFee($weight);
}
}

  1. 將來有新的貨運公司,也只要統一修改LogisticsFactory即可,將其變化封裝在LogisticsFactory,對於 ShoppingService開放封閉。
  2. ShoppingService 從相依於 3 個 class 降低成僅相依於 LogisticsInterfaceLogisticsFactory,實質相依數降為 2。

程式的可測試性


符合 spec 的程式,並不代表是好的程式,一個好的程式還要符合 5 個要求 :

  1. 容易維護
  2. 容易新增功能
  3. 容易重複使用
  4. 容易上Git,不易與其他人衝突
  5. 容易寫測試

使用 interface + 工廠模式,已經達到以上前4點要求,算是很棒的程式。

根據單元測試的定義 :15 15單元測試的定義來自於30天快速上手TDD Day 5:如何隔離相依性 - 基本的可測試性

單元測試必須與外部環境、類別、資源、服務獨立,而不能直接相依。這樣才是單純的測試目標物件本身的邏輯是否符合預期。

若要對 ShippingService 進行單元測試,勢必將 BlackCatHsinchuPostOffice 加以抽換隔離,但使用了工廠模式之後,ShippingService 依然直接相依了 LogisticsFactory,而 LogisticsFactory 又直接相依 BlackCatHsinchuPostOffice,當我們對 ShippingService 做單元測試時,由於無法對 LogisticsFactory 做抽換隔離,因此無法對ShippingService 做單元測試。

簡單的說,interface + 工廠模式,仍然無法達到可測試性的要求,我們必須繼續重構。

依賴反轉


為了可測試性,單元測試必須可決定待測物件的相依物件,如此才可由單元測試將相依物件加以抽換隔離。

換句話說,我們不能讓待測物件直接相依其他 class,而應該由單元測試訂出 interface,讓待測物件僅能相依於 interface,而實際相依的物件可由單元測試來決定,如此我們才能對相依物件加以抽換隔離。

這也就是所謂的依賴反轉原則 :

High-level modules should not depend on low-level modules. Both should depend on abstractions.

高階模組不該依賴低階模組,兩者都應該要依賴其抽象。

其中相依依賴是相同的,只是翻譯用字的問題。

抽象就是 interface。

何謂高階模組? 何謂低階模組?

高階與低階是相對的。

簡單的說:

  • 當A class 去 new B class,A 就是高階模組,B就是低階模組。

若以本例而言 :

  1. ShippingService 相對於 BlackCatShippingService 是高階模組,BlackCat 是低階模組,
  2. 單元測試相對於 ShippingService,單元測試是高階模組,ShippingService 是低階模組。
  3. ShippingController 相對於 ShippingServiceShippingController 是高階模組,ShippingService 是低階模組。

也就是高階模組不應該值去 new 低階模組,也就是 class,而應該由高階模組定義 interface。

高階模組只依賴自己定義的 interface,而低階模組也只依賴 (實踐) 高階模組所定義的 interface。

Abstractions should not depend on details. Details should depend on abstractions.

抽象不要依賴細節,細節要依賴抽象。

好像越講越抽象 XDD。

這句話最詭異的是兩個 abstraction 並不是指同一件事情,是一個雙關字

第一個 abstraction 指的是高階模組,第二個 abstraction 指的是 interface

也就是白話應該翻成 :

高階模組不要依賴細節,細節要依賴 interface。

高階模組不要依賴細節高階模組不該依賴低階模組是同義的。

細節要依賴 interface兩者都應該要依賴其抽象也是同義的。

何謂抽象? 何謂細節?
  • 高階模組為抽象,interface 為抽象,abstract class 為抽象。
  • 低階模組為細節,class 為細節。

若以本例而言 :

在沒有使用 interface 前 :

  • ShippingService 直接 new BlackCat()
  • ShippingService 直接相依於 BlackCat
  • 也就是高階模組依賴低階模組。

使用了 interface 之後 :

  • ShippingService 沒有相依於 BlackCat,也就是高階模組沒有依賴於低階模組。
  • ShippingService 改成相依於 LogisticsInterface,也就是高階模組依賴其抽象(因為 new 而相依)。
  • BlackCat 改成相依於 LogisticsInterface,也就是低階模組也依賴其抽象(因為 implements 而相依)。
  • 也就是目前高階模組與低階模組都改成依賴其抽象。
  • 高階模組ShippingService 原本依賴的是低階模組 BlackCat,有了 interface 之後,變成反過來低階模組 BlackCat 要依賴高階模組所定義 LogisticsInterfacecalculateFee(),所以稱為依賴反轉

更簡單的說,依賴反轉就是要你使用 interface 來寫程式,而不要直接相依於 class。

我們之前已經重構出 LogisticsInterface,事實上已經符合依賴反轉。

依賴注入


有了依賴反轉還不足以達成可測試性,依賴反轉只確保了待測物件的相依物件相依於 interface。

既然相依物件相依於 interface,若單元測試可以產生該 interface 的物件,並加以注入,就可以將相依物件加以抽換隔離,這就是依賴注入。

Constructor Injection


ShippingService.php 16 16GitHub Commit : ShippingService重構成constructor injection

app/Services/ShippingService.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
namespace App\Services;

class ShippingService
{

/** @var LogisticsInterface */
private $logistics;

/**
* ShippingService constructor.
* @param LogisticsInterface $logistics
*/

public function __construct(LogisticsInterface $logistics)
{

$this->logistics = $logistics;
}

/**
* @param int $weight
* @return int
*/

public function calculateFee($weight)
{

return $this->logistics->calculateFee($weight);
}
}

12行

1
2
3
4
5
6
7
8
9
10
11
/** @var LogisticsInterface */
private $logistics;

/**
* ShippingService constructor.
* @param LogisticsInterface $logistics
*/

public function __construct(LogisticsInterface $logistics)
{

$this->logistics = $logistics;
}

原本相依的 LogisticsInterface 型別的物件,改由 constructor 注入,藉由 PHP 的 type hint,描述要注入的物件型別為 LogisticsInterface

原本使用 interface + 工廠模式,實質相依數為 2,改用 constructor injection 之後,連 LogisticsFactory都不需要了,僅相依於 LogisticsInterface,實質相依數降為 1。

17行

1
2
3
4
5
6
7
8
/**
* @param int $weight
* @return int
*/

public function calculateFee($weight)
{

return $this->logistics->calculateFee($weight);
}

將原本的 logistics 物件改成 field。

Service Container


我們目前已經有了依賴注入,對於可測試性只剩下最後一哩路,若我們能將 mock 出的假物件,透過依賴注入取代掉原來的相依物件,就能將相依物件加以抽換隔離,達成隔離測試的要求,service container 就是要幫我們將相依物件抽換隔離。

Laravel 4 稱為 IoC container,Laravel 5 稱為 service container。17 17以下句子來自於30天快速上手TDD Day 5:如何隔離相依性 - 基本的可測試性事實上 IoC (Inversion of Conttrol) 與 DI (Dependency Inversion) 講的是同一件事情,也就是由單元測試決定待測物件的相依物件。

單元測試
ShippingService.php 18 18GitHub Commit : 新增ShippingService的單元測試

tests/Services/ShippingServiceTest.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
use App\Services\BlackCat;
use App\Services\LogisticsInterface;
use App\Services\ShippingService;

class ShippingServiceTest extends TestCase
{

/** @test */
public function 黑貓單元測試()
{

/** arrange */
$mock = Mockery::mock(BlackCat::class);
$mock->shouldReceive('calculateFee')
->once()
->withAnyArgs()
->andReturn(110);

App::instance(LogisticsInterface::class, $mock);

/** act */
$weight = 1;
$actual = App::make(ShippingService::class)->calculateFee($weight);

/** assert */
$expected = 110;
$this->assertEquals($expected, $actual);
}
}

10 行

1
2
3
4
5
$mock = Mockery::mock(BlackCat::class);
$mock->shouldReceive('calculateFee')
->once()
->withAnyArgs()
->andReturn(110);

因為單元測試,我們只想測試 ShippingService,因此想將其相依的 BlackCat 物件抽換隔離,因此利用 Mockery 根據 BlackCat建立假物件 $mock,並定義 calculateFee() 回傳的期望值為 110

once() 為預期 calculateFee()會被執行一次,且只會被執行一次,若完全沒被執行,或執行超過一次,PHPUnit 會顯示 紅燈

withAngArgs() 為不特別在乎 calculateFee() 的參數型別與個數,一般來說,單元測試在乎的是被 mock method 是否被正確執行,以及其回傳值是否如預期,至於參數則不太重要。

17 行

1
App::instance(LogisticsInterface::class, $mock);

mock 物件已經建立好,接著要告訴 service container,當 constructor injection 的 type hint 遇到 LogisticsInterface時,該使用我們剛建立的 $mock 物件抽換隔離,而不是原來的相依物件。

App::instance() 用到的地方不多,一般就是用在需要 mock 時。

22行

1
$target = App::make(ShippingService::class);

當 mock 與 service container 都準備好時,接著要建立待測物件準備測試,這裡不能再使用 new 建立物件,而必須使用 service container 提供的 App::make() 來建立物件,因為我們就是希望靠 service container 幫我們將 mock 物件抽換隔離原來的相依物件,因此必須改用 service container 提供的 App::make()

你 assert 的 $expected 值就是你 mock 的 andReture() 值,都是 110,這樣測試有意義嗎?

這是因為 ShippingServicecalculateFee() 剛好沒有邏輯,只是 delegate 去呼叫 BlackCatcalculateFee(),實務上根據單一職責原則,每個 method 都有它該做的事情,而單元測試就是要測試該 method 的結果,所以 $expectedandReture() 不會一樣,本範例只是剛好相同。

整合測試
ShippingService.php 19 19GitHub Commit : 新增ShippingService的整合測試

tests/Services/ShippingServiceTest.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/** @test */
public function 黑貓整合測試()
{

/** arrange */
App::bind(LogisticsInterface::class, BlackCat::class);

/** act */
$weight = 1;
$actual = App::make(ShippingService::class)->calculateFee($weight);

/** assert */
$expected = 110;
$this->assertEquals($expected, $actual);
}

單元測試為了只測試單一功能,將其相依的物件全部抽換隔離,這種測試雖然精準,卻有盲點,也就是可能每個 class 都是正確的,但組合起來實際運作就錯了,所以必須將所有 mock 拿掉,這樣才接近實際系統。

所謂整合測試,就是把 mock 部分全部拿掉,直接改用 App::bind() 指定相依物件。

第 5 行

1
App::bind(LogisticsInterface::class, BlackCat::class);

當 constructor injection 配合 type hint 時,若是 class,Laravel 的 service container 會自動幫我們注入其相依物件,但若 type hint 為 interface 時,因為可能有很多 class implements 該 interface,所以必須先使用 App::bind() 告訴 service container,當 type hint 遇到 LogisticsInterface 時,實際上要注入的是 BlackCat 物件。

第 9 行

1
$actual = App::make(ShippingService::class)->calculateFee($weight);

App::bind() 完成後,就可以使用 App::make() 建立待測物件,service container 也會根據剛剛 App::bind() 的設定,自動依賴注入 BlackCat 物件。

Method Injection


Laravel 4 提出了 constructor injection 實現了依賴注入,而 Laravel 5 更進一步提出了 method injection。

有 constructor injection 不就已經可測試了嗎? 為什麼還需要 method injection 呢?

由於 Laravel 4 只有 constructor injection,所以只要 class 要實現依賴注入,唯一的管道就是 constructor injection,若有些相依物件只有單一 method 使用一次,也必須使用 constructor injection,這將導致最後 constructor 的參數爆炸而難以維護。

對於一些只有單一 method 使用的相依物件,若能只在 method 的參數加上 type hint,就可自動依賴注入,而不需要動用 constructor,那就太好了,這就是 method injection。

1
2
3
4
public function store(StoreBlogPostRequest $request)
{

// The incoming request is valid...
}

如大家熟悉的form request,就是使用 method injection,相依的 StoreBlogPostRequest 物件並不是透過 constructor 注入,而是在 store() 注入。

ShippingService.php 20 20GitHub Commit : ShippingService重構成method injection

app/Services/ShippingService.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
namespace App\Services;

class ShippingService
{

/**
* @param LogisticsInterface $logistics
* @param int $weight
* @return int
*/

public function calculateFee(LogisticsInterface $logistics, $weight)
{

return $logistics->calculateFee($weight);
}
}

重構成 method injection 後,就不必再使用 constructor 與 field,程式更加精簡。21 21要注入的物件參數位置並不一定要排第一個,可以依實際需求調整。

第 1 個參數為我們要注入的 LogisticsInterface 物件,第 2 個參數為我們原本要傳的 $weight 參數。

單元測試
ShippingService.php 22 22GitHub Commit : ShippingService method injection的單元測試

tests/Services/ShippingServiceTest.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
use App\Services\BlackCat;
use App\Services\LogisticsInterface;
use App\Services\ShippingService;

class ShippingServiceTest extends TestCase
{

/** @test */
public function 黑貓單元測試()
{

/** arrange */
$mock = Mockery::mock(BlackCat::class);
$mock->shouldReceive('calculateFee')
->once()
->withAnyArgs()
->andReturn(110);

App::instance(LogisticsInterface::class, $mock);

/** act */
$weight = 1;
$actual = App::call(ShippingService::class . '@calculateFee', [
'weight' => $weight
]);

/** assert */
$expected = 110;
$this->assertEquals($expected, $actual);
}
}

19 行

1
2
3
4
 /** act */
$actual = App::call(ShippingService::class . '@calculateFee', [
'weight' => $weight
]);

之前 mock 的部分,與 constructor injection 相同,就不再解釋。

關鍵在於 App::call(),這是一個在 Laravel 官方文件沒有介紹的 method,但 Laravel 內部卻到處在用。23 23method injection 的介紹,始見於 Matt Stauffer Blog 的 Laravel 5.0 - Method Injection

之前我們使用 constructor injection,就要搭配 App::make() 才能自動依賴注入。

現在我們使用 method injection,就要搭配 App::call() 才能自動依賴注入。

第 1 個參數要傳的字串,是 class 完整名稱,加上 @ 與 method名稱。

第 2 個參數要傳的是陣列,也就是我們自己要傳的參數,其中參數名稱為 key,參數值為 value。

整合測試
ShippingService.php 24 24GitHub Commit : 新增ShippingService method injection的整合測試

tests/Services/ShippingServiceTest.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/** @test */
public function 黑貓整合測試()
{

/** arrange */
App::bind(LogisticsInterface::class, BlackCat::class);

/** act */
$weight = 1;
$actual = App::call(ShippingService::class . '@calculateFee', [
'weight' => $weight
]);

/** assert */
$expected = 110;
$this->assertEquals($expected, $actual);
}

第 9 行

1
2
3
4
/** act */
$actual = App::call(ShippingService::class . '@calculateFee', [
'weight' => $weight
]);

關鍵一樣是使用 App::call()

為什麼只能在 controller 使用 method injection,而無法在自己的 presenter、service 或 repository 使用 method injection?

當初學習 method injection時,我也非常興奮,總算可以解決 Laravel 4 的 constructor 參數爆炸的問題,但發現只能用在 controller,但無法用在自己的 presenter、service 或 repository,一直學習到 App::call() 時,問題才迎刃而解。

因為 Laravel 內部使用 App::call() 呼叫 controller 的 method,因此你可以在 controller 無痛使用 method injection,但若你自己的 presenter、service 或 repository 要使用 method injection,就必須在 controller 搭配 App::call(),如此 service containter 才會幫你自動依賴注入相依物件。

再談可測試性


本文從頭到尾,都是以可測試性的角度去談依賴注入,而我個人也的確是在寫單元測試之後,才領悟依賴反轉與依賴注入的重要性。

若是不寫測試,是否就不需要依賴反轉與依賴注入呢?

之前曾經提到 :

IoC (Inversion of Conttrol) 與 DI (Dependency Inversion) 講的是同一件事情,也就是由單元測試決定待測物件的相依物件。

根據之前的經驗,我們可以發現待測物件的相依物件都是在測試的 App::bind() 所決定。

之前有提到所謂的高階模組與低階模組是相對的,單元測試相對於 service,單元測試是高階模組,而 service 是低階模組。

對照於實際狀況,controller 相對於 service,controller是高階模組,而 service 是低階模組。

我們可以在單元測試以 App::bind() 決定 service 的相依物件,同樣的,我們也可以在 controller 以 App::bind() 去決定 service 的相依物件。

既然我們可以由 controller 去決定,去注入 service 的相依物件,我們就不再被底層綁死,不再依賴底層 service,而是由低階模組去依賴高階模組所制定的 interface,再由 controller 的 App::bind() 來決定低階模組的相依物件,這就是所謂的依賴反轉。

也就是說,若高層模組可以決定低階模組的相依物件,那整個設計的彈性與擴充性會非常好,因為需求都來自於人,而人所面對的是高階模組,而高階模組可以透過依賴注入去決定低階模組的相依物件,而不是被低階模組綁死,可彈性地依照需求而改變。

若程式符合可測試性的要求,表示其具有低耦合的特性,也就是物件導向強調的高內聚,低耦合,因此程式將更容易維護,更容易新增功能,更容易重複使用,更容易上Git,不易與其他人衝突,也就是說我們可以將程式的可測試性,當成是否為好程式的指標之一。

生活中的依賴反轉


舉個生活上實際的例子,事實上硬體產業就大量使用依賴反轉。

比如電腦需要將畫面送到顯示器,系統廠對 design house 發出需求,此時系統廠相當於高階模組,而 design house 相當於低階模組。

Design house 當然可以設計出 IC 符合系統廠需求,但由於系統廠沒有規定任何傳輸介面規格,只提出顯示需求,因此 design house 可以使用自己設計的專屬傳輸介面,系統廠的電路板只要符合 design house 的專屬傳輸介面規格,就可以將電腦畫面傳送到顯示器。

這樣雖然可以達成需求,但有幾個問題:

  1. 傳輸介面由 design house 規定,只要 design house 傳輸介面更改,系統廠的電路板就得跟著修改。
  2. Design house 的專屬傳輸介面,需要搭該公司的控制 IC,因此系統廠還被綁死要使用該 design house 的控制 IC。
  3. 由於使用專屬傳輸介面,因此系統廠無法使用替代料,只能乖乖使用該 design house 的 IC,沒有議價空間,且備料時間也被綁死。

這就是典型的高階模組依賴低階模組,也就是系統廠被 design house 綁死了。

所以系統廠很聰明,會聯絡各大系統廠一起制定傳輸介面規格,如VGA、HDMI、Display Port…等,如此 deisgn house 就得乖乖的依照系統廠制定的傳輸介面規格來設計 IC,這樣系統廠就不再被單一 design house 綁死,可以自行選擇控制 IC,還可以找替代料,增加議價空間,備料時間也更加彈性,這就是典型的低階模組反過來依賴高階模組所制定的規格,也就是依賴反轉。

Conclusion


  • Interface + 工廠模式無法達成可測試性的要求,因此才有了依賴注入與 service container。
  • 若很多 method 都使用相同相依物件,可使用 constructor injection,若只有單一 method 使用的相依物件,建議改用 method injection。
  • Method Injection 必須搭配 App::call(),除了自動依賴注入相依物件外,也可以自行傳入其他參數。
  • 可測試性與物件導向是相通的,我們可以藉由程式的可測試性,當成是否為好程式的指標之一。

Sample Code


完整的範例可以在我的 GitHub上找到。

Reference


Joey Chen, 30 天快速上手 TDD