TechBridge 技術共筆部落格

var topics = ['Web前後端', '行動網路', '機器人/物聯網', '數據分析', '產品設計', 'etc.']

用 TypeScript 輕鬆學 Design pattern - Command Pattern


前言

雖然直到最近才實際在工作上使用 TypeScript 進行開發,但是早在去年就有斷斷續續在自己的 side project 上玩玩 TypeScript,剛好後來公司讀書會在研讀經典的 Design Pattern 書籍,就索性使用 TypeScript 來製作範例,意外發現 TypeScript 在講解一些 Design Pattern 的時候非常適合,不僅能夠有類別、物件等清楚的表示,也能夠直接在網頁上做出會動的範例。

雖然這是蠻久之前製作的,但最近想想還是可以分享一下!所以今天就以 TypeScript 來製作範例,為大家講解 Command Pattern

Command Pattern

在 GoF Design Patterns(也就是 Design Patterns: Elements of Reusable Object-Oriented Software 這本書)中 Command Pattern 屬於 Behavioural Patterns 的一種,主要是定義類別與物件之間的溝通與互動方法。

Command Pattern 的初衷,是希望能將各種對物件的操作(request)也都獨立包裝成物件,如此一來,這個 request,就能夠在各個物件對象之間傳遞,甚至提供 undoable 的 operation。

我們可以從 UML 圖來理解:

UML - Command Pattern

By Sae1962 - Own work, CC BY-SA 4.0, Link

  • Command

    • 定義出一個 Command 介面,用來執行操作,該介面只需要定義一個 Execute() 這個抽象 method。
  • Concrete Command

    • 從抽象的 Command 介面所實作的 Concrete Command,會實作該 Command 實際上要執行的動作內容,也就是實作 Execute(),此外,他還需要負責將 接收操作的物件(receiver) 與 動作(Action)本身連結起來。簡單來說,就是他會被傳遞進一個物件,並且在 Execute() 函數中,trigger receiver 執行相對的動作。這邊值得注意的是,Concrete Command 本身並不管也不知道 receiver 會怎麼實作他們的 action,他只需要告訴 receiver 他想要完成的是什麼命令即可。
  • Client

    • 負責創建 Concrete Command,並指定其 receiver。
  • Invoker

    • 就是下達指令的人,負責呼叫 Command。
  • Receiver

    • 知道接受到 Command 時,要執行什麼動作(Action)的人。

光看 UML,一定還不太清楚,接著我們從 sequence diagram 來看看這幾個對象彼此之間如何互動:

Sequence diagram

可以想像,Receiver 就是餐廳服務生,而 Client 就是顧客,顧客會知道要對服務生下達指令,所以會產生 new Command(Receiver);但他可能需要透過服務鈴(Invoker),才能讓服務生真正去執行他的指令。

接著我們先來看看實際的範例。

TypeScript 實作範例

當顧客點選 “Give me food” 按鈕時,服務生就會接收到指令,並趕緊去找廚師;而當顧客想更改餐點時,可以透過 “Hey get back”,下達指令來回復服務生的上一個動作。

很簡單暴力的範例吧XD

實際上的程式怎麼寫呢?

首先我們先從定義 Command 介面開始:

1
2
3
4
5
6
7
8
export class Command {
public execute(): void {
throw new Error("Abstract method!");
}
public unexecute(): void {
throw new Error("Abstract method!");
}
}

如同先前 UML 圖所描述,我們只定義抽象的 execute() method,在 TypeScript 中要模擬抽象方法的最簡單做法就是讓他 throw Error。
另外我們同時也定義一個 unexecute(),作為回復的操作。


[2019/04/22 - Update]

感謝 @pilagod 的補充,TypeScript 有提供 Abstract 類別,可以強迫繼承者去實作其抽象函數,所以我們的 Command class 可以寫成:

1
2
3
4
export abstract class Command {
abstract execute(): void;
abstract unexecute(): void;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import * as React from "react";
import { Waiter } from "./Waiter";
export class ConcreteOrderCommand extends Command {
private receiver: React.RefObject<Waiter>;
private position: number;
constructor(receiver: React.RefObject<Waiter>) {
super();
this.receiver = receiver;
this.position = 0;
}
public execute(): void {
console.log("`execute` method of ConcreteOrderCommand is being called!");
this.position = 300;
this.receiver.current.action(this.position);
}
public unexecute(): void {
console.log("`execut` method of ConcreteOrderCommand is being called!");
this.position = 0;
this.receiver.current.backAction(this.position);
}
}

再來實作 ConcreteCommand,我們 extend 抽象的 Command 類別,指定兩個私有變數:receiverposition

receiver 可以從其宣告的類別看出,就是 Waiter,這就是 TypeScript 適合說明 Design Pattern 的優點之一,可以很明確看到關聯性,我們待會會看到 Waiter 的實作。而 position 則是範例中我們用在 execute() 中,告訴 receiver 該如何 action 的工具,在範例中就是改變 Waiter 的位置。

ConcreteCommandexecute() 實作中,我們定義出 “這個 Command” 要讓 “其受指定的 receiver” 進行怎樣的 “action”,也就是這行:

this.receiver.current.action(this.position);

我們告訴 receiver 說,請依照 this.position 這個參數去執行你的 action,但記住,你的 action 到底是怎麼實作的我並不管你。

(這邊跟 React 相關的不做太多解釋,基本上因為我們需要在 ConcreteCommand 中去呼叫 receiveraction method,所以採用 React.RefObject<T> 作為 receiver 的類型,這樣我們能透過 .current 的 API 去取得 React 實體。)

看到這邊可以發現,由於我們並不需要知道 receiver 本身怎麼實作 action,所以關注點可以分離,只要管好自己這邊如何呼叫 receiver 即可。

到這邊為止,我們已經實作完 UML 中的 Command 介面與 ConcreteCommand 了,接著輪到 Receiver

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
import * as React from "react";
const waiterStyle = {
display: "flex",
flexDirection: "column",
alignItems: "center",
transition: "all 1s ease-in-out"
};
type WaiterProps = {
ref: React.RefObject<Waiter>;
};
type WaiterState = {
position: number;
};
export class Waiter extends React.Component<WaiterProps, WaiterState> {
constructor(props: any) {
super(props);
}
state = {
position: 0
};
action = (position: number) => {
console.log("command action!");
this.setState({
position
});
};
backAction = (position: number) => {
console.log("command back action!");
this.setState({
position
});
};
render() {
const { position } = this.state;
return (
<div style={{ ...waiterStyle, transform: `translateX(${position}px)` }}>
<img
width="100px"
src="https://image.flaticon.com/icons/svg/816/816577.svg"
/>
</div>
);
}
}

Receiver 也很簡單,重點在於實作 action。以範例來說,我們會從 ConcreteCommand 接收到新的 position state,所以我們要執行的動作就是 this.setState({ position }),將自身的狀態改變。透過 TypeScript,可以清楚定義 state 與 props 的型別。

Waiter 需要接收一個 ref: React.RefObject<Waiter>; 的 Props,則是與 React 實作相關,因為我是利用 const WaiterRef = React.createRef<Waiter>(); 將 Waiter 的 ref 傳遞給 ConcreteCommand。(可參考 Codesandbox 內的完整程式碼)

最後,有了指令(ConcreteCommand),有了接收指令的人(Waiter),我們來看看 Customer 要如何使用指令來呼叫 Waiter 吧:

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
// 有刪除一些不重要的 code,i.e. style, etc.
import * as React from "react";
import { Waiter } from "./Waiter";
import { Command, ConcreteOrderCommand } from "./CommandPattern";
type CustomerProps = {
WaiterRef: React.RefObject<Waiter>;
};
type CustomerState = {
orderCommand: Command;
};
export class Customer extends React.Component<CustomerProps, CustomerState> {
constructor(props: any) {
super(props);
const { WaiterRef } = props;
this.state = {
orderCommand: new ConcreteOrderCommand(WaiterRef)
};
}
render() {
return (
<div style={CustomerStyle}>
<div
onMouseEnter={() => this.setState({ btn1OnHover: true })}
onMouseLeave={() => this.setState({ btn1OnHover: false })}
onClick={() => {
this.state.orderCommand.execute();
}}
>
Give me food!!
</div>
<div
onMouseEnter={() => this.setState({ btn2OnHover: true })}
onMouseLeave={() => this.setState({ btn2OnHover: false })}
onClick={() => {
this.state.orderCommand.unexecute();
}}
>
Hey get back!!
</div>
</div>
);
}
}

基本上 Customer 就是會保有 ConcreteCommand 在 state 當中,這樣才能合理的不斷下達指令。

另外也需要取得 WaiterRef 這個 props,才能在初始 ConcreteCommand 時指定其 receiver

接著我們只要在不同功能的 button 上綁定 command 即可,分別呼叫 this.state.orderCommand.execute();this.state.orderCommand.unexecute();

如此一來,我們就成功用 TypeScript 實作一個完整的 Command Pattern 範例啦!(完整的程式碼請看上方 CodeSandbox 連結)

Command Pattern 的 Consequences

  • Command Pattern 將呼叫指令的物件與執行指令的物件切割開來,降低彼此之間耦合度。
  • Command 是 first-class objects,可以被傳遞、操作與繼承。
  • 可以 composite 多個 Command 到一個 Command 中,類似巨集概念。
  • 增加 Command 很容易,因為你不需要更改其他的現有 Class。

實作 Command Pattern 上所需要注意的細節

  • 需要思考一個 Command 該擁有多少能力,是要單純作 receiver 與 action 之間的 binding,大部分實作擺在 receiver;抑或是將大多實作自己處理掉,receiver 只是一個命令的受體?
  • 是否需要支援 undo 功能,Command Pattern 設計上天生支援 undo,但實際實作上你可能會需要額外的儲存 state 來達成,會增加開發複雜度。
  • 若是支援 undo,還得注意發生 error 時,會不會不斷累積,難以 debug。

結論

TypeScript 現在非常熱門,Design Pattern 則是萬年不敗,兩者搭配起來一起學習可說是天作之合!尤其是平常工作沒有機會碰觸到的話,透過這種方式學習不僅兩者皆能吸收,還能累積點小玩具。

但說起來,TypeScript 的爭議也不少,最近參加了優秀新同事們主辦的幾場 Meetup,再次燃起我對 Elm 的興趣,可惜剛到新環境,瑣事太多,還來不及有個成果分享,不過也算是有點心得,希望之後能夠分享一下。

資料來源

  1. Wiki - Design_Patterns
  2. TypeScript
  3. TypeScript Design Pattern

關於作者:
@arvinh 前端攻城獅,熱愛數據分析和資訊視覺化

喜歡我們的文章嗎?歡迎分享按讚給予我們支持和鼓勵!





訂閱 TechBridge Weekly 技術週刊,每週發送最精華的技術開發、產品設計的資訊給您



TechBridge Weekly 技術週刊編輯團隊

TechBridge Weekly 技術週刊團隊是一群對用技術改變世界懷抱熱情的團隊。本技術共筆部落格初期專注於Web前後端、行動網路、機器人/物聯網、資料科學與產品設計等技術分享。This is TechBridge Weekly Team Tech Blog, which focus on web, mobile, robot, IoT, Data Science technology sharing.

關於我們 / 技術日報 / 技術週刊 / 粉絲專頁 / 訂閱RSS

留言討論