設計模式/原則

命令模式 (Command Pattern)

命令模式 (Command Pattern),GoF 23 種設計模式其一,行為型
又稱為 指令、 action (動作)、transaction (交易) 模式,
其定義:

Encapsulate a request as an object, thereby letting you parameterize clients with
different requests, queue or log requests, and support undoable operations.

 

將請求封裝為物件,使你藉由不同的請求 (如: 佇列或日誌請求),
對客戶端請求參數化,並支援可取消的操作。

 

 


 

簡介

Command 模式,是我認為最簡單且優雅的模式之一,
其可運用的範圍,或許沒有界限。
—— 敏捷開發大師 Robert C. Martin

 
誠如大師所言,Command 模式 範疇超廣
無論是資料庫交易系統、裝置控制、遊戲、管理系統⋯⋯,都能見其身影。
 
Ex:
按下電源開關 —> 開機命令
Ctrl + Z —> 還原命令
我要一碗陽春乾麵小辣 —> 點餐命令
D↓A + D↑J + D→J + D↓J + D↑A —> 邪鬼地獄火焰 🔥
 
並且,大多數 命令模式 (Command Pattern),如定義所述,
實現了 佇列 (Queue)日誌 (Log)復原 (undoable) ⋯⋯ 等功能。
 


 

結構


 

  • Client (負責建立 具體命令 並組裝 接收者):
    建立 具體的命令物件 (ConcreteCommand),
    並設定其接收者 (Receiver),
    此處的 Client 是站在『命令模式』的立場,而非泛指的『客戶』

 

  • Invoker (負責儲存與呼叫命令):
    儲存 具體的命令物件 (ConcreteCommand) ,
    並負責呼叫該命令 —— ConcreteCommand.Execute(),
    若該 Command 有實作 『復原』功能,則在執行之前,先儲存其狀態。

 

  • Command (負責制定命令使用介面):
    如其名,是此模式的關鍵之處 。
    『至少』會含有一個 Execute() 的抽象操作 (方法) (abstract operation) 。

 

  • Receiver (負責執行命令的內容):
    知道如何根據命令的請求,執行任務內容,
    因此任何能實現命令請求的類別,都有可能當作 Receiver。

 

  • ConcreteCommand (負責呼叫 Receiver 的對應操作):
    具體 的命令類別,
    通常持有 Receiver 物件

 


 

耦合 (Coupling)

模組的解耦

以往在小吃店點餐,都直接說:「老闆,我要雞排小辣,不要切。」
UML 類別圖:

 
這樣的方式,既方便又有人情味,
但若需擴增店面 (模組) ,老闆一人要備料、點餐、料理、餐桌清潔…,
除非是 20 年經驗的早餐店阿姨,不然不大可能。
 

於是『內場』 與 『外場』人員就出現啦!

 
透過 命令模式 (Command Pattern) 來實現後:

服務生 (Invoker)』 儲存好『命令 (Command)』並呼叫,
廚師 (Receiver)』 收到訊息,開始料理。

 
這便是 命令模式 (Command Pattern) 耳熟能詳的 餐廳範例 😂 。
 
對應命令模式:

 
 
p.s 上方 括弧內的英文,並非中文翻譯,
而是 命令模式中的 對應角色。
 
 
程式範例:

import java.util.LinkedList;
import java.util.Queue;

public class 餐廳 {

    public static void main(String[] args) {

        廚師 cook = new 廚師(); // 準備廚師 cook (Receiver)

        服務生 waiter = new 服務生(); // 準備 服務生 waiter (Invoker)

        // 準備命令 並 設置廚師
        命令 command = new 點牛排命令(cook);
        命令 command2 = new 點豬排命令(cook);

        // 將準備好的命令 告訴服務生
        waiter.addOrder(command);
        waiter.addOrder(command2);

        // 讓服務生送出命令
        // 開始準備餐點
        waiter.sendOrders();

    }
}

interface 命令 {
    void execute();
}

