跳至主要内容

Command Pattern (命令模式) (Action) (Transaction)

命令模式是一種行為設計模式,可將請求封裝成物件,讓你可以將請求、佇列或紀錄等物件參數化,並支援可復原的的操作

命令模式結構

命令模式結構

命令模式的應用場景

  1. 如果需要把動作變成參數化對象

    命令模式可將特定的方法轉化成獨立對象。你可以將命令作為方法的參數進行傳遞,將命令保存在其他對象中,或是在執行期切換已連接的命令。

  2. 如果想要將動作放入隊列中、動作的執行或遠程執行動作

    同其他對象一樣,命令也可以實現序列化,從而方便的寫入文件或數據庫中。一段時間後,再將他恢復成最初的命令對象。因此,可以延遲或計劃命令的執行。也可以將命令放入隊列、紀錄命令、通過網路傳送命令。

  3. 如果你想要實現操作回滾功能

    盡管有很多方法可以實現撤銷和恢復功能,但命令模式是其中常用的一種。 為了能夠回滾操作,你需要實現已執行操作的歷史紀錄功能。對所有已執行命令對象及其相關程序狀態備份的 stack 結構,但是這種方法有兩個缺點。

    1. 程式狀態的保存功能並不容易實現,因為部分狀態為私有。可以使用備忘錄模式來在一定程度上解決這個問題。

    2. 備份狀態可能會占用大量內存。有時需要借助另一種實現方式:反向操作。反向操作也有代價:他可能會很難甚至無法實現。

使用時機

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

優缺點

優點

  1. 單一職責原則。可以解藕觸發和執行操作的類。
  2. 開放封閉原則。可以在不修改已有客戶端程式碼的情況下,在程式碼中創建先的命令。
  3. 可以實現撤銷和恢復功能(Undoable Operations)
  4. 可以實現操作的延遲執行(Temporal Decoupling)
  5. 可以將一組簡單命令組合成一個複雜命令

缺點

  1. 程式碼會變得更加複雜,因為你在發送者和接收者之間增加了一個全新的層次。

命令模式(Command Pattern)

command 物件藉著將準備送給 receiver 的一組行動綁在一起,來封裝請求。command 物件將動作和 receiver 都包在它裡面,只公開一個方法 excute()。當你呼叫 execute()時,就會呼叫 receiver 的動作。在外面,其他的物件都不知道哪個 receiver 執行什麼動作,他們只知道呼叫 execute()方法之後,他們的請求就會被處理。

提示

命令模式可將請求封裝成物件,讓你可以將請求、佇列或紀錄等物件參數化,並支援可復原的的操作。

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

遇到的需求

  • 我們想要設計一個控制器,上面可以登入很多設備,另外有 ON 和 OFF 的按鈕來控制設備,以及最後一個 UNDO 可以復原的按鈕

控制器

遇到的問題

  • 廠商的類別有非常多,而且有著很不一樣的介面,因此希望遙控器不需要知道太多家電的介面細節,也不想在遙控器裡面有一堆家電的 if,(如: if slot1 == Light, then light.on())

廠商的類別

命令物件

  • 命令物件可以用特定的物件(例如客廳電燈物件)來封裝做某件事情的請求(例如打開電燈)
  • 我們幫遙控器的按鈕指定一個命令物件,按下按鈕時,只需要呼叫命令物件做某項工作即可
  1. 實作 Command 介面,所有的 Command 物件都實作同一個介面,介面有一個通用方法(execute)
public interface Command {
public void execute(); // 只需要一個execute方法
}
  1. 實作一個 Command 來開燈。(或是說將廠商的物件封裝在 Command 介面中)
public class LightOnCommand implements Command { // 我們要實作Command介面
Light light; // 家電的物件

public LightOnCommand(Light light) { // 讓建構式接收這個command要控制的電燈,假設是客廳電燈,並將它存入變數中。當execute被呼叫的時候,接收請求的就是這個物件。
this.light = light;
}

public void execute() { // execute()方法呼叫物件的on()方法
light.on();
}
}
  1. 建立 Invoker,使用 command 物件。假設我們的遙控器只有一個按鈕及其對應的位置
