2016年9月9日 星期五

Laradiner 讀書會 - Laravel: From Apprentice To Artisan - Ch. 6 Applied Architecture: Decoupling Handlers (解耦合處理程序) 導讀心得整理筆記

介紹

前置作業 (Demo 帶快速講解 15 分鐘)

Git Repo 按此

  1. laravel new ch6_demo
  2. cd ch6_demo
  3. php artisan key:generate
  4. php artisan serve 開一個 web server 測試用
  5. 用 CDN 加入 bootstrap css,快速建 form 表單
  6. php artisan make:controller SmsController
  7. 設 routes 測試 controller (新手注意:有雷,CSRF Token)
  8. php artisan make:event SendSMSEvent 建立事件
  9. php artisan handler:event SendSMS --event SendSMSEvent --queued 建立事件的處理 Handler
  10. 在 EventServiceProvider 註冊 event handler (Ref: Registering Events / Listeners)
  11. 把 form data 傳入 event 物件,在 handler 中取出
  12. 實作 handler (SendSMS class)
  13. 設定 .env 檔並建立資料庫 ch6_demo (demo/secret)
  14. php artisan migrate (建立 User Table)
  15. php artisan make:seeder UsersSeeder 建立 seeder 用來存假 User 資料,並編輯 DatabaseSeeder.php
  16. php artisan db:seed (或 php artisan migrate –seed),記得重啟 php artisan serve
  17. php artisan migrate:refresh --seed 重建資料庫及匯入假 User 資料
  18. php artisan make:model Messages --migration 建 messages table 和 model class
  19. 清空資料庫,重新 php artisan migrate --seed,記得重啟 php artisan serve
  20. 建立 user 和 messages 兩表之間的 relation (Ref: One to Many Relation)

解耦合 handler

這裡書中使用一段 Code 來解釋「解耦合 handler」的概念,其實講的就是 SOLID 中的「Single Responsibility Principle」單一職責原則。它給了一個傳簡訊(SMS)序列(Queue)的例子,用的版本是 Laravel 4 ,下面這段 Code 已經被我用 Laravel 5.1 的 Event 機制改寫過和原書中稍有不同,大意是當有要傳送簡訊的 event 被送進 Queue 裡,按 Queue 的機制,會呼叫對應的 handler 來處理這個 event,所謂的 handler 就是指 SendSMS 這個類別,Queue 會讓 handler 類別中的 handle 方法被觸發。
handler 透過實例化 Mitake_SMS 這個類別,使用 sendTextMessage 方法呼叫第三方的 API 完成傳送線上簡訊(SMS)。在發出簡訊後,把送出的簡訊用 Eloquent 物件存進本地的資料庫中做為 log 紀錄起來,且和 User 進行關聯,這樣如有需要我們就可以查詢所有這個 User 送出過的簡訊歷史紀錄。原書中存進資料庫的是「傳給該 User 的簡訊」,為了講解方便我這邊用的例子是存「該 User 送出過的簡訊」,我有對原始碼稍做修改,講起來比較順也比較容易和實務對應做理解,之後會以這段 Code 為實例 demo 怎樣解耦合,後續實例也都以此為基礎。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class SendSMS implements ShouldQueue {

    use InteractsWithQueue;

    public function handle(SendSMSEvent $event)
    {
        $data = $event->getData();
        $mitake = new Mitake_SMS($this->apiKey);
        $mitake->sendTextMessage([
            'to'      => $data['phone'],
            'message' => $data['message'],
        ]);

        $user = \App\User::find($data['user']['id']);
        $user->messages()->create([
            'to'      => $data['phone'],
            'message' => $data['message'],
        ]);
    }
}

在這段 Code 的例子中,你有沒有看出來什麼問題?(2 分鐘)

可往 可維護性(Maintainability)、可擴充性(Scalability)、可測試性(Testability)、重用性(Reusability) … 來想
[閃亮亮PS] : 如果你對這段 Code 的實作細節有興趣,你可以從下面連結瞭解 Laravel 的 Eloquent 和 Event Handler 的用法:
  1. Retrieving Single Models / Aggregates
  2. One To Many Relation
  3. Inserting Related Models (示例在第三小點:The Create Method)
  4. Laravel Events (建議完整看完,沒有很長)




問題與挑戰

這段 Code 的問題在於:

  1. 這一段 Code 很難被「自動化地」測試,因為在 handle 方法中直接實例化 Mitake_SMS 這個類別,我們無法利用依賴注入 (DI) 的方式,注入一個 Mock 物件來進行測試。
  2. handle 方法裡直接使用 \App\User (Eloquent 物件) 導致另一個測試的問題,我們無法在不觸及資料庫的情況下對 handle 方法進行單元測試。
  3. 還有,因為傳送簡訊的主要邏輯寫在 handle 方法裡,我們要傳簡訊變成一定要經過 Queue 的機制才能傳送,使得傳簡訊邏輯和 Queue 緊密耦合。換種說法,就是我們沒辦法不用 Queue 來傳簡訊。

這些問題,帶來了幾個挑戰:

  • 挑戰 1:有沒有什麼寫法是可以換簡訊平台卻不需要修改已經寫好的 Production Code?
  • 挑戰 2:在修改最少的情況下,讓這個 Mitake_SMS 類別可以被 Mock 取代,進而測試 handle 方法
  • 挑戰 3:如何在不觸及資料庫操作的前提下,寫測試驗證 handle 方法內的處理邏輯?
  • 挑戰 4:如果我不想使用 Event 系統來發簡訊的話怎麼辦?

