深入淺出設計模式(Design Pattern)-命令模式(6-Command-Pattern)

Ben
生活的各種可能性
31 min readJan 30, 2024
photo by hannah joshua

這次的封面圖片就是我們要講的主題,這篇要來介紹命令模式(Command Pattern)。

命令模式(Command Pattern)

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

接下來我們來看看這次的情境

需求情境示意圖

我們收到一個遙控器的需求,我們必須要創造一個能夠設置任意設備,並且能夠控制它們的遙控器,當然那些設備是可以任意更換的,同樣的它們的操作方式也不盡相同,最後我們還會有個復原的按鈕,可以還原上一次按鈕按下的動作。

廠商類別示意圖

這個是目前的廠商類別,它們看起來都擁有不太一樣的規格,數量也不少,當然,在以後我們只會遇到更多類別。

那我們就直接開始設計我們的程式

首先我們會有個Command介面,它有一個execute以及undo方法,所有的command都必須實作它。

// 所有的命令都必須實作Command
interface Command {
execute(): void;
undo(): void;
}

// 我們有一個電燈,它將會有一些屬於操作它自己的方法
class Light {
public on(): void {}
public off(): void {}
}

// 立體音響也有屬於它自己的操作方法
class Stereo {
public on(): void {}
public off(): void {}
public setCD(): void {}
public setDVD(): void {}
public setRadio(): void {}
public setVolume(): void {}
}


// 我們將light的on封裝成一個命令
class LightOnCommand implements Command {
light: Light;

// 建構式中接受將要控制的電燈
constructor(light: Light) {
this.light = light;
}

// 當light onCommand的execute被呼叫,接受請求的是light物件
public execute(): void {
this.light.on();
}

// 對on來說它的上一個狀態是off
public undo(): void {
this.light.off();
}
}

// light off command以此類推...

// 我們將stereo的on封裝成一個命令
class StereoOnWithCDCommand implements Command {
stereo: Stereo;

// 建構式接受將要控制的音響
constructor(stereo: Stereo) {
this.stereo = stereo;
}

// 當stereo onCommand的execute被呼叫時,接受請求的是stereo物件
public execute(): void {
this.stereo.on();
this.stereo.setCD();
this.stereo.setVolume(11)
}

// 對on來說它的上一個狀態是off
public undo(): void {
this.stereo.off();
}
}

// stereo off command以此類推...
// 以下這邊我們還會製作更多的裝置與它的command...

接著我們會有個遙控器來設定所有的裝置的位置和該位置對應的命令,而我們會給每個遙控器位置的預設command是NoCommand。

// noCommand中execute與undo我們將不做任何事情
interface NoCommand {
execute(): void {};
undo(): void {};
}

class RemoteControl {
// 我們會有onCommands、offCommands、undoCommand來存放目前的command
onCommands: Command[] | NoCommand[] = [];
offCommands: Command[] | NoCommand[] = [];
undoCommand: Command | NoCommand;

// 建構式接受一個數字初始化能設定的位置有幾個
constructor(length: number) {
const noCommand = new NoCommand();

// 這邊預設會是noCommand
// 會這麼做的原因是這樣我們就不用每次都要檢查它是不是有載入command
for (let i = 0; i < length; i++) {
this.onCommands[i] = noCommand
this.offCommands[i] = noCommand
}
}

// 我們將調用這個方法來設定我們的命令
public setCommand(slot: number, onCommand: Command, offCommand: Command): void {
this.onCommands[slot] = onCommand;
this.offCommands[slot] = offCommand;
}

// 當on被按下時我們就會執行該位置的命令,並且設置目前的undoCommand
public onButtonWasPushed(slot: number): void {
this.onCommands[slot].execute();
this.undoCommand = this.onCommand[slot];
}

// off跟on是一樣的方式
public offButtonWasPushed(slot: number): void {
this.offCommands[slot].execute();
this.undoCommand = this.offCommand[slot];
}

// 當undo被按下時將會觸發它,我們將會使用目前所被設置的undoCommand
public undoButtonWasPushed(): void {
this.undoCommand.undo();
}
}

接下來我們快要完成了,我們來整合一下我們目前的所有東西

