設計模式--命令模式

weixin_33936401發表於2017-11-03

目錄

本文的結構如下:

  • 什麼是命令模式
  • 為什麼要用該模式
  • 模式的結構
  • 程式碼示例
  • 優點和缺點
  • 適用環境
  • 模式應用
  • 總結

一、前言

在軟體設計中,經常需要向某些物件傳送請求,但是並不知道請求的接收者是誰,也不知道被請求的操作是哪個,我們只需在程式執行時指定具體的請求接收者即可,此時,可以使用命令模式來進行設計,使得請求傳送者與請求接收者消除彼此之間的耦合,讓物件之間的呼叫關係更加靈活。

二、什麼是命令模式

上面說了,命令模式可以將請求傳送者和接收者完全解耦,傳送者與接收者之間沒有直接引用關係,傳送請求的物件只需要知道如何傳送請求,而不必知道如何完成請求。那麼到底什麼是命令模式?

2.1、官方解釋

命令模式(Command Pattern):將一個請求封裝為一個物件,從而讓我們可用不同的請求對客戶進行引數化;對請求排隊或者記錄請求日誌,以及支援可撤銷的操作。命令模式是一種物件行為型模式,其別名為動作(Action)模式或事務(Transaction)模式。

2.2、舉個例子

那怎麼理解上面比較正式的定義呢?

這裡借用劉偉老師的一張圖。

7017386-29471eb2de25df79.png
20171103_command01.png

假設你新買的200多平米的房子已經到手,正準備裝修,你買了一些開關,用於控制新買的電器,比如電燈和排氣扇。開關剛買回來的時候,是不知道具體控制哪個電器的,只有當你用電線將開關和電器連線起來後,一個開關才控制了一個具體的電器。和電燈相連就控制了電燈的開關,和排氣扇相連則控制了排氣扇的開關。

開關的這種設計思路其實就是一個很好的命令模式。開關可以理解為請求的傳送者,電燈和排氣扇則為請求的接受者,不同的請求就可以理解為是連線開關和電器的不同的電線。通過這種模式,開關(傳送者)就和電器(接收者)鬆耦合了,只需要更換一下連線的電線(不同的請求),就能夠輕鬆實現同一個開關(傳送者)控制不同的電器(接收者),也就是用不同的請求對客戶進行引數化。

至於“對請求排隊或者記錄請求日誌,以及支援可撤銷的操作”又當作何理解呢?請求排隊其實就是將很多不同請求放入一個工作佇列中,然後接收者將請求從佇列中一個一個取出去處理;記錄請求日誌,就是將請求記錄在日誌當中,當系統當機後,可以從日誌中取出這些請求,再一個個去處理恢復之前的狀態。

三、為什麼要用該模式

使用命令模式最重要的原因就是為了解耦,通過引入一個第三方--抽象命令,讓請求者和接收者鬆耦合,讓物件之間的呼叫關係更加靈活,這對系統的擴充套件和維護是有極大好處的。

比如你的豪華大房子又新買了一個電器,恩,就是那種老式吊扇,你想用連線排氣扇的開關去控制這個吊扇,怎麼辦呢?換根電線將開關和吊扇連起來就好了。

四、模式的結構

命令模式的核心在於引入了命令類,通過命令類來降低傳送者和接收者的耦合度,請求傳送者只需指定一個命令物件,再通過命令物件來呼叫請求接收者的處理方法,其結構如圖所示:

7017386-12786a6fdaa2df4f.png
20171103_command02.png

在命令模式結構圖中包含如下幾個角色:

  • Command(抽象命令類):抽象命令類一般是一個抽象類或介面,在其中宣告瞭用於執行請求的execute()等方法,通過這些方法可以呼叫請求接收者的相關操作
  • ConcreteCommand(具體命令類):具體命令類是抽象命令類的子類,實現了在抽象命令類中宣告的方法,它對應具體的接收者物件,將接收者物件的動作繫結其中。在實現execute()方法時,將呼叫接收者物件的相關操作(Action)。
  • Invoker(呼叫者):呼叫者即請求傳送者,它通過命令物件來執行請求。一個呼叫者並不需要在設計時確定其接收者,因此它只與抽象命令類之間存在關聯關係。在程式執行時可以將一個具體命令物件注入其中,再呼叫具體命令物件的execute()方法,從而實現間接呼叫請求接收者的相關操作。
  • Receiver(接收者):接收者執行與請求相關的操作,它具體實現對請求的業務處理

