使用 OOP 實現 Finite State Machine

State Pattern 是 OOP 中著名的 Design Pattern,當 method 的行為會隨著 field 而改變時特別有效。在本文中,我們將以 Angular 與 TypeScript 實現。

Version


macOS High Sierra 10.13.3
Node.js 8.9.4
Angular CLI 1.7.2
TypeScript 2.5.3
Angular 5.2.7

User Story


state000

  • 模擬 iPhone 的 Home 鍵動作
  • Home 雖然只有一個按鍵,但在不同的情境下有不同的功能
    • 當在 Locked 下,可以 Unlocked
    • 當在 Unlocked 下,可以進入 Home
    • 當在 App 開啟下,可以回到 Home
    • 當在 Desktop 下,可以切換 Home

Task


  • 因為 Home 有在不同情境下有不同的功能,勢必有很多 if else 判斷情境而切換功能
  • 先用 if else 寫法完成,最後再重構成 State Pattern

Definition


State Pattern

當 method 的行為會隨著 field 而改變時,將 if 改用 state 物件表示

外部 nested if 改由 內部決定物件串列 表示,藉以消除 if

state015

if 要處理的邏輯包在每個 state 內,但不包含 if

state012

  • Client : Context 的 user,實務上可能是 component 或 controller
  • Context : 根據 field 的不同,request() 會有不同功能,實務上可能是 service
  • State : 定義 ConcreteState 的 interface,只有 handle() ,負責封裝 if 要處理的邏輯
  • ConcreteState : 將 if 要處理的邏輯 封裝成物件

一個 if 要處理的邏輯放在一個 ConcreteState 物件內,並根據 state diagram 指定下一個 state。

由於 method 的行為會根據 field (state) 改變,因此稱為 State Pattern。

適用時機

  • 深層 nested if
  • 在 design-time 就可決定 if 組合 (以 state diagram 描述)
  • 當 method 的行為會隨著 field 而改變時

優點

  • 每個 if 要處理的邏輯 使用一個 class,符合 單一職責原則
  • 將來若有新的 if 要處理的邏輯,不用修改 service,而是新增 ConcreState,符合 開放封閉原則
  • Client 與 if 判斷 解耦合,兩者都僅相依於 interface,符合 依賴反轉原則
  • 可使用 state diagram 清楚描述狀態轉變

缺點

  • 若邏輯複雜,state class 可能會很多檔案,但還是比單一檔案 nested if 好維護

Architecture


state013

  • 只有在 UnlockAppDesktop 才能使用 Home
  • 只有在 HomeDesktop 才能切到 App
  • 只有 Home 才能切到 Desktop

state002

  • AppComponent 相當於 Client
  • PhoneContext 相當於 ContextAppComponent 負責注入 PhoneContext,無論怎麼重構,PhoneContext 都是穩定的,不會導致 AppComponent 修改
  • PhoneStateInterface 相當於 State interface,訂出所有 state 標準
  • LockedState 及其他 state 都是 ConcreteState,為實際 if 所處理的邏輯

Implmentation


app.component.html

1
2
3
4
5
6
7
<button (click)="onHomeClick()">Home</button>
<p></p>
<button (click)="onOpenAppClick()">Open App</button>
<p></p>
<button (click)="onSwitchDesktopClick()">Switch Desktop</button>
<p></p>
{{ message }}

在 HTML 提供 HomeOpen AppSwitch Desktop 3 個 button,任何訊息將顯示在 message

If Else

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
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
import { Component } from '@angular/core';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
private state = 'Locked';
message = 'The phone is locked';

onHomeClick() {
if (this.state === 'Locked') {
this.state = 'Unlocked';
this.message = 'The phone is unlocked';
} else if (this.state === 'Unlocked') {
this.state = 'Home';
this.message = 'The phone is at home';
} else if (this.state === 'App') {
this.state = 'Home';
this.message = 'The phone is at home';
} else if (this.state === 'Desktop') {
this.state = 'Home';
this.message = 'The phone is at home';
} else {
this.state = 'Home';
this.message = 'The phone is at home';
}
}

onOpenAppClick() {
if (this.state === 'Home' || this.state === 'Desktop') {
this.state = 'App';
this.message = 'The phone is opening app';
} else {
this.state = 'Null';
this.message = 'The operation is not allowed';
}
}

