如何使用 PhpStorm 對 Collection 除錯 ?
Laravel 的 Collection
在實務上非常好用,除了 Eloquent 直接回傳 Collection
外,還擴充了很多 method,讓我們可以使用 higher order function 與 fluent 風格開發,讓程式可讀性更高。不過 Collection
的除錯就比較麻煩,本文使用 PhpStorm 內建的 Watches,讓我們可以在不用修改程式碼的前提下,快速對 Collection
除錯。
Version
PHP 7.0.0
Laravel 5.2.39
PhpStorm 2016.1.2
Motivation
在看了 Adam Wathan 的 Refactoring to Collections 之後,發現這種 declarative 式的程式風格,不僅程式碼更精簡,可讀性更高,也符合 SOLID 原則的單一職責,因此開始大量使用 Collection
內建的 method 來寫程式。
但由於是 fluent 風格的程式,因此在 debug 時面臨困難,必須修改程式碼,加上很多暫存變數,設定中斷點後,透過 Variables 去觀察暫存變數,等除錯完後,再透過重構的 Inline Variable
去合併變數。
在 Freek Van der Herten 的 Debugging collections 一文中,提出了使用了 Collection Macro 配合 dd()
的方式,這種方式就不需要增加暫存變數,只要在要 debug 的 method 之後加上 ->dd()
即可,非常方便。
不過唯一小小的可惜是,這種方式仍然需要去修改程式碼去加上 ->dd()
,是否可能在完全不需修改程式碼的前提下,快速對 Collection
除錯呢?
實際案例
我們將以 Order
model 為例,顯示今天全部訂單金額
,並寫單元測試判斷結果是否如預期。
單元測試
以 TDD 方式開發,因此必須先寫單元測試。
OrderServiceTest.php1 1GitHub 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<?php
use App\Order;
use App\Services\OrderService;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class OrderServiceTest extends TestCase
{
use DatabaseMigrations;
/** @test */
public function 今天全部訂單金額()
{
/** Arrange */
Carbon::setTestNow(Carbon::create(2016, 6, 18));
Order::create([
'order_date' => Carbon::create(2016, 6, 17),
'quantity' => 1,
'price' => 100
]);
Order::create([
'order_date' => Carbon::create(2016, 6, 18),
'quantity' => 2,
'price' => 200
]);
Order::create([
'order_date' => Carbon::create(2016, 6, 18),
'quantity' => 3,
'price' => 300
]);
$expected = 1300;
/** Act */
$actual = app(OrderService::class)->calculateTodayTotalAmount();
/** Assert */
$this->assertEquals($expected, $actual);
}
}
16 行1
Carbon::setTestNow(Carbon::create(2016, 6, 18));
由於需求是今天全部訂單金額
,勢必使用 Carbon::now()
回傳今天日期,但 Carbon::now()
回傳的每天的真實日期,並不是個固定值,這將造成測試困難,因此 Carbon 提供了 setTestNow()
讓我們自行設定測試用的日期,讓 Carbon::now()
回傳我們預期的日期,這是寫單元測試常用的手法。
18 行1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17Order::create([
'order_date' => Carbon::create(2016, 6, 17),
'quantity' => 1,
'price' => 100
]);
Order::create([
'order_date' => Carbon::create(2016, 6, 18),
'quantity' => 2,
'price' => 200
]);
Order::create([
'order_date' => Carbon::create(2016, 6, 18),
'quantity' => 3,
'price' => 300
]);
由於我們是使用 SQLite in Memory 做測試,每個測試案例執行完就會釋放記憶體,所以除了需要重新 migration 外,還要重新塞假資料進資料庫。
由於我們要測試的日期為 2016, 6, 18
,除了塞兩筆 2016, 6, 18
資料外,還多塞了一筆 2016, 6, 17
,目的要測試日期時間有沒有抓錯。
36 行1
$expected = 1300;
根據我們所塞的假資料,人工計算其期望值為 1300
,將以此值與測試所得的實際值做 assertion。
38 行1
2/** Act */
$actual = app(OrderService::class)->calculateTodayTotalAmount();
實際建立 OrderService
物件,並測試 calculateTodayTotalAmount()
。2 2此時 OrderService
與 calculateTodayTotalAmount()
都還沒建立,TDD 會等待測試亮 紅燈 時,才去新增 OrderService
與 calculateTodayTotalAmount()
。
除了使用 app()
helper function 外,也可以使用 Facade 版本的 App::make()
,但不建議使用 new
,因為實務上待測物件可能會搭配依賴注入,若使用 new
必須自己在 constructor 輸入參數,非常麻煩,使用 app()
或 App::make()
後, Laravel 會自行依照 constructor 的 type hint 依賴注入,非常方便。
41 行1
2/** Assert */
$this->assertEquals($expected, $actual);
最後使用 assertEquals()
判斷期望值與實際值是否相等。
OrderService
實際跑測試,會得到第 1 個 紅燈,PHPUnit 抱怨 OrderService
與 calculateTodayTotalAmount()
尚未建立,須趕快補上。
OrderService.php3 3GitHub Commit : 建立 foreach 版本 OrderService1
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
37namespace App\Services;
use App\Order;
use Carbon\Carbon;
class OrderService
{
/** @var Order */
private $order;
/**
* OrderService constructor.
* @param Order $order
*/
public function __construct(Order $order)
{
$this->order = $order;
}
/**
* 計算今天全部訂單金額
* @return int
*/
public function calculateTodayTotalAmount() : int
{
$totalAmount = 0;
$orders = $this->order->all();
foreach($orders as $order) {
if ($order->order_date == Carbon::now()) {
$totalAmount = $totalAmount + $order->quantity * $order->price;
}
}
return $totalAmount;
}
}
第 8 行1
2
3
4
5
6
7
8
9
10
11/** @var Order */
private $order;
/**
* OrderService constructor.
* @param Order $order
*/
public function __construct(Order $order)
{
$this->order = $order;
}
使用 constructor injection 注入 Order
model。
20 行1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17/**
* 計算今天全部訂單金額
* @return int
*/
public function calculateTodayTotalAmount() : int
{
$totalAmount = 0;
$orders = $this->order->all();
foreach($orders as $order) {
if ($order->order_date == Carbon::now()) {
$totalAmount = $totalAmount + $order->quantity * $order->price;
}
}
return $totalAmount;
}
由於需求是今天全部訂單金額
,我們先建立一個 $totalAmout
初始變數,再由 $this->orders->all()
傳回資料庫目前所有訂單。4 4實務上不會直接使用 $this->order->all()
的方式回傳 Order
model 的所有資料,這裡只是為了範例便宜行事,應該從 OrderRepository
傳回必要的資料即可,這樣才不會造成 MySQL 與 PHP 的負擔。
接著使用 foreach
對全部 orders
判斷,只有 order_date
為 今天
,也就是等於 Carbon::now()
才加以計算。
訂單金額並沒有直接一個欄位,需要使用 $order->quantity
* $order->price
加以計算,才能與 $totalAmount
相加。
得到第 1 個 綠燈,完成 OrderService
。
使用 Collection 重構
以上為典型的 Imperative Programming 寫法,透過暫存變數 $totalAmount
,迴圈 foreach()
與判斷式 if
的方式寫程式,這也是過去我們習慣的 PHP 風格。
這種方式的缺點是程式可讀性較差,當你在 trace calculateTodayTotalAmount()
時,需馬上與一堆變數、迴圈與判斷式纏鬥,而不能一眼就看出程式所有表達的意思。
OrderService.php5 5GitHub Commit : 建立 Collection 版本的 OrderService1
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
37namespace App\Services;
use App\Order;
use Carbon\Carbon;
class OrderService
{
/** @var Order */
private $order;
/**
* OrderService constructor.
* @param Order $order
*/
public function __construct(Order $order)
{
$this->order = $order;
}
/**
* 計算今天全部訂單金額
* @return int
*/
public function calculateTodayTotalAmount() : int
{
return $this->order->all()
->filter(function ($value) {
return $value->order_date == Carbon::now();
})
->map(function ($value) {
return $value->quantity * $value->price;
})
->reduce(function ($carry, $value) {
return $carry + $value;
});
}
}
20 行1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17/**
* 計算今天全部訂單金額
* @return int
*/
public function calculateTodayTotalAmount() : int
{
return $this->order->all()
->filter(function ($value) {
return $value->order_date == Carbon::now();
})
->map(function ($value) {
return $value->quantity * $value->price;
})
->reduce(function ($carry, $value) {
return $carry + $value;
});
}
需求為 今天全部訂單金額
,因此有以下幾個重點 :
- 今天 : 必須先過濾出
今天
的資料。 - 金額 : 必須先由
$order->quantity
*$order->price
計算金額
。 - 全部訂單 : 必須由
$totalAmout
計算全部訂單
金額。
$this->order->all()
回傳的為 Collection
,事實上 Laravel 的 Collection
內建非常多的 method,可直接使用。6 6詳細請參考 Laravel 的官方文件 : Collections
若使用 Collection
的 method,可改寫成 :
- filter() : 由
filter()
過濾出今天
的資料。 - map() : 由
map()
計算出$order->quantity
*$order->price
。 - reduce() : 由
reduce()
計算出$totalAmount
。
27 行1
2
3->filter(function ($value) {
return $value->order_date == Carbon::now();
})
filter()
要求傳入一個 closure,第 1 個參數為 $value
,第 2 個參數為 $key
,只要在 closure 內 return filter()
所需要的布林條件式即可。7 7詳細請參考 Laravel 官方文件 : filter()
以本例來說,$value
就是 foreach
中 $orders
的 $order
,所以其 filter()
條件為 $value->order_date == Carbon::now()
。
30 行1
2
3->map(function ($value) {
return $value->quantity * $value->price;
})
map()
要求傳傳入一個 closure,第 1 個參數為 $value
,第 2 個參數為 $key
,只要在 closure 內 return map()
要成為的新值即可。8 8詳細請參考 Laravel 官方文件 : map()
以本例來說,$value
就是 foreach
中 $orders
的 $order
,map()
之後的新值為 $order->quantity
* $order->price
。
33 行1
2
3->reduce(function ($carry, $value) {
return $carry + $value;
});
reduce()
要求傳傳入一個 closure,第 1 個參數為 $carry
,第 2 個參數為 $value
,其中 $carry
為下一次執行 reduce()
時的累加值,只要在 closure 內 return 下一次執行 reduce()
時 $carry
的新值即可。9 9詳細請參考 Laravel 官方文件 : reduce()
以本例來說,$value
就是 foreach
中 $orders
的 $order
,而 $carry
就是 $totalAmount
。
得到第 2 個 綠燈,使用 Collection
重構 OrderService
。
將 Closure 加以重構
使用 filter()
、map()
與 reduce()
搭配 closure 的寫法,已經比 Imperative Programming 寫法精簡,但 closure 部分可讀性還不是很高,需要進一步重構。
選擇 filter()
內部的 closure,按熱鍵 ⌃ + T,出現 Refactor This
選單,選擇 7.Method
。
Visibility 選擇 Private
,在函式名稱輸入 filterToToday
。
PhpStorm 會替我們將 closure 重構出新的 filterToToday()
。
重構後趕快跑單元測試,確認 PhpStorm 有沒有改壞。
將 map()
與 reduce()
的 closure 也依照以上方式加以重構成 private method。
OrderService.php10 10GitHub Commit : 將 Closure 加以重構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
65namespace App\Services;
use App\Order;
use Carbon\Carbon;
use Closure;
class OrderService
{
/** @var Order */
private $order;
/**
* OrderService constructor.
* @param Order $order
*/
public function __construct(Order $order)
{
$this->order = $order;
}
/**
* 計算今天全部訂單金額
* @return int
*/
public function calculateTodayTotalAmount() : int
{
return $this->order->all()
->filter($this->filterToToday())
->map($this->mapToAmount())
->reduce($this->reduceToTotalAmount());
}
/**
* 只有今天的訂單
* @return Closure
*/
private function filterToToday()
{
return function ($value) {
return $value->order_date == Carbon::now();
};
}
/**
* 換算成金額
* @return Closure
*/
private function mapToAmount()
{
return function ($value) {
return $value->quantity * $value->price;
};
}
/**
* 換算成總金額
* @return Closure
*/
private function reduceToTotalAmount()
{
return function ($carry, $value) {
return $carry + $value;
};
}
}
22 行1
2
3
4
5
6
7
8
9
10
11/**
* 計算今天全部訂單金額
* @return int
*/
public function calculateTodayTotalAmount() : int
{
return $this->order->all()
->filter($this->filterToToday())
->map($this->mapToAmount())
->reduce($this->reduceToTotalAmount());
}
最後程式碼會重構成這樣,可讀性很高,就跟口語敘述一樣直覺。
- 先由
Order
model 傳回所有資料。 - 再透過
filter()
過濾今天
的資料。 - 再透過
map()
計算出金額
。 - 最後由
reduce()
計算出總金額
。
至於怎麼過濾
、計算
,那是 closure 的事情,若有必要再繼續 trace 下去,不需一開始就面臨一堆變數、迴圈與判斷,這就是所謂的 Declarative Programming 只重視 我們要做什麼,而不是要如何做
。
44 行1
2
3
4
5
6
7
8
9
10/**
* 換算成金額
* @return Closure
*/
private function mapToAmount()
{
return function ($value) {
return $value->quantity * $value->price;
};
}
由 closure 所重構出來的 method,也都符合 SOLID 的單一職責原則,將來若有需要,可以再進一步的重構成 interface 與 trait。
重構後趕快跑單元測試,確認 PhpStorm 有沒有改壞。
使用 Watches 除錯
使用 Declarative Programming 方式,程式可讀性雖然高,但除錯則面臨很大的挑戰,由於其 fluent 風格,基本上程式只有一行,因此無從下中斷點觀察變數,只能在除錯時加上很多暫存變數觀察。
加了 $aa
、$bb
… $dd
等暫存變數,雖然可以在 Debug Window 的 Variables 加以觀察,但必須修改程式碼,雖然之後可以靠重構的 Inline Variable
加以還原,但還是很麻煩。
比較理想的方式是使用 Debug Windw 的 Watches,比如說我們只想除錯 $this->order->all()
,用滑鼠選擇 $this->order->all()
,按滑鼠右鍵選擇 Add to Watches
。
$this->order->all()
將會新增到右側下方的 Watches,可直接展開觀察結果。
同理若要除錯 $this->orders->all()->filter($this->filterToToday())
,可將 $this->orders->all()->filter($this->filterToToday())
選起來,按滑鼠右鍵選擇 Add to Watches
。
$this->orders->all()->filter($this->filterToToday())
將會新增到右側下方的 Watches,可直接展開觀察結果。
你也可以將 Collection
的每個過程全部加到 Watches,且只要你不刪除,Watches 就永遠存在,將來除錯還可以繼續用,這樣就可以達到不用修改程式碼,又可以對 Collection
除錯的目的。
Conclusion
- Declarative 比 Imperative 方式更精簡,程式可讀性更高,也更符合單一職責原則。
- Laravel 的
Collection
非常好用,但除錯一直是大家的夢靨,透過 PhpStorm 的 Watches,不僅不用修改程式碼,也可以繼續使用中斷點的除錯方式。
Sample Code
完整的範例可以在我的 GitHub 上找到。
Reference
- Adam Watham, Refactoring to Collections
- Freek Van der Herten, Debugging collections
- Taylor Otwell, Laravel Collections
- PhpStorm 2016.1 Help, Debug Tool Window.Watches