使用 Watches 替 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 : 單元測試 : 今天全部訂單金額

tests/Unit/OrderServiceTest.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
<?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
17
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
]);

由於我們是使用 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此時 OrderServicecalculateTodayTotalAmount() 都還沒建立,TDD 會等待測試亮 紅燈 時,才去新增 OrderServicecalculateTodayTotalAmount()

除了使用 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 抱怨 OrderServicecalculateTodayTotalAmount() 尚未建立,須趕快補上。

OrderService.php3 3GitHub Commit : 建立 foreach 版本 OrderService

app/Services/OrderService.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
namespace 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 版本的 OrderService

app/Services/OrderService.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
namespace 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$ordermap() 之後的新值為 $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 加以重構

app/Services/OrderService.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
namespace 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());
}

最後程式碼會重構成這樣,可讀性很高,就跟口語敘述一樣直覺。

  1. 先由 Order model 傳回所有資料。
  2. 再透過 filter() 過濾今天的資料。
  3. 再透過 map() 計算出金額
  4. 最後由 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