onSwitchDesktopClick() {
if (this.state === 'Home') {
this.state = 'Desktop';
this.message = 'The phone is switching desktop';
} else {
this.state = 'Null';
this.message = 'The operation is not allowed';
}
}
}

12 行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
onHomeClick() {
if (this.state === 'Locked') {
this.state = 'Unlocked';
this.message = 'The phone is unlocked';
} else if (this.state === 'Unlocked') {
this.state = 'Home';
this.message = 'The phone is at home';
} else if (this.state === 'App') {
this.state = 'Home';
this.message = 'The phone is at home';
} else if (this.state === 'Desktop') {
this.state = 'Home';
this.message = 'The phone is at home';
} else {
this.state = 'Home';
this.message = 'The phone is at home';
}
}

模擬 Home 鍵,由於 Home 在不同情境有不同功能,因此分成 LockedUnlockedHomeAppDesktop 5 個 state,使用 if 判斷當目前什麼 state 下,要做什麼事情,以及即將切到什麼 state。

31 行

1
2
3
4
5
6
7
8
9
onOpenAppClick() {
if (this.state === 'Home' || this.state === 'Desktop') {
this.state = 'App';
this.message = 'The phone is opening app';
} else {
this.state = 'Null';
this.message = 'The operation is not allowed';
}
}

模擬 開啟 App,根據 state diagram,由於不可能在 LockedUnlockedApp state 下開啟 app,將顯示 The operation is not allowed,並切到特別建立的 Null state。

也就是只有 HomeDesktop state 下才能開啟 app,因此特別使用 if else 做判斷。

41 行

1
2
3
4
5
6
7
8
9
onSwitchDesktopClick() {
if (this.state === 'Home') {
this.state = 'Desktop';
this.message = 'The phone is switching desktop';
} else {
this.state = 'Null';
this.message = 'The operation is not allowed';
}
}

模擬 切換桌面,根據 state diagram,只能在 Home state 下切換桌面,因此需要 if else 做判斷。

以功能面來說,目前已經完全需求,只是程式碼含有眾多的 code smell,尚無法達成 production code 的標準,需要繼續重構。

Unit Test

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

app.component.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
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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
import { DebugElement } from '@angular/core';

describe('AppComponent', () => {
let fixture: ComponentFixture<AppComponent>;
let appComponent: AppComponent;
let debugElement: DebugElement;
let htmlElement: HTMLElement;
let target: AppComponent;

beforeEach(() => {
TestBed.configureTestingModule({
declarations: [
AppComponent
]
});

fixture = TestBed.createComponent(AppComponent);
appComponent = fixture.componentInstance;
debugElement = fixture.debugElement;
htmlElement = debugElement.nativeElement;
target = new AppComponent();
fixture.detectChanges();
});

it('should create the app', () => {
expect(appComponent).toBeTruthy();
});

it(`一開始應顯示 'The phone is locked'`, () => {
expect(appComponent.message).toBe('The phone is locked');
});

it(`當第 1 次按下 Home 應顯示 'The phone is unlocked'`, () => {
appComponent.onHomeClick();
expect(appComponent.message).toBe('The phone is unlocked');
});

it(`當第 2 次按下 Home 應顯示 'The phone is at home'`, () => {
appComponent.onHomeClick();
appComponent.onHomeClick();
expect(appComponent.message).toBe('The phone is at home');
});

it(`當第 3 次按下 Home 應顯示 'The phone is at home'`, () => {
appComponent.onHomeClick();
appComponent.onHomeClick();
appComponent.onHomeClick();
expect(appComponent.message).toBe('The phone is at home');
});

it(`當第 2 次按下 Home 與第一次按下 Open App 應顯示 'The phone is at home'`, () => {
appComponent.onHomeClick();
appComponent.onHomeClick();
appComponent.onOpenAppClick();
expect(appComponent.message).toBe('The phone is opening app');
});

it(`當按下 Open App 再按下 Home 應顯示 'The phone is at home'`, () => {
appComponent.onHomeClick();
appComponent.onHomeClick();
appComponent.onOpenAppClick();
appComponent.onHomeClick();
expect(appComponent.message).toBe('The phone is at home');
});

it(`當第 2 次按下 Home 與第一次按下 Switch Desktop 應顯示 'The phone is switching desktop'`, () => {
appComponent.onHomeClick();
appComponent.onHomeClick();
appComponent.onSwitchDesktopClick();
expect(appComponent.message).toBe('The phone is switching desktop');
});

it(`當按下 Switch Desktop 再按下 Home 應顯示 'The phone is switching desktop'`, () => {
appComponent.onHomeClick();
appComponent.onHomeClick();
appComponent.onSwitchDesktopClick();
appComponent.onHomeClick();
expect(appComponent.message).toBe('The phone is at home');
});

it(`當第 1 次按下 Home 再按下 Open App 應顯示 'The operation is not allowed'`, () => {
appComponent.onHomeClick();
appComponent.onOpenAppClick();
expect(appComponent.message).toBe('The operation is not allowed');
});

it(`當第 1 次按下 Home 再按下 Switch Desktop 應顯示 'The operation is not allowed'`, () => {
appComponent.onHomeClick();
appComponent.onSwitchDesktopClick();
expect(appComponent.message).toBe('The operation is not allowed');
});
});

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