// This is the invoker
public class SimpleRemoteControl {
Command slot;

public SimpleRemoteControl() {}

public void setCommand(Command command) {
slot = command;
}

public void buttonWasPressed() {
slot.execute();
}
}
  1. 建立簡單的測試程式來使用遙控器
public class RemoteControlTest { // 這是命令模式中的client
public static void main(String[] args) {
SimpleRemoteControl remote = new SimpleRemoteControl(); // 遙控器是一個invoker,它會收到一個command物件,那個command物件可以用來發出請求
Light light = new Light(); // 建立Light物件,它是接受請求的Receiver
LightOnCommand lightOn = new LightOnCommand(light); // 在這裡建立一個command,並將Receiver傳給他

remote.setCommand(lightOn); // 將commnad傳給Invoker
remote.buttonWasPressed(); // 模擬按鈕被按下
}
}

完整的遙控器製作

  1. 再遙控器中有七個位置,每一個位置都有開、關按鈕,可以將 command 指派給遙控器
onCommands[0] = onCommand;
offCommands[0] = offCommand;
  1. 當我們建立將被載入遙控器的 Command 時,我們會建立一個客廳電燈物件的 LightCommand,另外建立一個廚房電燈物件的 LightCommand。因此請求的 receiver 與封裝它的 command 是綁定的。所以,當按鈕被按下時,沒有人在乎電燈是哪一個,當 execute()方法被呼叫時,對的事情就會發生。
Light light = new Light("Living Room"); // 客廳電燈的物件
LightOnCommand lightOn = new LightOnCommand(light); // 客廳電燈的命令
Light light = new Light("Kitchen"); // 廚房電燈的物件
LightOnCommand lightOn = new LightOnCommand(light); // 廚房電燈的命令
  1. 將 Command 指派給位置

完成的控制器

// This is the invoker
public class RemoteControl { // 這一次控制器會處理7個On和Off command,我們將用相應的陣列來保存他們
Command[] onCommands;
Command[] offCommands;

public RemoteControl() { // 建構式裡,我們只需要實例化與初始化On 和 Off 陣列即可。
onCommands = new Command[7];
offCommands = new Command[7];

Command noCommand = new NoCommand();
for (int i = 0; i < 7; i++) {
onCommands[i] = noCommand;
offCommands[i] = noCommand;
}
}

public void setCommand(int slot, Command onCommand, Command offCommand) { // 接收位置,以及要存入那個位置的 On 和 Off command
onCommands[slot] = onCommand; // 將這些command放入On與Off陣列,以備後用
offCommands[slot] = offCommand;
}

public void onButtonWasPushed(int slot) { // 當On 或 Off 按鈕被按下時,由硬體負責呼叫對應的方法
onCommands[slot].execute();
}

public void offButtonWasPushed(int slot) {
offCommands[slot].execute();
}

public String toString() { // 列印出每一個位置及其command,測試時使用到它
StringBuffer stringBuff = new StringBuffer();
stringBuff.append("\n------ Remote Control -------\n");
for (int i = 0; i < onCommands.length; i++) {
stringBuff.append("[slot " + i + "] " + onCommands[i].getClass().getName()
+ " " + offCommands[i].getClass().getName() + "\n");
}
return stringBuff.toString();
}
}
  1. 實作 Command,使用上面寫好的 LightOnCommand,以及為 Stereo 編寫 On command
// Light on command
public class LightOnCommand implements Command {
Light light;

public LightOnCommand(Light light) {
this.light = light;
}

public void execute() {
light.on();
}
}

// stereo On with CD command
public class StereoOnWithCDCommand implements Command {
Stereo stereo;

public StereoOnWithCDCommand(Stereo stereo) {
this.stereo = stereo; /// 我們接收要控制的stereo實例,並將它存入實例變數
}

public void execute() {
stereo.on(); // 將它打開
stereo.setCD(); // 設定播放CD
stereo.setVolume(11); // 將音量設成11
}
}
  1. 逐步測試遙控器