class 點牛排命令 implements 命令 {

    private 廚師 cook;

    public 點牛排命令(廚師 cook) {
        this.cook = cook;
    }

    @Override
    public void execute() {
        cook.cookSteak();
    }
}

class 點豬排命令 implements 命令 {

    private 廚師 cook;

    public 點豬排命令(廚師 cook) {
        this.cook = cook;
    }

    @Override
    public void execute() {
        cook.cookPork();
    }
}


class 服務生 {
    private Queue<命令> orders = new LinkedList<>(); // 巨集佇列命令

    public void addOrder(命令 command) {
        orders.offer(command);
    }

    public void cancelOrder(命令 command) {
        orders.remove(command);
    }

    public void sendOrders() {

        while (!orders.isEmpty()) {
            命令 cmd = orders.poll();
            cmd.execute();
        }
    }

}

class 廚師 {

    void cookSteak() {
        System.out.println("牛排來嚕~");
    }

    void cookPork() {
        System.out.println("豬排來嚕~");
    }
}

// Result:
// 牛排來嚕~
// 豬排來嚕~

 
這樣的設計,符合了 單一職責原則
客戶端只需對服務生下命令,不需知道餐點是如何實現,
服務生 (Invoker) 只負責儲存訂單 (命令) 並 點餐,廚師 (Receiver) 可專注於料理。
 
這也是 命令模式 (Command Pattern) 最大的好處:

將『引發命令的物件』與『實際執行操作的物件』隔離開來。

 
系統邏輯 的隔離,大大的增加了擴充性:

新增一個命令物件,並配置 欲呼叫的 Receiver 與其操作,即可增加新功能。

 
並確保了程式碼的 覆用 (reuse)
無形中避免掉 『程式碼的壞味道 (Bad smells in Code)』中,
最悲劇的 『重複的程式碼 (Duplicated Code)』。
 
就像許多應用程式中,這兩者是一樣的功能:

  • 選單列的 『編輯 -> 複製』
  • 鍵盤 Ctrl + C

因為它們 都是呼叫 –> 複製『命令』
 
有 100 家雞排店,一樣的炸雞排方法,
你應該不會想寫 100 次吧 … ? QQ
 
 

時間的解耦 (Temporal Decoupling)

沒道理收到了命令,就必須立馬執行,

 
服務生 (Invoker) 儲存好命令 (Command),
可以於指定的時間,再去通知廚師 (Receiver),
也就是所謂的 『預約 (reservation)』。
 
個人相當喜愛 Robert C. Martin 舉的例子:
『有一個白天必須維持不變的資料庫,資料異動只能在午夜到凌晨 1 點之間施行,
如果等到半夜,才匆匆忙忙輸入所有命令,那也太可憐,
何不提早輸入完所有命令,並且在午夜 自動執行?』
 
 
命令模式 (Command Pattern) 提供了這樣的能力。
 


 

客戶端Client

如果你有發現並懷疑,為何上面範例中,
Client 是 『餐廳』,而非 『客戶』,
那說明你 好棒棒 👏
 
這是具有些許爭議的地方 (雖然意義不大..),
許多人 or 書,皆因其命名將其角色定位為 『客戶』,
這不一定正確,要根據個案用法去分析。
 
只要記得 Client 的 定義為:

負責建立 具體命令 並組裝 接收者

 
Client 一詞,是站在

使用命令模式 (Command Pattern) 的立場而定

 
在我們的例子中,Client 若為『客戶』:
『客戶』準備服務生 和 廚師 — 似乎沒道理 🤔 ?
 


 

失敗的設計

上面那些吹毛求疵的取名,不大重要,
重點在於:

—— 『釐清模式的 角色定位與職責

 
許多人想嘗試更好的封裝,但因為職責沒有釐清,
反而使其耦合程度加重,失去了使用 命令模式 的益處,
最常見到就是: 『接收者 (Receiver) 與 具體命令 (ConcreteCommand) 的耦合』。
 
