Command 模式 Step by Step

R-B發表於2021-09-09

Command 模式 Step by step

引言

提起Command模式,我想沒有什麼比遙控器的例子更能說明問題了,本文將透過它來一步步實現GOF的Command模式。

我們先看下這個遙控器程式的需求:假如我們需要為家裡的電器設計一個遠端遙控器,透過這個控制器,我們可以控制電器(諸如燈、風扇、空調等)的開關。我們的控制器上有一系列的按鈕,分別對應家中的某個電器,當我們在遙控器上按下“On”時,電器開啟;當我們按下“Off”時,電器關閉。

好了,讓我們開始Command 模式之旅吧。

HardCoding的實現方式

控制器的實現

一般來說,考慮問題通常有兩種方式:從最複雜的情況考慮,也就是儘可能的深謀遠慮,設計之初就考慮到程式的可維護性、擴充套件性;還有就是從最簡單的情況考慮,不考慮擴充套件,客戶要求什麼,我們就做個什麼,至於以後有什麼新的需求,等以後再說。當然這兩種方式各有優劣,本文我們從最簡單的情況開始考慮。

我們假設控制器只能控制 三個電器,分別是:燈、電扇、門(你就當是電子門好了^^)。那麼我們的控制器應該有三組,共六個按鈕,每一組按鈕分別有“On”,“Off”按鈕。同時,我們規定,第一組按鈕對應燈,第二組按鈕對應電扇,第三組則對應門,那麼控制器應該就像這樣:

圖片描述

類的設計

好了,控制器大致是這麼個樣子了,那麼 燈、電扇、門又是什麼樣子呢?如果你看過前面幾節的模式,你可能會以為此時又要為它們建立一個基類或者介面,然後供它們繼承或實現。現在讓我們先看看我們想要控制的電器是什麼樣子的:

圖片描述

很抱歉,你遺憾地發現,它們的介面完全不同,我們沒有辦法對它們進行抽象,但是因為我們此刻僅考慮客戶最原始的需求(最簡單的情況),那麼我們大可以直接將它們複合到 遙控器(ControlPanel) 中

NOTE:關於介面,有狹義的含義:就是一個宣告為interface的型別。還有一個廣義的含義:就是物件暴露給外界的方法、屬性,所以一個抽象類也可以稱作一個介面。這裡,說它們的介面不同,意思是說:這三個電器暴露給外界的方法完全不同。

圖片描述

注意到,PressOn方法,它代表著某一個按鍵被按下,並接受一個int型別的引數:SlotNo,它代表是第幾個鍵被按下。顯然,SlotNo的取值為0到2。對於PressOff則是完全相同的設計。

程式碼實現

namespace Command {

    // 定義燈
    public class Light{
       public void TurnOn(){
           Console.WriteLine("The light is turned on.");
       }
       public void TurnOff() {
           Console.WriteLine("The light is turned off.");
       }
    }

    // 定義風扇
    public class Fan {
       public void Start() {
           Console.WriteLine("The fan is starting.");
       }
       public void Stop() {
           Console.WriteLine("The fan is stopping.");
       }
    }

    // 定義門
    public class Door {
       public void Open() {
           Console.WriteLine("The door is open for you.");
       }
       public void Shut() {
           Console.WriteLine("The door is closed for safety");
       }
    }

    // 定義遙控器
    public class ControlPanel {
       private Light light;
       private Fan fan;
       private Door door;

       public ControlPanel(Light light, Fan fan, Door door) {
           this.light = light;
           this.fan = fan;
           this.door = door;
       }

       // 點選On按鈕時的操作。slotNo,第幾個按鈕被按
       public void PressOn(int slotNo){
           switch (slotNo) {
              case 0:
                  light.TurnOn();
                  break;
              case 1:
                  fan.Start();
                  break;
              case 2:
                  door.Open();
                  break;
           }
       }

       // 點選Off按鈕時的操作。
       public void PressOff(int slotNo) {
           switch (slotNo) {
              case 0:
                  light.TurnOff();
                  break;
              case 1:
                  fan.Stop();
                  break;
              case 2:
                  door.Shut();
                  break;
           }
       }
    }