public class RemoteLoader {

public static void main(String[] args) {
RemoteControl remoteControl = new RemoteControl();

// 在合適的位置建立所有的設備
Light livingRoomLight = new Light("Living Room");
Light kitchenLight = new Light("Kitchen");
CeilingFan ceilingFan= new CeilingFan("Living Room");
GarageDoor garageDoor = new GarageDoor("Garage");
Stereo stereo = new Stereo("Living Room");

// 建立所有的Light Command
LightOnCommand livingRoomLightOn =
new LightOnCommand(livingRoomLight);
LightOffCommand livingRoomLightOff =
new LightOffCommand(livingRoomLight);
LightOnCommand kitchenLightOn =
new LightOnCommand(kitchenLight);
LightOffCommand kitchenLightOff =
new LightOffCommand(kitchenLight);

// 建立吊扇的On與Off Command
CeilingFanOnCommand ceilingFanOn =
new CeilingFanOnCommand(ceilingFan);
CeilingFanOffCommand ceilingFanOff =
new CeilingFanOffCommand(ceilingFan);

// 建立車庫的Up與Down Command
GarageDoorUpCommand garageDoorUp =
new GarageDoorUpCommand(garageDoor);
GarageDoorDownCommand garageDoorDown =
new GarageDoorDownCommand(garageDoor);

// 建立音響的On與Off Command
StereoOnWithCDCommand stereoOnWithCD =
new StereoOnWithCDCommand(stereo);
StereoOffCommand stereoOff =
new StereoOffCommand(stereo);

// 完成所有command之後,將他們載入遙控器的位置
remoteControl.setCommand(0, livingRoomLightOn, livingRoomLightOff);
remoteControl.setCommand(1, kitchenLightOn, kitchenLightOff);
remoteControl.setCommand(2, ceilingFanOn, ceilingFanOff);
remoteControl.setCommand(3, stereoOnWithCD, stereoOff);

// 利用toString()方法,印出每一個遙控器位置,以及指派給它的command
System.out.println(remoteControl);

// 逐步按下每一個位置的on和off
remoteControl.onButtonWasPushed(0);
remoteControl.offButtonWasPushed(0);
remoteControl.onButtonWasPushed(1);
remoteControl.offButtonWasPushed(1);
remoteControl.onButtonWasPushed(2);
remoteControl.offButtonWasPushed(2);
remoteControl.onButtonWasPushed(3);
remoteControl.offButtonWasPushed(3);
}
}
  1. 建立 NoCommand 以取代 if 判斷
  • 為了避免每次引用位置都要檢查它有沒有載入 command
public void onButtonWasPushed(int slot) {
if (onCommands[slot] != null) { // 看起來很醜
onCommands[slot].execute();
}
}
  • 創建一個不做任何事情的 command
public class NoCommand implements Command {
public void execute() { }
}

  • 在上面例子中 RemoteControl 的建構式中,將 NoCommand 預先指派給每一個位置,每一個位置就一定有一個 command 可以呼叫
Command noCommand = new NoCommand();
for (int i = 0; i < 7; i++) {
onCommands[i] = noCommand;
offCommands[i] = noCommand;
}
備註

NoCommand 是一個null 物件。null 物件很適合在你無法回傳有意義的物件,而且不想讓用戶端處理沒有東西可用的(null)的情況時使用。

你會發現很多設計模式都有 Null 物件,甚至有人將「Null Object」視為一種設計模式

文件撰寫

遙控器設計API

使用 lambda 優化

// 原始的寫法
public class RemoteLoader {

public static void main(String[] args) {
RemoteControl remoteControl = new RemoteControl();

Light livingRoomLight = new Light("Living Room");

LightOnCommand livingRoomLightOn =
new LightOnCommand(livingRoomLight); // 可以移除具體的Command物件
LightOffCommand livingRoomLightOff =
new LightOffCommand(livingRoomLight);

remoteControl.setCommand(0, livingRoomLightOn, livingRoomLightOff);
}
}

// 使用lambda寫法
public class RemoteLoader {

public static void main(String[] args) {
RemoteControl remoteControl = new RemoteControl();

Light livingRoomLight = new Light("Living Room");

remoteControl.setCommand(0, () -> livingRoomLight.on(),
() -> livingRoomLight.off()); // 我們將具體command寫成lambda運算式
}
}
備註

