使用C# (.NET Core) 實現命令設計模式 (Command Pattern)

solenovex發表於2018-04-14

本文的概念內容來自深入淺出設計模式一書.

專案需求

有這樣一個可程式設計的新型遙控器, 它有7個可程式設計插槽, 每個插槽可連線不同的家用電器裝置. 每個插槽對應兩個按鈕: 開, 關(ON, OFF). 此外還有一個全域性的取消按鈕(UNDO).

現在客戶想使用這個遙控器來控制不同廠家的家用電器, 例如電燈, 熱水器, 風扇, 音響等等.

客戶提出讓我編寫一個介面, 可以讓這個遙控器控制插在插槽上的一個或一組裝置.

看一下目前各家廠商都有哪些家用電器?:

問題來了, 這些家用電器並沒有共同的標準....幾乎各自都有自己的一套控制方法.. 而且以後還要新增很多種家用電器.

設計思路

那就需要考慮一下設計方案了:

首先要考慮分離關注點(Separation of concerns),  遙控器應該可以解釋按鈕動作並可以傳送請求, 但是它不應該瞭解家用電器和如何開關家用電器等.

但是目前遙控器只能做開關功能, 那麼怎麼讓它去控制電燈或者音響呢? 我們不想讓遙控器知道這些具體的家用電器, 更不想寫出下面的程式碼:

if slot1 == Light then Light.On()
else if slot1 == Hub....

說到這就不得不提到命令模式(Command Pattern)了.

命令模式允許你把動作的請求者和動作的實際執行者解耦. 這裡, 動作的請求者就是遙控器, 而執行動作的物件就是某個家用電器.

這是怎麼解耦的呢? 怎麼可能實現呢?

這就需要引進"命令物件(command object)"了. 命令物件會封裝在某個物件上(例如臥室的燈)執行某個動作的請求(例如開燈). 所以, 如果我們為每一個按鈕都準備一個命令物件, 那麼當按鈕被按下的時候, 我們就會呼叫這個命令物件去執行某些動作. 遙控器本身並不知道具體執行的動作是什麼, 它只是有一個命令物件, 這個命令物件知道去對哪些電器去做什麼樣的操作. 就這樣, 遙控器和電燈解耦了.

一個命令模式的實際例子

一個快餐廳:

客戶給服務員訂單, 服務員把訂單放到櫃檯並說: "有新訂單了", 然後廚師按照訂單準備飯菜.

讓我們仔細分析一下它們是怎麼互動的:

客戶來了, 說我想要漢堡, 乳酪....就是建立了一個訂單 (createOrder()).

訂單上面寫著客戶想要的飯菜. 

服務員取得訂單 takeOrder(), 把訂單拿到櫃檯喊道: "有新訂單了" (呼叫orderUp())

廚師按照訂單的指示把飯菜做好 (orderUp()裡面的動作). 

 

分析一下這個例子的角色和職責:

  • 訂單裡封裝了做飯菜的請求. 可以把訂單想象成一個物件, 這個物件就像是對做飯這個動作的請求. 並且它可以來回傳遞. 訂單實現了一個只有orderUp()方法的介面, 這個方法裡面封裝了做飯的操作流程. 訂單同時對動作實施者的引用(廚師). 因為都封裝了, 所以服務員不知道訂單裡面有啥也不知道廚師是誰. 服務員只傳遞訂單, 並呼叫orderUp().
  • 所以, 服務員的工作就是傳遞訂單並且呼叫orderUp(). 服務員的取訂單takeOrder()方法會傳進來不同的引數(不同客戶的不同訂單), 但是這不是問題, 因為她知道所有的訂單都支援orderUp()方法.
  • 廚師知道如何把飯做好. 一旦服務員呼叫了orderUp(), 廚師就接管了整個工作把飯菜做好. 但是服務員和廚師是解耦的: 服務員只有訂單, 訂單裡封裝著飯菜, 服務員只是呼叫訂單上的一個方法而已. 同樣的, 廚師只是從訂單上收到指令, 他從來不和服務員直接接觸.

專案設計圖

回到我們的需求, 參考快餐店的例子, 使用命令模式做一下設計:

客戶Client建立了一個命令(Command)物件. 相當於客人拿起了一個訂單(點菜)準備開始點菜, 我在琢磨遙控器的槽需要插哪些家用電器. 命令物件和接收者是繫結在一起的. 相當於選單和廚師, 遙控器的插槽和目標家用電器.

命令物件只有一個方法execute(), 裡面封裝了呼叫接收者實際控制操作的動作. 相當於飯店訂單的orderUp().