state001

執行 Wallaby.js 單元測試,確保 AppComponent 所有的 if else 的 path 都有測到,也就是 coverage 是 100%,接下來的重構將以此 Unit Test 為標準,無論怎麼重構,都必須確保 11 個測試案例 綠燈

實務上若 TDD 有困難,其實可以先用 if else 寫一段 比較髒 的 production code,最少功能都符合需求,因為 比較髒,所以一定得重構,在重構之前補上 Unit Test,重點是 coverage 是 100%,然後不斷的重構維持 Unit Test 都是 綠燈,無論是先寫測試或是後寫測試,但要寫 Unit Test 與重構的目標都是一致的,只是先寫還是後寫而已。

若發現 比較髒 的 production code 已經無法寫出 Unit Test,就必須回頭修改 production code 寫法,那表示你的 production code 已經違反 SOLID 原則,導致 Unit Test 寫不出來。

Refactoring

目前這段 code 至少含有以下 3 項 code smell :

  1. 使用 else 造成 nested if
  2. statemessage 目前都以 string hardcode
  3. onHomeClick() 眾多 if else 嚴重違反 單一職責原則開放封閉原則

我們將針對這 3 點加以重構。

Guard Clause

重構目標 : 使用 Guard Clause 取代 else

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
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
import { Component } from '@angular/core';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
private state = 'Locked';
message = 'The phone is locked';

onHomeClick() {
if (this.state === 'Locked') {
this.state = 'Unlocked';
this.message = 'The phone is unlocked';
return;
}

if (this.state === 'Unlocked') {
this.state = 'Home';
this.message = 'The phone is at home';
return;
}

if (this.state === 'App') {
this.state = 'Home';
this.message = 'The phone is at home';
return;
}

if (this.state === 'Desktop') {
this.state = 'Home';
this.message = 'The phone is at home';
return;
}

this.state = 'Home';
this.message = 'The phone is at home';
}

onOpenAppClick() {
if (this.state === 'Home' || this.state === 'Desktop') {
this.state = 'App';
this.message = 'The phone is opening app';
return;
}

this.state = 'Null';
this.message = 'The operation is not allowed';
}

onSwitchDesktopClick() {
if (this.state === 'Home') {
this.state = 'Desktop';
this.message = 'The phone is switching desktop';
return;
}

this.state = 'Null';
this.message = 'The operation is not allowed';
}
}

25 行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (this.state === 'App') {
this.state = 'Home';
this.message = 'The phone is at home';
return;
}

if (this.state === 'Desktop') {
this.state = 'Home';
this.message = 'The phone is at home';
return;
}

this.state = 'Home';
this.message = 'The phone is at home';

return 取代 else,換來 if 全部壓平在第一層,避免寫出 nested if

最後一行即為預設值。

其他皆以這種方式重構。

Enum

重構目標 : 使用 enum 取代 string hardcode。

message.enum.ts

1
2
3
4
5
6
7
8
export enum MessageEnum {
Locked = 'The phone is locked',
Unlocked = 'The phone is unlocked',
Home = 'The phone is at home',
App = 'The phone is opening app',
Desktop = 'The phone is switching desktop',
Null = 'The operation is not allowed'
}

之前 statemessage 都使用 string hardcode,使用上雖然直覺簡單,但有兩個致命缺點 :

  1. message 到處散佈,將來若 message 修改,要改的地方很多
  2. state 使用 string,不僅容易 typo,也無法受到 compiler 保護

比較好的方式是 statestring 改由 enum,如此不會 typo,也受到 compiler 保護。