命令模式的本質是對請求進行封裝,一個請求對應於一個命令,將發出命令的責任和執行命令的責任分割開。每一個命令都是一個操作:請求的一方發出請求要求執行一個操作;接收的一方收到請求,並執行相應的操作。命令模式允許請求的一方和接收的一方獨立開來,使得請求的一方不必知道接收請求的一方的介面,更不必知道請求如何被接收、操作是否被執行、何時被執行,以及是怎麼被執行的
命令模式的關鍵在於引入了抽象命令類,請求傳送者針對抽象命令類程式設計,只有實現了抽象命令類的具體命令才與請求接收者相關聯。在最簡單的抽象命令類中只包含了一個抽象的execute()方法,每個具體命令類將一個Receiver型別的物件作為一個例項變數進行儲存,從而具體指定一個請求的接收者,不同的具體命令類提供了execute()方法的不同實現,並呼叫不同接收者的請求處理方法。

典型的抽象命令類程式碼:

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午1:25:14
 *
 */
public interface Command {
    void execute();
}

對於請求傳送者即呼叫者而言,將針對抽象命令類進行程式設計,可以通過構造注入或者設值注入的方式在執行時傳入具體命令類物件,並在業務方法中呼叫命令物件的execute()方法,其典型程式碼:

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午1:24:43
 *
 */
public class Invoker {
    private Command command;

    public Invoker() {
    }

    public void call() {
        command.execute();
    }

    public void setCommand(Command command) {
        this.command = command;
    }
}

具體命令類實現了命令類介面,它與請求接收者相關聯,實現了在抽象命令類中宣告的execute()方法,並在實現時呼叫接收者的請求響應方法action(),其典型程式碼:

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午1:27:56
 *
 */
public class ConcreteCommand implements Command {

    private Receiver receiver;

    public ConcreteCommand(Receiver receiver) {
        this.receiver = receiver;
    }

    @Override
    public void execute() {
        receiver.action();
    }
}

請求接收者Receiver類具體實現對請求的業務處理,它提供了action()方法,用於執行與請求相關的操作,其典型程式碼:

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午1:28:45
 *
 */
public class Receiver {
    public void action() {
        System.out.println("--------let us go to play computer game--------");
    }
}

五、程式碼示例

假設我們正在開發一個辦公軟體,為了給使用者更好的體驗,打算為這個辦公軟體加一個人性化的設計,提供一組按鈕,每個按鈕提供三個功能給使用者選擇,使用者選擇其中一個功能與按鈕繫結,繫結後使用者只要點選按鈕就能實現想要的功能。如下:

7017386-7ef7ab9cc3c93dc1.png
20171103_command03.png

以Button1為例,可以選擇“關閉”,“換膚”,“放大”三個其中的一個。如何設計這個功能呢?

5.1、不好的設計

最開始的程式碼也許是這樣的:

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午2:46:15
 *
 */
public class Button1 {
    private SkinPeelerHandler handler;

    public void onClick() {
        handler = new SkinPeelerHandler();
        handler.skinPeeler();// 換膚
    }
}

上面這段程式碼,Button1是invoker(請求發起者),SkinPeelerHandler是Receiver(請求接收者),它們是直接強耦合在一起的,如果想要將Button1同“放大”繫結起來,似乎只能更改Button1的原始碼了,這明顯破壞了“開閉原則”,對使用者來說,完全不具備可操作性,不靈活不實用。

也行有人會說,可以給“關閉”,“換膚”,“放大”功能設計一個公共抽象層,然後Button1可以通過與抽象層來打交道,這樣就靈活了。是可以的,但如果是一組毫無關聯的接受者呢?它們根本無法抽象出一個共同的抽象層來,這種情況怎麼辦呢?

5.2、命令模式設計

(1)所以用命令模式吧!(這裡就只列換膚和關閉兩個功能)

換膚

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午3:56:47
 *
 */
public class SkinPeelerHandler {
    public void skinPeeler() {
        System.out.println("skin peeler");
    }
}

關閉

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午4:02:05
 *
 */
public class CloseHandler {
    public void close() {
        System.out.println("close the software");
    }
}

(2)定義抽象命令:

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午1:25:14
 *
 */
public interface Command {
    void execute();
}

(3)再定義具體命令:

換膚

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午2:50:45
 *
 */
public class SkinPeelerCommand implements Command {
    private SkinPeelerHandler handler;

    public SkinPeelerCommand(SkinPeelerHandler handler) {
        this.handler = handler;
    }

