Python 設計模式-命令模式

goodspeed發表於2019-03-02

命令模式

題目: 現在要做一個智慧家居控制遙控器,功能如下圖所示。

智慧家居遙控器

下圖是家電廠商提供的類,介面各有差異,並且以後這種類可能會越來越多。

家電廠商類

觀察廠商提供的類,你會發現,好多類提供了 on()、off() 方法,除此之外,還有一些方法像 dim()、setTemperature()、setVolumn()、setDirection()。由此我們可以想象,之後還會有更多的廠商類,每個類還會有各式各樣的方法。

如果我們把這些類都用到遙控器程式碼中,程式碼就會多一大堆的 if 語句,例如

if slot1 == Light:
    light.on()
elif slot1 == Hottub:
    hottob.jetsOn()
複製程式碼

並且更嚴重的是,每次有新的廠商類加進來,遙控器的程式碼都要做相應的改動。

這個時候我們就要把動作的請求者(遙控器)動作的執行者(廠商類)物件中解耦。

如何實現解耦呢?

我們可以使用命令物件。利用命令物件,把請求(比如開啟電燈)封裝成一個特定物件。所以,如果對每個按鈕都儲存一個命令物件,那麼當按鈕按下的時候,就可以請求命令物件做相關的工作。此時,遙控器並不需要知道工作的內容是什麼,只要有個命令物件能和正確的物件溝通,把事情做好就可以了。

下面我們拿餐廳點餐的操作來介紹下命令模式。

餐廳通常是這樣工作的:

  1. 顧客點餐,把訂單交給服務員
  2. 服務員拿了訂單,把訂單交給廚師。
  3. 廚師拿到訂單後根據訂單準備餐點。

Python 設計模式-命令模式

這裡我們把訂單想象成一個用來請求準備餐點的物件,

  • 和一般物件一樣,訂單物件可以被傳遞:從服務員傳遞到訂單櫃檯,訂單的介面只包含一個方法 orderUp()。這個方法封裝了準備餐點所需的動作。
  • 服務員的工作就是接受訂單,然後呼叫訂單的 orderUp() 方法,她不需要知道訂單內容是什麼。
  • 廚師是一個物件,他知道如何準備準備餐點,是任務真正的執行者。

如果我們把餐廳想象成OO 設計模式的一種模型,這個模型允許將”發出請求的物件“和”接受與執行這些請求的物件“分隔開來。比如對於遙控器 API,我們要分隔開”發出請求的按鈕程式碼“和”執行請求的廠商特定物件”。

回到命令模式我們把餐廳的工作流程圖轉換為命令模式的流程圖:這裡 client 對應上一張圖的顧客,command 對應訂單,Invoker 對應服務員,Receiver 對應的是廚師。

Python 設計模式-命令模式

命令模式

先來看下命令模式的定義:

命令模式將”請求“封裝成物件,以便使用不同的請求、佇列或者日誌來引數化其他物件。命令模式也支援可撤銷的操作。

通過上邊的定義我們知道,一個命令物件通過在特定接收者上繫結一組動作來封裝一個請求。要達到這一點,命令物件將動作和接收者包進物件中。這個物件只暴露一個 execute() 方法,當此方法被呼叫時,接收者就會進行這些動作。

命令模式類圖如下:

命令模式類圖

回到遙控器的設計:我們打算將遙控器的每個插槽,對應到一個命令,這樣就讓遙控器變成了呼叫者。當按下按鈕,相應命令物件的 execute() 方法就會被呼叫,其結果就是接收者(例如:電燈、風扇、音響)的動作被呼叫。

Python 設計模式-命令模式

命令模式還支援撤銷,該命令提供和 execute() 方法相反的 undo() 方法。不管 execute() 做了什麼,undo() 都會倒轉過來。

程式碼實現

遙控器的實現

