專門應付特殊場的 if else

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


cor000

  • 檢查 產品編號
  • 必須為 整數2 的倍數與 3 的倍數才能傳回 true,否則均傳回 false

Task


先使用一般的寫法完成,最後再重構成 Chain of Responsbility。

Definition

Chain of Responsibility Pattern

當多個物件都必須依序處理訊息,訂定統一個 interface,避免 client 與物件直接耦合
外部 nested if 改由 外部決定物件串列 表示

cor007

cor006

  • 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


cor001

  • AppComponent 相當於 Client
  • ProductNoChecker 相當於 service,AppComponent 負責注入 ProductNoChecker,無論怎麼重構,ProductNoChecker 都是穩定的,不會導致 AppComponent 修改
  • CheckerInterface 相當於 HandlerInterface,訂出所有 checker 的標準
  • AbstractChecker 相當於 AbstractHandlernextChecker() 相當於 next(),負責處理下一個 checker 部分
  • IntegerChecker 相當於 ConcreteHandler,為實際 if 判斷 邏輯。

Implemetation


app.component.html

1
2
3
4
<input type="text" #productNo>
<button (click)="onCheckProductNoClick()">Check ProductNo</button>
<p></p>
{{ result }}

若要在 JavaScript 存取 HTML,傳統會使用 id 或 CSS selector,在 Angular 提出新的方法,我們可以為 HTML 加上 # 開頭的 Template Reference Variable。

app.component.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { Component, ElementRef, ViewChild } from '@angular/core';
import { ProductNoChecker } from './checkers/product-no.checker';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
result: boolean;

@ViewChild('productNo')
private productNoElement: ElementRef;

constructor(private productNoChecker: ProductNoChecker) {
}

onCheckProductNoClick() {
const productNo = parseInt(this.productNoElement.nativeElement.value, 10);
this.result = this.productNoChecker.check(productNo);
}
}

12 行

1
2
@ViewChild('productNo')
private productNoElement: ElementRef;

使用 @ViewChild() 取得 DOM element 的物件實體,參數以字串傳入 Template Reference Variable 的字串名稱。

注意其型別為 ElementRef

15 行

1
2
constructor(private productNoChecker: ProductNoChecker) {
}

ProductNoChecker 透過 DI 注入進 AppComponent

18 行

1
2
3
4
onCheckProductNoClick() {
const productNo = parseInt(this.productNoElement.nativeElement.value, 10);
this.result = this.productNoChecker.check(productNo);
}

當按下 <button> 時,執行 onCheckProductNoClick()

藉由 @ViewChild() 所宣告的變數,取得 DOM element 的值,因為為 string,再由 parseInt() 轉成 integer

最後呼叫 ProductNoChecker.check(),判斷所輸入的 ProductNo 是否符合商業邏輯。

app.module.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import {BrowserModule} from '@angular/platform-browser';
import {NgModule} from '@angular/core';
import {AppComponent} from './app.component';
import {ProductNoChecker} from './checkers/product-no.checker';

@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule
],
providers: [
ProductNoChecker
],
bootstrap: [AppComponent]
})
export class AppModule { }

13 行

1
2
3
providers: [
ProductNoChecker
],

providers 設定 ProductNoChecker,讓 DI 得以注入。

If Else

product-no.checker.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { Injectable } from '@angular/core';

@Injectable()
export class ProductNoChecker {
check(productNo: number): boolean {
let result: boolean;

if (Number.isInteger(productNo)) {
if ((productNo % 2) === 0) {
if ((productNo % 3) === 0) {
result = true;
} else {
result = false;
}
} else {
result = false;
}
} else {
result = false;
}

return result;
}
}

由於必須通過三層檢查,初學者很容易寫出三層的 nested if,這種寫法可讀性差也不好維護。

我們將繼續重構。

Unit Test

在重構之前,必須要有測試保護,才能確保沒把原本的商業邏輯重構壞,因此我們先準備好 ProductNoChecker 的 Unit Test,確保每個 if else 的 path 都有測到。

product-no.checker.spec.ts

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
import { TestBed } from '@angular/core/testing';
import { ProductNoChecker } from './product-no.checker';