    @Override
    public void execute() {
        handler.skinPeeler();
    }
}

關閉

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午4:01:24
 *
 */
public class CloseCommand implements Command {

    private CloseHandler handler;

    private CloseCommand(CloseHandler handler) {
        this.handler = handler;
    }

    @Override
    public void execute() {
        handler.close();
    }
}

(4) 呼叫者

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午2:46:35
 *
 */
public class Button {
    private Command command;

    public Button() {
    }

    public void call() {
        command.execute();
    }

    public void setCommand(Command command) {
        this.command = command;
    }
}

(5)最後看客戶端

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午4:15:41
 *
 */
public class Client {
    public static void main(String[] args) {
        // 呼叫者
        Button button = new Button();
        // 這裡可以通過配置檔案獲取具體命令名字,然後通過反射例項化具體命令,這樣更換命令時只需修改配置檔案而不需要修改原始碼
        Command command = new SkinPeelerCommand(new SkinPeelerHandler());
        // 將命令傳給invoker
        button.setCommand(command);
        button.call();
    }
}

如果需要修改功能鍵的功能,例如某個功能鍵可以實現“開啟音樂播放器”,只需要對應增加一個新的具體命令類,在該命令類與“開啟音樂播放器請求處理者”(MusicHandler)之間建立一個關聯關係,然後將該具體命令類的物件通過配置檔案注入到某個功能鍵即可,原有程式碼無須修改,符合“開閉原則”。在此過程中,每一個具體命令類對應一個請求的處理者(接收者),通過向請求傳送者(呼叫者)注入不同的具體命令物件可以使得相同的傳送者對應不同的接收者,從而實現“將一個請求封裝為一個物件,用不同的請求對客戶進行引數化”,客戶端只需要將具體命令物件作為引數注入請求傳送者,無須直接操作請求的接收者

5.3、命令模式中的撤銷

在命令模式中,可以通過呼叫一個命令物件的execute()方法來實現對請求的處理,如果需要撤銷(undo)請求,可通過在命令類中增加一個逆向操作來實現。

除了通過一個逆向操作來實現撤銷(undo)外,還可以通過儲存物件的歷史狀態來實現撤銷,後者可使用備忘錄模式(Memento Pattern)來實現。

以控制一個遊戲角色向前走為例說明。

(1)抽象command

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午1:25:14
 *
 */
public interface Command {
    void execute();

    void undo();
}

(2)接收者

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午5:14:26
 *
 */
public class Role {
    public void forward() {
        System.out.println("向前走10步!");
    }

    public void back() {
        System.out.print("後退10步!");
    }
}

(3)具體命令

public class ForwardCommand implements Command {

    private Role role;

    public ForwardCommand(Role role) {
        this.role = role;
    }

    @Override
    public void execute() {
        role.forward();
    }

    @Override
    public void undo() {
        role.back();
    }
}

(4)呼叫者

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午4:55:43
 *
 */
public class OperatorInterface {
    private Command command;

    public void operate() {
        command.execute();
    }

    public void reset() {
        command.undo();
    }

    public void setCommand(Command command) {
        this.command = command;
    }
}

(5)客戶端

public class Client {
    public static void main(String[] args) {
        OperatorInterface operator = new OperatorInterface();
        Command command = new ForwardCommand(new Role());
        operator.setCommand(command);
        operator.operate();
        operator.reset();
    }
}

結果:
向前走10步!
後退10步!

需要注意的是在本例項中只能實現一步撤銷操作,因為沒有儲存命令物件的歷史狀態,可以通過引入一個命令集合或其他方式來儲存每一次操作時命令的狀態,從而實現多次撤銷操作。除了Undo操作外,還可以採用類似的方式實現恢復(Redo)操作,即恢復所撤銷的操作(或稱為二次撤銷)。

5.4、請求日誌

請求日誌就是將請求的歷史記錄儲存下來,通常以日誌檔案(Log File)的形式永久儲存在計算機中。很多系統都提供了日誌檔案,例如Windows日誌檔案、Oracle日誌檔案等,日誌檔案可以記錄使用者對系統的一些操作(例如對資料的更改)。請求日誌檔案可以實現很多功能,常用功能如下:

  1. 一旦系統發生故障,日誌檔案可以為系統提供一種恢復機制,在請求日誌檔案中可以記錄使用者對系統的每一步操作,從而讓系統能夠順利恢復到某一個特定的狀態;
  2. 請求日誌也可以用於實現批處理,在一個請求日誌檔案中可以儲存一系列命令物件,例如一個命令佇列;
  3. 可以將命令佇列中的所有命令物件都儲存在一個日誌檔案中,每執行一個命令則從日誌檔案中刪除一個對應的命令物件,防止因為斷電或者系統重啟等原因造成請求丟失,而且可以避免重新傳送全部請求時造成某些命令的重複執行,只需讀取請求日誌檔案,再繼續執行檔案中剩餘的命令即可。

