將邏輯改用 LUT 表示,可將 LUT 改放到 app.php 設定檔

將建構物件的邏輯封裝在工廠模式,已經達到 90% 的開放封閉,最少其他 class 都已經開放封閉,將來所有的邏輯修改只剩下工廠模式,若能將工廠模式也開放封閉,那就太好了。

Version


PHP 7.0.0
Laravel 5.2.31

前言


如何使用 PhpStorm 實現 TDD、重構與偵錯?深入探討依賴注入中,為了達到工廠模式的開放封閉,我用了一個技巧 : 故意將參數名稱class名稱取相同,達到工廠模式的開放封閉,但實務上,可能參數來自於下拉式選單的 index,如 0, 1, 2 ….,或者參數與實際 class 名稱並不相同,需要一個 if elseswitch 轉換,這樣就必須在工廠模式的 create()bind() 寫邏輯,因此無法達成開放封閉原則的要求。1 1本範例為深入探討依賴注入的延伸,若覺得本文的範例看不懂,請先閱讀深入探討依賴注入

實際案例


假設目前有 3 家貨運公司,每家公司的計費方式不同,使用者可以動態選擇不同的貨運公司,將一步步的重構將工廠模式開放封閉。2 2本範例靈感來自於91哥的30天快速上手TDD Day 17:Refactoring - Stagegy Pattern

單元測試


ShippingServiceTest.php3 3GitHub Commit : 新增黑貓單元測試

tests/Services/ShippingServiceTest.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use App\Services\ShippingService;

class ShippingServiceTest extends TestCase
{

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

/** arrange */
$companyNo = 0;
$weight = 1;
$expected = 110;

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

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

先建立 ShippingService 的單元測試。

ShippingService


ShippingService.php4 4GitHub Commit : 新增 ShippingService

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

class ShippingService
{

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

public function calculateFee(int $companyNo, int $weight) : int
{

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

因為已經制定了 LogisticsInterface,3 家貨運公司的計費方式,已經分別被封裝在 BlackCatHsinchuPostOffice 3 個 class 內,所以必須使用工廠模式根據 $companyNo,回傳適當的貨運公司物件。

工廠模式


LogisticsFactory.php5 5GitHub Commit : 新增 LogisticsFactory

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

class LogisticsFactory
{

public static function create(int $companyNo = 0) : LogisticsInterface
{

if ($companyNo == 0) {
return new BlackCat();
} elseif ($companyNo == 1) {
return new Hsinchu();
} elseif ($companyNo == 2) {
return new PostOffice();
} else {
return new BlackCat();
}
}
}

使用 if else 判斷 $companyNo,根據不同的 $companyNo,回傳不同的貨運公司物件。

目前為止完全符合需求,會得到第 1 個 綠燈

將 if else 重構成 switch


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

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

class LogisticsFactory
{

public static function create(int $companyNo = 0) : LogisticsInterface
{

switch ($companyNo) {
case 0:
return new BlackCat();
case 1:
return new Hsinchu();
case 2:
return new PostOffice();
default:
return new BlackCat();
}
}
}

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

重構後趕快執行測試,看看有沒有重構壞掉。

目前為止完全符合需求,會得到第 2 個 綠燈

將 swtich 重構成 LUT


LogisticsFactory.php8 8GitHub Commit : 將 switch 重構成 LUT

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

use Illuminate\Support\Collection;

class LogisticsFactory
{

public static function create(int $companyNo = 0) : LogisticsInterface
{

$lut = [
0 => BlackCat::class,
1 => Hsinchu::class,
2 => PostOffice::class
];
$className = Collection::make($lut)->get($companyNo, BlackCat::class);

return new $className;
}
}

switch 的條件式,改用 LUT (Look Up Table) 的方式表示,其中 case 重構成陣列的 key,要 new 的 class 重構成陣列的 value。

使用 Collection::make() 將陣列轉成 Laravel 的 collection。9 9使用 collect() helper 亦可。

使用 collection 的 get(),針對 $lut 做搜尋。

  • 第 1 個參數傳入的是 key 的比對值,相當於 switchcase
  • 第 2 個參數傳入的示若 key 搜尋不到,所傳回的預設值,相當於 switchdefault

重構後趕快執行測試,看看有沒有重構壞掉。

目前為止完全符合需求,會得到第 3 個 綠燈

將 LUT 重構到 app.php


app.php10 10GitHub Commit : 將 LUT 重構到 config/app.php

config/app.php
1
2
3
4
5
'logistics' => [
0 => App\Services\BlackCat::class,
1 => App\Services\Hsinchu::class,
2 => App\Services\PostOffice::class
],

將陣列搬到 config/app.php 下,將來若對應邏輯有所修改,只需改 app.php 即可。

LogisticsFactory.php11 11GitHub Commit : 將 LUT 重構到 config/app.php

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

use Illuminate\Support\Collection;

class LogisticsFactory
{

public static function create(int $companyNo = 0) : LogisticsInterface
{

$lut = config('app.logistics');
$className = Collection::make($lut)->get($companyNo, BlackCat::class);

return new $className;
}
}

$lut 改由 config() 讀取 config/app.php 的設定,目前工廠不包含建立物件的邏輯,LUT 已經搬到 app.php,因此完成工廠模式的開放封閉。

重構後趕快執行測試,看看有沒有重構壞掉。

目前為止完全符合需求,會得到第 4 個 綠燈

重構成依賴注入


為了達到可測試性的要求,你可能會想將貨運公司物件改用依賴注入的方式,因此我們繼續重構。

ShippingService.php12 12GitHub Commit : ShippingService 改用依賴注入

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
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(int $weight) : int
{

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

改用 constructor injection 的方式,將貨運物件注入。

重構單元測試


由於 ShippingService 重構成依賴注入方式,因此單元測試也要跟著重構。

ShippingServiceTest.php13 13GitHub Commit : 重構黑貓單元測試

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
use App\Services\LogisticsFactory;
use App\Services\ShippingService;

class ShippingServiceTest extends TestCase
{

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

/** arrange */
$companyNo = 0;
$weight = 1;
$expected = 110;

/** act */
LogisticsFactory::bind($companyNo);
$target = App::make(ShippingService::class);
$actual = $target->calculateFee($weight);

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

因為改成依賴注入,工廠模式的功能不再是建立物件,而是在決定 App::bind() 該與什麼 class 做連結,因此也將工廠模式的 create() 改成 bind()

重構工廠模式


LogisticsFactory.php14 14GitHub Commit : 重構 LogisticsFactory

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

use App;
use Illuminate\Support\Collection;

class LogisticsFactory
{

public static function bind(int $companyNo = 0)
{

$lut = config('app.logistics');
$className = Collection::make($lut)->get($companyNo, BlackCat::class);

App::bind(LogisticsInterface::class, $className);
}
}

$lut 也是改由 config() 讀取 config/app.php 的設定,目前工廠不包含 App::bind() 的邏輯,LUT 已經搬到 app.php,因此完成工廠模式的開放封閉。

重構後趕快執行測試,看看有沒有重構壞掉。

目前為止完全符合需求,會得到第 5 個 綠燈

Conclusion


  • if elseswitch 邏輯改用 LUT 表示,可將陣列改放到 config/app.php 下,將來若有任何邏輯修改,都是修改在設定檔,而達成工廠模式的開放封閉。
  • 透過 collection 的 get(),可以很方便的搭配 LUT,還可傳入預設參數,配合 switchdefault

Sample Code


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