TDD要求我們先寫測試,雖然會在專案一開始多花一點時間,但只要我們選對工具,就可將花在測試、重構與偵錯的時間再省回來,讓我們雖然輸在起跑點,卻可贏在決勝點。
Version
OS X 10.11.2
PHP 7.0.0
Laravel 5.1.28
PhpStorm 10.0.3
PHP 7.0.0
Laravel 5.1.28
PhpStorm 10.0.3
物件導向
假如今天PM開的需求,就是希望我們做出一台X戰機,我們當然可以完全手刻出符合Spec的X戰機,但只要需求一變,需要我們改功能,加功能時,我們就很頭大了。
因此我們需要將X戰機樂高化,改功能只要換樂高積木,加功能只要加樂高積木即可。
物件導向簡單的說就是樂高導向,每個樂高積木就是
class
,樂高積木的規格就是interface
或abstract class
,只要符合規格的積木,我們都可以換掉或加上去。SOLID
程式語言提供了各種物件導向程式的語法,倒底要怎樣寫才是符合物件導向精神的程式呢?
SOLID原則11詳細請參考大澤木小鐵的從實例學習設計模式 威力加強版 使用PHP
- S : Single Responsibility Principle (單一職責原則)
- O : Open Closed Principle (開放封閉原則)
- L : Liskov Substitution Principle (里氏替換原則),Least Knowledge Principle (最小知識原則)
- I : Interface Segregation Principle (介面隔離原則)
- D : Dependency Inversion Principle (依賴反轉原則)
Laravel的作者Taylor Otwell曾有一段話 : 22詳細請參考Laravel之父 : 學習出色的Design Pattern
如果有人想成為更棒的PHP工程師,你會怎麼建議?學習出色的Design Pattern。這不只適用在PHP。你可以在任何程式語言使用這些pattern。尤其是SOLID。把這五個徹底學好,它會把你帶到新的境界,我每次寫code幾乎都在想這五個。
除了Design Pattern,重點在於更根本的SOLID,這5點才是物件導向的心法。
設計模式
設計模式其實就是大神們留下來好的物件導向設計範本。33物件導向設計模式-可再利用物件導向軟體之要素
優點 :
- 具體方案 : 至少是個具體的物件導向設計方式,不再流於抽象概念。
- 用得巧就很棒 : 只要在適當的場合,使用適當的模式,就會非常漂亮。
缺點 :
- 學習門檻高 : 理解設計模式已經不容易,要套用在實務上更難,很依賴天份。
- 容易over design : 初學者容易一開始就套大量設計模式,導致系統提前過於複雜。
重構
將既有的程式整形成符合物件導向精神的程式。44重構 : 改善既有程式的設計 (二版)
優點 :
- 學習門檻較低 : 重構招式較平易近人,容易學習。
- 可套用在legacy code : 不再只有新的專案才能物件導向。
缺點 :
- 需要依賴測試做保障 : 重構需要頻繁的測試,也需要測試保證重構沒有出錯。
TDD
既然重構需要測試,到底要先寫測試,還是後寫測試呢?
TDD的全名為Test Driven Development (測試驅動開發) 顛覆大家以往的習慣,強調
先寫測試,再寫程式
,整個流程是 :
優點 :
- 提供重構堅固的屏障 : 有寫測試,我們才敢放膽重構。
- 避免over design : 只為紅燈變成綠燈寫程式,不會寫出額外的程式。
- Top Down思維 : 因為測試先寫,會以測試好寫的角度去寫程式, 會比較接近使用者,也符合物件導向的精神。55詳細請參考使用TDD實踐SOLID
- 偵錯快速 : 將來要debug時,只要一跑測試,就可以快速找到錯誤所在。
缺點 :
- 先寫測試,一開始會多花一點時間 : 所以我們要找更強的工具幫我們將時間省回來。
- 需要學習如何寫測試 : 寫測試有不少技巧,如3A原則、Mock物件、依賴注入、Assertion…。
設定環境
建立Laravel專案
1
| oomusou@mac:~$ composer create-project laravel/laravel Laravel51Refactor_demo 5.1 --prefer-dist
|
安裝Laravel Elixir
1
| oomusou@mac:~/MyProject$ npm install
|
1
| oomusou@mac:~/MyProject$ gulp
|
執行
gulp
之後,若能出現如下圖的Sass Compiled
,表示Laravel Elixir已經安裝成功。8 8Laravel Elixir是否能安裝成功,取決幾個因素:Node.js、Gulp與Laravel Elixir之間的版本相依,詳細請參考如何在OS X安裝Laravel前端開發環境?使用PhpStorm開啟
啟動PhpStorm。
選擇剛建立的專案目錄。
一開始
indexing
雖稍微久一點,但只要做一次即可。99PhpStorm會對整個專案的檔案做index,以加速將來檔案的搜尋。設定Namespace Roots
第一次開啟專案,PhpStorm會跳出
Detect PSR-0 namespaces roots
要求你設定。選擇Settings | Directories
。1010理論上選擇automatically
也可以,不過由於之前下了npm install
之後,將大量node packages安裝在node_modules
目錄下,若由PhpStorm自動去偵測目錄,將花較長時間,因此在此採用手動設定,詳細請參考如何使用PhpStorm建立Laravel專案?
設定
Sources
目錄。
由於Laravel預設的namespace目錄是從
app
目錄開始,因此選擇app
目錄,按下Sources
,右側會出現藍色Sources Folders : app
。
設定namespace名稱。
按下
P
,設定prefix。
根據PSR-4,我們可以有很多namespace root,因此可以對目錄設定prefix,將
app
目錄的prefix設定為App
。1111這個步驟非常重要,設定好namespace root後,將來只要建立class,PhpStorm都會幫你管理namespace,不用再對namespace操心。
設定
Resource Root
目錄。
Laravel預設將前端的asset放在
resources
目錄,選擇resources
,按下Resource Root
,右側會出現紫色Resource roots : resources
。
設定
Tests
目錄。
Laravel預設將測試程式放在
tests
目錄,選擇tests
,按下Tests
,右側會出現綠色Test Source Folders : tests
。設定PHP Interpreter
PhpStorm允許我們直接在IDE內執行測試與偵錯,因此我們必須告訴PhpStorm,我們使用PHP的版本,以及PHP interpreter位置。1212詳細請參考如何使用PhpStorm測試與除錯?
設定PHPUnit
PhpStorm允許我們直接在IDE內跑單元測試,因此我們必須告訴PhpStorm,PHPUnit的autoloader與
phpunit.xml
設定檔位置。1313詳細請參考如何使用PhpStorm測試與除錯?測試Gulp TDD
一開始已經使用
npm install
安裝了Laravel Elixir,為了要使Elixir能自動在背景執行PHPUnit,只要我們一存檔就執行測試,需修改gulpfile.js
,加上.phpUnit();
。
在命令列執行
gulp tdd
,啟動Laravel Elixir在背景執行PHPUnit。1414在PhpStorm可按熱鍵:⌥ + F12,可在下方顯示terminal直接輸入指令。
開啟
tests/ExampleTest.php
,這是Laravel所提供預設的測試,用來測試Laravel預設的首頁是否有Laravel 5
字串。
將
5
改成4
,存檔後就會在右上角顯示紅燈,顯示測試錯誤。
若從
4
改成5
,存檔後就會在右上角顯示綠燈,顯示測試成功。
若紅燈與綠燈都能出現,表示Gulp TDD正常。
TDD
Spec
計算一位顧客所有訂單的金額。1515本範例改編自重構:改善既有程式的設計的第一章範例,原書使用Java,經簡化後改成PHP版本。
影片種類 | 租期 | 租金 | 逾期費 |
---|---|---|---|
普通片 | 7天 | 100 | 10 |
新片 | 3天 | 150 | 30 |
兒童片 | 7天 | 40 | 10 |
測試案例
- 普通片1支,10天1616寫測試的第一步,就是要將spec寫成測試案例,也就是實際的input與output結果,如此才能根據input與output判斷測試結果是否正確。100 + (10-7) * 10 = 130
- 新片1支,5天150 + (5-3) * 30 = 210
- 兒童片1支,8天40 + (8-7) * 10 = 50
設定Domain目錄
我們會將所有的class放在自己的Domain目錄下,或稱為Business Layer。1717詳細請參考Laravel的中大型專案架構
首先,在
app
目錄下建立VideoRental
子目錄。
輸入
VideoRental
。1818在左側選擇app
目錄,按下⌃ + N,出現下拉選單,選擇Directory
建立新目錄。
由於新目錄會有自己的namespace名稱,因此要修改
composer.json
的psr-4
設定,加上VideoRental
與其目錄。
在PhpStorm設定
PhpStorm -> Preferences… -> Project:xxx -> Directories
VideoRental
namespace。2020這一步一定要做,如此PhpStorm才會知道新的VideoRental
namespace,將來建立新class時,才可以選的到此namespace。PhpStorm -> Preferences… -> Project:xxx -> Directories
選擇
app/VideoRental
目錄,按下Sources
,右側會出現藍色Sources Folders : app/VideoRental
。
按下
P
,設定namespace名稱。第一個測試
接下來會介紹3種測試方式。
第一種測試方式 : 使用Gulp TDD
在命令列使用
php artisan make:test
建立測試class,預設會繼承tests
目錄下的TestCase
。
在命令列執行
gulp tdd
,讓Laravel Elixir在背後執行PHPUnit,將來只要我們一存檔就會自動執行測試。
建立PHPUnit Test Method。2222在寫測試的class內,按熱鍵 : ⌃ + N,會出現
Generate
選單,選擇PHPUnit Test Method
,可幫我們自動建立test method。
PhpStorm自動幫我們建立以
test
為開頭的test method。2323PHPUnit預設會將2種method視為test method,一種是以test
為開頭的method,一種是在PHPDoc註解加上@test
。
在test method內加上arrange,act, assert,以3A原則寫測試。2525因為每個test method都需要3A原則當架構,建議可以自行加入PhpStorm的Live Template
3A原則
Arrange
Arrange
- 建立物件 (待測物件,相依物件,Mock物件)。
- 建立假資料。
- 設定期望值。
Act
- 實際執行待測物件的method,獲得實際值。
Assert
- 使用PHPUnit提供的assertion,測試期望值與實際值是否相等。
依3A原則為骨架,依次將測試補上。2626實務上第一個會將
act
先補上,也就是先決定要測試哪一個method。
先寫測試讓我們會以測試好寫為前提設計,會幫助我們以使用者需求的抽象化角度去思考架構。
Arrange
因為我們的需求是:
因為我們的需求是:
計算一位顧客所有訂單的金額
,且金額會隨著電影種類而不同,因此最基本,我們會有Movie
、Order
與Customer
三個class,且一位顧客會有多筆訂單,因此會有addOrder()
提供新增訂單。2727此時Movie
、Order
、Customer
、addOrder
與calculateTotalPrice()
都還沒建立,因此在PhpStorm會反白,這不用擔心,因為我們現在是先寫測試,以Top Down的方式去思考,不用擔心這些class與method還沒建立,只要先思考這樣子我們測試最好寫
就好了,這是TDD很重要的心法。
將測試案例的期望值寫入
$expected
。
Act
實際測試
實際測試
Customer
的calculateTotalPrice()
,獲得實際值$actual
。
Assert
使用PHPUnit的
使用PHPUnit的
assertEquals()
驗證期望值與實際值是否相同。
該自己用if else寫測試嗎?
這裡當然可以自己用PHP寫
if ($expected == $actual)
判斷,不過因為牽涉到人為的邏輯判斷,當測試錯誤時,很難確定到底是測試有問題,還是我們自己寫的PHP邏輯有問題,所以在測試中不應該寫邏輯,而應該使用PHPUnit的assertion
28 28PHPUnit提供很多assertion method,詳細請參考PHPUnit Assertions,因為PHPUnit已經被測試過
了,當測試結果有錯時,不用再懷疑是不是測試寫錯,一定是我們的程式寫錯了。
The Three Rules of TDD No.1
You are not allowed to write any production code unless it is to make a failing unit test pass.
目的:
- 先亮紅燈,表示你已經先寫了測試,只是因為沒寫程式所以紅燈。
- 先亮紅燈,表示你之前寫的程式沒有over design。
測試錯誤訊息告訴我們 :
Class Movie not found
。因為我們還沒建立Movie
。
直接在PhpStorm內建立
Movie
。3131將滑鼠游標放在Movie
之後,按熱鍵⌥ + ↩,會出現Create class
,按下可自動建立Movie
。
出現Create New PHP Class對話框,選擇目錄在
app/VideoRental
下,並選擇namespace : VideoRental
。
PhpStorm會幫我們建立
Movie
。
The Three Rules of TDD No.2
You are not allowed to write any more of a unit test than is sufficient to fail; and compilation failures are failures.
目的 :
- 將程式聚焦在目前的需求,方便程式解決目前的紅燈。
- 不會一開始就將架構想的太複雜,造成over design,而枉費我們分拆測試範例。3434詳細請參考91哥的The Three Laws of TDD - 從紅燈變綠燈的過程
測試錯誤訊息告訴我們 :
Order not found
。因為我們還沒建立Order
。
直接在PhpStorm內建立
Order
。3535將滑鼠游標放在Order
之後,按熱鍵⌥ + ↩,會出現Create class
,按下可自動建立Order
。
出現Create New PHP Class對話框,選擇目錄在
app/VideoRental
下,並選擇namespace : VideoRental
。
PhpStorm會幫我們建立
Order
。
不要因為在測試時看到紅燈而沮喪
事實上TDD的開發流程本來就是先有紅燈才去寫程式,這也是TDD能解決over design的關鍵,因為測試案例的紅燈來自於需求,由紅燈變成綠燈就是解決需求,若沒有紅燈而直接綠燈,就表示程式有over design。
測試錯誤訊息告訴我們 :
Class Customer not found
。因為我們還沒建立Customer
。
直接在PhpStorm內建立
Customer
。3737將滑鼠游標放在Customer
之後,按熱鍵⌥ + ↩,會出現Create class
,按下可自動建立Customer
。
出現Create New PHP Class對話框,選擇目錄在
app/VideoRental
下,並選擇namespace : VideoRental
。
PhpStorm會幫我們建立
Customer
。
存檔後出現第四個紅燈,錯誤訊息為
Call to undefined method VideoRental\Customer::addOrder()
。3838GitHub Commit : 第一個測試 : 第四個紅燈
測試錯誤訊息告訴我們 :
Call to undefined method VideoRental\Customer::addOrder()
。因為我們還沒建立addOrder()
。
直接在PhpStorm內建立
addOrder()
。3939將滑鼠游標放在addOrder()
之後,按熱鍵⌥ + ↩,會出現Add method
,按下可自動建立addOrder()
。
PhpStorm會幫我們建立
addOrder()
。
存檔後出現第五個紅燈,錯誤訊息為
Call to undefined method VideoRental\Customer::calculateTotalPrice()
。4040GitHub Commit : 第一個測試 : 第五個紅燈
測試錯誤訊息告訴我們 :
Call to undefined method VideoRental\Customer::calculateTotalPrice()
。因為我們還沒建立calculateTotalPrice()
。
直接在PhpStorm內建立
calculateTotalPrice()
。4141將滑鼠游標放在calculateTotalPrice()
之後,按熱鍵⌥ + ↩,會出現Add method
,按下可自動建立calculateTotalPrice()
。
PhpStorm會幫我們建立
calculateTotalPrice()
。
既然測試要求
130
,我們就直接很無恥的回傳130
。
這樣我們就獲得了第一個測試的第一個綠燈。4343GitHub Commit : 第一個測試 : 第一個綠燈
直接使用return也太無恥了吧!!
第一個測試為了剛好符合第一個測試案例的需求,我們可以先無恥的使用return方式,反正接下來的測試案例我們自然會重構。
第二個測試
將第一個測試
test_order_1_regular_movie_with_10_days()
複製貼上,改成第二個測試test_order_1_new_release_movie_with_5_days()
。4444不用擔心在test code使用複製貼上,test code不用擔心duplicated code問題,只有production code才必須考慮。
先在命令列使用⌃ + C結束
gulp tdd
,然後執行vendor/bin/phpunit
執行測試。
實務上可以在PHPDoc加上
@group
標籤為test method分類,如誰寫的測試,哪一個class的測試,方便vendor/bin/phpunit
執行時只跑該group。
執行測試後,出現第二個測試案例的第一個紅燈,錯誤訊息為
Failed asserting that 130 matches expected 210
。4545GitHub Commit : 第二個測試 : 第一個紅燈
別忘了Uncle Bob的叮嚀,每個測試案例都要先出現第一個紅燈,若一開始就出現綠燈,表示你之前程式有over design了。
之前我們只新增了
addOrder()
,並還沒有填入程式。
由於將來
calculateTotalPrice()
也要使用$orders
陣列,因此我們想將$orders
從method內的變數變成class的field。4747將滑鼠游標放在$orders
之後,按⌃ + T,會出現PhpStorm所有的重構選單,選擇Extract Field…。
出現兩種重構方式,選擇第一種 :
$orders
。
出現Introduce field對話框,預設field已經幫我們填入
orders
。
可以自行選擇
Initialize in
與Visibility
的方式。
這裡我們選擇
Field declaration
,也就是會直接在field宣告時初始化陣列。
如我們所願,宣告成
protected $orders = []
。
並且push部分也自動改成field。4848GitHub Commit : 第二個測試:Customer->addOrder()將$orders重構成field
由於需求是
計算一位顧客所有訂單的金額
,所以勢必有$totalPrice
變數負責累加,然後需要一個foreach
將整個$orders
loop一次,計算每種影片種類的金額。
以Top Down的方式思考,由於我們現在是在
Order
class,所以必須透過getMovie()
傳回Movie
物件,並透過其getType()
method傳回影片種類,然後將計費方式的演算法寫在裡面。4949GitHub Commit : 第二個測試 : 補齊Customer->calculateTotalPrice()的Regular計算方式
執行測試後,出現第二個測試案例的第二個紅燈,錯誤訊息為
Call to undefined method VideoRental\Order::getMovie()
。
錯誤訊息告訴我們 :
Call to undefined method VideoRental\Order::getMovie()
,因為我們還沒建立getMovie()
。
直接在PhpStorm內建立
getMovie()
。5050將滑鼠游標放在getMovie()
之後,按熱鍵⌥ + ↩,會出現Add method
,按下可自動建立getMovie()
。
出現
Can not find target class for modification
的錯誤訊息,因為PhpStorm無法得知$order
變數的型別,因此不知道要將getMovie()
建立在哪一個class。
加上註解描述
$order
的型別為Order
。5151這種寫法雖然可行,但不是最漂亮的寫法,最漂亮的寫法應該是直接在field註解型別,也就使在protected $order = []
之前直接@var Order[]
。
之後PhpStorm就會自動在
Order
建立getMovie()
。
由於
getMovie()
的$movie
來自於field,因此在constructor先將參數補全。
由於我們對constructor加了參數,因此出現了
Argument PHPDoc missing
的警告。
使用PhpStorm幫我們補齊PHPDoc。5252將滑鼠游標放在
int $days
之後,按熱鍵⌥ + ↩,會出現Update PHPDoc Comment
,按下可自動將參數新增至PHPDoc。
PhpStorm幫我們將新的參數註解加入了PHPDoc,將原來的參數註解改成
@internal
,這顯然是多餘的。
使用PhpStorm幫我們建立field。5353將滑鼠游標放在
int $days
之後,按熱鍵⌥ + ↩,會出現Initialize fields
,按下可自動將constructor參數新增成field。
選擇所要建立field的參數。
PhpStorm自動幫我們宣告field,並在contructor自動加上初始化的程式。
回到
getMovie()
,除了加上return $this->movie
之外,還補上了回傳型別Movie
。
使用PhpStorm自動幫我們加上
getMovie()
註解。5454將滑鼠游標放在Movie
之後,按熱鍵⌥ + ↩,會出現Generate PHPDoc for function
,按下可自動替getMovie()
產生註解。
在註解第一行加上人看得懂的註解。5555GitHub Commit : 第二個測試 : 補齊Order->getMovie()
執行測試後,出現第二個測試案例的第三個紅燈,錯誤訊息為
Call to undefined method ViderRental\Movie::getType()
。
錯誤訊息告訴我們 :
Call to undefined method ViderRental\Movie::getType()
,因為我們還沒建立getType()
。
直接在PhpStorm內建立
getType()
。5656將滑鼠游標放在getType()
之後,按熱鍵⌥ + ↩,會出現Add method
,按下可自動建立getType()
。
PhpStorm會幫我們建立
getType()
。
由於
getType()
的$type
來自於field,因此在constructor先將參數補全。
由於我們對constructor加了參數,因此出現
Argument PHPDoc missing
的警告。
使用PhpStorm幫我們補齊PHPDoc。5757將滑鼠游標放在
$type
之後,按熱鍵⌥ + ↩,會出現Update PHPDoc Comment
,按下可自動將參數新增至PHPDoc。
PhpStorm幫我們將新的參數註解加入了PHPDoc,將原來的參數改成
@internal
,這顯然是多餘的。
刪除多餘的
@internal
註解。
使用PhpStorm幫我們建立field。5858將滑鼠游標放在
string $type
之後,按熱鍵⌥ + ↩,會出現Initialize fields
,按下可自動將constructor參數新增成field。
選擇所要建立field的參數。
PhpStorm自動幫我們宣告field,並在contructor自動加上初始化的程式。
回到
getType()
,補上了回傳型別string
。
使用PhpStorm自動幫我們加上
getType()
註解。5959將滑鼠游標放在string
之後,按熱鍵⌥ + ↩,會出現Generate PHPDoc for function
,按下可自動替getType()
產生註解。
加上
return $this->type
。
在註解第一行加上人看得懂的註解。6060GitHub Commit : 第二個測試:補齊Order->getType()
執行測試後,出現第二個測試案例的第四個紅燈,錯誤訊息為
Call to undefined method VideoRental\Order::getDays()
。
錯誤訊息告訴我們 :
Call to undefined method VideoRental\Order::getDays()
,因為我們還沒建立getDays()
。
直接在PhpStorm內建立
getDays()
。6161將滑鼠游標放在getDays()
之後,按熱鍵⌥ + ↩,會出現Add method
,按下可自動建立getDays()
。
PhpStorm會幫我們建立
getDays()
。
執行測試後,出現第二個測試案例的第五個紅燈,錯誤訊息為
Failed asserting that 0 matches expected 210
。
錯誤訊息告訴我們 :
Failed asserting that 0 matches expected 210
,因為我們還沒寫NewRelease
的計費方式的演算法。
補上
NewRelease
的計費方式演算法。
這樣我們就獲得了第二個測試的第一個綠燈。6363GitHub Commit : 第二個測試 : 第一個綠燈
其實Children的計費方式也蠻接近的,我就順便elseif將Children補上好了!!
The Three Rules of TDD No.3
You are not allowed to write any more production code than is sufficient to pass the one failing unit test.
白話就是:若沒有測試案例,就不要自作聰明去寫程式。6464The Three Rules of TDD
目的 :
- 避免over design,導致系統提早無謂的複雜。
- 將來若有新的測試案例,到時候再重構就好,不用現在去擔心。
第三個測試
將第一個測試
test_order_1_regular_movie_with_10_days()
複製貼上,改成第三個測試test_order_1_children_movie_with_8_days()
。6565不用擔心在test code使用複製貼上,test code不用擔心duplicated code問題,只有production code才必須考慮。
直接在PhpStorm執行測試。6666在左側選擇
CustomerTest.php
,按熱鍵⌃ + ⇧ + R。
我們發現前兩個測試案例都是綠燈,只有第三個測試案例是紅燈。
這是第三個測試案例的第一個紅燈,錯誤訊息為
Failed asserting that 130 matches expected 210
。6767GitHub Commit : 第三個測試 : 第一個紅燈
錯誤訊息告訴我們 :
Failed asserting that 130 matches expected 210
,因為我們還沒寫Childer
的計費方式演算法。
補上
Children
的計費方式演算法。重構
3個測試案例都通過,代表程式基本上已經符合Spec要求,可以即時交付程式。
但是符合Spec的程式並不代表這是好的程式,一個好的程式至少要符合5個要求:
- 容易維護。
- 容易新增功能。
- 容易重複使用。
- 容易寫測試。
- 容易上Git,不易與其他人發生衝突。
若更簡單的說,就是要符合SOLID原則的程式,才算是好程式。
接下來我們將使用重構,將目前的程式調整成符合SOLID原則的好程式。
if else改成switch
在
Customer
的calculateTotalPrice()
,有個if...elseif
,因為都是判斷$order->getMovie()->getType()
,將if...elseif
改成switch
,會讓程式碼比較容易閱讀。6969這並不是重構這本書所談的重構手法,不過switch
的確比if...elseif
容易閱讀,所以實務上也常常會將if...elseif
重構成switch
。Extract Method
改成
switch
之後,雖然程式碼已經比較容易閱讀了,但是在calculateTotalPrice()
內還是顯得很臃腫,因此想使用重構的Extract Method將此switch
重構成一個method
。7171使用滑鼠選擇想要重構的程式碼,按熱鍵⌃ + T,會出現PhpStorm所有的重構選單,選擇Extract Method…。
出現Extract Method對話框,輸入重構後method名稱與選擇Visibility。
還可以選擇回傳值的處理方式,可以是
return
,也可以是pass by reference
。
最後對於
switch
的處理方式,可以選擇在case
內直接return
,還是最後一起return
,這裡選擇每個case
內直接return
。
重構之後,PhpStorm會自動幫你建立
calculatePrice()
,且原來程式也幫你自動呼叫calculatePrice()
。
馬上跑測試,確認PhpStorm的Extract Method沒將程式改壞掉。7272GitHub Commit : 重構 : Extract Method (switch -> Customer->calculatePrice())
Inline
經過Extract Method重構之後,我們發現
$price
這個暫存變數沒有存在的價值了。可使用重構的Inlilne將此暫存變數移除。7373將滑鼠游標放在$price
之後,按熱鍵⌃ + T,會出現PhpStorm所有的重構選單,選擇Inline…。
PhpStorm會詢問你是否將所有的
$price
變數都inline。
Inline之後,程式就變成我們所預期的只有一行。
馬上跑測試,確認PhpStorm的Inline沒將程式改壞掉。7474GitHub Commit : 重構 : Inline
Move Method
使用重構的Move Method將
calculatePrice()
從Customer
搬到Order
內。7575將滑鼠游標放在calculatePrice
之後,按熱鍵⌃ + T,會出現PhpStorm所有的重構選單,選擇Move…。
出現
Move non-static method is not supported
錯誤訊息,也就是PhpStorm目前只能支援將對static method
進行重構的Move Method,而一般method
不支援。
因為PhpStorm目前僅支援
static method
的Move Method,因此先暫時將calculatePrice()
改成static method
。
重新對
calculatePrice()
執行重構的Move Method。
出現Move Static Member對話框,因為我們要將
calculatePrice()
搬到Order
,要連namespace一起輸入。
重構之後,因為
calculatePrice()
已經變成static method
,原來的calculateTotalPrice()
內是使用$this->calculatePrice($order)
,已經被PhpStorm重構成Order::calculatePrice($order)
。
但這顯然不是我們要的。
將
Order::
改成$order->
。7676加上註解描述$order
的型別為Order
這種寫法雖然可行,但不是最漂亮的寫法,最漂亮的寫法應該是直接在field註解型別,也就使在protected $order = []
之前直接@var Order[]
,之後所有的foreach
內都不用再加上註釋。
PhpStorm雖然已經幫我們把
calculatePrice()
搬到Order
,但顯然static
是多餘的,且因為已經在Order
,所以不需再傳入$order
了。
將
static
刪除。
因為已經搬到
Order
,所以如getMovie()
與getDays()
都不在需要$order
,而是需要改成$this
。
使用重構的Rename,將
$order
改成$this
。7777將滑鼠游標放在$order
之後,按熱鍵⌃ + T,會出現PhpStorm所有的重構選單,選擇Rename…。
將全部的
$order
都改成$this
。
剛才我們只是借用重構的Rename將
$order
改成$this
,事實上calculatePrice()
根本不需要任何參數,將Order $this
刪除。
馬上跑測試,確認PhpStorm的Rename沒將程式改壞掉。7878GitHub Commit : 重構 : Move Method : Order->calculatePrice()
在剛剛重構產生的
Order
的calculatePrice()
內,我們發現switch
竟然是去判斷Movie
的getType()
,這是不合理的,似乎暗示著應該將這個switch
判斷搬到Movie
內。7979使用滑鼠選擇想要重構的程式碼,按熱鍵⌃ + T,會出現PhpStorm所有的重構選單,選擇Extract Method…。
出現Extract Method對話框,原本我們是想將此
switch
重構成Movie
的calculatePrice()
,但PhpStorm的Extract Method無法跨class,我們只好先Extract Method在Order
內,然後再使用Move Method搬到Movie
。
因為同一個class不能存在兩個
calculatePrice()
,因此先取名為MovieCalculatePrice()
。
使用Move Method將
MovieCalculatePrice()
搬到Movie
。
因為目前PhpStorm只支援
static method
的Movie Method,因此先改成static
。8080將滑鼠游標放在MovieCalculatePrice
之後,按熱鍵⌃ + T,會出現PhpStorm所有的重構選單,選擇Move…。
出現Move Static Member對話框,因為我們要將
MovieCalculatePrice()
搬到Movie
,要連namespace一起輸入。
重構之後,因為
MovieCalculatePrice()
已經變成static method
,原來的calculatePrice()
內是使用$this->MovieCalculatePrice()
,已經被PhpStorm重構成Movie::MovieCalculatePrice()
。
但這顯然不是我們要的。
將
Movie::MovieCalculatePrice()
改成$this->getMovie()->calculatePrice()
。
PhpStorm雖然已經幫我們把
MovieCalculatePrice()
搬到Movie
,但顯然static
是多餘的。
且
$this->getMovie()
也不需要了,因為已經在Movie
內,改用$this
就好。$this->getDays()
則比較麻煩,因為這原本是Order->getDays()
,現在搬到Movie之後,勢必要靠參數從Order
傳進來。
將
static
刪除。
將
$this->getMovie()
改成$this
。
我們是希望將
$this->getDays()
能Extract Parameter,不過目前在PhpStorm無法將一個method直接Extract Parameter,需要透過一些技巧。
先使用PhpStorm將
$this->getDays()
透過Extract Variable成變數。8181選擇$this->getDays()
,按熱鍵⌃ + T,會出現PhpStorm所有的重構選單,選擇Extract Variable…。
出現Introduce variable對話框,我們希望重構成
$days
變數。
將
Replace all occurrences
打勾,我們打算將全部的$this->getDays()
都取代。
我們看到
$this->getDays()
已經全部被$days
所取代。
但
$days = $this->getDays()
顯然是多餘的。
將
$days = $this->getDays()
刪除。
加上
int $days
參數,並加上註解。
由於多了
int $days
參數,因此在Order->calculatePrice()
要多傳$this->getDays()
進來。
馬上跑測試,確認PhpStorm沒將程式改壞掉。8282GitHub Commit : 重構 : Move Method : Movie->calculatePrice()
Replace Type Code with State/Stratgey
在重構的技巧中,有一招叫做Replace Type Code with State/Strategy,簡單的說,當你的程式會使用
switch
對同一個變數去做判斷時,可以改用物件導向的多型來處理,或者更白話的說,改用設計模式的State模式或Strategy模式去處理。
這樣的好處是會使你的程式符合SOLID的開放封閉原則,將來若新的需求要新增,將不用去改原來程式的
switch
,只要去新增class即可。8383開放封閉原則 : 軟體中的類別、函式對於擴展是開放的,對於修改是封閉的。Self Encapsulate Field
我們即將重構成State模式,因為State模式會將變化抽象化成一個物件,也就是需要將原本的字串,如
Regular
、NewRelease
、Children
最後抽象化成物件,因此重構教我們要使用Replace Type Code with State/Strategy前,先執行另一招重構 : Self Encapsulate Field。
Self Encapsulate Field簡單的說,就是將field全部改用
setter
的方式寫入,這樣我們就可以在setter
內將字串抽象化成物件。8484將滑鼠游標放在$type
之後,按熱鍵⌘ + N,會出現Generate
選單,選擇Setters
,可幫我們自動建立$type
的setter。
選擇所要建立setter的field。
PhpStorm自動幫我們加上
$type
的setter : setType()
。
將setType()的參數加上type hint。
建立setter只是Self Encapsulate Field的第一步,接下來就將程式所有地方改用setter去寫入
$type
field。8585GitHub Commit : 重構 : Self Encapsulate Field將變化封裝在class
State模式會將變化封裝在class內,也就是以物件導向的多型取代
switch
,無論將來怎麼變化,對使用者看起來都是相同的abstract class
。
在
VideoRental
目錄下建立AbstractMovieType
。8686在左側選擇VideoRental
目錄,按熱鍵⌃ + N,會出現New
選單,選擇PHP Class
,可幫我們自動建立新的class。
出現Create New PHP class對話框,class名稱輸入
AbstractMovieType
,namespace選擇VideoRental
。
PhpStorm會幫我們建立
AbstractMovieType
。
因為我們要建立的是abstract class,所以在class前面加上
abstract
。
建立RegularMovieType Class
接著我們要將各種影片類型的計費方式,封裝在class內。
新增
RegularMovieType
,負責普通片的計費方式。8888在左側選擇VideoRental
目錄,按熱鍵⌃ + N,會出現New
選單,選擇PHP Class
,可幫我們自動建立新的class。
出現Create New PHP class對話框,class名稱輸入
RegularMovieType
,namespace選擇VideoRental
。
PhpStorm會幫我們建立
RegularMovieType
。
繼承
AbstractMovieType
,使用PhpStorm幫我們建立abstract class所定義的method。8989將滑鼠游標放在AbstractMovieType
之後,按熱鍵⌥ + ↩,會出現Add method stubs
,按下可自動根據所繼承的abstract class
建立method。
PhpStorm會幫我們建立
calculatePrice()
,連註解也會一併建立。
將原本在
Movie->calculatePrice()
內的普通片計費方式剪下。
貼到
RegularMovieType
的calculatePrice()
內。
新增
NewReleaseMovieType
,負責新片的計費方式。9191在左側選擇VideoRental
目錄,按熱鍵⌃ + N,會出現New
選單,選擇PHP Class
,可幫我們自動建立新的class。
出現Create New PHP class對話框,class名稱輸入
NewReleaseMovieType
,namespace選擇VideoRental
。
PhpStorm會幫我們建立
NewReleaseMovieType
。
繼承
AbstractMovieType
,使用PhpStorm幫我們建立abstract class所定義的method。9292將滑鼠游標放在AbstractMovieType
之後,按熱鍵⌥ + ↩,會出現Add method stubs
,按下可自動根據所繼承的abstract class
建立method。
PhpStorm會幫我們建立
calculatePrice()
,連註解也會一併建立。
將原本在
Movie->calculatePrice()
內的新片計費方式剪下。
貼到
NewReleaseMovieType
的calculatePrice()
內。
建立ChildrenMovieType Class
新增
ChildrenMovieType
,負責兒童片的計費方式。9494在左側選擇VideoRental
目錄,按熱鍵⌃ + N,會出現New
選單,選擇PHP Class
,可幫我們自動建立新的class。
出現Create New PHP class對話框,class名稱輸入
ChildrenMovieType
,namespace選擇VideoRental
。
PhpStorm會幫我們建立
ChildrenMovieType
。
繼承
AbstractMovieType
,使用PhpStorm幫我們建立abstract class所定義的method。9595將滑鼠游標放在AbstractMovieType
之後,按熱鍵⌥ + ↩,會出現Add method stubs
,按下可自動根據所繼承的abstract class
建立method。
PhpStorm會幫我們建立
calculatePrice()
,連註解也會一併建立。
將原本在
Movie->calculatePrice()
內的兒童片計費方式剪下。
貼到
ChildrenMovieType
的calculatePrice()
內。
之前的
setType()
只是單純的$type
field的setter,不過使用State模式之後,setType()
的角色就有了改變,不再只是單存的setter,而是要建立適當的AbstractMovieType
物件。
將
calculatePrice()
剩下的switch
剪下。
貼到
setType()
內。
將
$this->getType()
改成$type
。
將
$this->type
改new
我們剛剛建立,用來封裝計費方式的物件。private $type
的PHPDoc註解型別,也從原來的string
改成AbstractMovieType
。
因為
$type
field的型別已經從原本的string
改成AbstractMovieType
,因此getType()
的回傳型別與PHPDoc註解也要更新。
由於
setType()
已經幫我們切換計費方式物件,據SOLID的里氏替換原則,且這些物件都是繼承於AbstractMovieType
,根我們可以直接呼叫子類別的calculatePrice()
。
馬上跑測試,確認重構沒將程式改壞掉。9797GitHub Commit : 重構 : Movie->setType(), getType()與calculatePrice()
Replace Constructor with Factory Method
在重構的技巧中,有一招叫做Replace Constructor with Factory Method,簡單的說,當你使用
new
去建立物件時,就直接相依了該物件,我們可將new
物件的邏輯封裝在Simple Factory模式內,如此我們就只相依工廠物件,而不會直將相依於計費方式物件。
新增
MovieTypeFactory
,負責建立計費方式物件。9898在左側選擇VideoRental
目錄,按熱鍵⌃ + N,會出現New
選單,選擇PHP Class
,可幫我們自動建立新的class。
出現Create New PHP class對話框,class名稱輸入
MovieTypeFactory
,namespace選擇VideoRental
。
PhpStorm會幫我們建立
MovieTypeFactory
。
建立
create()
,並宣告成static。
將原本在
Movie->setType()
的程式全部貼到create()
內。create()
加上string $type
參數,並加上回傳型別AbstractMovieType
。
因為
create()
功能就是在建立物件,所以全部改成return
。
原本
Movie->setType()
,改由MovieFactory::create()
來建立計費方式物件。
如此
Movie
將不再直接相依於每個計費物件,只相依於MovieTypeFactory
。
馬上跑測試,確認重構沒將程式改壞掉。9999GitHub Commit : 重構 : Simple Factory
Replace Conditional with Polymorphism
在重構技巧中,有一招叫做Replace Conditional with Polymorphism,簡單的說,就是要使用物件導向的多型來取代
switch
,達到SOLID的開放封閉原則。
使用Laravel的service container,利用
App::bind()
將AbstractMovieType
與實際的計費方式連結。
使用
App::make()
建立AbstractMovieType
型別的物件。
也就是說,只要計費方式物件改變,
App::bind()
會重新與AbstractMovieType
連結,但對於App::make()
來說,都是建立AbstractMovieType
型別的物件,這就是物件導向的多型。100100詳細請參考深入探討Service Provider
馬上跑測試,確認重構沒將程式改壞掉。101101GitHub Commit : 重構 : 多型App::bind()
開放封閉原則
影片種類 | 租期 | 租金 | 逾期費 |
---|---|---|---|
普通片 | 7天 | 100 | 10 |
新片 | 3天 | 150 | 30 |
兒童片 | 7天 | 40 | 10 |
國片 | 10天 | 80 | 10 |
現在需求改變,為了鼓勵國片,決定調降租金,並且延長租期。
測試案例
- 普通片1支,10天100 + (10-7) * 10 = 130
- 新片1支,5天150 + (5-3) * 30 = 210
- 兒童片1支,8天40 + (8-7) * 10 = 50
- 國片1支,12天80 + (12-10) * 10 = 100
新增國片測試案例,得到第一個紅燈。
新增
TaiwanMovieType
,負責國片的計費方式。102102在左側選擇VideoRental
目錄,按熱鍵⌃ + N,會出現New
選單,選擇PHP Class
,可幫我們自動建立新的class。
出現Create New PHP class對話框,class名稱輸入
TaiwanMovieType
,namespace選擇VideoRental
。
PhpStorm會幫我們建立
TaiwanMovieType
。
繼承
AbstractMovieType
,使用PhpStorm幫我們建立abstract class所定義的method。103103將滑鼠游標放在AbstractMovieType
之後,按熱鍵⌥ + ↩,會出現Add method stubs
,按下可自動根據所繼承的abstract class
建立method。
PhpStorm會幫我們建立
calculatePrice()
,連註解也會一併建立。
補上國片的計費方式。
馬上跑測試,確認新增的國片測試案例是否正常。104104GitHub Commit : 重構 : 開放封閉原則
若使用原本switch的方式,無論switch寫在哪裡,只要新增功能,就一定要去改switch,這就違反了SOLID的開放封閉原則,若使用物件導向的多型之後,若新增功能,只要新增class,繼承abstract class即可,原來的程式完全不用修改,完全達到開放封閉原則的要求。
我們做了哪些重構?
- Extract Method : 將原本很長的函式利用Extract Method拆解成數個小小的method。
- Move Method : 利用Move Method將method搬到它適合的class內。
- 使用多型取代switch : 若程式內有
switch
,考慮使用物件導向多型的State模式或Strategy模式取代,達成SOLID的開放封閉原則。
偵錯
假設在國片的計費方式,故意將逾期費用
10
元改成5
元,我們該如何找到這個bug呢?使用測試案例偵錯
執行測試,發現錯在
test_order_1_taiwan_movie_with_12_days()
這個測試案例,且期望值是100,而實際值是90。
且由於每個測試案例是針對單一class的method,我們可以很快的鎖定問題是出在
Customer->calculatePrice()
的錯誤。使用PhpStorm偵錯
PhpStorm允許我們直接在PHP內下中斷點,假如你知道問題在哪裡,可以直接在該class的method內下中斷點,若完全沒有頭緒,可以在
act
之處下中斷點,最少在執行target的method前會停下來。105105在欲中斷的程式之處按熱鍵 : ⌘ + F8,可設定或取消中斷點。
啟動偵錯模式。106106在欲啟動偵錯的測試案例內,按熱鍵 : ⌃ + ⇧ + D啟動偵錯模式,程式會停在剛剛建立的中斷點。
Step Into進
Step Over : 熱鍵 : F8
Step Out : 熱鍵 : ⇧ + F8
Customer->calculateTotalPrice()
。107107Step Into : 熱鍵 : F7Step Over : 熱鍵 : F8
Step Out : 熱鍵 : ⇧ + F8
找到root cause在
TaiwanMovieType
的calculatePrice()
的18行有問題。學習資源
- 重構 : 重構 : 改善既有程式的設計 (二版)
- 設計模式 : 大話設計模式
學習方式
- 直接由設計模式學習物件導向的學習曲線較為陡峭,很吃天份,失敗機率較高。
- 學習測試與重構達成設計模式的學習曲線較為平緩,適合常人,成功機率較高。
Conclusion
- 好程式不是設計出來的,而是重構出來的。
- 重構一定要搭配測試。TDD讓我們以Top Down方式,以需求出發,幫助我們抽象化思考,不用太早就去思考細節,可以更容易設計出符合SOLID的物件導向程式。
- 測試重構會多花一點時間,因此我們要選擇更強悍的工具將時間省回來。
Sample Code
完整的範例可以在我的GitHub上找到。
from : http://oomusou.io/phpstorm/phpstorm-tdd-refactor/
沒有留言:
張貼留言