這裡用一個簡單的日誌記錄說明:

(1)請求接收者

/**
 * 請求接收者。因為Command依賴Operator,它也將隨Command物件一起序列化, 所以Operator也實現Serializable介面
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午5:25:35
 *
 */
class Operator implements Serializable {
    private static final long serialVersionUID = 4962794574238371441L;

    public void insert(String args) {
        System.out.println("insert operation: " + args);
    }

    public void modify(String args) {
        System.out.println("update operation: " + args);
    }

    public void delete(String args) {
        System.out.println("delete peration: " + args);
    }
}

(2)抽象命令類

/**
 * 抽象命令類,由於需要將命令物件寫入檔案,因此它實現了Serializable介面,保證其序列化
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午5:24:04
 *
 */
public abstract class Command implements Serializable {
    private static final long serialVersionUID = -4023087706968880848L;
    protected String name; // 命令名稱
    protected String args; // 命令引數
    protected Operator operator; // 維持對接收者物件的引用

    public Command(String name) {
        this.name = name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public void setOperator(Operator operator) {
        this.operator = operator;
    }

    /**
     * 抽象的執行方法execute(),帶引數
     * 
     * @param args
     */
    public abstract void execute(String args);

    /**
     * 抽象的執行方法execute(),不帶引數
     * 
     * @param args
     */
    public void execute() {
        execute(this.args);
    }
}

(3)具體命令類

/**
 * 具體插入命令類
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午5:26:28
 *
 */
class InsertCommand extends Command {
    private static final long serialVersionUID = -6239610676788773397L;

    public InsertCommand(String name) {
        super(name);
    }

    public void execute(String args) {
        this.args = args;
        operator.insert(args);
    }
}

/**
 * 具體刪除命令類
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午5:29:19
 *
 */
class DeleteCommand extends Command {
    private static final long serialVersionUID = -4259959904986587353L;

    public DeleteCommand(String name) {
        super(name);
    }

    public void execute(String args) {
        this.args = args;
        operator.delete(args);
    }
}

/**
 * 具體修改命令類
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午5:28:40
 *
 */
class ModifyCommand extends Command {
    private static final long serialVersionUID = -4259959904986587353L;

    public ModifyCommand(String name) {
        super(name);
    }

    public void execute(String args) {
        this.args = args;
        operator.modify(args);
    }
}

(4)請求傳送者

/**
 * 請求傳送者
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午5:31:20
 *
 */
public class OperatorWindow {
    // 定義一個集合來儲存每一次操作時的命令物件
    private List<Command> commands = new ArrayList<Command>();
    private Command command;

    // 設定具體命令物件
    public void setCommand(Command command) {
        this.command = command;
    }

    // 執行命令,同時將命令物件新增到命令集合中
    public void call(String args) {
        command.execute(args);
        commands.add(command);
    }

    // 記錄請求日誌,將命令集合寫入日誌檔案
    public void save() {
        FileUtil.writeCommands(commands);
    }

    // 從日誌檔案中提取命令集合,並呼叫所有命令的execute()方法來實現命令的重新執行
    public void recover() {
        List<Command> commands = FileUtil.readCommands();

        for (Command command : commands) {
            command.execute();
        }
    }
}

/**
 * 檔案操作類
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午5:32:16
 *
 */
class FileUtil {
    
    private static final Logger LOGGER = LoggerFactory.getLogger(FileUtil.class);
    
    public static void writeCommands(List<Command> commands) {
        try {
            FileOutputStream fos = new FileOutputStream("operator.log");
            ObjectOutputStream oos = new ObjectOutputStream(new BufferedOutputStream(fos));
            oos.writeObject(commands);
            oos.close();
        } catch (Exception e) {
            LOGGER.error("writeCommands error!", e);
        }
    }