一般語言由於 enum 傳統只能代表 int,因此還掉搭配其他 collection,如 ES6 的 Map 或 C# 的 Dictionary ,但 TypeScript 2.4 的 enum 可代表 string,因此可省掉 MapDictionary,直接使用 enum 搞定。

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
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
import { Component } from '@angular/core';
import { MessageEnum } from './message.enum';

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

onHomeClick() {
if (this.message === MessageEnum.Locked) {
this.message = MessageEnum.Unlocked;
return;
}

if (this.message === MessageEnum.Unlocked) {
this.message = MessageEnum.Home;
return;
}

if (this.message === MessageEnum.App) {
this.message = MessageEnum.Home;
return;
}

if (this.message === MessageEnum.Desktop) {
this.message = MessageEnum.Home;
return;
}

this.message = MessageEnum.Home;
}

onOpenAppClick() {
if (this.message === MessageEnum.Home || this.message === MessageEnum.Desktop) {
this.message = MessageEnum.App;
return;
}

this.message = MessageEnum.Null;
}

onSwitchDesktopClick() {
if (this.message === MessageEnum.Home) {
this.message = MessageEnum.Desktop;
return;
}

this.message = MessageEnum.Null;
}
}

第 10 行

1
message = MessageEnum.Locked;

state 拿掉,只留下 message

13 行

1
2
3
4
if (this.message === MessageEnum.Locked) {
this.message = MessageEnum.Unlocked;
return;
}

原本判斷 state,直接判斷 message 即可。

其他以此類推。

State Pattern

重構目標 : 使用 State Pattern 實現 單一職責原則開放封閉原則

AppComponent

state010

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
23
24
25
26
27
28
29
import { Component } from '@angular/core';
import { MessageEnum } from './message.enum';
import { PhoneContext } from './phone.context';
import { AppState } from './AppState';
import { DesktopState } from './DesktopState';

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

constructor(private phoneContext: PhoneContext) {
}

onHomeClick() {
this.message = this.phoneContext.request();
}

onOpenAppClick() {
this.message = this.phoneContext.setState(new AppState());
}

onSwitchDesktopClick() {
this.message = this.phoneContext.setState(new DesktopState());
}
}

15 行

1
2
constructor(private phoneContext: PhoneContext) {
}

使用 DI 依賴注入 PhoneContext

18 行

1
2
3
onHomeClick() {
this.message = this.phoneContext.request();
}

onHomeClick() 只剩下一行 Context.request()

我們可以發現 onHomeClick()if 都不見了,也就是說,State Pattern 用多個檔案的 class 去換一個檔案的 if

  • 由於多個檔案,一個 class 代表一個 if ,符合 單一職責原則
  • 將來若有新的 state,只要根據 interface 新增 class,不用修改原來的 code,符合 開放封閉原則

22 行

1
2
3
onOpenAppClick() {
this.message = this.phoneContext.setState(new AppState());
}

原本需要在 client 判斷是否可以切換 state,現在只管新增 state 即可,不用再用 if 判斷。

PhoneContext

state003

phone.context.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
import { Inject, Injectable } from '@angular/core';
import { MessageEnum } from './message.enum';
import { PhoneStateInterface } from './phone.state.interface';
import { PhoneStateInterfaceToken } from './interface.token';

@Injectable()
export class PhoneContext {

constructor(@Inject(PhoneStateInterfaceToken) private state: PhoneStateInterface) {
}

request(): MessageEnum {
this.state = this.state.handle();
return this.state.getMessage();
}

setState(state: PhoneStateInterface): MessageEnum {
if (!state.chkContext(this.state)) {
return MessageEnum.Null;
} else {
this.state = state;
return state.getMessage();
}
}
}

第 9 行

1
2
constructor(@Inject(PhoneStateInterfaceToken) private state: PhoneStateInterface) {
}

即將使用 State Pattern,使用 DI 依賴注入 state,注意其型別為 PhoneStateInterface

12 行

1
2
3
4
request(): MessageEnum {
this.state = this.state.handle();
return this.state.getMessage();
}

request() 為原本 State Pattern 所設計的 method。

執行目前 statehandle(),此為目前 state 主要的商業邏輯所在。

handle() 將回傳下一個 state

state.getMessage() 將回傳目前 state 要顯示的訊息。

17 行

1
2
3
4
5
6
7
8
setState(state: PhoneStateInterface): MessageEnum {
if (!state.chkContext(this.state)) {
return MessageEnum.Null;
} else {
this.state = state;
return state.getMessage();
}
}