    class Program {
       static void Main(string[] args) {
           Light light = new Light();
           Fan fan = new Fan();
           Door door = new Door();

           ControlPanel panel = new ControlPanel(light, fan, door);
                     
           panel.PressOn(0);     // 按第一個On按鈕,燈被開啟了
           panel.PressOn(2);     // 按第二個On按鈕,門被開啟了
           panel.PressOff(2);        // 按第二個Off按鈕,門被關閉了                       
       }
    }
}

輸出為:

The light is turned on.
The door is open for you.
The door is closed for safety

存在問題

這個解決方案雖然能解決當前的問題,但是幾乎沒有任何擴充套件性可言。或者說,被呼叫者(Receiver:燈、電扇、門)與它們的呼叫者(Invoker:遙控器)是緊耦合的。遙控器不僅需要確切地知道它能控制哪些電器,並且需要知道這些電器由哪些方法可供呼叫。

  • 如果我們需要調換一下按鈕所控制的電器的次序,比如說我們需要讓按鈕1不再控制燈,而是控制門,那麼我們需要修改 PressOn 和 PressOff 方法中的Switch語句。

  • 如果我們需要給遙控器多添一個按鈕,以使它多控制一個電器,那麼遙控器的欄位、建構函式、PressOn、PressOff方法都要修改。

  • 如果我們不給遙控器多添按鈕,但是要求它可以控制10個或者電器,換言之,就是我們可以動態分配某個按鈕控制哪個電器,這樣的設計看上去簡直無法完成。

HardCoding 的另一實現

新設計方案

在考慮新的方案以前,我們先回顧前面的設計,第三個問題似乎暗示著我們的遙控器不夠好,思考一下,我們發現可以這樣設計遙控器:

圖片描述

對比一下,我們看到可以透過左側可以上下活動的閥門來控制當前遙控器控制的是哪個電器(按照圖中當前顯示,控制的是燈),在選定了閥門後,我們可以再透過On,Off按鈕來對電器進行控制。此時,我們需要多添一個方法,透過它來控制閥門(進而選擇想要控制的電器)。我們管這個方法叫做SetDevice()。那麼我們的設計變成下圖所示:

NOTE:在圖中,以及現實世界中,閥門所能控制的電器數總是有限的,但在程式中,可以是無限的,就看你有多少個諸如light的電器類了

圖片描述

注意到幾點變化:

  • 因為我們假設遙控器可以控制的電器是無限多的,所以這裡不能指定具體電器型別,因為在C#中所有型別均繼承自Object,我們將SetDevice()方法接受的引數設定成為Object。

  • ControlPanel不知道它將控制哪個類,所以圖中ControlPanel和Light、Door、Fan沒有聯絡。

  • PressOn()和PressOff()方法不再需要引數,因為很明顯,只有一組On和Off按鈕。

程式碼實現

namespace Command {

    public class Light {  // 略   }
    public class Fan {    // 略 }
    public class Door {   // 略 }

    // 定義遙控器
    public class ControlPanel {
       private Object device;

       // 點選On按鈕時的操作。
       public void PressOn() {
           Light light = device as Light;
           if (light != null) light.TurnOn();

           Fan fan = device as Fan;
           if (fan != null) fan.Start();

           Door door = device as Door;
           if (door != null) door.Open();
       }

       // 點選Of按鈕時的操作。
       public void PressOff() {
           Light light = device as Light;
           if (light != null) light.TurnOff();

           Fan fan = device as Fan;
           if (fan != null) fan.Stop();

           Door door = device as Door;
           if (door != null) door.Shut();
       }
      
       // 設定閥門控制哪個電器
       public void SetDevice(Object device) {
           this.device = device;
       }
    }

    class Program {
       static voidMain(string[] args) {

           Light light = new Light();
           Fan fan = new Fan(); 

           ControlPanel panel = new ControlPanel();
          
           panel.SetDevice(light);      // 設定閥門控制燈
           panel.PressOn();             // 開啟燈
           panel.PressOff();            // 關閉燈

           panel.SetDevice(fan);    // 設定閥門控制電扇
           panel.PressOn();             // 開啟門
       }
    }
}

存在問題

我們首先可以看到,這個方案似乎解決了第一種設計的大多數問題,除了一點點瑕疵:

  • 儘管我們可以控制任意多的裝置,但是我們每新增一個可以控制的裝置,仍需要修改PressOn()和PressOff()方法。