class RemoteControl(object):

    def __init__(self):
        # 遙控器要處理7個開與關的命令
        self.on_commands = [NoCommand() for i in range(7)] 
        self.off_commands = [NoCommand() for i in range(7)]
        self.undo_command = None  # 將前一個命令記錄在這裡

    def set_command(self, slot, on_command, off_command):
        # 預先給每個插槽設定一個空命令的命令
        # set_command 命令必須要有三個引數(插槽的位置、開的命令、關的命令)
        self.on_commands[slot] = on_command
        self.off_commands[slot] = off_command

    def on_button_was_pressed(self, slot):
        command = self.on_commands[slot]
        command.execute()
        self.undo_command = command
        
    # 當按下開或關的按鈕,硬體就會負責呼叫對應的方法
    def off_button_was_pressed(self, slot):
        command = self.off_commands[slot]
        command.execute()
        self.undo_command = command

    def undo_button_was_pressed(self):
        self.undo_command.undo()

    def __str__(self):
        # 這裡負責列印每個插槽和它對應的命令
        for i in range(7):
            print('[slot %d] %s %s' % (i,
                                       self.on_commands[i].__class__.__name__,
                                       self.off_commands[i].__class__.__name__))
        return ''

複製程式碼

命令的實現

這裡實現一個基類,這個基類有兩個方法,execute 和 undo,命令封裝了某個特定廠商類的一組動作,遙控器可以通過呼叫 execute() 方法,執行這些動作,也可以使用 undo() 方法撤銷這些動作:

class Command(object):

    def execute(self):
        # 每個需要子類實現的方法都會丟擲NotImplementedError
        # 這樣的話,這個類就是真正的抽象基類
        raise NotImplementedError()

    def undo(self):
        raise NotImplementedError()


# 在遙控器中,我們不想每次都檢查是否某個插槽都載入了命令,
# 所以我們給每個插槽預先設定一個NoCommand 物件
# 所以沒有被明確指定命令的插槽,其命令將是預設的 NoCommand 物件
class NoCommand(Command):

    def execute(self):
        print('Command Not Found')

    def undo(self):
        print('Command Not Found')
複製程式碼

以下是電燈類,利用 Command 基類,每個動作都被實現成一個簡單的命令物件。命令物件持有對一個廠商類的例項的引用,並實現了一個 execute()。這個方法會呼叫廠商類實現的一個或多個方法,完成特定的行為,在這個例子中,有兩個類,分別開啟電燈與關閉電燈。

class Light(object):

    def __init__(self, name):
        # 因為電燈包括 living room light 和 kitchen light
        self.name = name

    def on(self):
        print('%s Light is On' % self.name)

    def off(self):
        print('%s Light is Off' % self.name)


# 電燈開啟的開關類
class LightOnCommand(Command):

    def __init__(self, light):
        self.light = light

    def execute(self):
        self.light.on()

    def undo(self):
        # undo 是關閉電燈
        self.light.off()

        
class LightOffCommand(Command):

    def __init__(self, light):
        self.light = light

    def execute(self):
        self.light.off()

    def undo(self):
        self.light.on()
複製程式碼

執行程式碼,這裡建立多個命令物件,然後將其載入到遙控器的插槽中。每個命令物件都封裝了某個家電自動化的一項請求:

def remote_control_test():
    remote = RemoteControl()

    living_room_light = Light('Living Room')
    kitchen_light = Light('Kitchen')

    living_room_light_on = LightOnCommand(living_room_light)
    living_room_light_off = LightOffCommand(living_room_light)
    kitchen_light_on = LightOnCommand(kitchen_light)
    kitchen_light_off = LightOffCommand(kitchen_light)

    remote.set_command(0, living_room_light_on, living_room_light_off)
    remote.set_command(1, kitchen_light_on, kitchen_light_off)

    print(remote)

    remote.on_button_was_pressed(0)
    remote.off_button_was_pressed(0)
    remote.undo_button_was_pressed()
    remote.on_button_was_pressed(1)
    remote.off_button_was_pressed(1)
    remote.undo_button_was_pressed()
複製程式碼

執行後輸出為:

