如何使用 TypeScript 實現 Chain Of Responsibility Pattern ?
Chain of Responsibility 是 OOP 中著名的 Design Pattern,在特殊場合使用特別有效。在本文中,我們將以 Angular 與 TypeScript 實現。
Version
macOS High Sierra 10.13.3
Node.js 8.9.4
Angular CLI 1.7.0
TypeScript 2.5.3
Angular 5.2.5
Wallaby.js 3.9.4
User Story
- 檢查
產品編號
- 必須為
整數
,2
的倍數與3
的倍數才能傳回true
,否則均傳回false
Task
先使用一般的寫法完成,最後再重構成 Chain of Responsbility。
Definition
Chain of Responsibility Pattern
當多個物件都必須依序處理訊息,訂定統一個 interface,避免 client 與物件直接耦合
將外部 nested if
改由外部決定
的物件串列
表示
- Client :
ConcreteHandler
的 user,實務上可能是 component 或 service - HandlerInterface : 定義
ConcreteHandler
的 interface - AbstractHandler : 實作各
ConcreteHandler
共用的部分 - ConcreteHandler : 將
if
封裝成物件
一個 if
放在一個 ConcreteHandler
物件內,當一個 if
判斷完後,就換下一個 ConcreteHandler
物件。
由於物件是串起來的,因此稱為 Chain
of Responsibility。
適用時機
- 深層
nested if
- 須在 run-time 決定
if
組合 if
層數不確定,由 run-time 決定
優點
- 每個
if 判斷
使用一個 class,符合單一職責原則
- 將來若有新的
if 判斷
,不用修改 service,而是新增ConcreteHandler
,符合開放封閉原則
- Client 與
if 判斷
解耦合,兩者都僅相依於 interface,符合依賴反轉原則
if
邏輯物件化後,可在 run-time 自由組合與加入if
物件 (組合ConcreteHandler
)
缺點
- Chain 太長時會有效能問題
Architecture
AppComponent
相當於Client
ProductNoChecker
相當於 service,AppComponent
負責注入ProductNoChecker
,無論怎麼重構,ProductNoChecker
都是穩定的,不會導致AppComponent
修改CheckerInterface
相當於HandlerInterface
,訂出所有 checker 的標準AbstractChecker
相當於AbstractHandler
,nextChecker()
相當於next()
,負責處理下一個 checker 部分IntegerChecker
相當於ConcreteHandler
,為實際if 判斷
邏輯。
Implemetation
app.component.html
1 | <input type="text" #productNo> |
若要在 JavaScript 存取 HTML,傳統會使用 id
或 CSS selector,在 Angular 提出新的方法,我們可以為 HTML 加上 #
開頭的 Template Reference Variable。
app.component.ts
1 | import { Component, ElementRef, ViewChild } from '@angular/core'; |
12 行
1 | @ViewChild('productNo') |
使用 @ViewChild()
取得 DOM element 的物件實體,參數以字串傳入 Template Reference Variable 的字串名稱。
注意其型別為 ElementRef
。
15 行
1 | constructor(private productNoChecker: ProductNoChecker) { |
將 ProductNoChecker
透過 DI 注入進 AppComponent
。
18 行
1 | onCheckProductNoClick() { |
當按下 <button>
時,執行 onCheckProductNoClick()
。
藉由 @ViewChild()
所宣告的變數,取得 DOM element 的值,因為為 string,再由 parseInt()
轉成 integer
。
最後呼叫 ProductNoChecker.check()
,判斷所輸入的 ProductNo
是否符合商業邏輯。
app.module.ts
1 | import {BrowserModule} from '@angular/platform-browser'; |
13 行
1 | providers: [ |
在 providers
設定 ProductNoChecker
,讓 DI 得以注入。
If Else
product-no.checker.ts
1 | import { Injectable } from '@angular/core'; |
由於必須通過三層檢查,初學者很容易寫出三層的 nested if
,這種寫法可讀性差也不好維護。
我們將繼續重構。
Unit Test
在重構之前,必須要有測試保護,才能確保沒把原本的商業邏輯重構壞,因此我們先準備好 ProductNoChecker
的 Unit Test,確保每個 if else
的 path 都有測到。
product-no.checker.spec.ts
1 | import { TestBed } from '@angular/core/testing'; |
由於本文重點不是在講 Unit Test,因此就不浪費篇幅解釋以上程式碼。
Default Value
product-no.checker.ts
1 | import { Injectable } from '@angular/core'; |
由於整個 nested if
只有 三個 if
都成立時才是 true
,其他都是 false
,因此可以將 result
的預設值設定為 false
,則所有的 else 都可拿掉。
但仍有三層 nested if
,還是不夠好,我們將繼續重構。
Guard Clause
product-no.checker.ts
1 | import { Injectable } from '@angular/core'; |
Guard Clause
將 return false 的邏輯先處理,最後再來處理 return true,所有的 nested if 將攤平成只有一層
先處理掉 return false
之後,剩下的事實上就是 else
,因此就不需要 nested if 了。
實務上推薦使用 Guard Clause,變免使用 nested if 與 else
Nested if
經過 Guard Clause 重構後,已經相當清爽了,但仍然還沒進入 OOP 層次,我們將繼續重構。
Chain of Responsibility
CheckerInterface
checker.interface.ts
1 | export interface CheckerInterface { |
我們即將為每個 if
建立一個 class,先定義其 interface。
setNextChecker()
: 由於每個 class 都是一個if
,因此我們必須設定下一個if
是哪一個 class 該檢查check()
: 每個 class 寫if
的地方
ProductNoChecker
product-no.checker.ts
1 | import { Injectable } from '@angular/core'; |
由於我們是重構,且 AppComponent
所相依的是 ProductNoChecker.check()
,因此 check()
的 signature 不應該修改。
第 9 行
1 | check(productNo: number): boolean { |
由於 Chain of Responsibility 是一個 object 一個 object 依序檢查,因此只要先 new 第一個 IntegerChecker
即可,注意其型別為 CheckerInterface
,這才符合 依賴反轉原則
。
接下來必須使用 setNextChecker()
依序設定下一個 checker,這也是我們可以在 runtime 自由組合 if 判斷
的地方。
最後執行 IntegerChecker.check()
開始檢查 ProductNo
。
若是直接使用
if
,則if
將在 compile-time 就被決定無法更改,但使用 Chain of Responsibility 可以讓我們在 run-time 自行new
決定是否使用這個if
AbstractChecker
abstract.checker.ts
1 | import { CheckerInterface } from './checker.interface'; |
雖然 CheckerInterface
為每個 checker 都須具備的 interface,但其中的 setNextChecker()
顯然並不需要每個 checker 自己實踐,可將共用的部分交由 AbstractChecker
處理。
第 4 行
1 | protected nextChecker: CheckerInterface; |
每個 checker 都必須紀錄下一個 checker 為何,因此特別宣告 nextChecker
field。
由於將來繼承 AbstractChecker
的 class 都要使用,因此為 protected
。
注意其型別為 CheckerInterface
。
第 6 行
1 | setNextChecker(checker: CheckerInterface): CheckerInterface { |
原本寫法為
1 | setNextChecker(checker: CheckerInterface): CheckerInterface { |
但可以將兩行併成一行完成。
10 行
1 | abstract check(source: number): boolean; |
check()
需要由各 class 的自行完成,因此 AbstractChecker
不實作,只設定成 abstract
交由所繼承的 class 完成。
IntegerChecker
integer.checker.ts
1 | import { AbstractChecker } from './abstract.checker'; |
一樣使用 Guard Clause 的方式做判斷。
第 3 行
1 | export class IntegerChecker extends AbstractChecker |
IntegerChecker
也必須遵守 CheckInterface
,因為 AbstractChecker
已經實現 CheckerInterface
,所以 IntegerChecker
只要繼承 AbstractChecker
即可。
第 5 行
1 | if (!Number.isInteger(source)) { |
若不是 integer
,則 return false
。
第 9 行
1 | if (!this.nextChecker) { |
若沒有 nextChecker
,則表示為最後一個 checker,return true
。
13 行
1 | return this.nextChecker.check(source); |
若有 nextChecker
,則執行下一個 cheker 繼續檢查。
DoubleChecker
與 TripleChecker
的寫法類似,就不再贅述。
目前為止,我們已經在 Angular 實現了經典的 Chain of Resposibility Pattern,但還可以對此 pattern 做小幅重構。
Refactoring
IntegerChecker
integer.checker.ts
1 | import { AbstractChecker } from './abstract.checker'; |
使用 Guard Clause 寫法雖然已經非常精簡,但若使用 ?:
,則可寫出更精簡,且可讀性更高的寫法。
DoubleChecker
與 TripleChecker
的寫法類似,就不再贅述。
Functional Way
Chain of Responsibility 雖然是 OOP 的 Design Pattern,但亦可使用 FP 實踐。
checker.interface.ts
1 | export interface CheckerInterface { |
之前 CheckerInterface
雖然定義了 setNextChecker()
與 check()
兩個 method,但平心而論,只有 check()
才是真的 if
所要用的 method,setNextChecker()
算是 pattern 本身所使用的 method。
也就是說若我們能將 class interface 退化成 function interface,只描述 check()
的 signature,則 Chain of Responsibility 不一定得用 class 才能實踐,只要 function 即可。
TypeScript 的 interface 可以只描述 function 規格 :
- Input 以
()
表示 - 沒有 function name
- Return 寫在
:
之後
TypeScript 的 interface 不再只是描述 class 的規格,也可以描述 function 的規格
integer.checker.ts
1 | export function checkInteger(source: number): boolean { |
integer.checker.ts
不用再使用 class,使用 function 即可,當然要符合 checker.interface.ts
的規格。
我們可發現 FP 版本的
checkInteger()
甚至不用判斷nextChecker()
,專心的判斷isInteger()
即可,比 OOP 版更加符合單一職責原則
product-no.checker.ts
1 | import { Injectable } from '@angular/core'; |
第 9 行
1 | checkers: CheckerInterface[]; |
存放所有 checker function 的陣列,別忘了之前已經定義了 CheckerInterface
的 function interface,在此更可描述 checkers
陣列所放的全部都是 CheckerInterface
的 function,除此之外,TypeScript 編譯器也會加以檢查,若function 不符合 CheckerInterface
規格,將編譯錯誤。
儘管使用了 FP,一樣要使用 interface,才能受到編譯器的保護
11 行
1 | constructor() { |
定義 checkers
陣列該含有哪些 checker function,類似 OOP 版的 setNextChecker()
。
若這些 checker function 需經常變動,還可以將
checkers
放在 config 檔,且 checker function 還受到 TypeScript 編譯器保護,符合開放封閉原則
19 行
1 | check(productNo: number): boolean { |
every()
為 JavaScript 提供的 array method,將會執行 array 內所有的 function,若其中任何一個 function 回傳 false
,則回傳 false
,必須全部 function 回傳 true
時,才會回傳 true
,與 OOP 版的 Chain of Responsibility 有異曲同工之妙。
OOP 版當然也可以使用陣列內放
ConcreteHandler
方式,不過由於沒有 FP 版精妙,因此不特別說明
Refactoring
原本 OOP 版是只要回傳 false
,就不會執行下一個 object;但 Array.every()
卻要求我們執行所有的 function,所以效能較差。
JavaScript 原生提供了 Array.some()
,只要回傳 true
,就不會執行下一個 function,跟我們的需求很類似,但又不完全一樣,因此只好自己打造 Array.any()
。
19 行
1 | check(productNo: number): boolean { |
由於 Array.some()
是判斷 true
,所以須將 fn(item)
加上 !
反向迎合 some()
,但最後 this.som()
還必須再加上 !
還原真實狀況。
使用 prototype 打造了自己的 any()
之後,就不用擔心 every()
影響效能了。
Summary
- FP 版的
checkers
陣列甚至可以改寫在 config 檔案,彈性比 OOP 更大 - FP 也要使用 interface,這樣編譯器才能幫你做檢查
Conclusion
- 並不是所有的
if
判斷都該使用 Chain of Responsibility,當特別深層的nested if
,或需要 runtime 自由組合if 判斷
,或if
層數不確定時,就適合使用 - FP 的出現,讓 Design Pattern 的實踐方式,不再只有 OOP 一途,可視實際需求決定該使用 OOP 或 FP
- 無論是 OOP 或 FP,最終都是符合 SOLID 的
開放封閉原則
與依賴反轉原則
,通常 FP 的實踐都會比 OOP 更精簡
Sample Code
完整的範例可以在我的 GitHub 上找到