這種做法只能在 Command 介面只有一個抽象方法時使用,一旦加在第二個抽象方法,lambda 簡寫就無法使用

復原按鈕

電燈開關恢復

  • 假設客廳的電燈是關閉的,當按下 on 之後,電燈會開啟。當你按下復原的時候,理論上電燈會關閉。
  1. 讓 Command 介面可以支援復原,因此建立一個 execute()方法對應的 undo()方法
public interface Command {
public void execute();
public void undo(); // 建立一個新的undo方法
}
  1. 我們先處理簡單的 LightOnCommand,如果 On 就是上一次被呼叫的方法,我們知道 undo 必須呼叫 Off 方法,LightOffCommand 同理
public class LightOnCommand implements Command {
Light light;
public LightOnCommand(Light light) {
this.light = light;
}

public void execute() {
light.on();
}

public void undo() { // execute 會將電燈開起,所以undo只要將電燈關閉即可
light.off();
}
}

public class LightOffCommand implements Command {
Light light;
public LightOffCommand(Light light) {
this.light = light;
}

public void execute() {
light.off();
}

public void undo() { // 同理undo將電燈開起
light.on();
}
}
  1. 我們必須讓遙控器追蹤上一次被按下的按鈕以及 undo 按鈕。在 Remote Control 類別中,加入一個新的實例變數來記錄上一個呼叫的 command,當 undo 按下時,讀取那個 command,並呼叫它的 undo()方法
public class RemoteControlWithUndo {
Command[] onCommands;
Command[] offCommands;
Command undoCommand; // 將上一次執行的command存在這裡,讓undo按鈕使用

public RemoteControlWithUndo() {
onCommands = new Command[7];
offCommands = new Command[7];

Command noCommand = new NoCommand();
for(int i=0;i<7;i++) {
onCommands[i] = noCommand;
offCommands[i] = noCommand;
}
undoCommand = noCommand; // undo與其他的位置一樣,在一開始被設定為NoCommand,所以在按下任何其他按鈕之前,按下undo不會做任何事情
}

public void setCommand(int slot, Command onCommand, Command offCommand) {
onCommands[slot] = onCommand;
offCommands[slot] = offCommand;
}

public void onButtonWasPushed(int slot) {
onCommands[slot].execute();
undoCommand = onCommands[slot]; // 將他的參考存入undoCommand之中
}

public void offButtonWasPushed(int slot) {
offCommands[slot].execute();
undoCommand = offCommands[slot]; // 將他的參考存入undoCommand之中
}

public void undoButtonWasPushed() { // 當undo按鈕被按下時,我們呼叫undoCommand所儲存的command的undo方法(),它會恢復上一次執行的command動作。
undoCommand.undo();
}

public String toString() {
StringBuffer stringBuff = new StringBuffer();
stringBuff.append("\n------ Remote Control -------\n");
for (int i = 0; i < onCommands.length; i++) {
stringBuff.append("[slot " + i + "] " + onCommands[i].getClass().getName()
+ " " + offCommands[i].getClass().getName() + "\n");
}
stringBuff.append("[undo] " + undoCommand.getClass().getName() + "\n"); // 加入undo的測試
return stringBuff.toString();
}
}
  1. 測試電燈恢復
public class RemoteLoader {

public static void main(String[] args) {
RemoteControlWithUndo remoteControl = new RemoteControlWithUndo();

Light livingRoomLight = new Light("Living Room");

LightOnCommand livingRoomLightOn =
new LightOnCommand(livingRoomLight);
LightOffCommand livingRoomLightOff =
new LightOffCommand(livingRoomLight);

remoteControl.setCommand(0, livingRoomLightOn, livingRoomLightOff);

remoteControl.onButtonWasPushed(0); // Light is on
remoteControl.offButtonWasPushed(0); // Light is off
System.out.println(remoteControl); // [undo] LightOffCommand
remoteControl.undoButtonWasPushed(); // Light is on
remoteControl.offButtonWasPushed(0); // Light is off
remoteControl.onButtonWasPushed(0); // Light is on
System.out.println(remoteControl); // [undo] LightOnCommand
remoteControl.undoButtonWasPushed(); // Light is off
}
}