  • 在PressOn()和PressOff()方法中,需要對所有可能控制的電器進行型別轉換,無疑效率低下。

封裝呼叫

問題分析

我們的處境似乎一籌莫展,想不到更好的辦法來解決。這時候,讓我們先回頭再觀察一下ControlPanel的PressOn()和PressOff()程式碼。

// 點選On按鈕時的操作。
public void PressOn() {
    Light light = device as Light;
    if (light != null) light.TurnOn();

    Fan fan = device as Fan;
    if (fan != null) fan.Start();

    Door door = device as Door;
    if (door != null) door.Open();
}

我們發現PressOn()和PressOff()方法在每次新增新裝置時需要作修改,而實際上改變的是對物件方法的呼叫,因為不管有多少個if語句,只會呼叫其中某個不為null的物件的一個方法。然後我們再回顧一下OO的思想,Encapsulate what varies(封裝變化)。我們想是不是應該有辦法將這變化的這部分(方法的呼叫)封裝起來呢?

在考慮如何封裝之前,我們假設已經有一個類,把它封裝起來了,我們管這個類叫做Command,那麼這個類該如何使用呢?

我們先考慮一下它的構成,因為它要封裝各個物件的方法,所以,它應該暴露出一個方法,這個方法既可以代表 light.TurnOn(),也可以代表fan.Start(),還可以代表door.Open(),讓我們給這個方法起個名字,叫做Execute()。

好了,現在我們有了Command類,還有了一個萬金油的Execute()方法,現在,我們修改PressOn()方法,讓它透過這個Command類來控制電器(呼叫各個類的方法)。

// 點選On按鈕時的操作。
public void PressOn() {
    command.Execute();
}

哇,是不是有點簡單的過分了!?但就是這麼簡單,可我們還是發現了兩個問題:

  1. Command應該能知道它呼叫的是哪個電器類的哪個方法,這暗示我們Command類應該儲存對於具體電器類的一個引用。

  2. 我們的ControlPanel應該有兩個Command,一個Command對應於所有開啟的操作(我們管它叫onCommand),一個Command對應所有關閉的操作(我們管它叫offCommand)。

同時,我們的SetDevice(object)方法,也應該改成SetCommand(onCommand,offCommand)。好了,現在讓我們看看新版ControlPanel 的全景圖吧。

圖片描述

Command型別的實現

顯然,我們應該能看出:onCommand實體變數(instance variable)和offCommand變數屬於Command型別,同時,上面我們已經討論過Command類應該具有一個Execute()方法,除此以外,它還需要可以儲存對各個物件的引用,透過Execute()方法可以呼叫其引用的物件的方法。

那麼我們按照這個思路,來看下開燈這一操作(呼叫light物件的TurnOn()方法)的Command物件應該是什麼樣的:

public class LightOnCommand{
    Light light;
    public Command(Light light){
       this.light = light;
    }
   
    public void Execute(){
       light.TurnOn();
    }
}

再看下開電扇(呼叫fan物件的Start()方法)的Command物件應該是什麼樣的:

public class FanStartCommand{
    Fan fan;
    public Command(Fan fan){
       this.fan = fan;
    }
   
    public void Execute(){
       fan.Start();
    }
}

這樣顯然是不行的,它沒有解決任何的問題,因為FanStartCommand和LightOnCommand是不同的型別,而我們的ControlPanel要求對於所有開啟的操作應該只接受一個型別的Command的。但是經過我們上面的討論,我們已經知道所有的Command都有一個Execute()方法,我們何不定義一個介面來解決這個問題呢?

圖片描述

OK,現在我們已經完成了全部的設計,讓我們先看一下最終的UML圖,再進行程式碼實現吧(簡單起見,只加入了燈和電扇)。

圖片描述

我們先看下這張圖說明了什麼,以及發生的順序:

  1. ConsoleApplication,也就是我們的應用程式,它建立電器Fan、Light物件,以及LightOnCommand和FanStartCommand。

  2. LightOnCommand、FanStartCommand實現了ICommand介面,它儲存著對於Fan和Light的引用,並透過Execute()呼叫Fan和Light的方法。