describe('ProductNoService', () => {
let productNoChecker: ProductNoChecker;

beforeEach(() => {
TestBed.configureTestingModule({
providers: [ProductNoChecker]
});

productNoChecker = TestBed.get(ProductNoChecker);
});

it('should be created', () => {
expect(productNoChecker).toBeTruthy();
});

it('當ProductNo為整數,且為2與3的倍數,應回傳 true', () => {
const productNo = 6;
expect(productNoChecker.check(productNo)).toBeTruthy();
});

it('當ProductNo為整數,為2的倍數但不是3的倍數,應回傳 false', () => {
const productNo = 2;
expect(productNoChecker.check(productNo)).toBeFalsy();
});

it('當ProductNo為整數,但不是2的倍數,應回傳 false', () => {
const productNo = 1;
expect(productNoChecker.check(productNo)).toBeFalsy();
});

it('當ProductNo不是整數,應回傳 false', () => {
const productNo = parseInt('t1', 10);
expect(productNoChecker.check(productNo)).toBeFalsy();
});
});

由於本文重點不是在講 Unit Test,因此就不浪費篇幅解釋以上程式碼。

Default Value

product-no.checker.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { Injectable } from '@angular/core';

@Injectable()
export class ProductNoChecker {
check(productNo: number): boolean {
let result = false;

if (Number.isInteger(productNo)) {
if ((productNo % 2) === 0) {
if ((productNo % 3) === 0) {
result = true;
}
}
}

return result;
}
}

由於整個 nested if 只有 三個 if 都成立時才是 true,其他都是 false,因此可以將 result 的預設值設定為 false,則所有的 else 都可拿掉。

但仍有三層 nested if,還是不夠好,我們將繼續重構。

Guard Clause

product-no.checker.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { Injectable } from '@angular/core';

@Injectable()
export class ProductNoChecker {
check(productNo: number): boolean {
if (!Number.isInteger(productNo)) {
return false;
}

if (productNo % 2) {
return false;
}

if (productNo % 3) {
return false;
}

return true;
}
}

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

cor002

checker.interface.ts

1
2
3
4
export interface CheckerInterface {
setNextChecker(checker: CheckerInterface): CheckerInterface;
check(source: number): boolean;
}

我們即將為每個 if 建立一個 class,先定義其 interface。

  • setNextChecker() : 由於每個 class 都是一個 if ,因此我們必須設定下一個 if 是哪一個 class 該檢查
  • check() : 每個 class 寫 if 的地方

ProductNoChecker

cor003product-no.checker.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { Injectable } from '@angular/core';
import { IntegerChecker } from './integer.checker';
import { CheckerInterface } from './checker.interface';
import { DoubleChecker } from './double.checker';
import { TripleChecker } from './triple.checker';

@Injectable()
export class ProductNoChecker {
check(productNo: number): boolean {
const checker: CheckerInterface = new IntegerChecker();

checker
.setNextChecker(new DoubleChecker())
.setNextChecker(new TripleChecker());

return checker.check(productNo);
}
}

由於我們是重構,且 AppComponent 所相依的是 ProductNoChecker.check(),因此 check() 的 signature 不應該修改。

第 9 行

1
2
3
4
5
6
7
8
9
check(productNo: number): boolean {
const checker: CheckerInterface = new IntegerChecker();

checker
.setNextChecker(new DoubleChecker())
.setNextChecker(new TripleChecker());

return checker.check(productNo);
}

由於 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

cor004

abstract.checker.ts

1
2
3
4
5
6
7
8
9
10
11
import { CheckerInterface } from './checker.interface';

export abstract class AbstractChecker implements CheckerInterface {
protected nextChecker: CheckerInterface;

setNextChecker(checker: CheckerInterface): CheckerInterface {
return this.nextChecker = checker;
}

abstract check(source: number): boolean;
}

雖然 CheckerInterface 為每個 checker 都須具備的 interface,但其中的 setNextChecker() 顯然並不需要每個 checker 自己實踐,可將共用的部分交由 AbstractChecker 處理。

第 4 行

1
protected nextChecker: CheckerInterface;

每個 checker 都必須紀錄下一個 checker 為何,因此特別宣告 nextChecker field。

由於將來繼承 AbstractChecker 的 class 都要使用,因此為 protected

注意其型別為 CheckerInterface

第 6 行

1
2
3
setNextChecker(checker: CheckerInterface): CheckerInterface {
return this.nextChecker = checker;
}

原本寫法為

1
2
3
4
setNextChecker(checker: CheckerInterface): CheckerInterface {
this.nextChecker = checker;
return checker;
}