使用狀態來實作恢復

  • 例如 CeilingFan 可以設定一些速度,也有 OFF 方法
  1. 查看 CeilingFan 的原始碼
public class CeilingFan {
public static final int HIGH = 3;
public static final int MEDIUM = 2;
public static final int LOW = 1;
public static final int OFF = 0;
String location;
int speed; // 類別保存一些區域狀態,代表吊扇的速度

public CeilingFan(String location) {
this.location = location;
speed = OFF;
}

// 用這些方法設定吊扇的速度
public void high() {
speed = HIGH;
System.out.println(location + " ceiling fan is on high");
}

public void medium() {
speed = MEDIUM;
System.out.println(location + " ceiling fan is on medium");
}

public void low() {
speed = LOW;
System.out.println(location + " ceiling fan is on low");
}

public void off() {
speed = OFF;
System.out.println(location + " ceiling fan is off");
}

// 可以使用這個方法取得目前吊扇的速度
public int getSpeed() {
return speed;
}
}
  1. 為吊扇的 command 加入恢復功能
public class CeilingFanHighCommand implements Command { // High、Medium、Low、Off各需要一個command物件
CeilingFan ceilingFan;
int prevSpeed; // 加入區域變數來記錄吊扇上一次的速度

public CeilingFanHighCommand(CeilingFan ceilingFan) {
this.ceilingFan = ceilingFan;
}

public void execute() {
prevSpeed = ceilingFan.getSpeed(); // 再更改吊扇速度前,先記錄他之前的狀態,在恢復的時候使用
ceilingFan.high();
}

public void undo() { // 在恢復時,將吊扇的速度設回去他之前的速度
if (prevSpeed == CeilingFan.HIGH) {
ceilingFan.high();
} else if (prevSpeed == CeilingFan.MEDIUM) {
ceilingFan.medium();
} else if (prevSpeed == CeilingFan.LOW) {
ceilingFan.low();
} else if (prevSpeed == CeilingFan.OFF) {
ceilingFan.off();
}
}
}
  1. 測試吊扇

package headfirst.designpatterns.command.undo;

public class RemoteLoader {

public static void main(String[] args) {
RemoteControlWithUndo remoteControl = new RemoteControlWithUndo();

CeilingFan ceilingFan = new CeilingFan("Living Room");

// 這裡實例化三個command
CeilingFanMediumCommand ceilingFanMedium =
new CeilingFanMediumCommand(ceilingFan);
CeilingFanHighCommand ceilingFanHigh =
new CeilingFanHighCommand(ceilingFan);
CeilingFanOffCommand ceilingFanOff =
new CeilingFanOffCommand(ceilingFan);

// 將medium放入第0個位置,將high放入第1個位置,並且也載入off command
remoteControl.setCommand(0, ceilingFanMedium, ceilingFanOff);
remoteControl.setCommand(1, ceilingFanHigh, ceilingFanOff);

remoteControl.onButtonWasPushed(0); // Living Room ceiling fan is on medium
remoteControl.offButtonWasPushed(0); // Living Room ceiling fan is off
System.out.println(remoteControl); // [undo] CeilingFanOffCommand
remoteControl.undoButtonWasPushed(); // Living Room ceiling fan is on medium

remoteControl.onButtonWasPushed(1); // Living Room ceiling fan is on high
System.out.println(remoteControl); // [undo] CeilingFanHighCommand
remoteControl.undoButtonWasPushed(); // Living Room ceiling fan is on medium
}
}

歷史紀錄的方法

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

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

備註

命令模式實務上也經常使用 抽象類別,來實現模板方法(Template method),或儲存 Receiver 狀態等共用功能

  1. 使用擴展方法 undo()的方式,為了實現公共功能(Cloneable)將 Command 介面改成抽象類別