[slot 0] LightOnCommand LightOffCommand
[slot 1] LightOnCommand LightOffCommand
[slot 2] NoCommand NoCommand
[slot 3] NoCommand NoCommand
[slot 4] NoCommand NoCommand
[slot 5] NoCommand NoCommand
[slot 6] NoCommand NoCommand

Living Room Light is On
Living Room Light is Off
Living Room Light is On
Kitchen Light is On
Kitchen Light is Off
Kitchen Light is On
複製程式碼

集合多個命令

通常,我們還希望能有一個開關一鍵開啟所有的燈,然後也可以一鍵關閉所有的燈,這裡我們使用 MacroCommand:

class MacroCommand(Command):

    def __init__(self, commands):
        # 首先建立一個 commands 的 list,這裡可以存放多個命令
        self.commands = commands

    def execute(self):
        # 執行時,依次執行多個開關
        for command in self.commands:
            command.execute()

    def undo(self):
        # 撤銷時,給所有命令執行 undo 操作
        for command in self.commands:
            command.undo()
複製程式碼

測試開關集合:

def remote_control_test():
    remote = RemoteControl()
    
    living_room_light = Light('Living Room')
    kitchen_light = Light('Kitchen')
    garage_door = GarageDoor()

    living_room_light_on = LightOnCommand(living_room_light)
    living_room_light_off = LightOffCommand(living_room_light)
    kitchen_light_on = LightOnCommand(kitchen_light)
    kitchen_light_off = LightOffCommand(kitchen_light)

    garage_door_open = GarageDoorOpenCommand(garage_door)
    garage_door_close = GarageDoorCloseCommand(garage_door)
    
    # 測試開關集合
    party_on_macro = MacroCommand([living_room_light_on, kitchen_light_on])
    party_off_macro = MacroCommand([living_room_light_off, kitchen_light_off])
    remote.set_command(3, party_on_macro, party_off_macro)
    print('--pushing macro on--')
    remote.on_button_was_pressed(3)
    print('--pushing macro off--')
    remote.off_button_was_pressed(3)
    print('--push macro undo--')
    remote.undo_button_was_pressed()
複製程式碼

當然,我們也可以使用一個列表來記錄命令的記錄,實現多層次的撤銷操作。

命令模式的用途

1. 佇列請求

命令可以將運算塊打包(一個接收者和一組動作),然後將它傳來傳去,就像是一般的物件一樣。即使在命令物件被建立許久以後,運算依然可以被呼叫。我們可以利用這些特性衍生一些應用,例如:日程安排、執行緒池、工作佇列等。

想象一個工作佇列:你在某一端新增命令,然後在另一端則是執行緒。執行緒進行下面的動作:從佇列中取出一個命令,呼叫它的 execute() 方法,等待這個呼叫完成,然後將次命令物件丟棄,再取下一個命令

此時的工作佇列和計算的物件之間是完全解耦的,此刻執行緒可能進行的是音訊轉碼,下一個命令可能就變成了使用者評分計算。

2. 日誌請求

某些應用需要我們將所有的動作都記錄在日誌中,並能在系統當機之後,重新呼叫這些動作恢復到之前的狀態。通過新增兩個方法(store()、load()),命令模式能夠支援這一點。這些資料最好是持久化到硬碟。

要怎麼做呢? 當我們執行命令時,將歷史記錄儲存到磁碟,一旦系統當機,我們就將命令物件重新載入,併成批的依次呼叫這些物件的 execute() 方法。

比如對於excel,我們可能想要實現的錯誤恢復方式是將電子表格的操作記錄在日誌中,而不是每次電子表格一有變化就記錄整個電子表格。資料庫的事務(transaction)也是使用這個技巧,也就是說,一整群操作必須全部進行完成,或者沒有任何操作。

參考連結

命令模式完整程式碼


最後,感謝女朋友支援。

歡迎關注(April_Louisa) 請我喝芬達
歡迎關注
請我喝芬達

相關文章