class RemoteLoader {
// 在這個測試的類別中我們會建立我們所需的內容

// 我們會有個搖控器
remoteControl: RemoteControl;

// 有一個電燈以及它的on和off的command
light: Light;
lightOn: LightOnCommand;
lightOff: LightOffCommand;

// 有一個音響以及它的on和off的command
stereo: Stereo;
stereoOnWithCD: StereoOnWithCDCommand;
stereoOff: StereoOffCommand;

// 在建構式中我們初始化這些東西
constructor() {
// 我們設定遙控器的裝置有幾個
this.remoteControl = new RemoteControl(7);

// 實例化電燈與它的command
this.light = new Light();
this.lightOn = new LightOnCommand(this.light);
this.lightOff = new LightOffCommand(this.light);

// 實例化音響與它的command
this.stereo = new Stereo();
this.stereoOnWithCD = new StereoOnWithCDCommand(this.stereo);
this.stereoOff = new StereoOffCommand(this.stereo);

// 調用remoteControl的setCommand來設置該位置的裝置及命令
this.remoteControl.setCommand(0, this.lightOn, this.lightOff);
this.remoteControl.setCommand(1, this.stereoOnWithCD, this.stereoOff);

// 用用看light
this.remoteControl.onButtonWasPushed(0);
this.remoteControl.offButtonWasPushed(0);

// 用用看stereo
this.remoteControl.onButtonWasPushed(1);
this.remoteControl.offButtonWasPushed(1);
this.remoteControl.undoButtonWasPushed();
}
}

接著我們有一個新的需求:派對模式,如果遙控器無法同時調按燈光、打開音響和做其他許多事情,那麽用它就沒太大的意義了。

接著來看我們將如何實作這個需求

// 我們會有個macro類別它一樣會實作Command
class MacroCommand implements Command {
commands: Command[];

// 建構式接受的是一組Command型態的陣列
constructor(commands: Command[]) {
this.commands = commands;
}

// 我們使用for去執行commands裡每一個command物件中的命令
public execute(): void {
this.commands.forEach((item: Command) => command.execute());
}

// undo也是一樣的
public undo(): void {
this.commands.forEach((item: Command) => command.undo());
}
}

在上方的例子中我們用集合的方式封裝了派對模式中需要的各種Command來達成我們的需求。

我們來看一下整個模式中各自負責的責任圖

職責示意圖

invoker:是我們的遙控器,它只負責設置各種裝置和對應的指令,它不在乎指令的內容是什麼,只在乎這些command是不是都實作了Command介面。

receiver:我們藉由設置好的位置的對應按鈕來觸發execute來呼叫對應的receiver(Light、Stereo)動作,也就是將實際執行委託給這些receiver。

而在實作Command介面的過程中,我們實際在做的事情就是定義command和receiver之間的關係,並將定義好的內容交給invoker設置。

我們再來看看另一個更清楚執行流程關係示意圖

流程關係示意圖

以上就是我們整個命令模式的解說,這樣我們就完成了我們所有的內容,下方是完整的範例

// 所有的命令都必須實作Command介面
interface Command {
execute(): void;
undo(): void;
}

// 沒有插入Command的位置預設是No Command
class NoCommand implements Command {
execute(): void {};
undo(): void {};
}


// receiver(light): 負責定義燈的行為
class Light {
name: string;

constructor(name: string) {
this.name = name;
}

public on(): void {
console.log(`${this.name} light is on`)
}

public off(): void {
console.log(`${this.name} light is off`)
}
}

// command(light on): 建構式接受一個參數(receiver)以及負責定義command與receiver之間的關係。
class LightOnCommand<T extends Light> implements Command {
light: T;

constructor(light: T) {
this.light = light;
}

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

public undo() {
this.light.off();
}
}

// command(light off): 建構式接受一個參數(receiver)以及負責定義command與receiver之間的關係。
class LightOffCommand<T extends Light> implements Command {
light: T;

constructor(light: T) {
this.light = light;
}

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

public undo() {
this.light.on();
}
}