setState() 並非 State Pattern 所設計的 method,目前因為 Open AppSwitch Desktop 需要動態切換 state,因而衍生出動態設定 state 的需求。

由於不是每個 state 都能任意切換到其他 state,因此特別在每個 state 設計 chkContext(),判斷是否允許切換 state。

Context.setState()State.chkContext() 並非原始 State Pattern 所設計,為了能動態切換 state,實務上經常會有此需求

PhoneStateInterface

state004

phone.state.interface.ts

1
2
3
4
5
6
7
import { MessageEnum } from './message.enum';

export interface PhoneStateInterface {
handle(): PhoneStateInterface;
getMessage(): MessageEnum;
chkContext(state: PhoneStateInterface): boolean;
}

定義 State Pattern 的 interface :

  • handle() : 為 State Pattern 所設計的 method,專門放每個 if 的邏輯
  • getMessage() : 回傳目前 state 的 message,非 State Pattern 所設計,為根據目前需求所設計的 method
  • chkContext() : 檢查目前 state 是否可以允許切換到下一個 state,非 State Pattern 所設計,為根據目前需求所設計的 method

LockedState

state005

接下來要將每個 state 的 if 搬進 class,實現 單一職責原則開放封閉原則

locked.state.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { PhoneStateInterface } from './phone.state.interface';
import { MessageEnum } from './message.enum';
import { UnlockedState } from './UnlockedState';

export class LockedState implements PhoneStateInterface {
handle(): PhoneStateInterface {
return new UnlockedState();
}

getMessage(): MessageEnum {
return MessageEnum.Locked;
}

chkContext(state: PhoneStateInterface): boolean {
return true;
}
}

第 6 行

1
2
3
handle(): PhoneStateInterface {
return new UnlockedState();
}

將原本 Locked state 的 code 搬到 handle(),最後回傳下一個 state,其型別為 PhoneStateInterface

10 行

1
2
3
getMessage(): MessageEnum {
return MessageEnum.Locked;
}

回傳目前 state 的 message,注意其型別為 MessageEnum,而不是 string

14 行

1
2
3
chkContext(state: PhoneStateInterface): boolean {
return true;
}

檢查目前 state 是否進進入 Locked state,因為毫無限制,傳回 true 即可。

UnlockedState

state006

unlocked.state.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { PhoneStateInterface } from './phone.state.interface';
import { MessageEnum } from './message.enum';
import { HomeState } from './HomeState';

export class UnlockedState implements PhoneStateInterface {
getMessage(): MessageEnum {
return MessageEnum.Unlocked;
}

chkContext(state: PhoneStateInterface): boolean {
return state.getMessage() === MessageEnum.Locked;
}

handle(): PhoneStateInterface {
return new HomeState();
}
}

10 行

1
2
3
chkContext(state: PhoneStateInterface): boolean {
return state.getMessage() === MessageEnum.Locked;
}

根據 state diagram,只有當 Locked state 才能進入 Unlocked state。

HomeState

state007

home.state.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { PhoneStateInterface } from './phone.state.interface';
import { MessageEnum } from './message.enum';

export class HomeState implements PhoneStateInterface {
getMessage(): MessageEnum {
return MessageEnum.Home;
}

chkContext(state: PhoneStateInterface): boolean {
return true;
}

handle(): PhoneStateInterface {
return new HomeState();
}
}

LockedState 類似,就不再贅述。

AppState

state008

app.state.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
import { PhoneStateInterface } from './phone.state.interface';
import { MessageEnum } from './message.enum';
import { HomeState } from './HomeState';

export class AppState implements PhoneStateInterface {
getMessage(): MessageEnum {
return MessageEnum.App;
}

chkContext(state: PhoneStateInterface): boolean {
if (state.getMessage() === MessageEnum.Home) {
return true;
}

if (state.getMessage() === MessageEnum.Desktop) {
return true;
}

return false;
}

handle(): PhoneStateInterface {
return new HomeState();
}
}

10 行

1
2
3
4
5
6
7
8
9
10
11
chkContext(state: PhoneStateInterface): boolean {
if (state.getMessage() === MessageEnum.Home) {
return true;
}

if (state.getMessage() === MessageEnum.Desktop) {
return true;
}

return false;
}

根據 state diagram,只有 HomeDesktop state 才能進入 App state。

DesktopState

state009

