歸納自己天天使用的重構步驟

TDD 不僅是先寫測試而已,當第一個 綠燈 之後,剩下的半壁江山就是拼重構功力,重構的書多半來自於 Java,因此有些 PHP 獨門的重構技巧在 Java 書上是看不到的,也因為編程思維的持續演進,重構也有了新的面貌,本文整理出自己在實務上,天天必用的 9 個適用於 PHP 重構的 SOP。

Motivation


對於很多人來說,使用 imperative 方式寫程式不難,只要將所想的演算法以程式表達即可,也會使用 procedure 方式實現 DRY,但若要長出 classinterfaceabstract class 則有難度,更遑論 closuretrait,事實上這些東西,都不是設計出來的,而是重構出來的,也就是 TDD 第一個 綠燈 之後,透過重構慢慢長出 classinterfaceabstract classclosuretrait

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 : 單元測試 : 黑貓、新竹、郵局測試案例

tests/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
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
declare(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 個測試案例都先寫好測試。

根據 Uncle Bob 的 The Three Rule of TDD,我們應該一個測試寫完,綠燈後才能寫下一個測試,這樣才能避免 over design,本文重點是重構,而不是 TDD,所以先將 3 個測試先寫好方便講解,實務上應該遵照 The Three Rule of TDD 方式進行。

使用 if else


ShippingService.php3 3GitHub Commit : if else 計算運費

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
declare(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 elseforeach() 就完成了。

但這樣只是功能完成而已,所有高低階邏輯全寫在一起,程式碼不容易閱讀,將來也不好維護。

雖然是很爛的寫法,但仍然有 綠燈

使用 switch


改用 switch 寫法會比 if else 可讀性高些,PhpStorm 也提供工具可以直接將 if else 轉成 switch,按熱鍵 ⌥ + ↩,選擇 Replace if with switch

ShippingService.php4 4GitHub Commit : 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
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
declare(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 開始,當我們發現程式碼中有以下特徵 :

  1. 當一段程式碼需要寫註解特別解釋時。
  2. if else 內有一段邏輯時。
  3. switch case 內有一段邏輯時。

就可以開始使用重構第一式 : Extract Method,將一段程式碼重構成 method

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
declare(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 Method

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
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
83
declare(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 Method 讓我們將原本一段很長的程式碼,依照其功能先拆成較小的 method,可增加程式碼的可讀性與可維護性。

第二式 : Extract Class


只有 Extract Method 還是不夠的,物件導向程式碼最大的特點就是 class,我們要將更相關的 method 放在同一個 class,達到高內聚的目標。

在重構第一式 Extract Method 時,我們特別以名詞 + 動詞的方式替 method 命名,其中若名詞相同,則表示這些 mehtod 的內聚性很高,適合將這些 method 再透過重構第二式 : Extract Class 重構到新的 class 內。

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
declare(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 之 BlackCat

app/Services/BlackCat.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
declare(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 之 Hsinchu

app/Services/Hsinchu.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
declare(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 之 Post

app/Services/Post.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
declare(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 之 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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
declare(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 Class 讓我們將更相關的 method 放在同一個 class 內,達到高內聚的目標,可增加程式碼的可讀性與可維護性,也更容易重複使用。

第三式 : Extract Superclass


Extract Class 之後,雖然已經長出 class,但實務上會發現,method 內仍然有些程式碼是重複的,根據 DRY 原則,我們不希望有程式碼重複,這會造成日後維護上的困難,因為每次修改就得修改好幾份程式碼,還可能忘記修改其中一份,而造成邏輯上的不一致。

對付 method 內重複的程式碼,就必須使用重構第三式 : Extract Superclass,將重複的程式碼重構到 abstract class

BlackCat.php

app/Services/BlackCat.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
declare(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;
}
}

我們發現在 BlackCatHsinchuPost 3 個 classcalculateFee() 內,都有 $weight = collect($weightArray),我們可以使用 Extract Superclass 將這段程式碼重構到 abstract class 內。

AbstractLogistics.php10 10GitHub Commit : 重構 3 式 : Extract Super Class 之 AbstractLogistics

app/Services/AbstractLogistics.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
declare(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) 部分重構到 AbstractLogisticsarrayToCollection()

BlackCat.php11 11GitHub Commit : 重構 3 式 : Extract Super Class 之 BlackCat

app/Services/BlackCat.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
declare(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,所以 BlackCatHsinchuPost 都要改成 $weights = $this->arrayToCollection($weightArray);,如此重複的邏輯就統一都只存在於 AbstractLogistics,符合 DRY 原則。

重構之後馬上跑測試,務必要全部測試案例都 綠燈,確認沒有重構失敗。

Extract Super Class 讓我們將 class 內重複的部分抽出來,放到 abstract class 的 protected method 內,如此繼承的 class 就可共用此 method,避免程式碼重複。

第四式 : Extract Closure


雖然我們可以將重複的程式碼透過 Extract Superclass 重構到 abstract class,但有時候會遇到一種程式碼,並不是整塊重複,而是外層重複,內層卻不重複。

對於 method 內有一段外層重複,內層卻不重複的程式碼,就必須使用重構第四式 : Extract Closure,將重複的的程式碼重構到 abstract class,不重複的部分重構到 closure

BlackCat.php

app/Services/BlackCat.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
declare(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;
}
}

我們在 BlackCatHsinchuPostcalculateFee() 都可發現,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 之 AbstractLogistics

app/Services/AbstractLogistics.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
declare(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 都重構到 AbstractLogisticsloopWeights() 內,但我們清楚 $amount = $amount + (100 + $weight * 10); 是不重複的,必須使用 closure 代替,且因為 closure 必須使用到 $weight 變數,還必須將 $weight 傳入 closure

所以新重構的 loopWeights() 除了有 int $amountCollection $weights 參數外,還要多一個 callable $closure 傳進來。

BlackCat.php13 13GitHub Commit : 重構 4 式 : Extract Closure 之 BlackCat

app/Services/BlackCat.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
declare(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 已經重構到 AbstractLogisticsloopWeights()BlackCatHsinchuPost 不同的計算邏輯就以 closure 的方式傳入 loopWeights()

重構之後馬上跑測試,務必要全部測試案例都 綠燈,確認沒有重構失敗。

Extract Closure 讓我們將 class 內重複的部分抽出來,放到 abstract class 的 protected method 內,不重複的部分則放在各 class, 以 closure 的方式傳入 abstract class,如此就可確保程式碼符合 DRY 原則,且各 class 也保有不重複部分。

第五式 : Extract Interface


當使用 Extract SuperclassExtract Closure 之後,基本上已經沒有重複的程式碼,也就是已經符合 DRY 原則。

名詞 + 動詞method 名稱部分,名詞不同已經使用 Extract Class 解決,剩下的是相同的動詞,也就是我們發現在 3 個 class 都有相同的 method

既然 3 個 classmethod 都相同,我們就可以使用重構第五式 : Extract Interface,以用更宏觀的角度,將這 3 個 class 抽象化成一個相同interface

BlackCat.php

app/Services/BlackCat.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
declare(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;
}
}

既然 BlackCatHsinchuPost 都有 calculateFee(),我們可以使用 Extract InterfacecalculateFee() 抽成 interface,將 BlackCatHsinchuPost 抽象化成 LogisticsInterface

LogisticsInterface.php15 15GitHub Commit : 重構 5 式 : Extract Interface 之 LogisticsInterface

app/Services/LogisticsInterface.php
1
2
3
4
5
6
7
8
9
10
11
12
13
declare(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,定義其 methodcalculateFee()

AbstractLogistics.php16 16GitHub Commit : 重構 5 式 : Extract Interface 之 AbstractLogistics

app/Services/AbstractLogistics.php
1
2
3
4
5
6
7
8
9
declare(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 之 ShippingService

app/Services/AbstractLogistics.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
32
33
34
35
36
37
38
39
40
41
42
declare(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;
}
}

既然 BlackCatHsinchuPost 都抽象化成 LogisticsInterface,無論是 new BlackCatHsinchuPost,抽象化看起來都是同一個 $logistics 物件,這就是物件導向的多型,同一個物件,卻可能來自於相同 interface 的不同 class

重構之後馬上跑測試,務必要全部測試案例都 綠燈,確認沒有重構失敗。

Extract Interface 讓我們將相同 method 的 class,抽象化成相同 interface 的物件,儘管將來需求改變,但只要 interface 不變,程式碼就不用修改,方便將來維護。

第六式 : 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 之 ShippingService

app/Services/ShippingService.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
declare(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,因此將參數 $companyNamecalculateFee() 移除。

新增 LogisticsInterface $logistics 參數,由 Service Container 幫我們將所依賴的 $logistics 物件注入。

由於 $logistics 已經抽象化為 LogisticsInterface,所以根本不需要任何 switch case 判斷,因此將來若有新的 class 也不用擔心,一定不需修改 calculateFee()

ShippingServiceTest.php18 18GitHub Commit : 重構 6 式 : Dependency Injection 之 ShippingServiceTest

app/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
declare(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);
}
}

但是 BlackCatHsinchuPost 接實踐 LogisticsInterface,Service Container 怎麼知道要注入哪一個物件呢?

必須在 ShippingService 建立之前,先使用 App::bind() 告訴 Service Container,LogisticsInterface 須與哪一個 class 綁定,若要綁定 BlackCat,就是 App::bind(LogisticsInterface::class, BlackCat::class);

實務上 App::bind() 要寫哪裡呢?

  1. 寫在整合測試。
  2. 若要動態切換 class : 寫在 Controller。
  3. 若一開始就決定 class : 寫在 Service Provider。

重構之後馬上跑測試,務必要全部測試案例都 綠燈,確認沒有重構失敗。

Dependency Injection 讓我們由外部注入內部所需相依的物件,因此 method 就不必再根據條件去判斷該相依哪一個物件,也因為不用判斷,因此將來若有新的相依物件,也不需修改程式碼,方便日後維護。

第七式 : Extract Trait


重構一到六式,都是教我們的都是以垂直方式將 method 抽取到 classabstract classinterface,也就是其都有垂直的關係,但某些 method,並沒有垂直的關係,反而是跨 class水平關係。

對於這種水平關係的 method,就必須使用重構第七式 : Extract Trait,將 method 重構到 trait

LogTrait.php19 19GitHub Commit : 重構 7 式 : Extract Trait 之 LogTrait

app/Services/LogTrait.php
1
2
3
4
5
6
7
8
9
10
11
12
13
declare(strict_types = 1);

namespace App\Services;

use Log;

trait LogTrait
{
public function writeLog(int $amount)
{

Log::info('Amount : ' . $amount);
}
}

我們在寫好 ShippingService 之後,被要求在 calculateFee() 加上 writeLog() 功能。

writeLog() 這種功能,要放在 classabstract class 都很怪,因為他根本與 ShippingService 無關,反而是每個 class 都會有 writeLog() 的需求,像這種水平關係,我們可以使用 Extract TraitwriteLog() 重構到 LogTrait

AbstractLogistics.php20 20GitHub Commit : 重構 7 式 : Extract Trait 之 AbstractLogistics

app/Services/AbstractLogistics.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
declare(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 classloopWeights() 內,這樣 $this->writeLog() 就只有一份,符合 DRY 原則。

重構之後馬上跑測試,務必要全部測試案例都 綠燈,確認沒有重構失敗。

Extrait Trait 讓我們不用特別將不相關的 method 重構到 class 與 abstract class 內,所有水平關係的 method,都適合重構到 trait,然後跨 class 重複使用。

第八式 : Refactor to Pattern


當程式碼重構到第七式,基本上已經符合了物件導向的 SOLID 原則,也達到高內聚、低耦合目標,算是不錯的程式碼,兼具容易閱讀、容易維護的優點。

單一職責原則 Single Responsibility Principle

應該且僅有一個原因引起類別的變更

原本所有的計算運費邏輯都包在 ShippingServicecalculateFee() 內,只要任何一個廠商的運費修改,都必須修改 ShippingService,因此違反單一職責原則,透過 Extract MethodExtract Class,現在已經將黑貓的計算運費邏輯包在 BlackCatcalculateFee(),新竹貨運的計算運費邏輯包在 HsinchucalculateFee(),而郵局的計算運費邏輯也包在 PostcalculateFee(),每個廠商的運費修改,都不會影響到其他 class,符合單一職責原則的要求。

開放封閉原則 Open Closed Principle

對於擴展是開放的,對於修改是封閉的

BlackCatHsinchuPost 透過 Extract Interface 抽象化成 LogisticsInterface,對於 ShippingServie 來說,不再直接相依 BlackCatHsinchuPost 三個 class,僅相依於 LogisticsInterface,將來若有新的需求,只需新建立 class 實踐 LogisticsInterface 即可,也就是對擴展是開放的,但因為 ShippingService 僅相依於 LogisticsInterface,不相依於任何 class,就算將來有新建立的 class,也不需修改 ShippingService,所以對修改是封閉的,符合開放封閉原則的要求。

里式替換原則 Liskov Substitution Principle

所有參照基礎類別的地方,必須可以透明地使用衍生類別的物件代替,而不需要任何改變

我們將 BlackCatHsinchuPost 程式碼重複的地方,透過 Extract Super ClassExtract Closure 重構到 AbstractLogistics 這個 abstract class,為了確保所有繼承於 AbstractLogisticsclass 都能被替換,我們特別要求 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 BlackCatHsinchuPost,也就是高階模組的 ShippingService 直接依賴於低階模組的 BlackCatHsinchuPost,這就違反了高階模組不該依賴低階模組,既然已經透過 Extract Interface 抽象化出 LogisticsInterface,我們就可以透過 Dependency Injection,由高階模組自行注入所依賴的低階模組,此時高階模組 ShippingService 只依賴於 LogisticsInterface,而低階模組 BlackCatHsinchuPost 也只依賴於 LogisticsInterface,也就是兩者都應該依賴其抽象,符合依賴反轉原則的要求。

高內聚 High Cohesion

應該將功能高度相關的 method 放在同一個 class 與 interface 內,這樣才方便閱讀、方便維護、方便重複使用。

單一職責原則介面隔離原則講的就是高內聚Extract MethodExtract ClassExtract Trait 則是實現高內聚的具體方法,讓我們將功能高度相關的 method 放在同一個 classinterface 內。

低耦合 Low Coupling

class 與 class 間的耦合應該盡量減少,避免因為 class 的修改,而導致其他 class 也必須隨之修改。

開放封閉原則里氏替換原則依賴反轉原則講的就是低耦合Extract InterfaceDependency InjectionReplace Interface with Closure 則是實現低耦合的具體方法,讓我們將原本 classclass 之間的耦合,變成只與 interfaceclosure 的耦合,由於耦合變少變小,因此將來所做的任何修改,影響將降到最低。

設計模式 Design Pattern
若重構到這個階段,突然靈機一動想到在這個情境下,某個 Design Pattern 更適合,則可以繼續重構,基本上,Design Pattern 就是前人所留下來破解某個劍招的精妙劍法,只要用的時機對,就會非常的巧,但不必刻意的追求一定要用什麼 Design Pattern 才算好的物件導向,就如獨孤九劍一樣,隨機應變,用得到是緣份,用不到也沒關係,因為重構到第七式,已經符合了 SOLID 原則了。

以本例而言,事實上就是 Strategy Pattern,但若你完全不知道 Strategy Pattern 也沒關係,只要從重構一式打到重構七式,就會自然長出 Strategy Pattern 了。

Refactor to Pattern 讓我們可以重構出更精妙的程式碼,但不必強求,隨緣使用即可。

第九式 : Replace Interface with Closure


物件導向為人詬病的,就是 interface 滿天飛,與檔案數目爆炸,但函數式編程思維加入後,一切有了變化。

若我們的 interface 只有一個 method,或經由 ISP 介面隔離原則切成很多小小的 interface 後,就可使用重構第九式 : Replace Interface with Closure,將 interface 拿掉,改用 closure

LogisticsInterface.php

app/Services/LogisticsInterface.php
1
2
3
4
5
6
7
8
9
10
11
12
13
declare(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 之 Logistics

app/Services/Logistics.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
32
33
34
35
36
37
38
39
40
declare(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 之 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
declare(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 已經拿掉,所以 BlackCatHsinchuPost 也順便拿掉,也就是說,原本需要 interfaceclass 封裝計算運費邏輯,現在完全退化到只需 closure 即可。

ShippingServiceTest.php22 22GitHub Commit : 重構 9 式 : Replace Interface to Closure 之 ShippingServiceTest

app/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
declare(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);,但目前沒有 LogisticsInterfaceBlackCat,所以 App::bind() 也不需要了。

原本計算運費邏輯是封裝在 BlackCat 內,但因為現在已經沒有 BlackCat,改用 closure,所以必須在測試提供 closure

重構之後馬上跑測試,務必要全部測試案例都 綠燈,確認沒有重構失敗。

我們也發現檔案只剩下 ShippingServiceLogisticsLogTrait 而已,其他檔案都因為 Replace Interface with Closure 而重構刪除了。

Replace Interface with Closure 讓我們適時的將不必要的 interface 以 closure 取代,將可解決原來物件導向 interface 滿天飛與檔案數目爆炸的問題,但也不是每個 interface 都要重構成 closure,必須自己依照實際情形加以判斷,沒有最佳解,只有最適解。

Conclusion


  • 基本上重構第一式第七式,我每次 TDD 重構都會打一遍,讓自己的程式碼符合 SOLID 原則與實現高內聚、低耦合,但第八式第九式則不一定,會視實際狀況隨機應變。
  • Replace Interface with Closure 的出現,讓物件導向有了不同的實作方式,事實上很多設計模式,如 Strategy PatternCommand PatternChain 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)