// receiver(stereo): 負責定義stereo的行為
class Stereo {
name: string;

constructor(name: string) {
this.name = name;
}

public on(): void {
console.log(`${this.name} stereo is on`)
}

public off(): void {
console.log(`${this.name} stereo is off`)
}

public setCd(): void {
console.log(`set ${this.name} cd`)
}

public setDvd(): void {
console.log(`set ${this.name } dvd`)
}

public setRadio(): void {
console.log(`set ${this.name} Radio`)
}

public setVolume(): void {
console.log(`set ${this.name} volume`)
}
}

// command(stereo on): 建構式接受一個參數(receiver)以及負責定義command與receiver之間的關係。
class StereoOnWithCDCommand<T extends Stereo> implements Command {
stereo: T;

constructor(stereo: T) {
this.stereo = stereo;
}

public execute(): void {
this.stereo.on();
this.stereo.setCd();
this.stereo.setVolume();
}

public undo(): void {
this.stereo.off();
}
}

// command(stereo off): 建構式接受一個參數(receiver)以及負責定義command與receiver之間的關係。
class StereoOffCommand implements Command {
stereo: any;

constructor(stereo: any) {
this.stereo = stereo;
}

public execute(): void {
this.stereo.off();
}

public undo(): void {
this.stereo.on();
}
}

// 風速的列舉
enum CeilingFanSpeed {
HIGH = 3,
MEDIUM = 2,
LOW = 1,
OFF = 0
}

// receiver(ceilingFan): 負責定義ceilingFan的行為
class CeilingFan {
name: string;
private speed: number;

constructor(name: string) {
this.name = name;
this.speed = CeilingFanSpeed.OFF;
}

public high(): void {
this.speed = CeilingFanSpeed.HIGH;
}

public medium(): void {
this.speed = CeilingFanSpeed.MEDIUM;
}

public low(): void {
this.speed = CeilingFanSpeed.LOW;
}

public off(): void {
this.speed = CeilingFanSpeed.OFF;
}

public getSpeed(): number {
return this.speed;
}
}

// command(ceilingFan high speed): 建構式接受一個參數(receiver)以及負責定義command與receiver之間的關係。
class CeilingFanHighCommand<T extends CeilingFan> implements Command {
ceilingFan: T;
prevSpeed: number = 0;

constructor(ceilingFan: T) {
this.ceilingFan = ceilingFan;
}

public execute(): void {
this.prevSpeed = this.ceilingFan.getSpeed();
this.ceilingFan.high();
this.toString();
}

public undo(): void {
if (this.prevSpeed === CeilingFanSpeed.HIGH) {
this.ceilingFan.high();
} else if (this.prevSpeed === CeilingFanSpeed.MEDIUM) {
this.ceilingFan.medium();
} else if (this.prevSpeed === CeilingFanSpeed.LOW) {
this.ceilingFan.low();
} else if (this.prevSpeed === CeilingFanSpeed.OFF) {
this.ceilingFan.off();
}

this.toString();
}

public toString(): void {
console.log(`${this.ceilingFan.name} CeilingFan speed is ${this.ceilingFan.getSpeed()}(${CeilingFanSpeed[this.ceilingFan.getSpeed()]})`)
}
}

// command(ceilingFan medium speed): 建構式接受一個參數(receiver)以及負責定義command與receiver之間的關係。
class CeilingFanMediumCommand<T extends CeilingFan> implements Command {
ceilingFan: T;
prevSpeed: number = 0;

constructor(ceilingFan: T) {
this.ceilingFan = ceilingFan;
}

public execute(): void {
this.prevSpeed = this.ceilingFan.getSpeed();
this.ceilingFan.medium();
this.toString();
}

public undo(): void {
if (this.prevSpeed === CeilingFanSpeed.HIGH) {
this.ceilingFan.high();
} else if (this.prevSpeed === CeilingFanSpeed.MEDIUM) {
this.ceilingFan.medium();
} else if (this.prevSpeed === CeilingFanSpeed.LOW) {
this.ceilingFan.low();
} else if (this.prevSpeed === CeilingFanSpeed.OFF) {
this.ceilingFan.off();
}

this.toString();
}

public toString(): void {
console.log(`${this.ceilingFan.name} CeilingFan speed is ${this.ceilingFan.getSpeed()}(${CeilingFanSpeed[this.ceilingFan.getSpeed()]})`)
}
}