例如:
省去 Client 為 ConcreteCommand 設置 Receiver 的動作』 本是很好的出發點
但 許多人做法卻是:

  1. 將 ConcreteCommand 直接傳遞給 Receiver
  2. 透過 setReceiver(this); 的方式組裝
  3. Receiver 再去執行命令 ..

 
這使得

  1. Receiver 除了得撰寫業務邏輯,還要去考慮到 Command 的狀態
  2. Invoker 職責是 要求 Command 執行命令,你現在丟給 Receiver 做,Invoker 要欉尛 😯
  3. 命令模式的最大好處: 「將『引發命令的物件』與『實際執行操作的物件』隔離開來」,
    這下好了,Receiver 同時代表兩者你還用這個模式幹嘛?😏

 
 

Invoker 爭議

另外,許多人納悶:

為什麼 Client 與 Invoker 在 UML 中,沒有顯示關聯 ?

 
這是個老問題,
原著中並無詳加說明,因此大家各說各話,
其中,我最支持的論點是:

Client 與 Invoker 並不一定有 直接的依賴關係,
他們需要的 僅是做好各自職責 (詳見 上方 UML 圖示說明),
Client 如何獲取 Invoker 或 根本不需要獲取 (e.g., 委託別的類別) 不是我們在意的。

 
以一個極端的例子來看,
Client 並無依賴 Inovker !

public class Main {

    public static void main(String[] args) {

        Client client = new Client(); // 實例 Client

        // Client 盡了本分:『建立具體 cmd 與 組裝 Receiver』
        Command command = client.makeConcreteCommand();

        Invoker invoker = new Invoker(); // 實例 Invoker

        // invoker 盡了本分:『儲存命令 並 呼叫』
        invoker.storeCommand(command);

        invoker.execute(); // 執行
    }
}

 
另外,也有人說 (個人覺得是腦補):
Client 依賴 ConcreteCommand,
ConcreteCommand 實作 Command 介面,
Command 介面 聚合於 Invoker,
所以,Client “間接” 依賴了 Invoker。
 


 

可還原操作 (Undoable Operations)

若命令執行後反悔了,藉由還原功能,回到原本的狀態。
 
 

擴充類別 or 方法

首先,有兩種常見選擇:

  1. 新增一個 具體命令類別,讓 execute() 呼叫復原邏輯。
  2. 抽象命令類別 (or 介面) 中,新增反向操作 unExecute(),來呼叫復原邏輯。(如下圖)


 
兩種方法優缺點剛好相反,開心就好,
第一種,容易產生許多冗餘類別。
第二種,會使所有具體命令,都必須實作復原功能,但使用上方便許多。
(當然復原方法也可留空啦,不過有點兒腦殘)
 
 

復原邏輯

接著,復原邏輯,也有兩種常見方式:

  1. 結合 備忘錄模式 (Momento),記錄『接收者 (Receiver) 的原先狀態』,
    且通常儲存在 具體命令類別中 (ConcreteCommand)。
  2. 接收者 (Receiver) 寫好反向邏輯,再由 Command 呼叫。
    (譬如: 原先操作是『+』 ,就新增一個操作為『 – 』)

 

其中 Java 的 序列化 (Serializable)
就是 備忘錄模式 (Momento) 的 一種實作。

 

歷史紀錄

由於大部分的還原功能,是有順序的,也就是:
執行: A —> B —> C
還原: C —> B —> A
 
因此,時常會在 呼叫者 (Invoker) 類別中,
用一個 堆疊 (Stack) 資料結構先進後出 (FILO)
來儲存 執行過的命令,
pop 出來的,即是最後一個執行的命令,
再去執行相對的反向操作 undo(),就成功啦~
 
當然,用其他資料結構,也 OK,
依照本身需求即可。
 
譬如: 若只限定還原一次,那資料型態就為 命令 (Command)。

class Invoker {

private 命令 history;

...

 
 

新增歷史紀錄

執行完命令後,要將命令存錄歷史中,
又有兩種方式 0.0