desktop.state.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { PhoneStateInterface } from './phone.state.interface';
import { MessageEnum } from './message.enum';
import { HomeState } from './home.state';

export class DesktopState implements PhoneStateInterface {
getMessage(): MessageEnum {
return MessageEnum.Desktop;
}

chkContext(state: PhoneStateInterface): boolean {
if (state.getMessage() === MessageEnum.Home) {
return true;
}

return false;
}

handle(): PhoneStateInterface {
return new HomeState();
}
}

根據 state diagram,只有 Home state 才能進入 Desktop state。

Refactoring

PhoneContext

state003

phone.context.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
import { Inject, Injectable } from '@angular/core';
import { MessageEnum } from './message.enum';
import { PhoneStateInterface } from './phone.state.interface';
import { PhoneStateInterfaceToken } from './interface.token';

@Injectable()
export class PhoneContext {

constructor(@Inject(PhoneStateInterfaceToken) private state: PhoneStateInterface) {
}

request(): MessageEnum {
this.state = this.state.handle();
return this.state.getMessage();
}

setState(state: PhoneStateInterface): MessageEnum {
const setCurrentState = (currentState) => {
this.state = currentState;
return currentState.getMessage();
};

return !state.chkContext(this.state) ? MessageEnum.Null :
setCurrentState(state);
}
}

17 行

1
2
3
4
5
6
7
8
9
setState(state: PhoneStateInterface): MessageEnum {
const setCurrentState = (currentState) => {
this.state = currentState;
return currentState.getMessage();
};

return !state.chkContext(this.state) ? MessageEnum.Null :
setCurrentState(state);
}

將原本的 if else 重構成 ?:

尤其原本 else 的部分抽成 function,讓可讀性更高。

AppState

state008

app.state.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { PhoneStateInterface } from './phone.state.interface';
import { MessageEnum } from './message.enum';
import { HomeState } from './home.state';

export class AppState implements PhoneStateInterface {
getMessage(): MessageEnum {
return MessageEnum.App;
}

chkContext(state: PhoneStateInterface): boolean {
return state.getMessage() === MessageEnum.Home ? true :
state.getMessage() === MessageEnum.Desktop;
}

handle(): PhoneStateInterface {
return new HomeState();
}
}

10 行

1
2
3
4
chkContext(state: PhoneStateInterface): boolean {
return state.getMessage() === MessageEnum.Home ? true :
state.getMessage() === MessageEnum.Desktop;
}

if else 重構成 ?:

DesktopState

state009

destop.state.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { PhoneStateInterface } from './phone.state.interface';
import { MessageEnum } from './message.enum';
import { HomeState } from './home.state';

export class DesktopState implements PhoneStateInterface {
getMessage(): MessageEnum {
return MessageEnum.Desktop;
}

chkContext(state: PhoneStateInterface): boolean {
return state.getMessage() === MessageEnum.Home;
}

handle(): PhoneStateInterface {
return new HomeState();
}
}

10 行

1
2
3
chkContext(state: PhoneStateInterface): boolean {
return state.getMessage() === MessageEnum.Home;
}

if else 重構成 ?:

state014

經過一系列的重構成 State Pattern,一樣必須確認 Wallaby.js 單元測試的 11 個測試案例都是 綠燈

Summary


Chain of Responsibility Pattern vs. State Pattern

state016

以 class diagram 角度,Chain of Responsibility 與 State Pattern 完全一樣。

學 Design Pattern 不能以 class diagram 的角度去思考,而要以他要解決什麼問題來思考

Similarity

  • 解決 nested if 難以維護
  • 都是將 if 改用 object 表示

Difference

  • CoR 特別適用於一連串的 判斷檢查;State 特別適用於 method 功能隨 field 改變
  • CoR 內仍然會有 if;State 內無 if
  • CoR 由 外部 決定下一個 handler;State 由 內部 決定下一個 state
  • State 會搭配 state diagam 描述 state 的切換

Conclusion


  • 並不是所有的 if 都該使用 State Pattern,當 method 功能會隨 field 改變時特別適用,如複雜的 GUI,button 可能根據不同情境有不同功能
  • Chain of Responsibility 與 State Pattern,從 class diagram 角度,兩者完全一樣,但適用時機與語意是不同的,尤其 Chain of Responsibility 是由外部決定 if 順序,但 State Pattern 是由內部決定 if 順序

Sample Code


完整的範例可以在我的 GitHub 上找到