撤消重做写了 300 行 if-else——命令模式的工程实现比你想的实用

📅 2026/6/16 10:11:58
撤消重做写了 300 行 if-else——命令模式的工程实现比你想的实用
撤消重做写了 300 行 if-else——命令模式的工程实现比你想的实用每一个做过富文本编辑器或工作流引擎的开发者一定写过一段类似的代码java public void undo() { if (lastAction null) return; switch (lastAction.getType()) { case INSERT_TEXT: document.deleteText(lastAction.getPosition(), lastAction.getLength()); break; case DELETE_TEXT: document.insertText(lastAction.getPosition(), lastAction.getDeletedText()); break; case FORMAT_BOLD: document.setBold(lastAction.getRange(), false); break; case INSERT_IMAGE: document.removeImage(lastAction.getImageId()); break; case MOVE_BLOCK: document.moveBlock(lastAction.getNewPosition(), lastAction.getOriginalPosition()); break; // ... 十几种操作每个都要写回滚逻辑 } }这段 switch-case 会随着功能增加无限膨胀。每加一个新操作表格、公式、批注你需要在这个 switch 里加一个新的 case然后在redo()方法里再加一遍。两个方法在同一个文件里遥遥相望漏一个就是 Bug。这就是没有命令模式的代价操作和它的逆操作被割裂了。命令模式不是什么新东西但大部分人没写对命令模式把每个操作封装成一个对象让它自己记住「怎么做」和「怎么撤销」java public interface Command { void execute(); void undo(); }最简单的文本插入命令java public class InsertTextCommand implements Command { private final Document document; private final int position; private final String text;public InsertTextCommand(Document document, int position, String text) { this.document document; this.position position; this.text text; } Override public void execute() { document.insertText(position, text); } Override public void undo() { document.deleteText(position, text.length()); }} 现在 undo 栈变成了一句话java public class CommandHistory { private final DequeundoStack new ArrayDeque(); private final DequeredoStack new ArrayDeque();public void execute(Command command) { command.execute(); undoStack.push(command); redoStack.clear(); // 新操作清空 redo 栈 } 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); }} 加一个新操作——比如「合并单元格」——你只需要写一个MergeCellCommand类不用碰CommandHistory不用碰undo()方法不用碰任何已有代码。改了 30 行新代码0 行旧代码。命令模式最致命的坑状态快照 vs 增量记录InsertTextCommand.undo()靠的是记住position和text.length()。但如果文档在其他操作中变了——比如先插入文字 A又插入文字 B再撤销 A——position 还是原来的位置吗java // 第 1 步在位置 10 插入 hello execute(new InsertTextCommand(doc, 10, hello)); // position10// 第 2 步在位置 5 插入 worldexecute(new InsertTextCommand(doc, 5, world)); // position5// 撤销第 1 步按 [10, 5] 删除 hello undo(); // 此时 hello 的实际位置已经不是 10 了 增量记录模式的致命缺陷命令之间的状态互相依赖。撤销 B 之后A 记录的 position 已经不再准确。两种解法方案一快照模式。每次操作前保存完整状态。java public class SnapshotCommand implements Command { private final Document document; private DocumentSnapshot snapshot;Override public void execute() { this.snapshot document.createSnapshot(); // 操作前拍照 doExecute(); } Override public void undo() { document.restoreFromSnapshot(snapshot); // 整个文档回滚 }} 快照模式实现简单不会出现状态不一致。代价是内存——大文档的快照可能几十 MBundo 栈深 50 层就是几 GB。方案二逆操作模式 全局序列号。每个命令不记录绝对位置而是记录逻辑位置。java public class InsertTextCommand implements Command { private final OperationId opId; // 全局唯一操作 ID private final LogicalPosition pos; // 逻辑位置不是字节偏移Override public void undo() { // 基于 CRDT 或 OT 算法计算当前位置的实际偏移 Position actual document.resolveLogicalPosition(pos, opId); document.deleteText(actual.getOffset(), text.length()); }} 这是 Google Docs 等实时协作文档的解法。复杂度远超状态快照不适合小团队。不只是编辑器——命令模式在业务系统里的应用定时任务调度java public class ScheduledJobInvoker { private final BlockingQueue jobQueue new LinkedBlockingQueue();public void submit(JobCommand job) { jobQueue.offer(job); } Scheduled(fixedDelay 1000) public void executeNext() { JobCommand job jobQueue.poll(); if (job ! null) { job.execute(); if (job.requiresRetry() !job.exceededMaxRetries()) { jobQueue.offer(job); // 失败重试命令自己决定要不要回队列 } } }} 调度器和具体任务解耦。加一个新任务类型比如「发送周报邮件」只写SendWeeklyReportCommand调度器一行不改。数据库操作的补偿机制java public class CreateOrderCommand implements TransactionalCommand { Override public void execute() { orderMapper.insert(order); inventoryMapper.deduct(order.getItems()); couponMapper.use(order.getCouponId()); }Override public void compensate() { // execute() 失败后的补偿反向操作 orderMapper.markCancelled(order.getId()); inventoryMapper.restore(order.getItems()); couponMapper.release(order.getCouponId()); }} 命令模式天然支持事务补偿——每个命令自带 undo 逻辑。Saga 分布式事务的本质就是一串命令的链式执行 失败补偿。命令的序列化——一个被低估的需求命令模式强调「把请求封装为对象」但很少人提这个对象能不能序列化。java // 错误lambda 表达式无法序列化 Command cmd () - userService.update(user);// 正确独立类字段可序列化 public class UpdateUserCommand implements Command, Serializable { private static final long serialVersionUID 1L; private final Long userId; private final String newName;Override public void execute() { userService.updateName(userId, newName); }} 为什么序列化重要延迟执行命令存到数据库定时任务捞出来执行分布式执行命令序列化后通过消息队列发给其他节点审计日志把所有命令存下来出问题可以重放如果你的命令对象只是内存里的 lambda这三件事都做不了。组合命令——多个操作的原子化富文本编辑器里用户点一下「加粗 斜体」实际上是两个操作。但撤销的时候两个操作应该一起撤销java public class CompositeCommand implements Command { private final Listcommands;Override public void execute() { for (Command cmd : commands) cmd.execute(); } Override public void undo() { // 逆序撤销 for (int i commands.size() - 1; i 0; i--) { commands.get(i).undo(); } }} 组合模式 命令模式的经典搭配。undo 栈里只压入一个CompositeCommand用户按一次 CtrlZ两个操作一起撤销。这个设计的陷阱组合命令的 undo 是逆序的——如果你先插入了文字再删除了图片撤销时必须先恢复图片再删除文字。顺序搞反了文档状态就错了。什么时候命令模式是过度设计一个简单的 CRUD 系统不需要命令模式。你的UserController.updateUser()不需要写成UpdateUserCommand——除非你需要撤销、重做、延迟执行、事务补偿这些能力。命令模式的价值和系统复杂度正相关。判断标准涉及 undo/redo → 必用需要异步执行 / 延迟执行 → 强烈建议需要审计日志操作可回溯→ 强烈建议普通 REST API → 完全不需要