客戶呼叫setCommand()方法. 相當於客戶想好點什麼菜了, 就寫在訂單上面了. 我也想好遙控器要控制哪些家電了, 列好清單了. 

呼叫者拿著已經setCommand的命令物件, 在未來某個時間點呼叫命令物件上面的execute()方法. 相當於服務員拿起訂單走到櫃檯前, 大喊一聲: "有訂單來了, 開始做菜吧". 相當於我把遙控器和裝置的介面連線上了, 準備開始控制.

最後接收者執行動作. 相當於廚師做飯. 家用電器使用自己獨有的控制方法進行動作.

這裡面:

客戶 --- 飯店客人, 我

命令 --- 訂單, 插槽

呼叫者 --- 服務員, 遙控器

setCommand()設定命令 --- takeOrder() 取訂單, 插上需要控制的電器

execute() 執行 ---  orderUp() 告訴櫃檯做飯, 按按鈕

接收者 --- 廚師, 家電

程式碼實施

所有命令物件需要實現的介面:

namespace CommandPattern.Abstractions
{
    public interface ICommand
    {
        void Execute();
    }
}

一盞燈:

using System;

namespace CommandPattern.Devices
{
    public class Light
    {
        public void On()
        {
            Console.WriteLine("Light is on");
        }

        public void Off()
        {
            Console.WriteLine("Light is off");
        }
    }
}

控制燈開啟的命令:

using CommandPattern.Abstractions;
using CommandPattern.Devices;

namespace CommandPattern.Commands
{
    public class LightOnCommand : ICommand
    {
        private readonly Light light;

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

        public void Execute()
        {
            this.light.On();
        }
    }
}

車庫門: 

using System;

namespace CommandPattern.Devices
{
    public class GarageDoor
    {
        public void Up()
        {
            Console.WriteLine("GarageDoor is opened.");
        }

        public void Down()
        {
            Console.WriteLine("GarageDoor is closed.");
        }
    }
}

收起車庫門命令:

using CommandPattern.Abstractions;
using CommandPattern.Devices;

namespace CommandPattern.Commands
{
    public class GarageDoorOpen : ICommand
    {
        private readonly GarageDoor garageDoor;

        public GarageDoorOpen(GarageDoor garageDoor)
        {
            this.garageDoor = garageDoor;
        }

        public void Execute()
        {
            garageDoor.Up();
        }
    }
}

簡易的遙控器:

using CommandPattern.Abstractions;

namespace CommandPattern.RemoteControls
{
    public class SimpleRemoteControl
    {
        public ICommand Slot { get; set; }
public void ButtonWasPressed()
        {
            Slot.Execute();
        }
    }
}

執行測試:

using System;
using CommandPattern.Commands;
using CommandPattern.Devices;
using CommandPattern.RemoteControls;

namespace CommandPattern
{
    class Program
    {
        static void Main(string[] args)
        {
            var remote = new SimpleRemoteControl();
            var light = new Light();
            var lightOn = new LightOnCommand(light);

            remote.Slot = lightOn;
            remote.ButtonWasPressed();

            var garageDoor = new GarageDoor();
            var garageDoorOpen = new GarageDoorOpenCommand(garageDoor);

            remote.Slot = garageDoorOpen;
            remote.ButtonWasPressed();
        }
    }
}

命令模式定義

命令模式把請求封裝成一個物件, 從而可以使用不同的請求對其它物件進行引數化, 對請求排隊, 記錄請求的歷史, 並支援取消操作.

類圖:

效果圖:

全功能程式碼的實施

遙控器:

using System.Text;
using CommandPattern.Abstractions;
using CommandPattern.Commands;

namespace CommandPattern.RemoteControls
{
    public class RemoteControl
    {
        private ICommand[] onCommands;
        private ICommand[] offCommands;

        public RemoteControl()
        {
            onCommands = new ICommand[7];
            offCommands = new ICommand[7];

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

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

        public void OnButtonWasPressed(int slot)
        {
            onCommands[slot].Execute();
        }
        public void OffButtonWasPressed(int slot)
        {
            offCommands[slot].Execute();
        }

        public override string ToString()
        {
            var sb = new StringBuilder("\n------------Remote Control-----------\n");
            for(int i =0; i< onCommands.Length; i++){
                sb.Append($"[slot{i}] {onCommands[i].GetType()}\t{offCommands[i].GetType()} \n");
            }
            return sb.ToString();
        }
    }
}

這裡面有一個NoCommand, 它是一個空的類, 只是為了初始化command 以便以後不用判斷是否為null.

關燈:

using CommandPattern.Abstractions;
using CommandPattern.Devices;

namespace CommandPattern.Commands
{
    public class LightOffCommand: ICommand
    {
        private readonly Light light;

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

