最近帮同事收拾一个老项目的烂摊子,核心问题就一句话:按钮的点击事件里写了太多不该它管的事。界面上七八个按钮,每个按钮的点击回调都把校验、数据组装、持久化、UI刷新全揉在一起。产品提了个很常见的要求——给某个操作加撤销功能,结果一排查,根本无从下手,因为操作不是一个独立的东西,它只是回调函数里的一串代码。
这种情景就是典型的“请求的发送者和接收者耦合太紧”。而解决这个问题最经典、也最实用的招,就是命令模式。说白了,命令模式就是给每个操作造一个对象,让这个对象自己知道怎么执行、怎么撤销。按钮只管按下去,具体发生了什么、该怎么补偿,全交给命令自己处理。这就像给操作装了个“后悔药”,撤回不再是改代码,而是把之前的命令对象拿出来反向执行一遍。
这篇文章我就带你从头到尾把这个模式讲透:它到底在解决什么问题,核心的三个角色各自干嘛,怎么用双栈实现撤销和重做,以及我在真实项目里踩过的坑和积累下来的排查习惯。全程用能直接“抄作业”的代码示例,不整虚的。
1. 命令模式到底在解决什么问题
1.1 三个角色,一台戏
先抛开术语,用生活里的场景帮助理解。你去餐厅吃饭,流程是这样的:你对着菜单点菜,服务员把订单传给后厨,厨师按照订单做菜。这里面有三个角色:
- 菜单上的菜品描述,就是“命令”,它把“顾客想要什么”变成了一张可传递的纸;
- 服务员,就是“调用者”,他只负责接收订单、传给后厨,不关心菜怎么做;
- 厨师,就是“接收者”,真正干活的人,知道怎么把菜做出来。
把这三个角色对应到代码里,就是命令模式的核心结构:
- Command(抽象命令):定义执行动作的接口,通常包含 execute() 和 undo() 两个方法;
- ConcreteCommand(具体命令):实现 Command 接口,内部持有接收者对象,并实现具体的执行和撤销逻辑;
- Receiver(接收者):真正执行业务逻辑的对象,比如文档、数据库、业务服务;
- Invoker(调用者):持有命令对象并触发执行,比如按钮、菜单项、工具栏按钮,它完全不关心命令内部做了什么。
这里最关键的一点:调用者不直接调用接收者的方法,而是调用命令对象的 execute()。命令对象就像一个中间信封,把“某个时刻对某个对象做某个操作”这件事完整地封装起来了。
1.2 设计意图:把“请求”变成“头等公民”
为什么非得把请求封装成对象?因为只有把请求变成对象,你才能对这个请求做各种各样的事情:存起来、排队、撤销、记录下来、组合起来、甚至通过网络传到别处执行。如果不封装,请求就是代码里的一串函数调用,你拿它没办法。
打个比方,函数调用就像你当面让人帮忙做事,做完了就完了,没有凭据。命令对象则像一张盖了章的工单,上面写清楚谁来做、做什么、怎么做。有了这张工单,你可以在它执行前审查,执行后归档,甚至后悔了可以照着工单反着做一遍,把事儿撤回。
撤销功能的实现逻辑就是这样来的:每次执行一个命令,就把这个命令对象压入“撤销栈”;需要撤销时,从栈里弹出最近一次的命令对象,调用它的 undo() 方法。所以,命令模式的核心价值可以概括成四个字:解耦和可追踪。
2. 按钮代码是怎么“管太宽”的:方案选型要看清需求
2.1 没有命令模式的按钮是什么样的
我在实际项目里见过无数种“按钮管太宽”的写法,简化一下大概是这个画风:
public void onSaveButtonClick() { // 1. 表单校验 if (!validateForm()) { showError("表单不完整"); return; } // 2. 组装数据 SaveRequest request = buildRequest(); // 3. 调用业务层 SaveResult result = saveService.save(request); // 4. 写日志 logger.info("保存成功: {}", result.getId()); // 5. 刷新界面 refreshPage(); // 6. 发送通知 notifyOtherModule(); }这段代码刚写完时非常爽,需求全都满足了。可问题在于:这个按钮的回调函数成了“上帝函数”,它什么都干,而且这些逻辑永远无法被复用、被替换、被撤销。
两周后产品来了个需求:“保存按钮误点了要能撤销”。你怎么做?你没法做。因为保存这个动作不是一个可逆的对象,它是函数里六个步骤的“合体”,没有一个东西能代表“保存操作”本身。你想撤销,只能重新写一遍“反向保存”的逻辑,还得小心翼翼地处理各种状态。
这就是“按钮管得太宽”的代价:按钮本来只应该负责“被点击后触发点什么”,结果它连业务逻辑、校验规则、数据组装全包圆了。
2.2 到底什么时候该用命令模式
命令模式不是银弹,别所有地方都上。我一般用这个判断清单来决定是否值得引入:
| 判断问题 | 如果答案是“是” |
|---|---|
| 这个操作需要支持撤销/重做吗? | 强烈建议用命令模式 |
| 这个操作需要记录日志、回放、审计吗? | 强烈建议用命令模式 |
| 这个操作需要进入队列、按顺序调度吗? | 建议用命令模式 |
| 多个操作需要组合成一个“宏操作”吗? | 建议用命令模式 |
| 调用方和执行方需要完全解耦吗?例如按钮和业务模块分属不同团队 | 建议用命令模式 |
| 只是一个简单的“点击就执行”的同步调用,没有后续需求? | 不需要,直接写回调就行 |
回到标题那句话——“别让你的按钮管得太宽”。按钮类、菜单项这类 UI 组件,本质上是“触发者”,不是“执行者”。如果业务逻辑注定要变、要撤销、要组合,那按钮就不应该持有具体业务对象,它应该只持有一个命令对象。按钮点击时调用 command.execute(),命令内部再去跟接收者打交道。
2.3 别把命令模式用错地方
命令模式也有被滥用的时候。我见过有人把所有 Service 方法都包一层 Command,结果代码里到处是 Command、CommandFactory、CommandManager,调试的时候跳来跳去,把简单的项目弄得极其复杂。
这里要泼一盆冷水:如果你的需求只是“点按钮调一个接口,不需要撤销、不需要排队、不需要回放”,那就老老实实直接调用。命令模式的本质是增加一层间接性,带来灵活性的同时也带来了额外的类和跳转,收益小于成本的时候,就别硬套。好的架构不是为了炫技,是为了让改代码的人不骂娘。
3. 实操实录:给文本编辑器装上撤销和重做
3.1 定义命令接口和接收者
这个场景我会用一个最经典的例子:做一个极简文本编辑器,支持插入文本、删除文本,并且要能撤销、重做。先定义一个命令接口:
// 命令接口:所有操作都要实现执行和撤销 public interface Command { void execute(); void undo(); }接着定义一个接收者:文档对象。它负责管理文本内容,并且暴露插入和删除的方法:
public class Document { private StringBuilder content = new StringBuilder(); public Document(String initialContent) { content.append(initialContent); } public void insert(int position, String text) { if (position < 0 || position > content.length()) { throw new IllegalArgumentException("插入位置越界"); } content.insert(position, text); } public void delete(int start, int end) { if (start < 0 || end > content.length() || start >= end) { throw new IllegalArgumentException("删除区间不合法"); } content.delete(start, end); } public String getContent() { return content.toString(); } }这个 Document 就是真正的“干活的厨师”,它只有最基础的能力:插入、删除。至于什么时候插入、插入什么内容、插错了怎么撤回,这些都不是 Document 关心的。
3.2 实现具体命令
现在来实现两个具体命令:插入命令和删除命令。这是命令模式最见功力的时候——execute() 和 undo() 最好是对称的。
插入命令:
public class InsertTextCommand implements Command { private Document document; private int position; private String text; public InsertTextCommand(Document document, int position, String text) { this.document = document; this.position = position; this.text = text; } @Override public void execute() { document.insert(position, text); } @Override public void undo() { // 插入的逆向操作:把插入的文本删掉 document.delete(position, position + text.length()); } }删除命令会稍微复杂一点,因为删除之前要先把被删掉的内容备份下来,否则撤销时不知道该恢复什么:
public class DeleteTextCommand implements Command { private Document document; private int start; private int end; private String deletedText; public DeleteTextCommand(Document document, int start, int end) { this.document = document; this.start = start; this.end = end; } @Override public void execute() { // 执行前先记录被删除的内容,这是撤销的关键 deletedText = document.getContent().substring(start, end); document.delete(start, end); } @Override public void undo() { // 删除的逆向操作:把删掉的内容放回原位 document.insert(start, deletedText); } }这里有一个特别重要的细节我要多说一句:删除命令在 execute() 之前必须把被删掉的文本保存下来。这是命令模式里“状态快照”的思想雏形——允许恢复的一个前提是,你得留得住历史。
3.3 调用者和撤销管理器
有了命令,接下来需要一个“按钮”来承接用户的点击。按钮类非常简单,只认识 Command 接口:
public class Button { private String name; private Command command; public Button(String name) { this.name = name; } public void setCommand(Command command) { this.command = command; } public void onClick() { if (command != null) { command.execute(); } } public String getName() { return name; } }但光有按钮还不够,撤销和重做需要一套“栈”机制来管理命令的来龙去脉。这里就是最有实操价值的双栈结构:
import java.util.ArrayDeque; import java.util.Deque; public class CommandHistory { private Deque<Command> undoStack = new ArrayDeque<>(); private Deque<Command> redoStack = new ArrayDeque<>(); // 执行一条命令,同时清空重做栈 public void execute(Command command) { command.execute(); undoStack.push(command); redoStack.clear(); } public void undo() { if (undoStack.isEmpty()) { return; } Command command = undoStack.pop(); command.undo(); redoStack.push(command); } public void redo() { if (redoStack.isEmpty()) { return; } Command command = redoStack.pop(); command.execute(); undoStack.push(command); } public boolean canUndo() { return !undoStack.isEmpty(); } public boolean canRedo() { return !redoStack.isEmpty(); } }把整个过程串起来跑一遍:
public class EditorDemo { public static void main(String[] args) { Document doc = new Document("Hello World"); CommandHistory history = new CommandHistory(); Button insertButton = new Button("插入按钮"); Button deleteButton = new Button("删除按钮"); // 在位置5处插入一个逗号 InsertTextCommand insertCmd = new InsertTextCommand(doc, 5, ","); insertButton.setCommand(insertCmd); insertButton.onClick(); System.out.println(doc.getContent()); // Hello, World // 删除位置5到6的内容 DeleteTextCommand deleteCmd = new DeleteTextCommand(doc, 5, 6); deleteButton.setCommand(deleteCmd); deleteButton.onClick(); System.out.println(doc.getContent()); // Hello World // 撤销删除 -> Hello, World history.execute(deleteCmd); // 等一下,这里重复执行了! } }注意上面这个示例我故意写了个经常犯的错误:history.execute(deleteCmd)会再次执行 deleteCmd,然后再把删除命令压栈。在实际项目里,正确的流程应该是:
// 正确的用法:所有命令都通过 history 统一执行,不能直接调 onClick 后又调 history.execute history.execute(new InsertTextCommand(doc, 5, ",")); history.execute(new DeleteTextCommand(doc, 5, 6)); System.out.println(doc.getContent()); // Hello World history.undo(); // 撤销删除 -> Hello, World history.undo(); // 撤销插入 -> Hello World history.redo(); // 重做插入 -> Hello, World这里有个特别容易踩的坑:按钮的 onClick 里如果直接调用了 command.execute(),那么跟 CommandHistory 就两套执行入口了,命令到底要不要入栈、何时入栈,很容易失控。我建议在项目里定死一个规矩:所有命令的执行统一走 CommandHistory.execute(),按钮只是触发 history.execute(某命令),不直接调命令的 execute()。这样撤销栈才能始终和用户的实际操作保持一致。
3.4 新需求加个“插入签名”按钮,代码要怎么动
命令模式的好处要在需求变化时才能充分体会。假设产品过来说:“我要加一个按钮,点击后自动在文档末尾追加一行签名:--- written by admin”。
没有命令模式时,你要打开按钮点击事件,再加一段业务代码。有了命令模式之后,逻辑完全变了:
public class InsertSignatureCommand implements Command { private Document document; public InsertSignatureCommand(Document document) { this.document = document; } @Override public void execute() { int position = document.getContent().length(); document.insert(position, "\n--- written by admin"); } @Override public void undo() { int position = document.getContent().indexOf("\n--- written by admin"); if (position >= 0) { document.delete(position, position + "\n--- written by admin".length()); } } }然后只需要:
Button signatureButton = new Button("插入签名"); signatureButton.setCommand(new InsertSignatureCommand(doc));按钮的实现、命令历史的实现、接收者的实现,全都不用改。这就是开闭原则的直观体验:新增操作,不是去修改现有代码,而是新增一个命令类,然后注入到调用者里。对已有代码的破坏降到了最低,测试范围也很好圈定。
4. 从编辑器到业务系统:命令模式的进阶用法
4.1 宏命令与组合命令
按钮功能越来越多,用户开始提“一键排版”这种需求:先删除多余空格,再设置缩进,然后加粗标题。如果每个动作都是一个命令,那“一键排版”就是一组命令的组合。宏命令应运而生:
import java.util.ArrayList; import java.util.List; public class MacroCommand implements Command { private List<Command> commands = new ArrayList<>(); public void addCommand(Command command) { commands.add(command); } @Override public void execute() { for (Command command : commands) { command.execute(); } } @Override public void undo() { // 撤销时务必要逆序执行! for (int i = commands.size() - 1; i >= 0; i--) { commands.get(i).undo(); } } }组合命令有一个特别容易忽略的细节:撤销的顺序必须和执行顺序相反。比如先执行 A 再执行 B,撤销时就得先撤 B 再撤 A。如果顺序反了,结果几乎必错。这个“栈式回溯”的思路在事务系统里到处都能见到。
4.2 命令日志与“回放”机制
命令对象既然能被存起来,自然也能被写进日志。把一个系统的操作记录成一条条命令,将来可以被回放。举个最典型的场景:某资产管理系统的业务员批量调价,操作员执行了 20 次调价操作,其中第 15 次调价的参数填错了。但系统只记录了新的价格,没记录操作过程,想恢复只能靠手工反向调价。如果所有调价操作都是命令对象,而且命令携带了完备的执行参数,那么系统只要把命令列表持久化,就能精确知道哪一条命令出了问题,甚至可以通过命令重放把数据库重建到任意时间点的状态。
实现思路不算复杂:命令接口里加一个序列化方法,比如toLog(),把命令的关键参数转成可存储的字符串;系统启动时读取日志,反向恢复出命令对象,再按顺序执行。这里要注意,命令里不能持有无法序列化的对象,比如数据库连接、网络会话。这也是我为什么建议命令的构造参数尽量用简单类型和值对象,别一上来就塞个 Service 进去,否则回放时你会非常难受。
4.3 命令队列与异步调度
命令对象还可以被丢到队列里异步执行。比如桌面应用里,用户点了“导出报表”,这个操作可能耗时十几秒,如果直接在 UI 线程里执行,界面会卡死。正确的做法是:把导出操作封装成命令对象,扔进后台线程池执行,UI 线程立刻返回显示“导出中”。命令对象之所以适合这种场景,正是因为它是一个完整的、自包含的“任务单元”——接收者是谁、参数是什么、如何执行,全都打包好了。
在这个场景里,命令模式是在为“延迟执行”和“任务调度”服务的。你的线程池不需要知道任务是导出报表还是发送邮件,它只知道每个任务都实现了 Command 接口,拉出来调用 execute() 就行。这一点和 JDK 里的 Runnable、Callable 思想完全一致——把逻辑打包成对象,然后交给执行框架去跑。
5. 我在实际项目中踩过的坑和排查清单
5.1 撤销栈与重做栈的经典雷区
第一个坑:重做栈没有清空。用户执行了 A、B、C 三个操作,然后撤销了 C,此时如果你没有清空重做栈,用户又执行了新操作 D,那重做按钮还留着 C。这时候按重做,系统就会把 C 重新执行一遍,但是用户已经跳到新分支了,界面状态就会很诡异。所以 CommandHistory 里每一处新执行命令的地方,都必须redoStack.clear()。我在文章前面给的代码里加了这一行,这个就是保命的一行。
第二个坑:撤销和重做的栈方向搞反。有人用队列实现撤销栈,结果撤销时拿的是最旧的操作,根本不是最后一次操作,整个体验就完全乱了。撤销必须用栈(后进先出),最后一次执行的操作总是最先被撤销。重做则正好相反,是把最近撤销的操作恢复。这两个栈的 push 和 pop 时机最好画个图自己推一遍,别想当然。
5.2 命令对象的内存与状态问题
命令对象不是省油的灯,它内部可能持有大量状态。我见过最夸张的写法:每条命令都把整个文档快照保存一份,用户输入几十个字,内存里就堆了几十份全文;文档大的时候内存直接爆掉。解决思路有两个方向:
一是缩小命令粒度,把“快照”改成“增量”。插入命令只需要记录插入位置和插入的文本,删除命令只需要记录开始位置和删除的文本,不需要保存完整状态。这是大多数编辑器撤销功能的做法。
二是限制历史栈的容量。业务系统的撤销栈不能无限增长,用一个固定容量比如 50,超过就丢弃最老的命令。或者对连续同类型的操作做合并——比如用户持续输入文本,可以把 1 秒钟内的多次插入合并成一条宏命令,这样输入五百个字只占用一条历史记录。
第三个状态问题:命令共享可变数据时存在线程安全隐患。如果多个线程同时操作同一个 Document 接收者,那么命令之间就会互相干扰。解决方案要么给接收者的关键方法加锁,要么让所有命令都提交到同一个单线程队列里执行,保证同一时刻只有一个命令在操作接收者。
5.3 调试命令模式的实用小技巧
命令模式把逻辑分散到了很多小类里,调试起来比单体函数麻烦。我自己积累了几个小技巧,能显著降低排查成本:
第一,给每条命令加上描述字段。把关键的参数信息拼成字符串,比如InsertTextCommand[pos=5, text=","]。这样无论打印日志还是在调试器里观察历史栈,你一眼就能看出当前执行的是哪条命令,参数是什么。可以说这是我用下来性价比最高的技巧。
第二,刻意在 undo() 里打印日志。很多时候撤销失败是因为 execute() 和 undo() 不对称,参数不一致。把执行和撤销的操作都打出来,对比一下两者操作的位置和内容,问题往往立刻能定位。
第三,把 CommandHistory 做成可观察的组件。历史栈变化时派发通知,UI 可以根据 canUndo() 和 canRedo() 来自动置灰“撤销”按钮和“重做”按钮。这个不仅提升体验,也方便测试时确认状态机的正确性。
| 常见问题 | 排查方向 |
|---|---|
| 撤销后界面没变 | 检查 undo() 是否真正执行了逆操作;确认命令是否真的压入撤销栈 |
| 撤销一次撤掉了多个操作 | 检查是否把多条逻辑放在同一个命令的 execute() 里 |
| 重做按钮不可用 | 确认执行新操作时是否误清了 redo 栈 |
| 执行后历史栈为空 | 检查调用者是否把命令通过 CommandHistory 执行 |
| 撤销后状态错乱 | 检查命令是否持有了过期的状态或者共享对象被并发修改 |
6. 一路踩坑过来的经验复盘
讲到这里,命令模式的核心已经被拆得差不多了。最后分享几个我在项目里一直坚持的习惯,给正准备下手重构按钮逻辑的同学一点参考。
第一,命令接口不要一上来就定义一堆方法。最简版本只需要 execute(),用到撤销再加 undo()。我曾经见过有人一上来就设计了 five 六个方法,后来一大半从没用过。接口的方法越少,实现类就越容易写,也越容易被复用。
第二,命令的参数尽量从构造器传入,不要用 setter 后来塞。因为命令对象经常要入栈、出栈、排队、序列化,参数如果在构造之后还能被随意修改,鬼知道执行时用的是哪一份值。不可变命令对象是更好的设计,也让并发环境下的安全性有了基础保障。
第三,关于命名,我习惯用“动词 + 名词 + Command”的结构,比如 SaveOrderCommand、ApproveLeaveCommand。这样读代码时看到命令列表,就像在读操作日志,可读性会高很多。
老实说,命令模式是设计模式里为数不多“学完马上能提高幸福感”的模式。它不是高深莫测的玄学,就是把“操作”这东西老老实实地变成一个能传递、能存储、能反悔的对象。如果你现在手上正好有一个按钮回调函数写得比我这篇文章还长,不妨挑一个操作试一试,把它改造成命令对象,再挂到按钮上。你会发现,产品再提出“误点了能不能撤回”这种需求时,你不再头疼,而是能淡定地说一句:早给你准备好了。