[閃亮亮PS] : 解法請來 Laradiner 看 Live Demo 或 GitHub Repo HERE,重構的過程比較重要,會一個一個挑戰完成,而不是一次完成四個。書中只示範了最終結果,而且省略了相關的一些 Controller 或是 Event 的原始碼,如果對 Laravel 不熟的朋友會比較難體會。
Demo (30 分鐘)

解耦合之後

以下是達成挑戰 1,2,3,4 之後,解耦合完成的結果。主要轉變是把 “傳簡訊” 這個行為交給了 User Model 不再直接透過 handler 執行,這樣就可以不用 Queue 也能傳簡訊。簡訊平台物件 courier 也不再直接實例化,改以參數傳入,也就是所謂的 Dependency Injection 依賴注入(俗稱 DI),達成解耦合。courier 因為是以參數傳入,不會綁死在 Mitake_SMS 這個類別上,方便切換簡訊平台,也方便測試時使用 Mock 物件替換 courier。但為了確認傳入的 courier 一定擁有並實作 sendTextMessage 這個方法,所以在參數列裡使用 type hinting 強制傳入的 courier 物件一定要實作 SmsCourierInterface 這個介面,介面中則定義一定要實作 sendTextMessage 方法。改寫過後,handler 的職責只剩一個:給 user 物件足夠資訊,叫 user 傳簡訊。也就是書中所指的 Translation Layer 唯一應該做的事情
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class SendSMS implements ShouldQueue
{
    use InteractsWithQueue;

    private $users;
    private $courier;

    public function __construct(UserRepository $repo, SmsCourierInterface $courier)
    {
        $this->users = $repo;
        $this->courier = $courier;
    }

    public function handle(SendSMSEvent $event)
    {
        $data = $event->getData();

        $user = $this->users->find($data['user']['id']);

        $user->sendSms($this->courier, $data['message'], $data['phone']);
    }
}
這邊是測試程式碼
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
class SmsTest extends TestCase
{
    /** @test */
    public function it_should_send_sms_message()
    {
        // Arrange
        $data = [
            'user'    => ['id' => 1],
            'phone'   => '12345678',
            'message' => 'test message here...'
        ];

        $user = Mockery::mock('\App\User[sms]'); // partial mock
        $relation = Mockery::mock('stdClass');
        $courier = Mockery::mock(SmsCourierInterface::class);

        $user->shouldReceive('sms')->once()->andReturn($relation);
        $relation->shouldReceive('create')->once()->with([
            'to' => $data['phone'],
            'message' => $data['message'],
        ]);

        $courier->shouldReceive('sendTextMessage')->once()->with([
            'to'      => $data['phone'],
            'message' => $data['message']
        ]);

        // Act & Assert
        $user->sendSms($courier, $data['message'], $data['phone']);
    }
}

其他 Handlers

這裡提的另一個例子比較單純,總而言之就是教你如何讓你的 handler 只做單純的 Translation Layer,把一些複雜的商業邏輯隔離在 Framework 的架構之外。想想看,這段 Code 又出了什麼問題?
1
2
3
Router::filter('premium', function() {
   return Auth::user() && Auth::user()->plan == 'premium';
});
[閃亮亮PS] : Laravel 5.1 中使用 middleware 來取代 filter ,所以這一段 return 的邏輯應該會出現是出現在某個 middleware 中。
你看!這麼簡短的 Code,看起來多麼無辜啊!怎麼可能會有什麼問題呢?
然而,雖然這是一段很小的 filter 程式碼,但它已經洩露太多國家機密(誤) 程式細節,好比,filter 居然知道 Auth 回傳的 user 物件中有一個叫 “plan” 的屬性,還知道,如果 “plan” 屬性等於 “premium” 表示可以擁有 premium 資格。這會埋下什麼地雷?
  1. 屬性名稱改了沒改到 filter。如果 plan 屬性被某人在 User 物件中改名成 privilege (也許是因為資料庫欄位改名稱了),卻沒注意到 filter 中也有用到 plan 這個屬性…
  2. 屬性值改了沒改到 filter。如果以後屬性要等於 “VIP” 而不是 “premium” 的話…
  3. 要改判斷 premium 資格的邏輯 filter 就得跟著改。例如,要擁有 ‘premium’ 資格還必需年消費總額到10萬元以上才行。
Sure,你當然可以說,那只要小心一點,記得改就好了。但如果有別人埋這個雷給你踩,在你花了數小時 debug 之後不氣死才怪!
好,怎麼改?看這樣有沒有好一點:
1
2
3
Router::filter('premium', function() {
    return Auth::user() && Auth::user()->isPremium();
});
多一個小小的 function 不會耗你多少效能啦,但上面3個問題都可以解決,且更有彈性了。就算修改 permium 資格的邏輯也完全不用動到原本寫好的 filter。已所不雷,勿施於人。

閃亮亮小結

我覺得書中這個例子裡,把 sendSms 的職責交給 User Model 還蠻合理的,User 可以 sendSms 唸起來就很順。但還是要小心,很容易就會讓 User Model 胖到爆掉,如果可以的話我自己是儘可能不放 User Model 就不放。另外建一個 Class,把傳簡訊的動作變成另一個 Service。也許,不是 User 也可以傳簡訊啊,例如如果簡訊是由系統自動送出的話,這樣不就沒有辦法關聯 User 了?
嗯,這次讀書會要 demo 前才發現 Laravel 5.1 有分 Event 和 Job ,兩個都有 Queue 的機制。還沒時間去研究差別和用途,期待讀書會朋友們的分享!

from : http://blog.dj1020.net/Laravel-From-Apprentice-To-Artisan-Ch-6-Applied-Architecture-Decoupling-Handlers-解耦合處理程序/

沒有留言:

wibiya widget