  3. ControlPanel複合了Command物件,透過呼叫Command的Execute()方法,間接呼叫了Light的TurnOn()方法或者是Fan的Stop()方法。

它們之間的時序圖是這樣的:

圖片描述

可以看出:透過引入Command物件,ControlPanel對於它實際呼叫的物件Fan或者Light是一無所知的,它只知道當On按下的時候就呼叫onCommand的Execute()方法;當Off按下的時候就呼叫offCommand的Execute()方法。Light和Fan當然更不知道誰在呼叫它。透過這種方式,我們實現了呼叫者(Invoker,遙控器ControlPanel) 和 被呼叫者(Receiver,電扇Fan等)的解耦。如果將來我們需要對這個ControlPanel進行擴充套件,只需要再新增一個實現了ICommand介面的物件就可以了,對於ControlPanel無需做任何修改。

程式碼實現

namespace Command {

    // 定義空調,用於測試給遙控器添新控制型別
    public class AirCondition {
       public void Start() {
           Console.WriteLine("The AirCondition is turned on.");
       }
       public void SetTemperature(int i) {
           Console.WriteLine("The temperature is set to " + i);
       }
       public void Stop() {
           Console.WriteLine("The AirCondition is turned off.");
       }
    }
   
    // 定義Command介面
    public interface ICommand {
       void Execute();
    }

    // 定義開空調命令
    public class AirOnCommand : ICommand {
       AirCondition airCondition;
       public AirOnCommand(AirCondition airCondition) {
           this.airCondition = airCondition;
       }
       public void Execute() {  //注意,你可以在Execute()中新增多個方法
           airCondition.Start();
           airCondition.SetTemperature(16);
       }
    }

    // 定義關空調命令
    public class AirOffCommand : ICommand {
       AirCondition airCondition;
       public AirOffCommand(AirCondition airCondition) {
           this.airCondition = airCondition;
       }
       public void Execute() {
           airCondition.Stop();
       }
    }


    // 定義遙控器
    public class ControlPanel {
       private ICommand onCommand;
       private ICommand offCommand;

       public void PressOn() {
           onCommand.Execute();
       }

       public void PressOff() {
           offCommand.Execute();
       }

       public void SetCommand(ICommand onCommand,ICommand offCommand) {
           this.onCommand = onCommand;
           this.offCommand = offCommand;
       }
    }

    class Program {
       static voidMain(string[] args) {

           // 建立遙控器物件
           ControlPanel panel = new ControlPanel();

           AirCondition airCondition = new AirCondition();       //建立空調物件

           // 建立Command物件,傳遞空調物件
           ICommand onCommand = new AirOnCommand(airCondition);
           ICommand offCommand = new AirOffCommand(airCondition);

           // 設定遙控器的Command
           panel.SetCommand(onCommand, offCommand);

           panel.PressOn();      //按下On按鈕,開空調,溫度調到16度
           panel.PressOff();     //按下Off按鈕,關空調

       }
    }
}

Command 模式

實際上,我們上面做的這一切,實現了另一個設計模式:Command模式。現在又到了給出官方定義的時候了。每次到了這部分我就不知道該怎麼寫了,寫的人太多了,資料也太多了,我相信你看到這裡對Command模式已經比較清楚了,所以我還是一如既往地從簡吧。

Command模式的正式定義:將一個請求封裝為一個物件,從而使你可用不同的請求對客戶進行引數化;對請求排隊或記錄請求日誌,以及支援可撤消的操作。

它的 靜態圖 是這樣的:

圖片描述

它的 時序圖 是這樣的:

圖片描述

可以和我們前面的圖對比一下,對於這兩個圖,除了改了個名字外基本沒變,我就不再說明了,也留給你一點思考的空間。

總結

本文簡單地介紹了GOF的Commmand模式,我們透過一個簡單的範例家電遙控器 實現了這一模式。

我們首先了解了不使用此模式的HardCoding方式的實現方法,討論了它的缺點;然後又換了另一種改進了的實現方法,再次討論了它的不足。  然後,我們透過將物件的呼叫封裝到一個Command物件中的方式,巧妙地完成了設計。最後,我們給出了Command模式的正式定義。

本文僅僅簡要介紹了Command模式,它的高階應用:取消操作(UnDo)、事務支援(Transaction)、佇列請求(Queuing Request) 以後有時間了會再寫文章。

希望這篇文章能對你有所幫助!

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/4560/viewspace-2811730/,如需轉載,請註明出處,否則將追究法律責任。

相關文章