// command(ceilingFan off): 建構式接受一個參數(receiver)以及負責定義command與receiver之間的關係。
class CeilingFanOffCommand<T extends CeilingFan> implements Command {
ceilingFan: T;
prevSpeed: number = 0;

constructor(ceilingFan: T) {
this.ceilingFan = ceilingFan;
}

public execute(): void {
this.prevSpeed = this.ceilingFan.getSpeed();
this.ceilingFan.off();
this.toString();
}

public undo(): void {
if (this.prevSpeed === CeilingFanSpeed.HIGH) {
this.ceilingFan.high();
} else if (this.prevSpeed === CeilingFanSpeed.MEDIUM) {
this.ceilingFan.medium();
} else if (this.prevSpeed === CeilingFanSpeed.LOW) {
this.ceilingFan.low();
} else if (this.prevSpeed === CeilingFanSpeed.OFF) {
this.ceilingFan.off();
}

this.toString();
}

public toString(): void {
console.log(`${this.ceilingFan.name} CeilingFan speed is ${this.ceilingFan.getSpeed()}(${CeilingFanSpeed[this.ceilingFan.getSpeed()]})`)
}
}

// commands: 混合指令,建構式接受一個commands參數(裡面包括各種receiver)以及負責定義command與receiver之間的關係。
class MacroCommand<T extends Command[]> implements Command {
commands: T;

constructor(commands: T) {
this.commands = commands;
}

public execute(): void {
this.commands.forEach((item: Command) => item.execute());
}

public undo(): void {
this.commands.forEach((item: Command) => item.undo());
}
}

// invoker: 負責設定指令位置(on、off)以及調用指令(on、off,以及每個指令都有的undo)
// 建構式接收三個參數: 指令位置、on指令、off指令
class RemoteControl {
onCommands: Command[] | NoCommand[] = [];
offCommands: Command[] | NoCommand[] = [];
undoCommand: Command | NoCommand;

constructor(length: number) {
const noCommand = new NoCommand()

for (let i = 0; i < length; i++) {
this.onCommands[i] = noCommand;
this.offCommands[i] = noCommand;
}

this.undoCommand = noCommand;
}

public setCommand(slot: number, onCommand: Command, offCommand: Command): void {
this.onCommands[slot] = onCommand;
this.offCommands[slot] = offCommand;
}

public onButtonWasPushed(slot: number): void {
this.onCommands[slot].execute();
this.undoCommand = this.onCommands[slot];
}

public offButtonWasPushed(slot: number): void {
this.offCommands[slot].execute();
this.undoCommand = this.offCommands[slot];
}

public undoButtonWasPushed(): void {
this.undoCommand.undo();
}
}

