如何在 TDD 使用「重構九式」?
TDD 不僅是先寫測試而已,當第一個 綠燈 之後,剩下的半壁江山就是拼重構功力,重構的書多半來自於 Java,因此有些 PHP 獨門的重構技巧在 Java 書上是看不到的,也因為編程思維的持續演進,重構也有了新的面貌,本文整理出自己在實務上,天天必用的 9 個適用於 PHP 重構的 SOP。
Motivation
對於很多人來說,使用 imperative 方式寫程式不難,只要將所想的演算法以程式表達即可,也會使用 procedure 方式實現 DRY,但若要長出 class
、interface
、abstract class
則有難度,更遑論 closure
與 trait
,事實上這些東西,都不是設計出來的,而是重構出來的,也就是 TDD 第一個 綠燈 之後,透過重構慢慢長出 class
、interface
、abstract class
、closure
與 trait
。
Version
PHP 7.0.8
Laravel 5.3.24
實際案例
假設我們想要計算運費,目前有黑貓、新竹客運與郵局三家可以選擇,每家針對不同的重量有其相對應的計算公式,而我們希望能寫出高內聚、低耦合,符合 SOLID 原則的程式碼,方便日後維護。1 1此範例並非我原創,靈感來自於 Joey Chen 的 30天快速上手TDD : Refactoring Legacy Code 簡介之範例,因為此範例非常容易懂,而且很適合介紹重構。
貨運商 | 計費規則 |
---|---|
黑貓 | 基本運費 100 元,每公斤加收 10 元 |
新竹貨運 | 基本運費 80 元,每公斤加收 15 元 |
郵局 | 基本運費 60 元,每公斤加收 20 元 |
測試案例
根據以上規則,我們可定出以下測試案例 :
重量 | 運費 |
---|---|
[1, 2, 3] | 360 元 |
[1, 2, 3] | 330 元 |
[1, 2, 3] | 300 元 |
單元測試
ShippingServiceTest.php2 2GitHub Commit : 單元測試 : 黑貓、新竹、郵局測試案例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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54declare(strict_types = 1);
use App\Services\ShippingService;
class ShippingServiceTest extends TestCase
{
/** @test */
public function 黑貓_當重量為1_2_3時_費用為360()
{
/** arrange */
/** @var ShippingService $target */
$target = App::make(ShippingService::class);
/** act */
$weights = [1, 2, 3];
$actual = $target->calculateFee($weights, 'BlackCat');
/** assert */
$expected = 360;
$this->assertEquals($expected, $actual);
}
/** @test */
public function 新竹_當重量為1_2_3時_費用為330()
{
/** arrange */
/** @var ShippingService $target */
$target = App::make(ShippingService::class);
/** act */
$weights = [1, 2, 3];
$actual = $target->calculateFee($weights, 'Hsinchu');
/** assert */
$expected = 330;
$this->assertEquals($expected, $actual);
}
/** @test */
public function 郵局_當重量為1_2_3時_費用為300()
{
/** arrange */
/** @var ShippingService $target */
$target = App::make(ShippingService::class);
/** act */
$weights = [1, 2, 3];
$actual = $target->calculateFee($weights, 'PostOffice');
/** assert */
$expected = 300;
$this->assertEquals($expected, $actual);
}
}
將 3 個測試案例都先寫好測試。
使用 if else
ShippingService.php3 3GitHub Commit : if else 計算運費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
32
33
34
35
36
37
38
39
40
41
42
43
44
45declare(strict_types = 1);
namespace App\Services;
class ShippingService
{
/**
* 計算運費
* @param array $weightArray
* @param string $companyName
* @return int
*/
public function calculateFee(array $weightArray, string $companyName): int
{
$amount = 0;
if ($companyName == 'BlackCat') {
$weights = collect($weightArray);
foreach ($weights as $weight) {
$amount = $amount + (100 + $weight * 10);
}
} elseif ($companyName == 'Hsinchu') {
$weights = collect($weightArray);
foreach ($weights as $weight) {
$amount = $amount + (80 + $weight * 15);
}
} else if ($companyName == 'PostOffice') {
$weights = collect($weightArray);
foreach ($weights as $weight) {
$amount = $amount + (60 + $weight * 20);
}
} else {
$weights = collect($weightArray);
foreach ($weights as $weight) {
$amount = $amount + (100 + $weight * 10);
}
}
return $amount;
}
}
一開始先求 綠燈 就好,因此我們很無腦的只使用 if else
與 foreach()
就完成了。
但這樣只是功能完成而已,所有高低階邏輯全寫在一起,程式碼不容易閱讀,將來也不好維護。
雖然是很爛的寫法,但仍然有 綠燈。
使用 switch
改用 switch
寫法會比 if else
可讀性高些,PhpStorm 也提供工具可以直接將 if else
轉成 switch
,按熱鍵 ⌥ + ↩,選擇 Replace if with switch
。
ShippingService.php4 4GitHub Commit : switch 計算運費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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50declare(strict_types = 1);
namespace App\Services;
class ShippingService
{
/**
* 計算運費
* @param array $weightArray
* @param string $companyName
* @return int
*/
public function calculateFee(array $weightArray, string $companyName): int
{
$amount = 0;
switch ($companyName) {
case 'BlackCat':
$weights = collect($weightArray);
foreach ($weights as $weight) {
$amount = $amount + (100 + $weight * 10);
}
break;
case 'Hsinchu':
$weights = collect($weightArray);
foreach ($weights as $weight) {
$amount = $amount + (80 + $weight * 15);
}
break;
case 'PostOffice':
$weights = collect($weightArray);
foreach ($weights as $weight) {
$amount = $amount + (60 + $weight * 20);
}
break;
default:
$weights = collect($weightArray);
foreach ($weights as $weight) {
$amount = $amount + (100 + $weight * 10);
}
break;
}
return $amount;
}
}
不過由 if else
變成 switch
並不算重構,只是讓程式碼稍微好閱讀些而已。
馬上跑測試,得到 綠燈,確定程式沒改壞。
第一式 : Extract Method
所有的重構,都是從 Extract Method
開始,當我們發現程式碼中有以下特徵 :
- 當一段程式碼需要寫註解特別解釋時。
- 在
if else
內有一段邏輯時。 - 在
switch case
內有一段邏輯時。
就可以開始使用重構第一式 : Extract Method
,將一段程式碼重構成 method
。
ShippingService.php1
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50declare(strict_types = 1);
namespace App\Services;
class ShippingService
{
/**
* 計算運費
* @param array $weightArray
* @param string $companyName
* @return int
*/
public function calculateFee(array $weightArray, string $companyName): int
{
$amount = 0;
switch ($companyName) {
case 'BlackCat':
$weights = collect($weightArray);
foreach ($weights as $weight) {
$amount = $amount + (100 + $weight * 10);
}
break;
case 'Hsinchu':
$weights = collect($weightArray);
foreach ($weights as $weight) {
$amount = $amount + (80 + $weight * 15);
}
break;
case 'PostOffice':
$weights = collect($weightArray);
foreach ($weights as $weight) {
$amount = $amount + (60 + $weight * 20);
}
break;
default:
$weights = collect($weightArray);
foreach ($weights as $weight) {
$amount = $amount + (100 + $weight * 10);
}
break;
}
return $amount;
}
}
每個 switch case
內都有一段計算運費邏輯,為了程式碼的可讀性與可維護性,我們應該將每個 switch case
內的程式碼加以 Extract Method
。
PhpStorm 內建支援 Extract Method
,先選擇要抽取的程式碼,按熱鍵 ⌃ + T,選擇 Method
,PhpStorm 就會自動幫你將那段程式碼 extract 成新的 method
。
ShippingService.php5 5GitHub Commit : 重構 1 式 : Extract Method1
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83declare(strict_types = 1);
namespace App\Services;
class ShippingService
{
/**
* 計算運費
* @param array $weightArray
* @param string $companyName
* @return int
*/
public function calculateFee(array $weightArray, string $companyName): int
{
$amount = 0;
switch ($companyName) {
case 'BlackCat':
$amount = $this->blackCatCalculateFee($weightArray, $amount);
break;
case 'Hsinchu':
$amount = $this->hsinchuCalculateFee($weightArray, $amount);
break;
case 'PostOffice':
$amount = $this->postCalculateFee($weightArray, $amount);
break;
default:
$amount = $this->blackCatCalculateFee($weightArray, $amount);
break;
}
return $amount;
}
/**
* @param array $weightArray
* @param int $amount
* @return int
*/
public function blackCatCalculateFee(array $weightArray, int $amount) : int
{
$weights = collect($weightArray);
foreach ($weights as $weight) {
$amount = $amount + (100 + $weight * 10);
}
return $amount;
}
/**
* @param array $weightArray
* @param int $amount
* @return int
*/
public function hsinchuCalculateFee(array $weightArray, int $amount) : int
{
$weights = collect($weightArray);
foreach ($weights as $weight) {
$amount = $amount + (80 + $weight * 15);
}
return $amount;
}
/**
* @param array $weightArray
* @param int $amount
* @return int
*/
public function postCalculateFee(array $weightArray, int $amount) : int
{
$weights = collect($weightArray);
foreach ($weights as $weight) {
$amount = $amount + (60 + $weight * 20);
}
return $amount;
}
}
在 method
命名方面,建議依照 名詞 + 動詞
的方式命名。
經過 Extract Method
後,最少原來一大坨的 calculateFee()
已經清爽多了,且可讀性也變高了,我們可以直接由 method
名稱,得知那段程式碼的意義,而不再是一段冷冰冰的 foreach()
迴圈而已。
重構之後馬上跑測試,務必要全部測試案例都 綠燈,確認沒有重構失敗。
第二式 : Extract Class
只有 Extract Method
還是不夠的,物件導向程式碼最大的特點就是 class
,我們要將更相關的 method
放在同一個 class
,達到高內聚的目標。
在重構第一式 Extract Method
時,我們特別以名詞 + 動詞的方式替 method
命名,其中若名詞相同,則表示這些 mehtod
的內聚性很高,適合將這些 method
再透過重構第二式 : Extract Class
重構到新的 class
內。
ShippingService.php1
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54declare(strict_types = 1);
namespace App\Services;
class ShippingService
{
/**
* @param array $weightArray
* @param int $amount
* @return int
*/
public function blackCatCalculateFee(array $weightArray, int $amount) : int
{
$weights = collect($weightArray);
foreach ($weights as $weight) {
$amount = $amount + (100 + $weight * 10);
}
return $amount;
}
/**
* @param array $weightArray
* @param int $amount
* @return int
*/
public function hsinchuCalculateFee(array $weightArray, int $amount) : int
{
$weights = collect($weightArray);
foreach ($weights as $weight) {
$amount = $amount + (80 + $weight * 15);
}
return $amount;
}
/**
* @param array $weightArray
* @param int $amount
* @return int
*/
public function postCalculateFee(array $weightArray, int $amount) : int
{
$weights = collect($weightArray);
foreach ($weights as $weight) {
$amount = $amount + (60 + $weight * 20);
}
return $amount;
}
}
在 ShippingService
透過 Extract Method
所產生的 blackCatCalculateFee()
、hsinchuCalculateFee()
與 postCalculateFee()
,我們發現名詞均不同,所以將這些 method 再拆分在不同的 class 內。
BlackCat.php6 6GitHub Commit : 重構 2 式 : Extract Class 之 BlackCat1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22declare(strict_types = 1);
namespace App\Services;
class BlackCat
{
/**
* @param array $weightArray
* @param int $amount
* @return int
*/
public function calculateFee(array $weightArray, int $amount) : int
{
$weights = collect($weightArray);
foreach ($weights as $weight) {
$amount = $amount + (100 + $weight * 10);
}
return $amount;
}
}
將名詞部分的 blackCat
重構成 BlackCat
class,動詞部分的 calculateFee()
重構成 BlackCat
的 method。
Hsinchu.php7 7GitHub Commit : 重構 2 式 : Extract Class 之 Hsinchu1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22declare(strict_types = 1);
namespace App\Services;
class Hsinchu
{
/**
* @param array $weightArray
* @param int $amount
* @return int
*/
public function calculateFee(array $weightArray, int $amount) : int
{
$weights = collect($weightArray);
foreach ($weights as $weight) {
$amount = $amount + (80 + $weight * 15);
}
return $amount;
}
}
將名詞部分的 hshinchu
重構成 Hsinchu
class,動詞部分的 calculateFee()
重構成 Hsinchu
的 method。
Post.php8 8GitHub Commit : 重構 2 式 : Extract Class 之 Post1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22declare(strict_types = 1);
namespace App\Services;
class Post
{
/**
* @param array $weightArray
* @param int $amount
* @return int
*/
public function calculateFee(array $weightArray, int $amount) : int
{
$weights = collect($weightArray);
foreach ($weights as $weight) {
$amount = $amount + (60 + $weight * 20);
}
return $amount;
}
}
將名詞部分的 post
重構成 Post
class,動詞部分的 calculateFee()
重構成 Post
的 method。
ShippingService.php9 9GitHub Commit : 重構 2 式 : Extract Class 之 ShippingService1
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
32
33
34
35
36
37
38
39
40
41
42declare(strict_types = 1);
namespace App\Services;
class ShippingService
{
/**
* 計算運費
* @param array $weightArray
* @param string $companyName
* @return int
*/
public function calculateFee(array $weightArray, string $companyName): int
{
$amount = 0;
switch ($companyName) {
case 'BlackCat':
$blackCat = new BlackCat();
$amount = $blackCat->calculateFee($weightArray, $amount);
break;
case 'Hsinchu':
$hsinchu = new Hsinchu();
$amount = $hsinchu->calculateFee($weightArray, $amount);
break;
case 'PostOffice':
$post = new Post();
$amount = $post->calculateFee($weightArray, $amount);
break;
default:
$blackCat = new BlackCat();
$amount = $blackCat->calculateFee($weightArray, $amount);
break;
}
return $amount;
}
}
原來 extract 出來的 method
,現在已經重構到各自 class
內,因此要使用時,必須先透過 new
將物件建立起來,才能呼叫 calculateFee()
。
重構之後馬上跑測試,務必要全部測試案例都 綠燈,確認沒有重構失敗。
第三式 : Extract Superclass
當 Extract Class
之後,雖然已經長出 class
,但實務上會發現,method
內仍然有些程式碼是重複的,根據 DRY 原則,我們不希望有程式碼重複,這會造成日後維護上的困難,因為每次修改就得修改好幾份程式碼,還可能忘記修改其中一份,而造成邏輯上的不一致。
對付 method
內重複的程式碼,就必須使用重構第三式 : Extract Superclass
,將重複的程式碼重構到 abstract class
。
BlackCat.php1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22declare(strict_types = 1);
namespace App\Services;
class BlackCat
{
/**
* @param array $weightArray
* @param int $amount
* @return int
*/
public function calculateFee(array $weightArray, int $amount) : int
{
$weights = collect($weightArray);
foreach ($weights as $weight) {
$amount = $amount + (100 + $weight * 10);
}
return $amount;
}
}
我們發現在 BlackCat
、Hsinchu
與 Post
3 個 class
的 calculateFee()
內,都有 $weight = collect($weightArray)
,我們可以使用 Extract Superclass
將這段程式碼重構到 abstract class
內。
AbstractLogistics.php10 10GitHub Commit : 重構 3 式 : Extract Super Class 之 AbstractLogistics1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19declare(strict_types = 1);
namespace App\Services;
use Illuminate\Support\Collection;
abstract class AbstractLogistics
{
/**
* @param array $weightArray
* @return Collection
*/
protected function arrayToCollection(array $weightArray): Collection
{
$weights = collect($weightArray);
return $weights;
}
}
將 $weight = collect($weightArray)
部分重構到 AbstractLogistics
的 arrayToCollection()
。
BlackCat.php11 11GitHub Commit : 重構 3 式 : Extract Super Class 之 BlackCat1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22declare(strict_types = 1);
namespace App\Services;
class BlackCat extends AbstractLogistics
{
/**
* @param array $weightArray
* @param int $amount
* @return int
*/
public function calculateFee(array $weightArray, int $amount) : int
{
$weights = $this->arrayToCollection($weightArray);
foreach ($weights as $weight) {
$amount = $amount + (100 + $weight * 10);
}
return $amount;
}
}
BlackCat
改繼承於 AbstractLogistics
。
因為 $weight = collect($weightArray)
已經搬到 AbstractLogistics
,所以 BlackCat
、Hsinchu
與 Post
都要改成 $weights = $this->arrayToCollection($weightArray);
,如此重複的邏輯就統一都只存在於 AbstractLogistics
,符合 DRY 原則。
重構之後馬上跑測試,務必要全部測試案例都 綠燈,確認沒有重構失敗。
第四式 : Extract Closure
雖然我們可以將重複的程式碼透過 Extract Superclass
重構到 abstract class
,但有時候會遇到一種程式碼,並不是整塊重複,而是外層重複,內層卻不重複。
對於 method
內有一段外層重複,內層卻不重複的程式碼,就必須使用重構第四式 : Extract Closure
,將重複的的程式碼重構到 abstract class
,不重複的部分重構到 closure
。
BlackCat.php1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22declare(strict_types = 1);
namespace App\Services;
class BlackCat extends AbstractLogistics
{
/**
* @param array $weightArray
* @param int $amount
* @return int
*/
public function calculateFee(array $weightArray, int $amount) : int
{
$weights = $this->arrayToCollection($weightArray);
foreach ($weights as $weight) {
$amount = $amount + (100 + $weight * 10);
}
return $amount;
}
}
我們在 BlackCat
、Hsinchu
與 Post
的 calculateFee()
都可發現,foreach()
與 return
是重複的,偏偏只有中間的 $amount = $amount + (100 + $weight * 10);
不重複,這也是各家計算運費演算法的關鍵。
為了符合 DRY 原則,我們應該將 foreach()
與 return
部分使用 Extract Super Class
重構到 abstract class
,但偏偏中間的 $amount = $amount + (100 + $weight * 10);
不同,我們可以使用 Extract Closure
將不同的部分重構成 closure
。
AbstractLogistics.php12 12GitHub Commit : 重構 4 式 : Extract Closure 之 AbstractLogistics1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23declare(strict_types = 1);
namespace App\Services;
use Illuminate\Support\Collection;
abstract class AbstractLogistics
{
/**
* @param int $amount
* @param Collection $weights
* @param callable $closure
* @return int
*/
protected function loopWeights(int $amount, Collection $weights, callable $closure): int
{
foreach ($weights as $weight) {
$amount = $amount + $closure($weight);
}
return $amount;
}
}
將整個 foreach()
與 return
都重構到 AbstractLogistics
的 loopWeights()
內,但我們清楚 $amount = $amount + (100 + $weight * 10);
是不重複的,必須使用 closure
代替,且因為 closure
必須使用到 $weight
變數,還必須將 $weight
傳入 closure
。
所以新重構的 loopWeights()
除了有 int $amount
與 Collection $weights
參數外,還要多一個 callable $closure
傳進來。
BlackCat.php13 13GitHub Commit : 重構 4 式 : Extract Closure 之 BlackCat1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22declare(strict_types = 1);
namespace App\Services;
class BlackCat extends AbstractLogistics
{
/**
* @param array $weightArray
* @param int $amount
* @return int
*/
public function calculateFee(array $weightArray, int $amount) : int
{
$weights = $this->arrayToCollection($weightArray);
$amount = $this->loopWeights($amount, $weights, function (int $weight) {
return (100 + $weight * 10);
});
return $amount;
}
}
重複的 foreach()
與 return
已經重構到 AbstractLogistics
的 loopWeights()
,BlackCat
、Hsinchu
與 Post
不同的計算邏輯就以 closure
的方式傳入 loopWeights()
。
重構之後馬上跑測試,務必要全部測試案例都 綠燈,確認沒有重構失敗。
第五式 : Extract Interface
當使用 Extract Superclass
與 Extract Closure
之後,基本上已經沒有重複的程式碼,也就是已經符合 DRY 原則。
在 名詞 + 動詞
的 method
名稱部分,名詞
不同已經使用 Extract Class
解決,剩下的是相同的動詞
,也就是我們發現在 3 個 class
都有相同的 method
。
既然 3 個 class
的 method
都相同,我們就可以使用重構第五式 : Extract Interface
,以用更宏觀的角度,將這 3 個 class
抽象化成一個相同的 interface
。
BlackCat.php1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22declare(strict_types = 1);
namespace App\Services;
class BlackCat extends AbstractLogistics
{
/**
* @param array $weightArray
* @param int $amount
* @return int
*/
public function calculateFee(array $weightArray, int $amount) : int
{
$weights = $this->arrayToCollection($weightArray);
$amount = $this->loopWeights($amount, $weights, function (int $weight) {
return (100 + $weight * 10);
});
return $amount;
}
}
既然 BlackCat
、Hsinchu
與 Post
都有 calculateFee()
,我們可以使用 Extract Interface
將 calculateFee()
抽成 interface
,將 BlackCat
、Hsinchu
與 Post
抽象化成 LogisticsInterface
。
LogisticsInterface.php15 15GitHub Commit : 重構 5 式 : Extract Interface 之 LogisticsInterface1
2
3
4
5
6
7
8
9
10
11
12
13declare(strict_types = 1);
namespace App\Services;
interface LogisticsInterface
{
/**
* @param array $weightArray
* @param int $amount
* @return int
*/
public function calculateFee(array $weightArray, int $amount) : int;
}
建立新的 LogisticsInterface
,定義其 method
為 calculateFee()
。
AbstractLogistics.php16 16GitHub Commit : 重構 5 式 : Extract Interface 之 AbstractLogistics1
2
3
4
5
6
7
8
9declare(strict_types = 1);
namespace App\Services;
use Illuminate\Support\Collection;
abstract class AbstractLogistics implements LogisticsInterface
{
}
abstract class
當然要遵守 interface
。
ShippingService.php16 16GitHub Commit : 重構 5 式 : Extract Interface 之 ShippingService1
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
32
33
34
35
36
37
38
39
40
41
42declare(strict_types = 1);
namespace App\Services;
class ShippingService
{
/**
* 計算運費
* @param array $weightArray
* @param string $companyName
* @return int
*/
public function calculateFee(array $weightArray, string $companyName): int
{
$amount = 0;
switch ($companyName) {
case 'BlackCat':
$logistics = new BlackCat();
$amount = $logistics->calculateFee($weightArray, $amount);
break;
case 'Hsinchu':
$logistics = new Hsinchu();
$amount = $logistics->calculateFee($weightArray, $amount);
break;
case 'PostOffice':
$logistics = new Post();
$amount = $logistics->calculateFee($weightArray, $amount);
break;
default:
$logistics = new BlackCat();
$amount = $logistics->calculateFee($weightArray, $amount);
break;
}
return $amount;
}
}
既然 BlackCat
、Hsinchu
與 Post
都抽象化成 LogisticsInterface
,無論是 new BlackCat
、Hsinchu
或 Post
,抽象化看起來都是同一個 $logistics
物件,這就是物件導向的多型,同一個物件,卻可能來自於相同 interface
的不同 class
。
重構之後馬上跑測試,務必要全部測試案例都 綠燈,確認沒有重構失敗。
第六式 : Dependency Injection
經過 Extract Interface
之後,我們已經將 3 個 class
抽象化成相同 interface
,理論上我們只需依賴 interface
即可,但程式碼中卻還使用 switch case
,並實際去 new 3 個 class
,也就是還實際依賴這 3 個 class
。
實際依賴這 3 個 class
目前沒什麼大問題,但只要將來有新的 class
,儘管也實踐相同 interface
,卻仍要繼續修改 swich case
去 new 新的 class
,這將造成維護上的負擔。理想是將來無論新增任何 class
,都不須修改程式碼,這就必須使用重構第六式 : Dependency Injection
,將 switch case
拿掉,由 Service Container 幫我們注入 LogisticsInterface
物件,而不是與特定 class
耦合,達到低耦合的目標。
ShippingService.php17 17GitHub Commit : 重構 6 式 : Dependency Injection 之 ShippingService1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19declare(strict_types = 1);
namespace App\Services;
class ShippingService
{
/**
* 計算運費
* @param array $weightArray
* @param LogisticsInterface $logistics
* @return int
*/
public function calculateFee(array $weightArray, LogisticsInterface $logistics) : int
{
$amount = 0;
return $logistics->calculateFee($weightArray, $amount);
}
}
因為不在需要由 switch case
判斷 $companyName
,因此將參數 $companyName
從 calculateFee()
移除。
新增 LogisticsInterface $logistics
參數,由 Service Container 幫我們將所依賴的 $logistics
物件注入。
由於 $logistics
已經抽象化為 LogisticsInterface
,所以根本不需要任何 switch case
判斷,因此將來若有新的 class
也不用擔心,一定不需修改 calculateFee()
。
ShippingServiceTest.php18 18GitHub Commit : 重構 6 式 : Dependency Injection 之 ShippingServiceTest1
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
27declare(strict_types = 1);
use App\Services\BlackCat;
use App\Services\Hsinchu;
use App\Services\LogisticsInterface;
use App\Services\Post;
use App\Services\ShippingService;
class ShippingServiceTest extends TestCase
{
/** @test */
public function 黑貓_當重量為1_2_3時_費用為360()
{
/** arrange */
App::bind(LogisticsInterface::class, BlackCat::class);
/** act */
$weights = [1, 2, 3];
$actual = App::call(ShippingService::class . '@calculateFee', [
'weightArray' => $weights
]);
/** assert */
$expected = 360;
$this->assertEquals($expected, $actual);
}
}
但是 BlackCat
、Hsinchu
與 Post
接實踐 LogisticsInterface
,Service Container 怎麼知道要注入哪一個物件呢?
必須在 ShippingService
建立之前,先使用 App::bind()
告訴 Service Container,LogisticsInterface
須與哪一個 class
綁定,若要綁定 BlackCat
,就是 App::bind(LogisticsInterface::class, BlackCat::class);
。
實務上 App::bind()
要寫哪裡呢?
- 寫在整合測試。
- 若要動態切換
class
: 寫在 Controller。 - 若一開始就決定
class
: 寫在 Service Provider。
重構之後馬上跑測試,務必要全部測試案例都 綠燈,確認沒有重構失敗。
第七式 : Extract Trait
重構一到六式,都是教我們的都是以垂直方式將 method
抽取到 class
、abstract class
、interface
,也就是其都有垂直的關係,但某些 method
,並沒有垂直的關係,反而是跨 class
的水平關係。
對於這種水平關係的 method
,就必須使用重構第七式 : Extract Trait
,將 method
重構到 trait
。
LogTrait.php19 19GitHub Commit : 重構 7 式 : Extract Trait 之 LogTrait1
2
3
4
5
6
7
8
9
10
11
12
13declare(strict_types = 1);
namespace App\Services;
use Log;
trait LogTrait
{
public function writeLog(int $amount)
{
Log::info('Amount : ' . $amount);
}
}
我們在寫好 ShippingService
之後,被要求在 calculateFee()
加上 writeLog()
功能。
writeLog()
這種功能,要放在 class
或 abstract class
都很怪,因為他根本與 ShippingService
無關,反而是每個 class 都會有 writeLog()
的需求,像這種水平關係,我們可以使用 Extract Trait
將 writeLog()
重構到 LogTrait
。
AbstractLogistics.php20 20GitHub Commit : 重構 7 式 : Extract Trait 之 AbstractLogistics1
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
27declare(strict_types = 1);
namespace App\Services;
use Illuminate\Support\Collection;
abstract class AbstractLogistics implements LogisticsInterface
{
use LogTrait;
/**
* @param int $amount
* @param Collection $weights
* @param callable $closure
* @return int
*/
protected function loopWeights(int $amount, Collection $weights, callable $closure): int
{
foreach ($weights as $weight) {
$amount = $amount + $closure($weight);
}
$this->writeLog($amount);
return $amount;
}
}
重構成 LogTrait
後,只要去 use LogTrait
,我們的 class
內會有 writeLog()
。
但 $this->writeLog()
要寫在哪裡呢? 當然也可以寫在每個 class
內的 calculateFee()
內,但這就違反 DRY 了,比較理想的方式是寫在 abstract class
的 loopWeights()
內,這樣 $this->writeLog()
就只有一份,符合 DRY 原則。
重構之後馬上跑測試,務必要全部測試案例都 綠燈,確認沒有重構失敗。
第八式 : Refactor to Pattern
當程式碼重構到第七式,基本上已經符合了物件導向的 SOLID 原則,也達到高內聚、低耦合目標,算是不錯的程式碼,兼具容易閱讀、容易維護的優點。
單一職責原則 Single Responsibility Principle
原本所有的計算運費邏輯都包在 ShippingService
的 calculateFee()
內,只要任何一個廠商的運費修改,都必須修改 ShippingService
,因此違反單一職責原則,透過 Extract Method
與 Extract Class
,現在已經將黑貓的計算運費邏輯包在 BlackCat
的 calculateFee()
,新竹貨運的計算運費邏輯包在 Hsinchu
的 calculateFee()
,而郵局的計算運費邏輯也包在 Post
的 calculateFee()
,每個廠商的運費修改,都不會影響到其他 class
,符合單一職責原則的要求。
開放封閉原則 Open Closed Principle
將 BlackCat
、Hsinchu
與 Post
透過 Extract Interface
抽象化成 LogisticsInterface
,對於 ShippingServie
來說,不再直接相依 BlackCat
、Hsinchu
與 Post
三個 class,僅相依於 LogisticsInterface
,將來若有新的需求,只需新建立 class
實踐 LogisticsInterface
即可,也就是對擴展是開放的,但因為 ShippingService
僅相依於 LogisticsInterface
,不相依於任何 class
,就算將來有新建立的 class
,也不需修改 ShippingService
,所以對修改是封閉的,符合開放封閉原則的要求。
里式替換原則 Liskov Substitution Principle
我們將 BlackCat
、Hsinchu
與 Post
程式碼重複的地方,透過 Extract Super Class
與 Extract Closure
重構到 AbstractLogistics
這個 abstract class
,為了確保所有繼承於 AbstractLogistics
的 class
都能被替換,我們特別要求 AbstractLogistics
實現 LogisticsInterface
,確保所有使用 abstract class
物件,都可以透明地使用其衍生 class
所代替,符合里氏替換原則的要求。
介面隔離原則 Interface Segregation Principle
因為 LogisticsInterface
只有 calculateFee()
單一 method
,因此看不到介面隔離原則,實務上當 interface
有多個 method
,而你發現 class
對於 interface
有空實作時,或者使用者根本沒用到 interface
所提供的所有 method
,就表示 interface
應該再細化,再切成更小的 interface
。
事實上,介面隔離原則就是另一個角度的單一職責原則,單一職責是以責任的角度來看 class
,而介面隔離原則是以需求的角度看 interface
,所以兩者並不衝突,符合單一職責的 class
仍然會實現多個 interface
,符合介面隔離原則的要求。
依賴反轉原則 Dependency Inversion Principle
在透過 Dependency Injection
之前,我們是直接在 ShippingService
直接去 new BlackCat
、Hsinchu
與 Post
,也就是高階模組的 ShippingService
直接依賴於低階模組的 BlackCat
、Hsinchu
與 Post
,這就違反了高階模組不該依賴低階模組,既然已經透過 Extract Interface
抽象化出 LogisticsInterface
,我們就可以透過 Dependency Injection
,由高階模組自行注入所依賴的低階模組,此時高階模組 ShippingService
只依賴於 LogisticsInterface
,而低階模組 BlackCat
、Hsinchu
與 Post
也只依賴於 LogisticsInterface
,也就是兩者都應該依賴其抽象,符合依賴反轉原則的要求。
高內聚 High Cohesion
單一職責原則、介面隔離原則講的就是高內聚,Extract Method
、Extract Class
、Extract Trait
則是實現高內聚的具體方法,讓我們將功能高度相關的 method
放在同一個 class
與 interface
內。
低耦合 Low Coupling
開放封閉原則、里氏替換原則、依賴反轉原則講的就是低耦合,Extract Interface
、Dependency Injection
、Replace Interface with Closure
則是實現低耦合的具體方法,讓我們將原本 class
與 class
之間的耦合,變成只與 interface
或 closure
的耦合,由於耦合變少變小,因此將來所做的任何修改,影響將降到最低。
設計模式 Design Pattern
若重構到這個階段,突然靈機一動想到在這個情境下,某個 Design Pattern 更適合,則可以繼續重構,基本上,Design Pattern 就是前人所留下來破解某個劍招的精妙劍法,只要用的時機對,就會非常的巧,但不必刻意的追求一定要用什麼 Design Pattern 才算好的物件導向,就如獨孤九劍一樣,隨機應變,用得到是緣份,用不到也沒關係,因為重構到第七式,已經符合了 SOLID 原則了。
以本例而言,事實上就是 Strategy Pattern,但若你完全不知道 Strategy Pattern 也沒關係,只要從重構一式打到重構七式,就會自然長出 Strategy Pattern 了。
第九式 : Replace Interface with Closure
物件導向為人詬病的,就是 interface
滿天飛,與檔案數目爆炸,但函數式編程思維加入後,一切有了變化。
若我們的 interface
只有一個 method
,或經由 ISP 介面隔離原則切成很多小小的 interface 後,就可使用重構第九式 : Replace Interface with Closure
,將 interface
拿掉,改用 closure
。
LogisticsInterface.php1
2
3
4
5
6
7
8
9
10
11
12
13declare(strict_types = 1);
namespace App\Services;
interface LogisticsInterface
{
/**
* @param array $weightArray
* @param int $amount
* @return int
*/
public function calculateFee(array $weightArray, int $amount) : int;
}
LogisticsInterface
只有單一的 calculateFee()
而已,我們可以嘗試將 interface
拿掉。
Logistics.php21 21GitHub Commit : 重構 9 式 : Replace Interface to Closure 之 Logistics1
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
32
33
34
35
36
37
38
39
40declare(strict_types = 1);
namespace App\Services;
use Illuminate\Support\Collection;
class Logistics
{
use LogTrait;
/**
* @param array $weightArray
* @return Collection
*/
protected function arrayToCollection(array $weightArray): Collection
{
$weights = collect($weightArray);
return $weights;
}
/**
* @param int $amount
* @param array $weightArray
* @param callable $closure
* @return int
*/
public function calculateFee(array $weightArray, int $amount, callable $closure): int
{
$weights = $this->arrayToCollection($weightArray);
foreach ($weights as $weight) {
$amount = $amount + $closure($weight);
}
$this->writeLog($amount);
return $amount;
}
}
建立新的 Logistics
,事實上就是將 AbstractLogistics
的程式碼全部搬過來,因為既然沒有 interface
,那 abstract class
也不需要了。
ShippingService.php21 21GitHub Commit : 重構 9 式 : Replace Interface to Closure 之 ShippingService1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20declare(strict_types = 1);
namespace App\Services;
class ShippingService
{
/**
* 計算運費
* @param array $weightArray
* @param callable $closure
* @param Logistics $logistics
* @return int
*/
public function calculateFee(array $weightArray, callable $closure, Logistics $logistics) : int
{
$amount = 0;
return $logistics->calculateFee($weightArray, $amount, $closure);
}
}
原本 calculateFee()
的最後一個參數是依賴注入進 LogisticsInterface
物件,為了要將 interface
拿掉,我們只注入 Logistics
即可。
也因為 LogisticsInterface
已經拿掉,所以 BlackCat
、Hsinchu
與 Post
也順便拿掉,也就是說,原本需要 interface
與 class
封裝計算運費邏輯,現在完全退化到只需 closure
即可。
ShippingServiceTest.php22 22GitHub Commit : 重構 9 式 : Replace Interface to Closure 之 ShippingServiceTest1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25declare(strict_types = 1);
use App\Services\ShippingService;
class ShippingServiceTest extends TestCase
{
/** @test */
public function 黑貓_當重量為1_2_3時_費用為360()
{
/** arrange */
/** act */
$weights = [1, 2, 3];
$actual = App::call(ShippingService::class . '@calculateFee', [
'weightArray' => $weights,
'closure' => function (int $weight) {
return (100 + $weight * 10);
},
]);
/** assert */
$expected = 360;
$this->assertEquals($expected, $actual);
}
}
原本我們必須在 arrange
使用 App::bind(LogisticsInterface::class, BlackCat::class);
,但目前沒有 LogisticsInterface
與 BlackCat
,所以 App::bind()
也不需要了。
原本計算運費邏輯是封裝在 BlackCat
內,但因為現在已經沒有 BlackCat
,改用 closure
,所以必須在測試提供 closure
。
重構之後馬上跑測試,務必要全部測試案例都 綠燈,確認沒有重構失敗。
我們也發現檔案只剩下 ShippingService
、Logistics
與 LogTrait
而已,其他檔案都因為 Replace Interface with Closure
而重構刪除了。
Conclusion
- 基本上重構第一式到第七式,我每次 TDD 重構都會打一遍,讓自己的程式碼符合 SOLID 原則與實現高內聚、低耦合,但第八式與第九式則不一定,會視實際狀況隨機應變。
Replace Interface with Closure
的出現,讓物件導向有了不同的實作方式,事實上很多設計模式,如Strategy Pattern
、Command Pattern
、Chain of Responsibility Pattern
….等,都可使用closure
方式實作,但也不是所有的interface
都要重構成closure
,但最少是個方法,可依實際需求決定是否重構。- 重構有很多方法,主要是針對 legacy code,若使用 TDD 方式,因為測試先寫,已經考慮了可測試性,基本上程式碼的體質已經不差,只要再加上重構九式的輔助,寫出符合 SOLID 原則與高內聚、低耦合的程式碼將不再是遙不可及的事情。
Sample Code
完整的範例可以在我的 GitHub 上找到。
Reference
Martin Fowler, Refactoring : Improving The Design of Existing Code
范綱, 大話重構
Joey Chen, 30 天快速上手 TDD
大澤木小鐵, 從實例學習設計模式 (使用 PHP)