但可以將兩行併成一行完成。

10 行

1
abstract check(source: number): boolean;

check() 需要由各 class 的自行完成,因此 AbstractChecker 不實作,只設定成 abstract 交由所繼承的 class 完成。

IntegerChecker

cor005

integer.checker.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { AbstractChecker } from './abstract.checker';

export class IntegerChecker extends AbstractChecker {
check(source: number): boolean {
if (!Number.isInteger(source)) {
return false;
}

if (!this.nextChecker) {
return true;
}

return this.nextChecker.check(source);
}
}

一樣使用 Guard Clause 的方式做判斷。

第 3 行

1
export class IntegerChecker extends AbstractChecker

IntegerChecker 也必須遵守 CheckInterface,因為 AbstractChecker 已經實現 CheckerInterface,所以 IntegerChecker 只要繼承 AbstractChecker 即可。

第 5 行

1
2
3
if (!Number.isInteger(source)) {
return false;
}

若不是 integer,則 return false

第 9 行

1
2
3
if (!this.nextChecker) {
return true;
}

若沒有 nextChecker ,則表示為最後一個 checker,return true

13 行

1
return this.nextChecker.check(source);

若有 nextChecker,則執行下一個 cheker 繼續檢查。

DoubleCheckerTripleChecker 的寫法類似,就不再贅述。

目前為止,我們已經在 Angular 實現了經典的 Chain of Resposibility Pattern,但還可以對此 pattern 做小幅重構。

Refactoring

IntegerChecker

cor005

integer.checker.ts

1
2
3
4
5
6
7
8
9
import { AbstractChecker } from './abstract.checker';

export class IntegerChecker extends AbstractChecker {
check(source: number): boolean {
return !Number.isInteger(source) ? false :
!this.nextChecker ? true :
this.nextChecker.check(source);
}
}

使用 Guard Clause 寫法雖然已經非常精簡,但若使用 ?:,則可寫出更精簡,且可讀性更高的寫法。

DoubleCheckerTripleChecker 的寫法類似,就不再贅述。

Functional Way

Chain of Responsibility 雖然是 OOP 的 Design Pattern,但亦可使用 FP 實踐。

checker.interface.ts

1
2
3
export interface CheckerInterface {
(source: number): boolean;
}

之前 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
2
3
export function checkInteger(source: number): boolean {
return Number.isInteger(source);
}

integer.checker.ts 不用再使用 class,使用 function 即可,當然要符合 checker.interface.ts 的規格。

我們可發現 FP 版本的 checkInteger() 甚至不用判斷 nextChecker(),專心的判斷 isInteger() 即可,比 OOP 版更加符合 單一職責原則

product-no.checker.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { Injectable } from '@angular/core';
import { CheckerInterface } from './checker.interface';
import { integerCheck } from './integer.checker';
import { doubleCheck } from './double.checker';
import { tripleCheck } from './triple.checker';

@Injectable()
export class ProductNoChecker {
private checkers: CheckerInterface[];

constructor() {
this.checkers = [
checkInteger,
checkDouble,
checkTriple
];
}

check(productNo: number): boolean {
return this.checkers.every(checker => checker(productNo));
}
}

第 9 行

1
checkers: CheckerInterface[];

存放所有 checker function 的陣列,別忘了之前已經定義了 CheckerInterface 的 function interface,在此更可描述 checkers 陣列所放的全部都是 CheckerInterface 的 function,除此之外,TypeScript 編譯器也會加以檢查,若function 不符合 CheckerInterface 規格,將編譯錯誤。

儘管使用了 FP,一樣要使用 interface,才能受到編譯器的保護

11 行

1
2
3
4
5
6
7
constructor() {
this.checkers = [
checkInteger,
checkDouble,
checkTriple
];
}

定義 checkers 陣列該含有哪些 checker function,類似 OOP 版的 setNextChecker()

若這些 checker function 需經常變動,還可以將 checkers 放在 config 檔,且 checker function 還受到 TypeScript 編譯器保護,符合 開放封閉原則

19 行

1
2
3
check(productNo: number): boolean {
return this.checkers.every(checker => checker(productNo));
}

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
2
3
4
5
6
7
check(productNo: number): boolean {
Array.prototype.any = function(fn) {
return !this.some(item => !fn(item));
};

return this.checkers.any(checker => checker(productNo));
}

由於 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 上找到