设计模式 with Python 6:命令模式
命令模式
命令模式是一种将命令的调用方和接收(实现)方解耦的设计模式。
如果不明白这句话的意思,不用担心,看完这篇文章就能理解了。
智能家居APP
假设我们需要开发一款智能家居客户端,我们需要接入不同厂家不同标准的只能家居产品,虽然这些产品都提供了SDK,但具体的SDK调用方式并不相同,比如:
用过小米台灯的应该知道,这款台灯有多个级别的亮度可以调节,而普通的台灯可能只能设置开和关,现在我们的APP需要同时接入这两款台灯,假设我们的控制面板上将接入的智能家居以一个个方块状控件显示,图标和名称表示是哪个智能家居产品,点击控件就可以简单的开启或关闭该只能家居,更详细的操作可以使用长按进入详细控制面板,简单起见我们先不考虑这种操作,只实现简单的控制面板以及具体只能家居的开关。
最先也最容易想到的设计应该是这样的:
代码这里就不展示了,具体见Github仓库的smart_home_v1。
这个方案目前似乎没有什么问题,如果需要引入新的智能家居设备,只要创建新的ControlButton
子类即可,对具体智能家居设备的SDK的调用逻辑会封装在新建的子类中。
这里实际上是采用了依赖倒置的设计原则。
但是从系统抽象方面思考存在一定问题,因为ControlButton
及其子类明显是属于系统的UI部分,理论上是不应该和业务逻辑部分紧耦合的。
如果我们的系统保持现在这般简单,也没有太大问题,单如果复杂一点,比如说创建一个顶部浮动菜单,可以进行对于某些常用智能家居进行快速关闭和打开,或者根本就是整合进语音助手,让语音助手可以通过语言关闭和打开具体的设备。在这些情况下让另一个UI或者干脆是语音应用再通过ControlButton
来进行调用显然是不合适的,因为那些控件根本用不到image
等属性,况且我们上边的设计中off()
和on()
根本就是受保护的。
像这种需要将命令的调用方(在这里是UI控件或者语音助手)与执行、接收方(这里指智能家居的SDK组件)进行解耦的设计,正是本文要介绍的设计模式:命令模式。
定义
我们先来看标准的命令模式的UML图:
图中的Client
表示命令的调用方,Receiver
表示命令的接收方,Command
表示命令的抽象基类,ConcretCommand1
与ConcretCommand2
表示具体的命令。
通过上面这种结构我们可以将命令的调用方与接收方进行解耦,这其中的要点在于我们将具体的命令执行逻辑和对具体命令接收方的引用都封装在了Command
及其子类中,并对外以统一的接口(这里表现为execute()
抽象方法`)的方式提供服务。而调用方不需要再与命令的接收方紧耦合,它只需要持有相关命令即可进行调用。
此外需要注意的是命令一般会持有一个具体接收方的引用,以实现具体的命令执行逻辑,就像这里Command
的receiver
属性一般,但是在实际实现中可能命令的接收方并不会具有一个共有的基类或接口,如果那样则必然不必要也不能在命令的基类中持有Receiver
引用。
最后要说明的是,命令的创建方在这个模式中并不重要,我们可以在任何地方进行创建并关联命令接收方,然后只需要在适当的时候将相关命令传递给相关的调用方即可。
实现
这里删除了ControlButton
的子类,并且ControlButton
也不再是抽象类。这是因为我们已经将具体的只能家居调用逻辑封装在了命令中,不需要再使用子类化ControlButton
的方式。
我们将小米台灯的开、关以及普通台灯的开、关封装为了四个命令,这是一般性的做法,当然你也可以封装为两个,每个命令具有开关两个方法,但是“粗粒度”的抽象自然会伴随较差的灵活性,你无法在其它地方随意的调用单个只需要开的命令,或者将命令组合。
这里没有绘制语音助手,因为在实现了命令模式的相关类后实现语音助手其实很简单,在具体编码时候实现即可。
同样,代码实现没有太大难度,这里就不作展示了,具体代码见Github仓库smart_home_v2。
事实上我们上面做出的改进在这个具体示例中有点“用牛刀杀鸡”的感觉,毕竟就算是小米台灯也只是调整亮度到固定度数就能简单实现具体的命令要求,业务逻辑并不复杂,但是像上面这样实现了命令模式后会有一些扩展性带来的其它好处,比如我们可以轻松给语音助手添加一个类似于“撤销上一个操作”这样的功能。
撤销命令
实现起来很简单,先给抽象基类Command
添加一个undo
方法用于实现撤销逻辑:
from abc import ABC, abstractmethod
class Command(ABC):
@abstractmethod
def execute(self):
pass
@abstractmethod
def undo(self):
pass
自然的,我们需要给Command
子类全部添加上undo
方法:
from smart_home_v3.src.MiDeskLamp import MiDeskLamp
from smart_home_v3.src.commnad.Command import Command
class MiDeskLampOffCommand(Command):
def __init__(self, miDeskLamp: MiDeskLamp) -> None:
super().__init__()
self._miDeskLamp = miDeskLamp
self._lastBrightness: int = 0
def execute(self):
self._lastBrightness = self._miDeskLamp.getBrightness()
self._miDeskLamp.setBrightness(0)
def undo(self):
self._miDeskLamp.setBrightness(self._lastBrightness)
对于小米台灯,因为不是简单的调用其开和关的功能,所以undo
可能需要将其亮度还原到原来的亮度,这里设置相关属性来记录原有亮度,相应的,在小米台灯的SDK中添加亮度相关属性:
class MiDeskLamp:
def __init__(self) -> None:
self._brightness: int = 0
def setBrightness(self, brightness: int):
self._brightness = brightness
print("now mi desk lamp's brightness is {}".format(brightness))
def getBrightness(self) -> int:
return self._brightness
需要注意的是,这里的撤销功能仅仅是针对命令,所以相关的上一步操作前的状态由命令来保存也是合理的,但是如果对智能家居的操作并非通过命令,比如说手动调节到某个亮度,是和我们这里通过命令来管理智能家居的系统无关的,自然也无法由这里的undo
进行撤销操作。
其它子类只要依葫芦画瓢即可。
对于语音助手,只需要采用类似的做法,保留上一个被执行的命令的引用即可实现撤销上一步操作的功能:
from smart_home_v3.src.commnad.Command import Command
from smart_home_v3.src.commnad.NoneCommand import Command, NoneCommand
class VoiceAssistant:
def __init__(self) -> None:
self._voiceCommand: dict[str, Command] = dict()
self._lastCommand: Command = NoneCommand()
def associateVoiceCommand(self, voiceStr: str, command: Command):
self._voiceCommand[voiceStr] = command
def say(self, voiceStr: str) -> None:
if voiceStr == "undo":
self._lastCommand.undo()
return
try:
command = self._voiceCommand[voiceStr]
except KeyError:
print("I don't know")
else:
self._lastCommand = command
command.execute()
值得注意的是这里我们将上一步命令初始化为一个Command
的新子类NoneCommand
,而非None
,这样做的好处在于我们无需在使用self._lastCommand
的时候检查self._lastCommand is not None
,这种创建一个“空类型”的方式也是设计模式中的常见做法,甚至可以将其视为一种简单的设计模式。
具体的NoneCommand
实现如下:
from smart_home_v3.src.commnad.Command import Command
class NoneCommand(Command):
def execute(self) -> None:
pass
def undo(self) -> None:
pass
测试:
voiceAssistant = VoiceAssistant()
voiceAssistant.associateVoiceCommand("open light", openMiDeskLampCommand)
voiceAssistant.associateVoiceCommand("close light", closeMiDeskLampCommand)
voiceAssistant.say("open light")
voiceAssistant.say("close light")
voiceAssistant.say("undo")
# now mi desk lamp's brightness is 50
# now mi desk lamp's brightness is 0
# now mi desk lamp's brightness is 50
通过VoiceAssistant
实现了撤销上一步命令的操作。
完整代码见Github仓库smart_home_v3。
宏命令
假如我们现在需要给语音助手提供一种语音命令“I’m back”,此时需要开启所有需要开启的智能家居,比如开启所有的灯,打开电视等。
当然我们可以在语音助手中将所需的一系列命令和调用逻辑硬编码,但那样显然不是一种好的方案,更好的选择是“宏命令”,采用这种方案可以无缝衔接进当前的设计方案中。
我们先来看宏命令的UML:
可以看到,宏命令MacroCommand
也是Command
的一个子类,不同的是宏命令具有一个Command
集合Commands
,其execute
的逻辑一般也是遍历该集合,并依次调用。
在我们这个案例中,可以这样创建宏命令类:
from typing import Sequence
from .Command import Command
class MacroCommand(Command):
def __init__(self, commands: Sequence[Command]) -> None:
super().__init__()
self._commands: list[Command] = list(commands)
def execute(self) -> None:
command: Command
for command in self._commands:
command.execute()
def undo(self) -> None:
command: Command
for command in reversed(self._commands):
command.undo()
方便起见,我们使用一个简单工厂创建具体实例:
from .MacroCommand import MacroCommand
from .MiDeskLampOnCommand import MiDeskLampOnCommand
from .NormalDeskLampOnCommand import NormalDeskLampOnCommand
from ..NormalDeskLamp import NormalDeskLamp
from ..MiDeskLamp import MiDeskLamp
class MacroCommandFactory:
@classmethod
def getBackHomeMacroCommand(cls, normalDeskLamp: NormalDeskLamp, miDeskLamp: MiDeskLamp) -> MacroCommand:
commands = list()
commands.append(MiDeskLampOnCommand(miDeskLamp))
commands.append(NormalDeskLampOnCommand(normalDeskLamp))
backHomeCommand = MacroCommand(commands)
return backHomeCommand
然后就可以像使用普通命令那样使用宏命令了:
backHomeCommand: MacroCommand = MacroCommandFactory.getBackHomeMacroCommand(normalDeskLamp,miDeskLamp)
voiceAssistant = VoiceAssistant()
voiceAssistant.associateVoiceCommand("open light", openMiDeskLampCommand)
voiceAssistant.associateVoiceCommand("close light", closeMiDeskLampCommand)
voiceAssistant.associateVoiceCommand("I'm back", backHomeCommand)
voiceAssistant.say("I'm back")
voiceAssistant.say("undo")
# now mi desk lamp's brightness is 50
# normal desk lamp is turn on
# normal desk lamp is turn off
# now mi desk lamp's brightness is 0
当然这里这个宏命令有点名不副实,只用于演示,如果接入的智能家居够多,就可以实现丰富的命令功能。
完整代码见Github仓库smart_home_v4。
其它应用场景
日志
如果接触过数据库备份相关知识的,应该知道数据库的备份一般分为两部分,一部分是完整备份,也就是将数据库的全部数据做一个镜像,这种备份当然是最好最全的,但是毕竟我们的服务器存储不是无限的,自然对这种备份方式也要有限度的使用,那么就产生一个问题,在两个完整备份之间的备份和还原要怎么处理。
其中一个方案就是将这期间所有的SQL全部记录下来,如果要还原到某个SQL执行节点,自然只需要先还原到此节点之前的完整备份,然后依次执行SQL到该节点即可。
相似的,我们的命令模式也可以做到类似的工作,将具体的一个个命令类比作一条条SQL,我们完全可以将这些命令实例使用具体编程语言的序列化功能进行永久存储,然后就可以实现相应的日志记录和还原功能。
当然,这依赖于具体编程语言的序列化模块所提供的功能,可能会因为其支持功能的不同在实现时有所不同。
工作队列
和日志功能相类似,借助编程语言的序列化功能,我们可以将命令实例在不同的服务之间传递,并在相应的服务反序列化后进行执行。
但是和日志一样,这依赖于具体编程语言的序列化模块,如果你不同服务压根就是不同的运行环境,比如说一台是Java一台是PHP,那多半是不能这么搞的,或者是你要付出额外的工作。
就我的工作经验而言,其实大可不必,更灵活的做法是通过通用数据格式(常见的是XML或JSON)来传递数据给工作队列服务,工作队列服务按照相应标识来对不同数据调用不同的业务处理模块进行处理,而非是传递序列化后的命令实例,这样做虽然需要在工作队列服务模块实现所有的相应的业务逻辑,但是避免了对具体编程语言的序列化模块的依赖。
关于命令模式的介绍到这里就结束了,谢谢阅读。
本系列文章的全部工程文件都存储于Github项目design-pattern-with-python。