设计模式之命令(Command)模式
也称为: Action, Transaction
意图
命令模式(Command)是一种行为设计模式,它将请求转换成包含所有请求信息的独立对象。这个转换允许你将请求转换成方法参数,将请求执行延迟或者加入队列,并支持可撤消的操作。
问题描述
想象一下,你正在开发一个新的文本编辑器应用程序。当前的任务是创建一个工具栏,其中包含一组用于编辑器各种操作的按钮。你创建了一个非常整洁的 Button 类,它可以用作工具栏上的按钮,也可以用于各种对话框中的通用按钮。
虽然这些按钮看起来很像,但它们所做的事情并不一样。你会把这些按钮的各种点击 handler 的代码放在哪里?最简单的解决方案是为每个使用按钮的地方创建大量的子类。这些子类将包含必须在单击按钮时执行的代码。
不久,你就会意识到这种方法存在严重缺陷。首先,你有大量的子类,如果每次修改基本 Button
类时不冒破坏这些子类中代码的风险,那也没关系。简单来说,你的 GUI 代码已经变得笨拙地依赖于业务逻辑的不稳定代码。
这是最丑陋的部分。有些操作,如复制/粘贴文本,需要从多个位置调用。例如,用户可以单击工具栏上的一个“复制”按钮,或者通过上下文菜单复制一些内容,或者只需按下键盘上的 Ctrl+C
。
最初,当我们的应用程序只有工具栏时,可以将各种操作的实现放入按钮子类中。换句话说,将用于复制文本的代码放在 CopyButton
子类中是可以的。但是,当你实现上下文菜单、快捷方式和其他东西时,你必须在许多类中复制此操作代码,或者使菜单依赖于按钮,这是一个更糟糕的选择。
方案
好的软件设计通常基于关注点分离的原则,这通常会导致将应用程序分解为多个层。最常见的例子是:一层用于图形用户界面,另一层用于业务逻辑。GUI 层负责在屏幕上渲染美丽的图片,捕捉任何输入并显示用户和应用程序正在做的事情的结果。然而,当涉及到做一些重要的事情时,比如计算月球的轨迹或撰写年度报告,GUI 层将工作委托给业务逻辑的底层。
在代码中,它可能看起来是这样的:GUI 对象调用业务逻辑对象的方法,并向其传递一些参数。这个过程通常被描述为一个对象向另一个对象发送请求。
命令模式建议 GUI 对象不应该直接发送这些请求。相反,你应该使用触发此请求的单个方法将所有请求详细信息(如被调用的对象、方法的名称和参数列表)提取到一个单独的命令(command)类中。
命令对象充当各种 GUI 和业务逻辑对象之间的链接。从现在起,GUI 对象不需要知道什么业务逻辑对象将接收请求以及如何处理请求。GUI 对象只是触发命令,由命令处理所有细节。
下一步是使你的命令实现相同的接口。通常它只有一个不带参数的执行方法。该接口允许你将各种命令与同一请求发送器一起使用,而无需将其耦合到具体的命令类。另外,现在你可以切换链接到发送方的命令对象,从而在运行时有效地更改发送方的行为。
你可能已经注意到这个谜题中缺少一块,也就是请求参数。GUI 对象可能已经为业务层对象提供了一些参数。由于命令执行方法没有任何参数,我们将如何将请求的详细信息传递给接收方?事实证明,该命令要么使用这些数据进行预配置,要么能够自行获取数据。
让我们回到文本编辑器。在使用命令模式之后,我们不再需要所有这些按钮子类来实现各种点击行为。只需在存储对命令对象的引用的 Button
基类中放入一个字段,并使按钮在单击时执行该命令就足够了。
你将为每个可能的操作实现一组命令类,并根据按钮的预期行为将它们与特定按钮链接。
其他 GUI 元素,如菜单、快捷方式或整个对话框,也可以用同样的方式实现。它们将链接到一个命令,该命令在用户与 GUI 元素交互时执行。正如你现在可能已经猜到的那样,与相同操作相关的元素将链接到相同的命令,从而防止任何代码重复。
因此,命令成为一个方便的中间层,减少了 GUI 和业务逻辑层之间的耦合。这只是 Command 模式所能提供的好处的一小部分!
真实世界类比
在城市里走了很长一段路后,你来到一家不错的餐馆,坐在靠窗的桌子旁。一位友好的服务员走近你,迅速把你点的菜记在纸上。服务员走到厨房,把点的菜贴在墙上。过了一段时间,点的菜传到厨师那里,厨师会阅读并相应地烹饪。厨师把饭菜和点菜一起放在托盘上。服务员发现托盘,检查订单,确保一切都如你所愿,然后把所有东西都端到你的桌子上。
纸质订单起到命令的作用。在厨师准备上菜之前,它一直排在队伍中。订单包含烹饪所需的所有相关信息。它允许厨师立即开始烹饪,而不用四处奔波,直接向你澄清订单细节。
结构
Sender 类(即调用者 Invoker)负责初始化请求。该类必须有一个字段存储命令对象的引用。Sender 触发命令而非直接发送请求给接收者(Reveiver)。请注意 Sender 不负责创建命令对象。通常, 它通过构造器从客户端获取 Command 对象。Command 接口通常声明执行该命令的单个方法。
具体命令(Concrete Command)实现各种请求。具体的命令不应该自己单独执行工作,而是将调用传递给其中一个业务逻辑对象。但是,为了简化代码,可以合并这些类。
在接收对象上执行方法所需的参数可以在具体命令中声明为字段。通过只允许通过构造函数初始化这些字段,可以使命令对象不可变。
Receiver 类包含一些业务逻辑。几乎任何对象都可以充当接收者。大多数命令只处理如何将请求传递给接收者的细节,而接收者本身则完成实际工作。
客户端(Client)创建并配置具体的命令对象。客户端必须将所有请求参数(包括一个接收者实例)传递到命令的构造函数中。之后,所得到的命令可以与一个或多个发送者相关联。
伪代码
本例中,命令模式帮助跟踪执行操作的历史,使之可以在必要时恢复操作。
导致更改编辑器状态的命令(例如,剪切和粘贴)会在执行与该命令相关联的操作之前备份编辑器的状态。执行命令后,它将与编辑器当时状态的备份副本一起放入命令历史记录(命令对象堆栈)中。稍后,如果用户需要恢复操作,应用程序可以从历史记录中获取最新的命令,读取编辑器状态的相关备份,并进行恢复。
客户端代码(GUI元素、命令历史记录等)没有耦合到具体的命令类,因为它通过命令接口处理命令。这种方法可以在不破坏任何现有代码的情况下将新命令引入应用程序。
// 基础命令类为所有的具体命令定义了通用接口
abstract class Command is
protected field app: Application
protected field editor: Editor
protected field backup: text
constructor Command(app: Application, editor: Editor) is
this.app = app
this.editor = editor
// 对编辑器状态进行备份。
method saveBackup() is
backup = editor.text
// 恢复编辑器状态
method undo() is
editor.text = backup
// 执行方法声明为抽象,
// 迫使具体类提供他们自己的实现
// 该方法必须根据命令是否改变编辑器状态
// 返回 true 或 false
abstract method execute()
// 具体的命令在这里
class CopyCommand extends Command is
// copy 命令不会保存到历史中
// 因为它没有改变了编辑器的状态
method execute() is
app.clipboard = editor.getSelection()
return false
class CutCommand extends Command is
// cut 命令确实修改了编辑器状态
// 因此必须将其保存到历史中。
// 只要改方法返回 true, 它就会保存。
method execute() is
saveBackup()
app.clipboard = editor.getSelection()
editor.deleteSelection()
return true
class PasteCommand extends Command is
method execute() is
saveBackup()
editor.replaceSelection(app.clipboard)
return true
// 撤销(undo) 操作也是命令
class UndoCommand extends Command is
method execute() is
app.undo()
return false
// 全局命令历史是一个堆栈
class CommandHistory is
private field history: array of Command
// Last in...
method push(c: Command) is
// 将命令 Push 到历史数组的顶端。
// ...first out
method pop():Command is
// 从历史中获取最新命令。
// 编辑器类有一个实际编辑文本的操作。
// 它扮演者接收者的角色:
// 所有的命令最终会委派执行给编辑器的方法。
class Editor is
field text: string
method getSelection() is
// 返回选中的文本。
method deleteSelection() is
// 删除选择的文本
method replaceSelection(text) is
// 在当前位置插入剪贴板内容
// 应用类设置了对象关联。
// 它扮演 sender 角色:
// 当需要某些操作时,它创建对象并执行。
class Application is
field clipboard: string
field editors: array of Editors
field activeEditor: Editor
field history: CommandHistory
// 分配命令给 UI 对象的代码大约如此。
method createUI() is
// ...
copy = function() { executeCommand(
new CopyCommand(this, activeEditor)) }
copyButton.setCommand(copy)
shortcuts.onKeyPress("Ctrl+C", copy)
cut = function() { executeCommand(
new CutCommand(this, activeEditor)) }
cutButton.setCommand(cut)
shortcuts.onKeyPress("Ctrl+X", cut)
paste = function() { executeCommand(
new PasteCommand(this, activeEditor)) }
pasteButton.setCommand(paste)
shortcuts.onKeyPress("Ctrl+V", paste)
undo = function() { executeCommand(
new UndoCommand(this, activeEditor)) }
undoButton.setCommand(undo)
shortcuts.onKeyPress("Ctrl+Z", undo)
// 执行命令并检测其是否被添加到历史中。
method executeCommand(command) is
if (command.execute())
history.push(command)
// 从历史中获取最新命令,并运行改命令。
// 请注意,我们并不知道改命令的类名。
// 不过,我们也不需要知道,因为
// 命令自己知道如何撤销其行为。
method undo() is
command = history.pop()
if (command != null)
command.undo()
适用性
当想通过操作将对象参数化时,可以使用命令模式。
命令模式可以将特定的方法调用转换为独立的对象。这一变化带来了许多有趣的用途:您可以将命令作为方法参数传递,将它们存储在其他对象中,在运行时切换链接的命令,等等。
这里有一个例子:您正在开发一个 GUI 组件,例如上下文菜单,并且您希望用户能够配置菜单项,使得最终用户点击菜单项时触发操作。
当要将操作加入队列、定期计划其执行或远程执行时,可以使用命令模式。
与任何其他对象一样,命令可以序列化,这意味着将其转换为可以轻松写入文件或数据库的字符串。稍后,可以将字符串恢复为初始命令对象。因此,您可以延迟和安排命令执行。但还有更多!以同样的方式,您可以通过网络排队、记入日志或发送命令。
当要实现可逆操作时,可以使用命令模式。
尽管有很多方法可以实现 undo/redo,但命令模式可能是最流行的。
为了能够恢复操作,你需要实现已执行操作的历史记录。命令历史记录是一个堆栈,包含所有执行的命令对象以及应用程序状态的相关备份。
这种方法有两个缺点。首先,保存应用程序的状态并不容易,因为其中一些状态可能是私有的。这个问题可以通过 Memento 模式来缓解。
其次,状态备份可能会消耗相当多的 RAM。因此,有时你可以采用另一种实现方式:命令执行相反的操作,而不是恢复过去的状态。反向操作也有代价:它可能很难实施,甚至不可能实施。
如何实现
使用单个执行方法声明命令接口。
将请求提取到实现命令接口的具体命令类中。每个类都必须有一组字段,用于存储请求参数以及对实际接收者对象的引用。所有这些值都必须通过命令的构造函数进行初始化。
识别发送者类。将用于存储命令的字段添加到这些类中。发送者应仅通过命令接口与其命令进行通信。发送者通常不会自己创建命令对象,而是从客户端代码中获取命令对象。
更改发送者,使其执行命令,而不是直接向接收者发送请求。
客户端应按以下顺序初始化对象:
- 创建接收者。
- 创建命令,并在需要时将其与接收者关联。
创建发送者,并将其与特定命令关联
优点和缺点
- ✔️单一职责原则。可以将调用操作的类与执行这些操作的类解耦。
- ✔️开闭原则。可以在不破坏现有客户端代码的情况下将新命令引入应用程序。
- ✔️可以实现 undo/redo 操作。
- ✔️可以实现操作的延迟执行。
- ✔️可以将一组简单命令组合到一个复杂的命令。
- ❌由于在发送者和接收者之间引入了一个全新的层,代码可能会变得更加复杂。
与其他模式的联系
- 责任链模式、命令模式、中介者模式和观察者模式解决了连接请求发送者和接收者的各种方式:
- 责任链模式(Chain of Responsibility)沿着潜在接收者的动态链顺序传递请求,直至其中一个接收者处理它。
- 命令模式(Command)在发送者和接收者之间建立单向连接。
- 中介者模式(Mediator)消除了发送者和接收者之间的直接连接,迫使它们通过中介对象进行间接通信。
- 观察者模式(Observer)允许接收机动态订阅,取消订阅解释请求。
- 责任链模式下的 Handler 可以实现为命令。这种情况下,你可以在请求表示的同一个上下文对象中执行许多不同的操作。
不过,还有另一种方法,即请求本身是一个 Command 对象。在这种情况下,你可以在一系列连接到链的不同上下文中执行相同的操作。
- 实现“撤消(undo)”时,可以同时使用 Command 和 Memento 模式。在这种情况下,命令负责对目标对象执行各种操作,而 memento 则在命令执行之前保存该对象的状态。
- 命令模式和策略模式(Strategy)可能看起来很相似,因为你可以使用两者参数化带有一些操作的对象。然而,它们有着截然不同的意图。
- 你可以使用命令模式将任何操作转换成一个对象。操作的参数称为那个对象的字段。这样的转换允许你将操作的执行延迟、加入队列、存储到命令历史中、发送给远程服务等。
- 另一方面,策略模式描述了作同一件事情的不同方法,使你在单个上下文类中切换这些算法。
- 原型模式(Prototype)可以助你将 Command 的副本保存到历史中。
可以将访问者模式(Visitor)视为命令模式的增强版本。它的对象可以对不同类的各种对象执行操作。
代码示例
<?php
namespace RefactoringGuru\Command\Conceptual;
/**
* The Command interface declares a method for executing a command.
*/
interface Command
{
public function execute(): void;
}
/**
* Some commands can implement simple operations on their own.
*/
class SimpleCommand implements Command
{
private $payload;
public function __construct(string $payload)
{
$this->payload = $payload;
}
public function execute(): void
{
echo "SimpleCommand: See, I can do simple things like printing (" . $this->payload . ")\n";
}
}
/**
* However, some commands can delegate more complex operations to other objects,
* called "receivers."
*/
class ComplexCommand implements Command
{
/**
* @var Receiver
*/
private $receiver;
/**
* Context data, required for launching the receiver's methods.
*/
private $a;
private $b;
/**
* Complex commands can accept one or several receiver objects along with
* any context data via the constructor.
*/
public function __construct(Receiver $receiver, string $a, string $b)
{
$this->receiver = $receiver;
$this->a = $a;
$this->b = $b;
}
/**
* Commands can delegate to any methods of a receiver.
*/
public function execute(): void
{
echo "ComplexCommand: Complex stuff should be done by a receiver object.\n";
$this->receiver->doSomething($this->a);
$this->receiver->doSomethingElse($this->b);
}
}
/**
* The Receiver classes contain some important business logic. They know how to
* perform all kinds of operations, associated with carrying out a request. In
* fact, any class may serve as a Receiver.
*/
class Receiver
{
public function doSomething(string $a): void
{
echo "Receiver: Working on (" . $a . ".)\n";
}
public function doSomethingElse(string $b): void
{
echo "Receiver: Also working on (" . $b . ".)\n";
}
}
/**
* The Invoker is associated with one or several commands. It sends a request to
* the command.
*/
class Invoker
{
/**
* @var Command
*/
private $onStart;
/**
* @var Command
*/
private $onFinish;
/**
* Initialize commands.
*/
public function setOnStart(Command $command): void
{
$this->onStart = $command;
}
public function setOnFinish(Command $command): void
{
$this->onFinish = $command;
}
/**
* The Invoker does not depend on concrete command or receiver classes. The
* Invoker passes a request to a receiver indirectly, by executing a
* command.
*/
public function doSomethingImportant(): void
{
echo "Invoker: Does anybody want something done before I begin?\n";
if ($this->onStart instanceof Command) {
$this->onStart->execute();
}
echo "Invoker: ...doing something really important...\n";
echo "Invoker: Does anybody want something done after I finish?\n";
if ($this->onFinish instanceof Command) {
$this->onFinish->execute();
}
}
}
/**
* The client code can parameterize an invoker with any commands.
*/
$invoker = new Invoker();
$invoker->setOnStart(new SimpleCommand("Say Hi!"));
$receiver = new Receiver();
$invoker->setOnFinish(new ComplexCommand($receiver, "Send email", "Save report"));
$invoker->doSomethingImportant();
Output.txt: 执行结果
Invoker: Does anybody want something done before I begin?
SimpleCommand: See, I can do simple things like printing (Say Hi!)
Invoker: ...doing something really important...
Invoker: Does anybody want something done after I finish?
ComplexCommand: Complex stuff should be done by a receiver object.
Receiver: Working on (Send email.)
Receiver: Also working on (Save report.)