    public static List<Command> readCommands() {
        try {
            FileInputStream fis = new FileInputStream("operator.log");
            ObjectInputStream ois = new ObjectInputStream(new BufferedInputStream(fis));
            @SuppressWarnings("unchecked")
            List<Command> commands = (List<Command>) ois.readObject();
            ois.close();
            return commands;
        } catch (Exception e) {
            LOGGER.error("readCommands error!", e);
            return null;
        }
    }
}

(5)客戶端

public class Client {
    public static void main(String args[]) {
        OperatorWindow window = new OperatorWindow(); // 請求傳送者
        Command command; // 命令物件
        Operator operator = new Operator(); // 請求接收者

        // 具體命令
        command = new InsertCommand("insert");
        command.setOperator(operator);
        window.setCommand(command);
        window.call("節點1");

        command = new InsertCommand("insert");
        command.setOperator(operator);
        window.setCommand(command);
        window.call("節點2");

        command = new ModifyCommand("modify");
        command.setOperator(operator);
        window.setCommand(command);
        window.call("節點1");

        command = new DeleteCommand("delete");
        command.setOperator(operator);
        window.setCommand(command);
        window.call("節點2");

        System.out.println("---------------------儲存操作記錄---------------------");
        window.save();

        System.out.println("---------------------當機---------------------");

        System.out.println("---------------------恢復操作---------------------");
        window.recover();
    }
}

結果:
insert operation: 節點1
insert operation: 節點2
update operation: 節點1
delete peration: 節點2
---------------------儲存操作記錄---------------------
---------------------當機---------------------
---------------------恢復操作---------------------
insert operation: 節點1
insert operation: 節點2
update operation: 節點1
delete peration: 節點2

5.5、請求排隊

其實和請求日誌差不多,就不列程式碼了。

六、優點和缺點

6.1、優點

  • 降低系統的耦合度。
  • 新的命令可以很容易地加入到系統中。
  • 可以比較容易地設計一個命令佇列和巨集命令(巨集命令又稱為組合命令,它是組合模式和命令模式聯用的產物。巨集命令是一個具體命令類,它擁有一個集合屬性,在該集合中包含了對其他命令物件的引用。當呼叫巨集命令的execute()方法時,將遞迴呼叫它所包含的每個成員命令的execute()方法。)。
  • 可以方便地實現對請求的Undo和Redo。

6.2、缺點

使用命令模式可能會導致某些系統有過多的具體命令類。因為針對每一個命令都需要設計一個具體命令類,因此某些系統可能需要大量具體命令類,這將影響命令模式的使用。

七、適用環境

在以下情況下可以使用命令模式:

  • 系統需要將請求呼叫者和請求接收者解耦,使得呼叫者和接收者不直接互動。
  • 系統需要在不同的時間指定請求、將請求排隊和執行請求。
  • 系統需要支援命令的撤銷(Undo)操作和恢復(Redo)操作。
  • 系統需要將一組操作組合在一起,即支援巨集命令。

八、模式應用

  • 很多系統都提供了巨集命令功能,如UNIX平臺下的Shell程式設計,可以將多條命令封裝在一個命令物件中,只需要一條簡單的命令即可執行一個命令序列,這也是命令模式的應用例項之一。

九、總結

  • 在命令模式中,將一個請求封裝為一個物件,從而使我們可用不同的請求對客戶進行引數化;對請求排隊或者記錄請求日誌,以及支援可撤銷的操作。命令模式是一種物件行為型模式,其別名為動作模式或事務模式。
  • 命令模式包含四個角色:抽象命令類中宣告瞭用於執行請求的execute()等方法,通過這些方法可以呼叫請求接收者的相關操作;具體命令類是抽象命令類的子類,實現了在抽象命令類中宣告的方法,它對應具體的接收者物件,將接收者物件的動作繫結其中;呼叫者即請求的傳送者,又稱為請求者,它通過命令物件來執行請求;接收者執行與請求相關的操作,它具體實現對請求的業務處理。
  • 命令模式的本質是對命令進行封裝,將發出命令的責任和執行命令的責任分割開。命令模式使請求本身成為一個物件,這個物件和其他物件一樣可以被儲存和傳遞。
  • 命令模式的主要優點在於降低系統的耦合度,增加新的命令很方便,而且可以比較容易地設計一個命令佇列和巨集命令,並方便地實現對請求的撤銷和恢復;其主要缺點在於可能會導致某些系統有過多的具體命令類。
  • 命令模式適用情況包括:需要將請求呼叫者和請求接收者解耦,使得呼叫者和接收者不直接互動;需要在不同的時間指定請求、將請求排隊和執行請求;需要支援命令的撤銷操作和恢復操作,需要將一組操作組合在一起,即支援巨集命令。

相關文章