设计模式修炼之路 ⑱ 命令模式——把请求封装成对象
把请求封装成对象、增量式撤销、白送的排队与重放、Executor 的铁证
目录
行为型第五站。
上一篇结尾说:责任链传的"请求"始终只是个参数,命令模式更进一步——把请求本身变成对象。这一篇兑现承诺。
场景就从最日常的编辑器讲起。你写文档时手一抖删了一段,按 Ctrl+Z,内容回来了;再按 Ctrl+Y,又删掉了。撤销、重做、任务队列、宏录制……这些功能的第一块基石,都是命令模式。
一、命令模式是什么
GoF 的原始定义:
Encapsulate a request as an object, thereby letting you parameterize clients with different requests, queue or log requests, and support undoable operations. (将一个请求封装成一个对象,从而使你可以用不同的请求对客户进行参数化;可以对请求排队、记录日志,以及支持可撤销的操作。)
翻译成人话:**把"要干的事"打包成对象。**调用方手里拿的不再是"干这件事"的过程调用,而是"干这件事"这个对象本身——而对象能存储、能传递、能排队、能记账、能反悔。
四个角色:
| 角色 | 干什么 | 本文对应 |
|---|---|---|
| Command(抽象命令) | 定义执行与撤销的统一接口 | Command |
| ConcreteCommand(具体命令) | 绑定接收者,实现"怎么执行、怎么撤销" | InsertCommand / DeleteCommand |
| Receiver(接收者) | 真正干活的对象 | TextEditor |
| Invoker(调用者) | 持有命令、负责执行、记录历史 | CommandHistory |
最好的类比是餐厅点单:你(客户端)不冲进后厨喊"来份宫保鸡丁"(直接方法调用),而是把需求写在订单上(命令对象)交给服务员(调用者)。服务员拿着订单能干的事就多了:立刻递后厨(执行)、压单等位(排队)、你说错了撤单(撤销)、月底翻存根对账(日志)。订单本身不会炒菜——真正炒菜的是后厨(接收者),订单只是把"要干什么"固化成了可搬运的纸片。
二、优点与缺点
优点:
- 请求与执行解耦。调用者只认识 Command 接口,根本不知道接收者是谁、操作怎么落地——命令对象把这一切打包带走了。
- 撤销是白送的。命令知道自己"干了什么",就有能力描述"怎么反着干"。调用者只管把命令压进历史栈,撤销时弹出来调 undo 即可。
- 排队、延迟、重试都是白送的。命令是对象,对象就能进队列。进队列之后什么时候执行、在哪个线程执行、失败了几时重试,全是调度层的自由。
- 可组合。一组命令可以拼成一个宏命令(组合模式 ⑨ 的老朋友),一键回放。
缺点:
- 类数量膨胀。每个操作一个命令类,操作多了类也多——Java 8 之前写起来相当啰嗦(lambda 缓解了大半,见第五节)。
- 间接层带来的理解成本。看代码时找不到直接调用,执行逻辑藏在命令对象的 execute 里,追起来要绕一层。
- 撤销的实现并不总是轻巧。多数操作可以定义逆操作,但"发邮件"这种不可逆操作,撤销只能语义补偿(再发一封道歉邮件?),设计时要掂量。
三、什么时候用
判断标准:"请求"需要在时间或空间上离开"发起者"——要排队、要延迟执行、要事后撤销、要记录重放,或者要跨线程跨进程搬运。
- 撤销/重做:编辑器、绘图工具、数据库事务——本文场景
- 任务队列:线程池、消息队列、定时任务,队列里排的一个个任务全是命令对象
- 操作日志与恢复:把命令序列化进日志,宕机后重放恢复现场
- 宏录制:把用户一连串操作录成命令列表,一键回放
- 跨进程分发:MQ 的消息体,本质是"生产者想让消费者干的活"的对象化
反面对照:请求发起后立刻执行、执行完就完、不需要历史——直接调用方法就好,别为了模式而模式。
四、实现方式:增量式撤销
4.1 反面教材:快照式撤销
撤销最容易想到的做法:每干一步,存一份全文快照,撤销就回滚到上一份。
public class BadEditor {
private String text = "";
// 为了撤销,只能每一步都存一份全文快照
private final Deque<String> snapshots = new ArrayDeque<>();
public void insert(String s) {
snapshots.push(text);
text = text + s;
System.out.println(" [编辑器] 插入 \"" + s + "\",当前:\"" + text + "\"");
}
public void delete(int count) {
snapshots.push(text);
text = text.substring(0, text.length() - count);
System.out.println(" [编辑器] 删除 " + count + " 个字符,当前:\"" + text + "\"");
}
// 撤销 = 回滚到上一份快照
// 文本是 String 还好;如果编辑的是 10MB 的文档对象,每一步存一份快照,内存直接爆炸
// 而且想加"重做"、"宏录制"?全部要在这个类里继续堆代码
public void undo() {
if (snapshots.isEmpty()) {
System.out.println(" [编辑器] 没有可撤销的操作");
return;
}
text = snapshots.pop();
System.out.println(" [编辑器] 撤销,当前:\"" + text + "\"");
}
}
看着能跑,但病灶清晰:**撤销的单位是"整个文档",而不是"那一步操作"。**String 倒还扛得住,换成 10MB 的富文档对象,每敲一个字存一份快照,内存曲线直接起飞。更糟的是"撤销"这个能力被焊死在编辑器内部——想加重做、宏录制、操作日志,全得往这一个类里继续堆。
4.2 正解:每一步操作变成一个命令对象
第一步,命令接口——除了执行,撤销是命令模式的招牌能力:
public interface Command {
void execute();
void undo();
}
第二步,接收者。一个最朴素的文本编辑器:
public class TextEditor {
private String text = "";
public void insert(String s) { text = text + s; }
public void delete(int count) { text = text.substring(0, text.length() - count); }
public String getText() { return text; }
}
第三步,具体命令。插入命令的执行和撤销是一对完美互逆:
public class InsertCommand implements Command {
private final TextEditor editor;
private final String s;
public InsertCommand(TextEditor editor, String s) {
this.editor = editor;
this.s = s;
}
@Override
public void execute() {
editor.insert(s);
System.out.println(" [插入] \"" + s + "\",当前:\"" + editor.getText() + "\"");
}
@Override
public void undo() {
editor.delete(s.length());
System.out.println(" [撤销插入] \"" + s + "\" 被移除,当前:\"" + editor.getText() + "\"");
}
}
删除命令多一个心眼——执行时把删掉的内容记下来,不然撤销时拿什么还原?
public class DeleteCommand implements Command {
private final TextEditor editor;
private final int count;
private String deleted; // 执行时记下被删的内容,撤销时才能还原
public DeleteCommand(TextEditor editor, int count) {
this.editor = editor;
this.count = count;
}
@Override
public void execute() {
String current = editor.getText();
deleted = current.substring(current.length() - count);
editor.delete(count);
System.out.println(" [删除] 删掉 \"" + deleted + "\",当前:\"" + editor.getText() + "\"");
}
@Override
public void undo() {
editor.insert(deleted);
System.out.println(" [撤销删除] \"" + deleted + "\" 回来了,当前:\"" + editor.getText() + "\"");
}
}
第四步,调用者。它只认识 Command 接口,干的就三件事:执行、记账、反悔:
public class CommandHistory {
private final Deque<Command> history = new ArrayDeque<>();
public void execute(Command command) {
command.execute();
history.push(command);
}
public void undo() {
if (history.isEmpty()) {
System.out.println(" [历史] 没有可撤销的操作");
return;
}
history.pop().undo();
}
}
最后串起来,四条命令压栈,再连按五次撤销:
public class CommandTest {
public static void main(String[] args) {
System.out.println("========== 反面教材:快照式撤销 ==========");
BadEditor bad = new BadEditor();
bad.insert("设计模式");
bad.delete(4);
bad.insert("修炼之路");
bad.undo();
bad.undo();
bad.undo();
bad.undo();
System.out.println();
System.out.println("========== 正解:命令模式 ==========");
TextEditor editor = new TextEditor();
CommandHistory history = new CommandHistory();
history.execute(new InsertCommand(editor, "设计"));
history.execute(new InsertCommand(editor, "模式"));
history.execute(new DeleteCommand(editor, 2));
history.execute(new InsertCommand(editor, "修炼"));
System.out.println(" 当前文本:\"" + editor.getText() + "\"");
System.out.println();
System.out.println("-- 连按五次撤销,按执行逆序回放 --");
history.undo();
history.undo();
history.undo();
history.undo();
history.undo();
System.out.println(" 当前文本:\"" + editor.getText() + "\"");
}
}
运行输出:
========== 反面教材:快照式撤销 ==========
[编辑器] 插入 "设计模式",当前:"设计模式"
[编辑器] 删除 4 个字符,当前:""
[编辑器] 插入 "修炼之路",当前:"修炼之路"
[编辑器] 撤销,当前:""
[编辑器] 撤销,当前:"设计模式"
[编辑器] 撤销,当前:""
[编辑器] 没有可撤销的操作
========== 正解:命令模式 ==========
[插入] "设计",当前:"设计"
[插入] "模式",当前:"设计模式"
[删除] 删掉 "模式",当前:"设计"
[插入] "修炼",当前:"设计修炼"
当前文本:"设计修炼"
-- 连按五次撤销,按执行逆序回放 --
[撤销插入] "修炼" 被移除,当前:"设计"
[撤销删除] "模式" 回来了,当前:"设计模式"
[撤销插入] "模式" 被移除,当前:"设计"
[撤销插入] "设计" 被移除,当前:""
[历史] 没有可撤销的操作
当前文本:""
4.3 三处细节咂摸一下
**细节一:撤销的本质是"逆操作",不是"回滚"。**反面教材的撤销单位是整个文档(快照),命令模式的撤销单位是那一步操作本身(增量)。撤销一条插入只需删掉那几个字,撤销一条删除只需补回那几个字——文档 10MB 也好 100MB 也好,每条命令只携带自己的增量。Ctrl+Z 能做到瞬响应,靠的就是这个。
**细节二:命令对象是有状态的。**DeleteCommand 的 deleted 字段在执行那一刻才被填上——同一个命令对象,执行前和执行后携带的信息不一样。这一点和策略模式形成了鲜明对比(策略通常无状态),第六节展开。
**细节三:CommandHistory 完全不知道 TextEditor 的存在。**它只认识 Command 接口,账本里存的也全是接口引用。这意味着"历史记录"这个能力可以原封不动地搬到任何命令体系上:绘图软件的撤销栈、数据库的事务日志、游戏录像的回放器——换个命令实现,调用者一行不改。
4.4 白送的能力
把请求变成对象之后,很多东西是"白送"的——调用者已经持有命令对象了,剩下的只是用法问题:
- 重做:undo 时别扔,压进另一个 redo 栈;Ctrl+Z 撤掉的 Ctrl+Y 再执行回来。
- 宏录制:
List<Command>就是一个宏。再套一层 CompositeCommand(内部持有一组命令,execute 遍历执行、undo 逆序回滚)——组合模式 ⑨ 的经典复用。 - 操作日志:execute 时把命令序列化进日志,系统宕机后按序重放,现场自动恢复——数据库 WAL、Redis AOF 的思路同源。
- 排队与调度:命令对象扔进 BlockingQueue,什么时候执行、在哪个线程执行、失败了隔多久重试,全是调度层说了算——线程池干的就是这个买卖,第五节看实物。
五、在框架里找它
命令模式在 JDK 里有一个官方盖章的铁证——线程池的执行方法签名:
public interface Executor {
void execute(Runnable command);
}
参数名就叫 command,JDK 自己承认了。Runnable 是命令接口,你的任务逻辑是命令对象,ThreadPoolExecutor 是调用者:它把命令对象排进队列(排队)、延迟调度(延迟执行)、必要时拒绝或重试。"把任务扔进线程池"这个每天写的动作,本质上和"把订单交给服务员"一模一样。
而且 JDK 在这里把命令接口的演化史也一并演示了。Java 8 之前提交一个任务得写匿名内部类,命令模式的"类爆炸"缺点被诟病多年;lambda 之后:
executor.submit(() -> orderService.close(orderId));
一行完事。函数式接口让命令模式从"重武器"退化成了日常空气——你天天在用,只是没意识到。
其他几处:
**Swing Action。**按钮和菜单项共用同一个 Action,点谁都是执行同一份命令——"工具栏的保存按钮"和"Ctrl+S"背后是同一个对象,启用禁用状态还能联动。GUI 领域命令模式的老前辈。
**Netty EventLoop。**每个 EventLoop 一条线程一个任务队列,普通任务和定时任务都是排队的命令对象——Netty 的定时任务能力就是"命令 + 优先级队列"的组合。
**MQ 消息。**生产者把"希望消费者干什么"序列化成消息体扔进队列,消费者取出来反序列化执行。这是命令模式的跨进程放大版:发起者和执行者不仅解耦,连进程、机器、时区都解耦了。事件驱动架构的底牌之一。
一句话总结这一节:凡是"请求要离开发起者独立旅行"的地方,命令模式都在场。
六、命令 vs 策略:结构上的孪生兄弟
写到这你可能后背发凉:接口 + 一堆实现类 + 一个持有者——这不就是 ⑮ 策略模式吗?长得确实几乎一样,区别全在灵魂上:
| 策略模式 ⑮ | 命令模式 ⑱ | |
|---|---|---|
| 打包的是什么 | 一种**算法**(怎么做) | 一次**请求**(做什么) |
| 生命周期 | 用完即弃,不关心历史 | 可存储、可排队、可撤销、可重放 |
| 有状态吗 | 通常无状态 | 通常有状态(执行时记增量,撤销时要还) |
| 持有者持几个 | Context 持**一个**,随时可换 | Invoker 持**一摞**(历史、队列) |
| 典型场景 | 促销算法、支付渠道 | 撤销栈、任务队列 |
分辨口诀:**策略回答"这事有几种做法",命令回答"这事能不能存起来反悔"。**同样是接口加实现类,策略是给调用方换算法的插槽,命令是给系统留的记账凭证。
写在最后
命令模式一句话总结:把请求封装成对象,执行与发起就此分离;对象能存能传能排队,撤销、重做、日志、重放全是白送。
下一篇 ⑲ 状态模式——换个场景,电商订单。"待支付"的订单点取消,直接关单退款都不用;"已发货"的订单点取消,只能走退货流程;同一个按钮,状态不同行为完全不同。如果用 if-else 判断状态,状态一多每个方法都是灾难现场。把每个状态做成类,状态一换行为整套跟着换——状态模式,下一篇见。