最早逼着我认真研究命令模式的,不是书上那个遥控器加电灯的经典示例,而是一次特别真实的改代码经历。当时我在做一个图形编辑工具的操作面板,整个模块的交互逻辑全写在按钮回调里:点保存,直接调文档模块的持久化方法;点撤销,直接调文档模块的还原方法;点加粗,直接改选中文字的样式。代码能跑,但每个人接手时都皱眉——按钮层和业务层黏在一起,新增一个操作要同时改好几个类。
真正让我下定决心重构的,是产品提了一个看似"很小"的需求:所有操作都要支持撤销和重做。这个需求一落地,直接调用方式的短板就全暴露了——每个按钮的回调都要手写一段历史记录逻辑,重复代码满天飞,而且撤销的细节跟业务操作紧紧绑在一起,改一处崩一片。也就是从那时起,我开始认真梳理命令模式,后来一步步把它用在模块协作、异步任务编排、操作回放这些场景里。这篇东西就当作一次完整梳理,把命令模式的设计思路、代码骨架、实战用法和踩坑记录都摊开来讲。
1. 命令模式解决的核心痛点:调用方和执行方的"三宗罪"
1.1 直接的调用方式为什么让人头疼
先看一段典型的反例代码,理解我们最初是怎么写的:
button.setOnClickListener(e -> { document.save(); // 调文档模块 editor.setStatusBar("已保存"); // 调界面模块 auditLogger.log("save"); // 调审计模块 history.push(document.snapshot()); // 手动记历史,为了撤销 });这段代码在功能上没问题,但至少有三个要命的毛病:
第一,调用方知道得太多。按钮回调同时依赖 document、editor、auditLogger、history 四个对象,按钮层根本不关心业务细节,却被迫了解"保存要做哪些事、先做什么后做什么"。这还不算最糟的,等业务复杂到"保存前要校验、保存后要通知其他模块刷新"时,按钮回调会膨胀到几百行。
第二,逻辑没法复用。假设另一个入口(比如快捷键 Ctrl+S)也要做同样的保存动作,你只能把这段逻辑复制一份。一旦保存流程加了新步骤,所有副本都要跟着改,漏改一个就是线上事故。这就是典型的"多处重复真实逻辑"的坏味道。
第三,操作无法被追溯和回退。撤销逻辑是散落在各处的,保存有自己的撤销逻辑,加粗有自己的撤销逻辑,每个操作都是手写的 if/else。想统一做一个"操作历史"面板?想批量重放某段时间的操作?在直接调用的结构里根本无从下手。
1.2 把"请求"变成"对象",一切就松开了
命令模式的核心思想一句话就够:把一次请求封装成一个独立的对象,这个对象里面有完整的执行逻辑,可以被传递、存储、排队、撤销和组合。
打个比方,直接调用就像在办公室里隔着两排工位喊"小张,帮我把合同打印了",喊话的人必须知道小张在、他有打印机、他愿意帮忙。而命令模式是写一张工单——"打印合同,A4纸,双面,三份"——把工单放进待办队列,谁有空谁处理,处理完还能归档备查。调用方不需要认识执行方,执行方也不需要关心工单是谁派下来的。
在代码层面,这意味着"保存文档"这件事不再是一段散落的调用序列,而是一个SaveCommand对象:
public class SaveCommand implements Command { private final Document document; public SaveCommand(Document document) { this.document = document; } @Override public void execute() { document.save(); } }这个对象可以被放进 List,可以被推入撤销栈,可以被网络传输到别的进程,甚至可以延迟 5 分钟再执行。调用方手里拿着的只是Command接口引用,具体的执行细节完全被封装在命令对象内部。
2. 命令模式的标准骨架:四个核心角色怎么分工
2.1 命令接口:别一上来就加一堆方法
命令模式经典的四个角色分别是:命令接口(Command)、具体命令(ConcreteCommand)、接收者(Receiver)、调用者(Invoker)。我先从接口说起。
很多初学者喜欢一上来就把接口定义得特别全,execute 有了还不够,再加 undo、redo、cancel、getStatus……我的建议是:接口保持最小,先只放 execute,后续有需要再加默认方法。原因很简单,命令模式的价值首先是解耦调用方和执行方,撤销、重做这些都是附加能力,不是每个命令都需要。接口方法越多,每个命令类要实现的东西就越多,心智负担越大。
public interface Command { void execute(); }等某个业务确实需要撤销时,再引入一个带默认实现的方法,这样老命令不用改,新命令按需覆盖:
public interface Command { void execute(); default void undo() { throw new UnsupportedOperationException("该命令暂不支持撤销"); } }2.2 接收者与调用者:谁干活、谁发令
接收者(Receiver)是真正干活的对象,它知道怎么具体执行一个动作;调用者(Invoker)只管持有一个命令引用,在合适的时机触发它。两者之间完全通过命令对象通信,互不感知对方的存在。
// 接收者:真正的业务逻辑在这里 public class Light { public void on() { System.out.println("灯亮了"); } public void off() { System.out.println("灯灭了"); } } // 具体命令:把接收者的动作包一层 public class TurnOnCommand implements Command { private final Light light; public TurnOnCommand(Light light) { this.light = light; } @Override public void execute() { light.on(); } } // 调用者:它不知道 light 的存在 public class RemoteControl { private Command slot; public void setCommand(Command command) { this.slot = command; } public void pressButton() { slot.execute(); } }这段代码里有个容易被忽略的设计点:具体命令通常持有接收者的引用,这个引用通过构造函数传入。为什么不直接在命令里 new 一个 Light?因为那样命令就和具体的 Light 实例硬绑死了,没法测试、没法复用、没法动态切换。构造函数注入让命令对象变成了一个可配置的组件,同一个TurnOnCommand可以服务于客厅的灯,也可以服务于走廊的灯,只要在构建时传入不同的实例。
2.3 为什么说命令对象是"参数化的行为"
命令模式的一个重要特性,是把"行为"本身变成了可以传递的参数。调用者可以在运行时动态切换命令,比如遥控器同一个按钮白天控制灯、晚上控制窗帘;也可以把一组命令塞进队列或者栈里统一管理。
这种参数化带来的直接好处,是行为可以被数据化。命令对象和普通数据对象一样,可以被 List 收集、被 Map 索引、被队列调度、被序列化传输。当你需要"记录用户操作并定时重放"时,你会发现这就是一份命令列表而已。能从数据层面去管理行为,这在直接调用时代是想都不敢想的灵活度。
3. 贴近实战的完整案例:带撤销重做的文本编辑器操作栈
3.1 把每个编辑动作封装成"可执行、可撤销"的对象
经典的电灯例子只能说明结构,真正体现命令模式威力的场景,是撤销重做(Undo/Redo)。我以文本编辑器为例,实现一个带历史栈的编辑操作体系。
先定义一个简单的接收者 Document:
public class Document { private final StringBuilder content = new StringBuilder(); public void insert(int position, String text) { content.insert(position, text); } public void delete(int position, int length) { content.delete(position, position + length); } public String getContent() { return content.toString(); } }然后定义两条命令。注意它们的共同点:每条命令不仅知道"怎么执行",还知道"怎么撤销"。插入命令的撤销是删除刚插入的文本,删除命令的撤销是在原位置重新插入被删的文本。命令对象自己记录执行时需要的参数(比如插入位置、文本内容),这样撤销时才有足够信息还原现场。
public class InsertCommand implements Command { private final Document document; private final int position; private final String text; public InsertCommand(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, text.length()); } } public class DeleteCommand implements Command { private final Document document; private final int position; private final String deletedText; public DeleteCommand(Document document, int position, String deletedText) { this.document = document; this.position = position; this.deletedText = deletedText; } @Override public void execute() { document.delete(position, deletedText.length()); } @Override public void undo() { document.insert(position, deletedText); } }这里有一个非常关键的细节:DeleteCommand在构造时就把被删除的文本捕获保存了。为什么?因为等到 execute 真的执行完,被删的文本就已经丢了,你不提前保存,撤销时根本不知道要恢复什么。命令对象在创建时就应当捕获它执行时需要的全部上下文,这是命令模式能否正确撤销的前提。
3.2 Undo/Redo 栈的完整生命周期
有了可撤销的命令对象,下一步就是用一个管理器统一管理执行、撤销、重做三件事。这个管理器就是典型的调用者升级版,它通过两个栈(undo 栈和 redo 栈)来管理系统状态。
public class CommandHistory { private static final int MAX_UNDO_DEPTH = 100; private final Deque<Command> undoStack = new ArrayDeque<>(); private final Deque<Command> redoStack = new ArrayDeque<>(); public void execute(Command command) { command.execute(); undoStack.push(command); redoStack.clear(); // 新操作发生后,重做历史作废 if (undoStack.size() > MAX_UNDO_DEPTH) { undoStack.pollLast(); // 超出深度后丢最老的命令 } } 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(); // 重做时执行,而不是再调 undo undoStack.push(command); } }这个实现有几个容易踩的坑,我详细说明。
第一个坑:新操作会清空 redo 栈。这是撤销系统的一个通用约定——用户撤销了几步,又新敲了几个字,那么之前被撤销的那些操作就不可能再"重做"回来了,因为历史已经被新操作打断。很多初版实现忘了这一步,导致用户撤销后再做新操作,点重做居然还能把已经被新内容覆盖的操作恢复出来,弄出完全混乱的状态。
第二个坑:命令对象会被执行两次,必须保证幂等。注意 redo 里调用的是command.execute(),不是command.undo()。这就意味着同一个命令对象在生命周期里可能先 execute,接着 undo,再 execute。如果你的命令 execute 里用了"状态翻转"逻辑(比如一个开关命令第一次按是开、第二次按是关),那么 undo 和 redo 就会错乱。正确做法是 execute 每次都执行同一个明确的动作,undo 执行精确的反动作,而不是用 boolean 去翻转状态。
第三个坑:栈深度必须设上限。没有上限的撤销栈,在长时间运行的编辑器里就是内存炸弹。每一条命令都保存着上下文数据(比如删除的文本),几百条操作累积下来很可观。我一般设 100 左右,具体取决于单条命令的数据量。
3.3 宏命令:把多条操作打包成一个语义单元
撤销重做做到这一步,已经可以应付大多数场景了。但产品往往还会提一个需求:把连续的操作合并成一步撤销。
比如用户给一段文字"加粗 + 变色 + 改字号",从编辑器的角度看是三条命令,但从用户的角度看是一个动作"调整段落格式"。用户按一次撤销,期望的是三件事同时被撤销,而不是只撤销最后一件。这时就要用到宏命令(MacroCommand):它是一个特殊的命令,内部持有一组子命令,执行时逐个执行,撤销时逆序逐个撤销。
public class MacroCommand implements Command { private final List<Command> commands; public MacroCommand(List<Command> commands) { this.commands = commands; } @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(); } } }注意 undo 的顺序:必须逆序。因为操作的依赖关系通常是后一次操作建立在前一次的结果之上,撤销时就得先把后做的撤销掉,再撤销先做的。好比先插入文字再加粗,撤销时得先取消加粗、再删文字,反了就会出错。
宏命令的内部子命令本身也可以是宏命令,这就天然形成了一棵命令树。你在界面上做的"撤销整组操作",本质上就是对这棵树做一次后序遍历。
4. 进阶演进:组合命令、异步队列与事务化
4.1 组合命令编排批量任务
宏命令其实已经展示了命令模式的一个进阶用法:用命令的组合来表达复杂的业务编排。
我实际做得比较多的一个场景是"批量初始化":系统启动时要同时加载配置、建立连接、拉起线程池。这些步骤有严格顺序,但每步可能独立变化。把每条初始化动作封装成命令,再用一个组合命令按序列执行,好处是任何一步要替换实现,只改对应的子命令就行,编排逻辑完全不受影响。也更方便复用——测试环境只需要执行其中一部分命令。
组合命令还可以支持条件执行和容错策略。比如设定"某条命令失败则跳过执行后续命令"或者"某条命令失败则触发补偿逻辑",这些都可以在组合命令的循环里做控制:
for (Command command : commands) { try { command.execute(); } catch (Exception e) { // 记录失败原因,决定继续、中断还是补偿 break; } }这么一写,你就把"批量任务执行引擎"的核心逻辑封装到了命令框架里,业务方只需要提供命令、声明依赖顺序即可。
4.2 延迟执行、异步队列与日志重放
命令对象是可存储的,这意味着它可以放进队列,等待某个时机再执行。这是命令模式在异步场景下的经典应用。
我举一个真实案例。某个后台管理系统的操作日志,要求把用户行为记录下来,并支持按时间范围重放。如果每个操作都直接执行并直接入库,重放几乎无从谈起。但把所有操作都封装成命令后,事情变得非常简单:
public class CommandQueue { private final BlockingQueue<Command> queue = new LinkedBlockingQueue<>(); private volatile boolean running = true; public void enqueue(Command command) { queue.offer(command); } public void startWorker() { Thread worker = new Thread(() -> { while (running) { try { Command command = queue.take(); command.execute(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } catch (Exception e) { // 单条命令失败不影响其他命令 } } }); worker.start(); } }再进一步,命令对象如果支持序列化——比如把"命令类型 + 参数"序列化成 JSON——你就能把操作日志落到磁盘上。重放时读日志文件,反序列化成命令对象,逐个执行。很多系统里的"审计审计回放""流量复制""事件溯源"本质上都是这个套路:命令即日志,日志即操作。
这里要提醒一句序列化的坑:千万不要把接收者(Recepient)一起序列化。命令对象里保存的应当是业务数据(插入文本、删除位置、修改字段值),而不应该是 document 这样的重量级业务对象。重放场景中,接收者应当通过注册表/工厂从命令类型和参数重建出来,而不是随着命令对象走。
4.3 事务化命令执行:要么全做,要么全不做
命令对象自带 execute 和 undo 这一对互逆操作,让它天然适合做"补偿式事务"。在微服务、分布式系统或者多模块协作中,跨模块的批量操作很难做到强一致,但命令模式可以提供一个足够好的"尽力而为的一致性"。
思路很简单:先把一批命令逐个执行,执行过程中任何一条失败,就回滚所有已执行的命令,恢复到批量操作之前的状态。
public class TransactionalExecutor { private final Deque<Command> executedCommands = new ArrayDeque<>(); public boolean executeInTransaction(List<Command> commands) { for (Command command : commands) { try { command.execute(); executedCommands.push(command); } catch (Exception e) { rollback(); return false; } } return true; } private void rollback() { while (!executedCommands.isEmpty()) { Command command = executedCommands.pop(); try { command.undo(); } catch (Exception e) { // 补偿失败的日志要单独记录,后面人工/定时任务处理 } } } }我知道很多读者会想:这不是跟数据库事务一样吗?其实不一样。数据库事务靠的是加锁和日志回滚,而命令模式的补偿式撤销是一种业务层面的逆操作,它假定每条命令都能精确地"反着做一遍"。因此它不适用于那些无法定义逆操作的场景(比如发送了一条无法撤回的消息)。不过在很多业务系统中,双模块更新的场景下,这种补偿式设计已经足够解决问题,而且实现成本远低于引入一个分布式事务框架。
5. 命令模式和策略模式的混淆与搭配
5.1 别再搞混了:命令封装动作本身,策略封装算法
结构上,命令模式和策略模式非常像——都是一组类实现同一个接口,然后找个地方去调用。很多初学者面对两者总是晕。我提供一个特别清晰的区分视角:
策略模式里,接口代表的是一类"可以互相替换的算法",它们做的事是一样的,只是做法不同。比如压缩工具,zip 和 rar 都是"压缩"这个动作,策略模式让你可以在运行期切换压缩算法。命令模式里,每个命令代表的是一个独立的"请求/动作",它们做的事本身就不同,比如"保存文档"和"删除文件"没有可比性,它们不是算法的不同实现,而是不同的操作。
一句话版本:策略模式换的是"怎么做",命令模式换的是"做什么"。命令模式关注的是把动作对象化、延迟化、可撤销化,策略模式关注的是把算法参数化、可插拔化。
5.2 命令模式和适配器、模板方法的实战组合
命令模式很少单独出现,实际工程里它经常和其他模式混搭。
比如适配器模式:老系统里有一个LegacyReportGenerator,接口很别扭,你不想让业务方直接依赖它,可以在它外面包一层GenerateReportCommand implements Command,让老接口乖乖地融入命令体系。这种适配的好处是,命令调用方完全不知道底层是老接口还是新实现,将来替换旧系统时,替换一个命令类就完事。
再比如模板方法模式:同一套系统中的命令,往往有公共的执行前校验、执行后审计、异常处理这些固定步骤。把这些步骤抽象到父类里,让不同命令只实现各自的业务核心,能避免一大批重复代码:
public abstract class AbstractCommand implements Command { @Override public final void execute() { validate(); beforeExecute(); doExecute(); afterExecute(); } protected abstract void validate(); protected void beforeExecute() { } protected abstract void doExecute(); protected void afterExecute() { } }这样设计的意图是:把命令执行的公共流程固定下来,把变化的业务逻辑留给子类。新命令接入时只需要关心业务本身,公共的日志、权限、事务边界都由父类统一处理。
5.3 这三种情况,真的别用命令模式
命令模式虽好,但也不是银弹。我自己在实际项目里,见过很多把命令模式用滥导致代码爆炸的情况。以下三种场景我建议你别硬套:
第一,你只有一处调用,且将来不可能有第二个调用方。为一个保存操作单独建三个文件,实在没必要。
第二,你的操作压根不需要撤销、重做、异步延迟、批量编排这些能力。命令模式的收益除了解耦,更多来自于对命令对象的生命周期管理。如果这些管理机制都用不上,仅仅是"把动作封装一下再调用",收益很小,类数量却成倍增加。
第三,操作本身带强状态依赖,很难定义明确的逆操作。比如一次网络请求、一次随机抽奖、一次自增计数,这些操作要么不可逆,要么撤销起来语义模糊。强行封装成可撤销命令,很可能写出"撤销了但没完全撤销"的假实现,比不做更糟。
6. 落地时的坑与我的个人经验
6.1 让我印象最深的一个撤销污染bug
说一个真实踩过的坑。某次我在做一个表单设计器,用户拖拽控件后进行样式调整,所有调整都封装成了命令。当时FontSizeCommand里保存的是"控件 ID + 新字号",撤销时将控件字号改回旧值。看起来没问题,但用户一旦拖动控件位置,问题就来了:
// 假设控件被移动了,旧位置已经变了 public void undo() { control.setFontSize(oldSize); // 控件还在,但用户期望的撤销场景完全不同 }更致命的场景是:控件被删除后再撤销样式调整,此时control引用已经指向了一个不存在的对象,直接空指针。根因是命令对象持有的是对象引用,而不是对象ID,也没有校验对象生命周期。后来我把所有命令的参数从"对象引用"改为"对象ID + 版本号",执行前校验目标是否存在,才彻底解决了这类撤销污染。
这给所有写命令模式的人提了个醒:命令对象保存的不应该是引用,而应该是定位信息和上下文快照。宁可在命令里多存几个 ID,也别图省事直接持有目标对象。
6.2 命令粒度的把握:太细了崩内存,太粗了撤不动
命令粒度是这个模式里最难平衡的点。以文本编辑器为例,如果每一次按键都生成一个InsertCommand,用户输入一篇三千字的文章,撤销栈里就压了三千条命令。每条命令保存几个字符和位置,内存占用还好,但撤销时得连续三千次操作,体验很差。
我常用的手段是命令合并(Coalescing):相邻的同类命令如果目标位置连续,就合并成一条。比如用户连续输入"hello",本来该产生五条插入命令,合并后变成一条"在位置 3 插入 hello"。这个优化对撤销体验的提升非常明显。实现时可以在命令栈的 push 前检查栈顶命令和当前命令是否可以 merge,如果可以,就扩展栈顶命令的参数范围,而不是新增一条。
反过来,粒度太粗也有问题。比如把"整个表单的保存"封装成一条命令,那用户撤销时只能整单回退,没法精确撤销其中某一个字段的修改。合适的粒度是以用户的"一个语义动作"为单位——保存、删除一行、调整一段样式,各是一条命令;而一个语义动作内部的多步实现,交给宏命令去组合。
6.3 关于命令模式,几句话掏心窝的总结
写了这么多年代码,我对命令模式最大的体会是:它看起来代码量增加了,但换来的是系统的可扩展边界。调用方只需要认识 Command 接口,新增一个操作就是新增一个命令类,永远不需要动调用方的代码。团队协作时,不同模块之间只约定命令的参数格式,不互相 import 对方的实现类,模块边界自然会变得干净。
如果你正在设计一个模块较多、操作繁复的系统,我建议你从操作历史、任务队列这两个场景入手尝试命令模式,这是它能发挥最大价值的地方,也最容易让你体会到"多写几个类换来的灵活性"到底值不值。等你在这些场景里用顺了,再回头审视其他直接调用点,心里自然就有数了。
最后分享一个小技巧:给每个命令类都重写toString(),返回它的业务语义描述。比如InsertCommand{position=42, text="hello"}。这看起来不起眼,但在排查问题时,你只需要打印撤销栈里的命令列表,就能瞬间还原用户的操作路径,比翻四五个模块的日志高效太多了。