  1. 直接儲存 (Reference)
  2. Clone 一個新的命令,再儲存

第一個沒什麼好說,
第二個是因為:
你無法確保執行命令後,
該命令物件不會產生變化,或重複呼叫
複製一份 可確保其 乾淨狀態 (Clean State),
這也是 Prototype 模式 的應用。
 
 

復原範例

增加了復原 (此指退餐,而非取消訂餐) 功能:
 
首先,我選擇了擴充方法 unExecute() 的方式,
且為了實現 『公共功能』Cloneable,
將 Command 介面 改為 抽象類別

abstract class 命令 implements Cloneable {

    protected 廚師 cook;

    abstract void execute();

    abstract void unExecute();

    @Override
    public Object clone() throws CloneNotSupportedException {
        return super.clone();
    }
}

 

命令模式 (Command Pattern) ,實務上也 經常使用 抽象類別
來實現 模板方法 (Template method),或儲存 Receiver 狀態 等公共功能,
且 DP 原著中,並未指定使用 介面 (interface) 喔~

 
廚師 (接收者 Receiver):
再來,我使用 反向邏輯 (雖然沒有任何邏輯成分 ._.) 的呼叫。

class 廚師 {

    private String name;

    public 廚師(String name) {
        this.name = name;
    }

    public void cookSteak() {
        System.out.println(" 廚師: " + name + "牛排來嚕~");
    }

    public void cookPork() {
        System.out.println("廚師: " + name + "豬排來嚕~");
    }

    // 反向邏輯範例: 復原牛排
    public void cancelSteak() {
        System.out.println("[復原] 顧客: 牛排難吃退貨~");
    }

    // 反向邏輯範例: 復原豬排
    public void cancelPork() {
        System.out.println("[復原] 顧客: 豬排難吃退貨~");
    }

}

 
服務生 (呼叫者 Invoker):
我使用了 堆疊 (Stack) 的儲存方式,
儲存的為 『Clone』後的命令
而非Reference

import java.util.LinkedList;
import java.util.Queue;
import java.util.Stack;

class 服務生 {

    private Queue<命令> orders = new LinkedList<>(); // 巨集佇列命令

    private Stack<命令> history = new Stack<>(); // 堆疊歷史記錄

    public void addOrder(命令 command) {
        orders.offer(command);
    }

    public void cancelOrder(命令 command) {
        orders.remove(command);
    }

    public void undo() {
        if (!history.isEmpty()) {
            命令 cmd = history.pop();
            cmd.unExecute();
        } else {
            System.out.println("[復原失敗] --- 查無點餐記錄");
        }
    }

    public void sendOrders() {

        while (!orders.isEmpty()) {
            命令 cmd = orders.poll();
            cmd.execute();
            
            addHistoryByClone(cmd); // 執行過後,用「Clone」的方式儲存 Command
        }
    }

    private void addHistoryByClone(命令 cmd) {
        命令 cmdClone = null;
        try {
            cmdClone = (命令) cmd.clone();
        } catch (CloneNotSupportedException e) {
            e.printStackTrace();
        }
        history.push(cmdClone);

    }

    private void addHistoryByReference(命令 cmd) {
        history.push(cmd);
    }

}

 
餐廳 (客戶端 Client):
模擬復原功能:

public class 餐廳 {

