命令模式:请求封装、撤销与重做
说实话,命令模式是我在实际项目中用得比较多的一个模式。为什么?因为它解决了一个很实在的问题——如何把「请求」变成「对象」。
你想想看,在传统的调用方式里,我们直接调用某个方法,比如 button.click()。这没问题,但一旦你需要记录操作、撤销操作、或者把操作排队执行,直接调用就行不通了。这时候,命令模式就派上用场了。
什么是命令模式?
命令模式的核心思想很简单:把每一个操作封装成一个对象。这个对象包含了执行操作所需的所有信息——调用哪个对象、调用什么方法、传什么参数。
我习惯把它理解成「外卖订单」。你打电话给餐厅点餐,服务员把订单写下来,然后交给厨师。这个订单就是命令对象,它把「你要吃什么」这个请求封装起来了。厨师不需要知道你是谁,只需要按照订单做菜就行。
定义:将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化、对请求排队或记录请求日志,以及支持可撤销的操作。
模式结构
命令模式涉及四个角色:
- Command(抽象命令):声明执行操作的接口
- ConcreteCommand(具体命令):实现抽象命令,持有接收者对象
- Invoker(调用者):要求命令对象执行请求
- Receiver(接收者):真正执行操作的对象
代码实现
我们用一个文本编辑器的撤销/重做功能来演示。这个例子我在项目中做过类似的,嗯,当时是做的一个在线文档编辑器。
// 1. 接收者 - 文本编辑器
class TextEditor {
private StringBuilder content = new StringBuilder();
public void append(String text) {
content.append(text);
System.out.println("当前内容: " + content.toString());
}
public void delete(int length) {
if (length > content.length()) {
length = content.length();
}
content.delete(content.length() - length, content.length());
System.out.println("当前内容: " + content.toString());
}
public String getContent() {
return content.toString();
}
}
// 2. 抽象命令
interface Command {
void execute();
void undo();
}
// 3. 具体命令 - 追加文本
class AppendCommand implements Command {
private TextEditor editor;
private String text;
public AppendCommand(TextEditor editor, String text) {
this.editor = editor;
this.text = text;
}
@Override
public void execute() {
editor.append(text);
}
@Override
public void undo() {
editor.delete(text.length());
}
}
// 4. 调用者 - 命令管理器(支持撤销和重做)
class CommandManager {
private Stack<Command> undoStack = new Stack<>();
private Stack<Command> redoStack = new Stack<>();
public void executeCommand(Command cmd) {
cmd.execute();
undoStack.push(cmd);
redoStack.clear(); // 执行新命令后,清空重做栈
}
public void undo() {
if (!undoStack.isEmpty()) {
Command cmd = undoStack.pop();
cmd.undo();
redoStack.push(cmd);
} else {
System.out.println("没有可撤销的操作");
}
}
public void redo() {
if (!redoStack.isEmpty()) {
Command cmd = redoStack.pop();
cmd.execute();
undoStack.push(cmd);
} else {
System.out.println("没有可重做的操作");
}
}
}
// 5. 客户端使用
public class Client {
public static void main(String[] args) {
TextEditor editor = new TextEditor();
CommandManager manager = new CommandManager();
// 执行一系列操作
manager.executeCommand(new AppendCommand(editor, "Hello "));
manager.executeCommand(new AppendCommand(editor, "World!"));
manager.executeCommand(new AppendCommand(editor, " 你好"));
System.out.println("--- 撤销两次 ---");
manager.undo();
manager.undo();
System.out.println("--- 重做一次 ---");
manager.redo();
}
}
运行结果:
当前内容: Hello
当前内容: Hello World!
当前内容: Hello World! 你好
--- 撤销两次 ---
当前内容: Hello World!
当前内容: Hello
--- 重做一次 ---
当前内容: Hello World!
命令模式的优点
| 优点 | 说明 |
|---|---|
| 解耦调用者和接收者 | 调用者不需要知道谁在执行操作 |
| 支持撤销/重做 | 通过保存命令历史实现 |
| 支持命令组合 | 宏命令可以组合多个子命令 |
| 支持延迟执行 | 命令可以排队、调度、异步执行 |
实际应用场景
我在项目中遇到过几个典型的应用场景:
- GUI按钮和菜单:每个按钮对应一个命令对象,点击时执行
- 事务操作:数据库操作可以封装成命令,支持回滚
- 任务队列:把命令放入队列,由工作线程依次执行
- 日志记录:记录所有操作命令,用于审计或恢复
我的习惯:在设计命令模式时,我会把命令对象设计成可序列化的。这样可以把命令持久化到磁盘,系统重启后还能恢复操作历史。这在做金融交易系统时特别有用。
命令模式的变体
除了基本的撤销/重做,命令模式还有一些实用的变体:
- 宏命令:一个命令包含多个子命令,执行时依次调用
- 延迟命令:命令创建后不立即执行,而是等待某个条件触发
- 事务命令:一组命令要么全部成功,要么全部回滚
我曾经踩过的坑:有一次我设计命令模式时,把接收者的状态也保存在命令对象里。结果命令对象变得非常庞大,内存消耗严重。后来我改成只保存操作参数,接收者的状态由接收者自己管理。记住:命令对象应该轻量,只保存「怎么做」的信息,不保存「做之前的状态」。
与策略模式的区别
很多人容易把命令模式和策略模式搞混。我简单说一下区别:
- 命令模式:关注的是「把操作封装成对象」,重点是请求的封装、排队、撤销
- 策略模式:关注的是「算法的封装和替换」,重点是不同算法的切换
说白了,命令模式是对「动作」的封装,策略模式是对「算法」的封装。一个命令通常只做一件事,而策略可以包含复杂的业务逻辑。
总结
命令模式是我个人非常喜欢的一个模式。它把「请求」这个抽象概念变成了实实在在的对象,让我们可以对请求进行各种操作——记录、排队、撤销、重做。
在实际项目中,我建议你从最简单的场景开始用。比如先实现一个撤销功能,等需要重做时再扩展。不要一开始就把命令模式设计得太复杂,否则容易过度设计。
记住:命令模式的核心价值在于「请求的封装」。当你需要把操作当作数据来处理时,就该考虑它了。
公众号:蓝海资料掘金营,微信deep3321