        public void Execute()
        {
            light.Off();
        }
    }
}

下面試一個有點挑戰性的, 音響:

namespace CommandPattern.Devices
{
    public class Stereo
    {
        public void On()
        {
            System.Console.WriteLine("Stereo is on.");
        }

        public void Off()
        {
            System.Console.WriteLine("Stereo is off.");
        }

        public void SetCD()
        {
            System.Console.WriteLine("Stereo is set for CD input.");
        }

        public void SetVolume(int volume)
        {
            System.Console.WriteLine($"Stereo's volume is set to {volume}");
        }
    }
}

音響開啟命令:

using CommandPattern.Abstractions;

namespace CommandPattern.Devices
{
    public class StereoOnWithCDCommand : ICommand
    {
        private readonly Stereo stereo;

        public StereoOnWithCDCommand(Stereo stereo)
        {
            this.stereo = stereo;
        }

        public void Execute()
        {
            stereo.On();
            stereo.SetCD();
            stereo.SetVolume(10);
        }
    }
}

測試執行:

using System;
using CommandPattern.Commands;
using CommandPattern.Devices;
using CommandPattern.RemoteControls;

namespace CommandPattern
{
    class Program
    {
        static void Main(string[] args)
        {
            var remote = new RemoteControl();
            var light = new Light();
            var lightOn = new LightOnCommand(light);
            var lightOff = new LightOffCommand(light);
            var garageDoor = new GarageDoor();
            var garageDoorOpen = new GarageDoorOpenCommand(garageDoor);
            var garageDoorClose = new GarageDoorCloseCommand(garageDoor);
            var stereo = new Stereo();
            var stereoOnWithCD = new StereoOnWithCDCommand(stereo);
            var stereoOff = new StereoOffCommand(stereo);

            remote.SetCommand(0, lightOn, lightOff);
            remote.SetCommand(1, garageDoorOpen, garageDoorClose);
            remote.SetCommand(2, stereoOnWithCD, stereoOff);

            System.Console.WriteLine(remote);

            remote.OnButtonWasPressed(0);
            remote.OffButtonWasPressed(0);
            remote.OnButtonWasPressed(1);
            remote.OffButtonWasPressed(1);
            remote.OnButtonWasPressed(2);
            remote.OffButtonWasPressed(2);
        }
    }
}

該需求的設計圖:

還有一個問題...取消按鈕呢?

實現取消按鈕

1. 可以在ICommand介面裡面新增一個undo()方法, 然後在裡面執行上一次動作相反的動作即可:

namespace CommandPattern.Abstractions
{
    public interface ICommand
    {
        void Execute();
        void Undo();
    }
}

例如開燈:

using CommandPattern.Abstractions;
using CommandPattern.Devices;

namespace CommandPattern.Commands
{
    public class LightOnCommand : ICommand
    {
        private readonly Light light;

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

        public void Execute()
        {
            light.On();
        }

        public void Undo()
        {
            light.Off();
        }
    }
}

遙控器:

using System.Text;
using CommandPattern.Abstractions;
using CommandPattern.Commands;

namespace CommandPattern.RemoteControls
{
    public class RemoteControlWithUndo
    {
        private ICommand[] onCommands;
        private ICommand[] offCommands;
        private ICommand undoCommand;

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

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

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

        public void OnButtonWasPressed(int slot)
        {            
            onCommands[slot].Execute();
            undoCommand = onCommands[slot];
        }

        public void OffButtonWasPressed(int slot)
        {
            offCommands[slot].Execute();
            undoCommand = offCommands[slot];
        }

        public void UndoButtonWasPressed()
        {
            undoCommand.Undo();
        }

        public override string ToString()
        {
            var sb = new StringBuilder("\n------------Remote Control-----------\n");
            for(int i =0; i< onCommands.Length; i++){
                sb.Append($"[slot{i}] {onCommands[i].GetType()}\t{offCommands[i].GetType()} \n");
            }
            return sb.ToString();
        }
    }
}

測試一下:

using System;
using CommandPattern.Commands;
using CommandPattern.Devices;
using CommandPattern.RemoteControls;

namespace CommandPattern
{
    class Program
    {
        static void Main(string[] args)
        {
            var remote = new RemoteControl();
            var light = new Light();
            var lightOn = new LightOnCommand(light);
            var lightOff = new LightOffCommand(light);
            var stereo = new Stereo();
            var stereoOnWithCD = new StereoOnWithCDCommand(stereo);
            var stereoOff = new StereoOffCommand(stereo);

            remote.SetCommand(0, lightOn, lightOff);
            remote.SetCommand(1, stereoOnWithCD, stereoOff);

            System.Console.WriteLine(remote);

            remote.OnButtonWasPressed(0);
            remote.OffButtonWasPressed(0);
            remote.OnButtonWasPressed(1);
            remote.OffButtonWasPressed(1);
        }
    }
}

基本是OK的, 但是有點小問題, 音響的開關狀態倒是取消了, 但是它的音量(也包括播放介質, 不過這個我就不去實現了)並沒有恢復.

下面就來處理一下這個問題.

修改Stereo:

namespace CommandPattern.Devices
{
    public class Stereo
    {