public abstract class Command implements Cloneable{
public abstract void execute();
public abstract void undo();

@Override
public Object clone() throws CloneNotSupportedException {
return super.clone();
}
}
  1. Receiver 不用更改,Invoker 使用 Stack 的方式儲存,儲存的為 Clone 後的命令,而非 Reference
public class InvokerWithHistory {
private Queue<Command> commands = new LinkedList<>(); // 巨集佇列命令

private Stack<Command> history = new Stack<>(); // stack 歷史紀錄

public InvokerWithHistory() {
}

public void addCommand(Command command) {
commands.offer(command);
}

public void cancelCommand(Command command) {
commands.remove(command);
}

public void undo() {
if (!history.isEmpty()) {
Command command = history.pop();
command.undo();
} else {
System.out.println("[復原失敗] --- 查無紀錄");
}
}

public void do() {
while (!commands.isEmpty()) {
Command command = commands.poll();
command.execute();

addHistoryByClone(command); // 執行過後,用Clone的方式儲存Command
}
}

private void addHistoryByClone(Command command) {
Command commandClone = null;
try {
commandClone = (Command) command.clone();
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}
history.push(commandClone);
}

private void addHistoryByReference(Command command) {
history.push(command);
}
}

使用巨集 Command(派對模式)

需求

  • 希望遙控器可以用一顆按鈕同時調暗燈光、打開音響和電視、讓熱水浴缸開始加溫

創建巨集的 Command

  1. 製作一種新的 Command,讓他可以執行其他多個 Command
public class MacroCommand implements Command {
Command[] commands;

public MacroCommand(Command[] commands) { // 接收一個Command陣列,並存入MacroCommand
this.commands = commands;
}

public void execute() {
for (int i = 0; i < commands.length; i++) { // 當遙控器執行巨集時,一次執行這些command
commands[i].execute();
}
}

/**
* NOTE: these commands have to be done backwards to ensure
* proper undo functionality
*/
public void undo() {
for (int i = commands.length -1; i >= 0; i--) {
commands[i].undo();
}
}
}
  1. 使用巨集 Command
public class RemoteLoader {

public static void main(String[] args) {

RemoteControl remoteControl = new RemoteControl();

// 建立所有的設備:電燈、電視、音響、熱水浴缸
Light light = new Light("Living Room");
TV tv = new TV("Living Room");
Stereo stereo = new Stereo("Living Room");
Hottub hottub = new Hottub();

// 建立所有的ON、OFF Command來控制他們
LightOnCommand lightOn = new LightOnCommand(light);
StereoOnCommand stereoOn = new StereoOnCommand(stereo);
TVOnCommand tvOn = new TVOnCommand(tv);
HottubOnCommand hottubOn = new HottubOnCommand(hottub);
LightOffCommand lightOff = new LightOffCommand(light);
StereoOffCommand stereoOff = new StereoOffCommand(stereo);
TVOffCommand tvOff = new TVOffCommand(tv);
HottubOffCommand hottubOff = new HottubOffCommand(hottub);

// 建立On Command的陣列、Off Command的陣列
Command[] partyOn = { lightOn, stereoOn, tvOn, hottubOn};
Command[] partyOff = { lightOff, stereoOff, tvOff, hottubOff};

// 並建立兩個對應的巨集來保存他們
MacroCommand partyOnMacro = new MacroCommand(partyOn);
MacroCommand partyOffMacro = new MacroCommand(partyOff);

// 將巨集command指派給按鈕,和處理任何command一樣
remoteControl.setCommand(0, partyOnMacro, partyOffMacro);

System.out.println(remoteControl);
System.out.println("--- Pushing Macro On---");
remoteControl.onButtonWasPushed(0);
/*
Light is on
Living Room stereo is on
Living Room TV is on
Living Room TV channel is set for DVD
Hottub is heating to a steaming 104 degrees
Hottub is bubbling!
*/
System.out.println("--- Pushing Macro Off---");
remoteControl.offButtonWasPushed(0);
/*
Light is off
Living Room stereo is off
Living Room TV is off
Hottub is cooling to 98 degrees
*/
}
}

沒有蠢問題

Q: 一定要使用 receiver 嗎?為何不讓 command 物件實作 execute()方法的細節?