class RemoteLoader {
remoteControl: RemoteControl;

livingRoomLight: Light;
livingRoomLightOn: LightOnCommand<Light>;
livingRoomLightOff: LightOffCommand<Light>;

kitchRoomLight: Light;
kitchRoomLightOn: LightOnCommand<Light>;
kitchRoomLightOff: LightOnCommand<Light>;

stereo: Stereo;
stereoOnWithCD: StereoOnWithCDCommand<Stereo>;
stereoOff: StereoOffCommand;

ceilingFan: CeilingFan;
ceilingFanHigh: CeilingFanHighCommand<CeilingFan>;
ceilingFanMedium: CeilingFanMediumCommand<CeilingFan>;
ceilingFanOff: CeilingFanOffCommand<CeilingFan>;

partyOnMacro: MacroCommand<Command[]>;
partyOffMacro: MacroCommand<Command[]>;
partyOn: Command[];
partyOff: Command[];

constructor() {
this.remoteControl = new RemoteControl(8);

// living light
this.livingRoomLight = new Light("Living Room");
this.livingRoomLightOn = new LightOnCommand(this.livingRoomLight);
this.livingRoomLightOff = new LightOffCommand(this.livingRoomLight);

// kitch light
this.kitchRoomLight = new Light("Kitch Room");
this.kitchRoomLightOn = new LightOnCommand(this.kitchRoomLight);
this.kitchRoomLightOff = new LightOffCommand(this.kitchRoomLight);

// stereo
this.stereo = new Stereo("Living Room");
this.stereoOnWithCD = new StereoOnWithCDCommand(this.stereo);
this.stereoOff = new StereoOffCommand(this.stereo);

// ceiling fan
this.ceilingFan = new CeilingFan("Living Room");
this.ceilingFanHigh = new CeilingFanHighCommand(this.ceilingFan);
this.ceilingFanMedium = new CeilingFanMediumCommand(this.ceilingFan);
this.ceilingFanOff = new CeilingFanOffCommand(this.ceilingFan);

// party
this.partyOn = [this.livingRoomLightOn, this.ceilingFanHigh, this.stereoOnWithCD]
this.partyOff = [this.livingRoomLightOff, this.ceilingFanOff, this.stereoOff]
this.partyOnMacro = new MacroCommand(this.partyOn);
this.partyOffMacro = new MacroCommand(this.partyOff);

// setCommand
this.remoteControl.setCommand(0, this.livingRoomLightOn, this.livingRoomLightOff);
this.remoteControl.setCommand(1, this.kitchRoomLightOn, this.kitchRoomLightOff);
this.remoteControl.setCommand(2, this.stereoOnWithCD, this.stereoOff);
this.remoteControl.setCommand(3, this.ceilingFanHigh, this.ceilingFanOff);
this.remoteControl.setCommand(4, this.ceilingFanMedium, this.ceilingFanOff);
this.remoteControl.setCommand(5, this.partyOnMacro, this.partyOffMacro);

// print on and off commands
console.log("remoteControl on has:")
this.remoteControl.onCommands.forEach((item: Command, idx: number) => console.log(idx, item))
console.log("------------------------------------------------------------")
console.log("remoteControl off has:")
this.remoteControl.offCommands.forEach((item: Command, idx: number) => console.log(idx, item))
console.log("------------------------------------------------------------")

console.log("----- living room light -----");
this.remoteControl.onButtonWasPushed(0);
this.remoteControl.offButtonWasPushed(0);
console.log("\n")

console.log("----- kitch room light -----")
this.remoteControl.onButtonWasPushed(1);
this.remoteControl.offButtonWasPushed(1);
console.log("\n")

console.log("----- stereo -----")
this.remoteControl.onButtonWasPushed(2);
this.remoteControl.offButtonWasPushed(2);
console.log("\n")

console.log("----- ceiling fan -----")
this.remoteControl.onButtonWasPushed(3);
this.remoteControl.offButtonWasPushed(3);
this.remoteControl.undoButtonWasPushed();
console.log("\n")

console.log("----- party -----")
this.remoteControl.onButtonWasPushed(5);
this.remoteControl.offButtonWasPushed(5);
this.remoteControl.undoButtonWasPushed();
}
}

const remoteLoader = new RemoteLoader();

重點提示

接下來我們來看看這個章節中重要的重點提示:

  • 命令模式可以將發出請求的物件和知道如何執行它的物件解耦合。
  • Command物件是解耦合的主角,它用動作(或一組)來封裝receiver。
  • invoker藉著呼叫Command物件的execute方法來發出請求,進而呼叫receiver的動作。
  • invoker可以接收Command參數,甚至在執行期動態接收它們。
  • command可以支援復原功能,做法是實作一個undo方法,來將物件復原至execute方法上一次被呼叫之前的狀態。
  • MacroCommand是命令模式的擴展版本,可讓你呼叫多個command,同時,MacroCommands也可以輕鬆的支援undo。
  • 在實務上,通常我們不會使用聰明的Command物件來自行實作請求,而是將請求委託給receiver。
  • 命令模式可以用來實作紀錄和交易系統。

結語

在命令模式中將封裝提升到一個全新的境界,透過實作Command介面,它將執行(execute)這個行為封裝起來,讓invoker和receiver只需要專心負責自己的職責,其中Command是最重要的角色,它除了負責擔任定義它們如何溝通的橋樑之外,也讓invoker和receiver之間解耦合。

下一篇要介紹:轉接器模式(Adapter Pattern)

這篇就到這邊瞜,如果有任何問題歡迎留言給我,喜歡的話也別忘了clap起來唷。

--

--