        public Stereo()
        {
            Volume = 5;
        }

        public void On()
        {
            System.Console.WriteLine("Stereo is on.");
        }

        public void Off()
        {
            System.Console.WriteLine("Stereo is off.");
        }

        public void SetCD()
        {
            System.Console.WriteLine("Stereo is set for CD input.");
        }

        private int volume;
        public int Volume
        {
            get { return volume; }
            set
            {
                volume = value;
                System.Console.WriteLine($"Stereo's volume is set to {volume}");
            }
        }

    }
}

命令:

using CommandPattern.Abstractions;

namespace CommandPattern.Devices
{
    public class StereoOnWithCDCommand : ICommand
    {
        private int previousVolume;

        private readonly Stereo stereo;
public StereoOnWithCDCommand(Stereo stereo)
        {
            this.stereo = stereo;
       previousVolume = stereo.Volume; }
public void Execute() { stereo.On(); stereo.SetCD(); stereo.Volume = 10; } public void Undo() { stereo.Volume = previousVolume; stereo.SetCD(); stereo.Off(); } } }

執行:

需求變更----一個按鈕控制多個裝置的多個動作

Party Mode (聚會模式):

思路是建立一種命令, 它可以執行多個其它命令

MacroCommand:

using CommandPattern.Abstractions;

namespace CommandPattern.Commands
{
    public class MacroCommand : ICommand
    {
        private ICommand[] commands;

        public MacroCommand(ICommand[] commands)
        {
            this.commands = commands;
        }

        public void Execute()
        {
            for (int i = 0; i < commands.Length; i++)
            {
                commands[i].Execute();
            }
        }

        public void Undo()
        {
            for (int i = 0; i < commands.Length; i++)
            {
                commands[i].Undo();
            }
        }
    }
}

使用這個MacroCommand:

using System;
using CommandPattern.Abstractions;
using CommandPattern.Commands;
using CommandPattern.Devices;
using CommandPattern.RemoteControls;

namespace CommandPattern
{
    class Program
    {
        static void Main(string[] args)
        {
            var light = new Light();
            var lightOn = new LightOnCommand(light);
            var lightOff = new LightOffCommand(light);
            var garageDoor = new GarageDoor();
            var garageDoorOpen = new GarageDoorOpenCommand(garageDoor);
            var garageDoorClose = new GarageDoorCloseCommand(garageDoor);
            var stereo = new Stereo();
            var stereoOnWithCD = new StereoOnWithCDCommand(stereo);
            var stereoOff = new StereoOffCommand(stereo);

            var macroOnCommand = new MacroCommand(new ICommand[] { lightOn, garageDoorOpen, stereoOnWithCD });
            var macroOffCommand = new MacroCommand(new ICommand[] { lightOff, garageDoorClose, stereoOff });

            var remote = new RemoteControl();
            remote.SetCommand(0, macroOnCommand, macroOffCommand);
            System.Console.WriteLine(remote);

            System.Console.WriteLine("--- Pushing Macro on ---");
            remote.OnButtonWasPressed(0);
            System.Console.WriteLine("--- Pushing Macro off ---");
            remote.OffButtonWasPressed(0);
        }
    }
}

 

命令模式實際應用舉例

請求佇列

這個工作佇列是這樣工作的: 你新增命令到佇列的結尾, 在佇列的另一端有幾個執行緒. 執行緒這樣工作: 它們從佇列移除一個命令, 呼叫它的execute()方法, 然後等待呼叫結束, 然後丟棄這個命令再獲取一個新的命令.

這樣我們就可以把計算量限制到固定的執行緒數上面了. 工作佇列和做工作的物件也是解耦的.

記錄請求

這個例子就是使用命令模式記錄請求動作的歷史, 如果出問題了, 可以按照這個歷史進行恢復.

 

其它

這個系列的程式碼我放在這裡了: https://github.com/solenovex/Head-First-Design-Patterns-in-CSharp

 

相關文章