A: 通常我們會盡量設計只知道如何呼叫 receiver 的「笨」command 物件。但是你也會看到很多「聰明的」command 物件,它們實作了執行請求所需的大部分(甚至到全部的)邏輯。不過如此一來,invoker 和 receiver 之間的解偶合程度就沒有那麼好了,也無法將 receiver 當成參數傳給 command

Q: 怎麼做出復原操作的歷史紀錄?我想要讓 undo 按鈕可以按下很多次

A: 可以用一個 stack 來保存執行過的所有 command,當 undo 被按下時,讓 invoker 從堆疊 pop 出一個 command,並呼叫它的 undo()方法

Q: 我可以將派對模式寫成 Command 嗎?建立一個 PartyCommand,並將執行其他 Command 的呼叫都放在 PartyCommand 的 execute()方法裡面

A: 可以,但這樣就將派對模式「寫死」在 PartyCommand 裡面了,如果使用 MacroCommand,可以動態決定哪些 Command,會更加靈活。

命令模式的其他用途: 將請求佇列化(ThreadPoolExecutor)

Command 可以讓我們將一個運算(一個 receiver 和一組動作)包裝起來,讓你將它當成一級(first-class)物件到處傳遞。那些計算可能在 client 應用程式建立 command 物件很久之後才被呼叫,甚至可以被不同的執行緒呼叫,利用這種特性來實作例如排程器、執行緒池、工作佇列。

  1. 抽象命令 Command: Runnable 介面
  2. 具體命令 ConcreteCommand: 由使用者自己創建這個類,實現 Runnable 介面,重寫 run 方法,在 run 方法中寫具體業務邏輯。(可能會持有 Receiver)
  3. 發送者 Invoker: ThreadPoolExecutor,他持有一個命令隊列,使用者可以向他提交要執行的命令
  4. 接收者 Receiver: 常常與 Runnable 實例中 run 方法重疊,不一定會有
  5. 命令隊列:BlockingQueue,任務阻塞隊列,實際上就是模式中的命令隊列

ThreadPoolExecutor

public interface Runnable {
public abstract void run();
}
public class ConcreteCommand implements Runnable {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " is running")
}
}
public class ThreadPoolExecutor {
private final BlockingQueue<Runnable> workQueue; // 命令隊列
public void execute(Runnable runnable) {/*與命令模式無關*/} // 加入命令隊列
final void runWorker(Worker w) {/*與命令模式無關*/} // 執行命令隊列中所有命令
}
public class ThreadPoolExecutorTest {
public static void main(String[] args) {
ThreadPoolExecutor pool = new ThreadPoolExecutor(5,
Runtime.getRuntime().availableProcessors() * 2,
60,
TimeUnit.SECONDS,
new ArrayBlockQueue<>(200),
new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r);
t.setName("order-thread");
return t;
}
}, new ThreadPoolExecutor.AbortPolicy());

Runnable concreteCommand = new ConcreteCommand();

pool.execute(concreteCommand);

pool.shutdown();
}
}

命令模式的其他用途: 記錄請求

備註

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

有些應用程式的語意(semantic)要求我們記錄所有的動作,並且在當機時,藉著重新呼叫那些動作來復原,命令模式可以讓我們藉著加入store()與 load()方法來支援這種語意。在 Java 裡,雖然我們可以用物件序列化來實作這些方法,但是序列化來進行持久保存時需要注意的事情依然存在。 (Java 序列化就是備忘錄模式(Momento)的一種實作)

在執行 command 時,我們將他們的歷史紀錄存入磁碟,當機時,我們重新載入 command 物件,並依序呼叫他們的 execute()。

藉著日記(Logging),我們可以將上一個檢查點之後的所有動作存起來,在系統故障的時候,將動作應用在那個檢查點上面。擴展這些技術,用**交易(Transactional)**機制來執行好幾組動作。

  • 儲存的時機:

    • 新增完命令式
    • 執行完命令式
    • 復原執行過的命令後
  • 載入的時機:

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

Store 和 Load 的做法比較少見,因為命令還要負責儲存載入,有點踰越職責