    public static void main(String[] args) {

        廚師 cook = new 廚師("Jason"); // 準備廚師 cook (Receiver)

        服務生 waiter = new 服務生(); // 準備 服務生 waiter (Invoker)

        // 準備命令 並 設置廚師
        命令 command = new 點牛排命令(cook);
        命令 command2 = new 點豬排命令(cook);

        // 將準備好的命令 告訴服務生
        waiter.addOrder(command);
        waiter.addOrder(command2);

        // 讓服務生送出命令
        // 開始準備餐點
        waiter.sendOrders();

        waiter.undo(); // 復原操作
        waiter.undo(); // 復原操作
        waiter.undo(); // 復原操作
    }
}

 

// Result:
/*
 廚師: Jason牛排來嚕~
 廚師: Jason豬排來嚕~
 [復原] 顧客: 豬排難吃退貨~
 [復原] 顧客: 牛排難吃退貨~
 [復原失敗] --- 查無點餐記錄
*/

 
 
大功告成~
 


 

日誌 (Log)

support logging changes so that they can be reapplied in case of a system crash.

 
許多 命令模式應用,皆有支援 日誌功能,
尤其是用於 交易 (Transactions) 時 ,
更讓 日誌 功能有了滿滿的大平台。
 
日誌,不單只是記錄,還要能支援:

— 當系統損毀時能夠『重新』執行。

 
說白了就是:

命令的 儲存 與 載入 (Store and Load)。

 
儲存的時機,通常有:

  • 新增完命令式
  • 執行完命令時
  • 復原執行過的命令後

 
載入的時機,則通常為:

  • 系統初始化時 (檢查是否有未執行命令)
  • 發生異常時

 
 

儲存與載入 (Store and Load)

GoF – DP 的原文中,僅粗略地說明我們可以在 Command 類別新增操作:

  • Store()
  • Load()

 
但就我觀察,此種做法較為少見,
因命令還要負責 儲存載入,有點 踰越職責 了。
 
較常見的做法是,
使用一個新的 介面,定義兩個基本操作:

  • writeFile (Store)
  • readFile (Load)

 
例如:

public interface Logger {

    void writeFile(String pathName, Object object);

    Object readFile(String pathName);
}

 
呼叫者 Invoker 在於上面說的各個時機點,呼叫具體的 Logger 方法 就完成囉,
當想要替換 儲存與載入的方式或演算法,只要新增一個類別 實作 Logger 介面。
 
Invoker 不用知道 具體的 Logger 是誰,只管呼叫方法
這也是 策略模式 (Strategy Pattern) 的結合應用 !
 
至於儲存的實現,最簡單直覺就是:

  • 物件序列化 (Serialization)
  • 資料庫 (Database)

 
p.s 但是,實務上的日誌處理,往往伴隨著大量業務邏輯,沒那麼簡單 😨 ..
畢竟損失一筆訂單,有可能會是很慘烈的結果。
 
以物件序列化來舉例:
首先,讓需要被序列化的物件,實作 Serializable 介面
 
[註]:
加上剛剛實作的 『Cloneable』,
我的 Command 類別 已經實作兩個介面,
這也是為何 命令 時常使用抽象類別

public abstract class 命令 implements Cloneable, Serializable {

 
接著 撰寫 具體的 Logger 類別,例如:

public class FileLogger implements Logger {