比較常見的做法是,使用一個新的介面

定義兩個基本操作:

  1. writeFile(Store)
  2. readFile(Load)
public interface Logger {
void writeFile(String pathName, Object object);
Object readFile(String pathName);
}

呼叫者 Invoker 在上面儲存和載入的時機點上,呼叫具體的 Logger 方法就可以了,當然要替換儲存與載入的方法或演算法,只需要新增一個類別時做 Logger 介面。

Invoker 不用知道具體的 Logger 是誰,只管呼叫方法,這也是策略模式的結合應用~!

儲存的實現

  1. 物件序列化(Serialization)
  2. 資料庫
  • 以物件序列化舉例
  1. 首先我們需要被序列化的物件,時做 Serializable 介面,Command 類別時常使用抽象類別
public abstract class Command implements Cloneable, Serializable{
public abstract void execute();
public abstract void undo();

@Override
public Object clone() throws CloneNotSupportedException {
return super.clone();
}
}
  1. 撰寫具體的 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;
}
}

命令模式的其他用途: 觀察者模式兼命令模式

Java的Swing程式庫有許多ActionListener形式的Observer可以監聽(或觀察)使用者介面元件的事件。

事實上ActionListener不但是Observer介面,也是Command介面,而且AngelListener、DevilListener類別不僅是Observer,也是具體的Command。

public class SwingCommandExample {
JFrame frame = new JFrame();
JPanel panel = new JPanel();

public static void main(String[] args) {
SwingCommandExample example = new SwingCommandExample();
example.go();
}

public void go() {
// The GUI is the client
// The buttons are the invokers
JButton onButton = new JButton("On");
JButton offButton = new JButton("Off");

// The light is the receiver
JLabel light = new JLabel("light");
light.setOpaque(true);
light.setBackground(Color.LIGHT_GRAY);

// The lambdas (ActionListeners) are the commands
// The interface that all the commands (listeners) implement is the ActionListener interface.
// This interface has one method, actionPerformed(). This is equivalent to the execute() method.
// This method which executes the code to run on the receiver, the light.
onButton.addActionListener(event ->
light.setBackground(Color.YELLOW)
);
offButton.addActionListener(event ->
light.setBackground(Color.LIGHT_GRAY)
);

// Set frame properties
frame.setContentPane(panel);
panel.add(onButton);
panel.add(light);
panel.add(offButton);

frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setSize(300,300);
frame.setVisible(true);
}
}

命令模式變形: 封裝接收者 (Encapsulate Receiver)

封裝掉 Receiver (除非真有必要,如撤銷處理),高階模組 Client,減少了對低階模組 Receiver 的依賴,也就不再需要進行 具體命令 (ConcreteCommand) 與 接收者 (Receiver) 的組裝。

Client 的職責轉變為純粹的:給予 呼叫者 (Invoker) 具體的命令 (ConcreteCommand) 而不需進行 接收者 (Receiver) 組裝

危險

個人並不喜歡

失敗的設計

最常見到的就是:

接收者 (Receiver) 與 具體命令 (ConcreteCommand) 的耦合

例如:

省去 Client 為 ConcreteCommand 設置 Receiver 的動作

出發點是好的,但許多人的做法卻是:

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

這會使的

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

命令模式變形: 客戶端 — 職責分離 (Client — Segregation of Duties)

其目的與 『封裝呼叫者 (Encapsulate Receiver)』 一模一樣,Client 的職責轉變為純粹的:給予 呼叫者 (Invoker) 具體的命令 (ConcreteCommand) 而不需進行 接收者 (Receiver) 組裝

資訊

個人較喜歡

去除 Client 組裝 接收者 (Receiver) 的職責,交給新的類別『RemoteLoader』來組裝。Client準備好命令(ConcreteCommand)再傳遞給遙控器(Invoker)即可。

// client
RemoteLoader remoteLoader = new RemoteLoader("remote");
remoteLoader.getRemoteControl();

命令模式變形: 智慧命令

命令 (Command) 不再需要 接收者 (Receiver),自己就知道怎麼實現功能。

資訊

個人較喜歡