    public void writeFile(String pathName, Object object) {
        File file = new File(pathName);

        try (FileOutputStream fs = new FileOutputStream(file);
             BufferedOutputStream bs = new BufferedOutputStream(fs);
             ObjectOutputStream os = new ObjectOutputStream(bs)
        ) {

            // if file doesn't exists, then create it

            if (!file.exists()) {
                file.createNewFile();
            }

            os.writeObject(object);

            System.out.println("-----寫入菜單日誌 (Log)-----");

        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public Object readFile(String pathName) {

        Object result = null;

        File file = new File(pathName);

        if (!file.exists()) {
            return null;
        }

        try (FileInputStream fs = new FileInputStream(file);
             BufferedInputStream bs = new BufferedInputStream(fs);
             ObjectInputStream os = new ObjectInputStream(bs)) {

            result = os.readObject();

            System.out.println("-----讀取菜單日誌 (Log)-----");

        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }
        return result;
    }
}

 


 

變形應用

封裝接收者 (Encapsulate Receiver)

命令模式 (Command Pattern) 在實務運用中,
很常見到此變形 — 封裝掉 Receiver (除非真有必要,如撤銷處理),
雖然個人並不喜歡 😐。
(在 “失敗的設計” 中提過)
 
如此一來,高階模組 Client,
減少了對 低階模組 Receiver 的依賴,
也就不再需要進行 具體命令 (ConcreteCommand) 與 接收者 (Receiver) 的組裝。
 
Client 的職責轉變為純粹的:

給予 呼叫者 (Invoker) 具體的命令 (ConcreteCommand)
不需進行 接收者 (Receiver) 組裝

 
至於如何給予 具體命令 (ConcreteCommand) 所需的 接收者 (Receiver) 依賴,
則可以參考 系列文章,
譬如,在 命令類別的建構元中 使用 工廠方法模式 (Factory Method Pattern) 取得。
 
 

客戶端 — 職責分離 (Client — Segregation of Duties)

其目的與 『封裝呼叫者 (Encapsulate Receiver)』 一模一樣,
旨在使 Client 的職責轉變為純粹的 (個人較偏好 🙂):

給予 呼叫者 (Invoker) 具體的命令 (ConcreteCommand)
不需進行 接收者 (Receiver) 組裝

 
其實就類似範例中的概念:
去除 Client 組裝 接收者 (Receiver) 的職責,
交給 的類別 『餐廳』來組裝。
 
客戶要取得 服務生,是透過如以下方式,取得 呼叫者 (Invoker):

餐廳 restaurant = new 餐廳("Good Good Eat Restaurant");
restaurant.getWaiter();

 
客戶 (Client) 準備好命令 (ConcreteCommand) 再傳遞給 服務生 (Invoker) 即可。
 
 

智慧命令

另一種常見變形則是: 『智慧』命令。
也就是 命令 (Command) 不再需要 接收者 (Receiver),
自己就知道怎麼實現功能 (個人蠻喜歡 😬)。
 


 

命令模式 (Command Pattern) 總結

缺點

許多人認為

命令模式 (Command Pattern) 將 函式的角色 提升至 類別的層次
褻瀆了 物件導向精神。

 
無錯,這點無法否認。
而且,它也具有 大部分設計模式的通病 —— 類別的膨脹:

每增加一個功能 就得增加一個命令類別。

 

《Clean Code》告訴我們: 為保持類別凝聚性,拆分出更多的類別,
讓我們的程式擁有更好的組織架構 及 更透明的結構。

 
然而許多人並不認為,
他們覺得增加了 系統的複雜度
 
這也是為何:

遵循 設計原則 與 模式,不一定是實務上的最佳方案,
還需考量到實際情形。

 

時機

儘管如此,
命令模式 (Command Pattern) 在軟體開發中,帶來的莫大好處。
 
想讓系統:

  • 想 參數化請求『欲執行的任務』時
  • 依不同時間 或 佇列 執行命令 時
  • 發送訊息者 與 接收執行者 生命週期不同時
  • 讓執行的任務 具有 復原 或 日誌 功能時
  • 實作 交易 (Transaction) 功能 時

皆可使用 命令模式。
 
 
或許,你已經用了許久的 命令模式 而渾然不知,
例如,Java 的 Runnable 就是 命令模式的變形應用:

public class Main {

    public static void main(String[] args) {

        // 原始碼範例
        Runnable runnable = () -> System.out.println("具體命令"); // Command cmd = ConcreteCommand
        Thread thread1 = new Thread(runnable); // 將 cmd 交給 Thread (Invoker)
        thread1.start(); // Invoker 調用 cmd 的 執行方法
    }
}

 
 

餐廳範例

最後,我以 Scanner 模擬顧客點餐:

 
點餐到一半,可終止程序 模擬系統當機:

 
再次運行,即可讀取日誌,恢復 Invoker 儲存的命令物件:

 
範例原始檔
 
 

作者: 鄭中勝

喜愛音樂,但不知為何總在打程式 ?
期許能重新審視、整理自身所學,幫助有需要的人。

在《命令模式 (Command Pattern)》中有 3 則留言

發表迴響