最新下载
热门教程
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
撤消重做写了 300 行 if-else - 命令模式的工程实现比你想的实用
时间:2026-06-17 09:06:01 编辑:袖梨 来源:一聚教程网
撤消重做写了 300 行 if-else——命令模式的工程实现比你想的实用
每一个做过富文本编辑器或工作流引擎的开发者,一定写过一段类似的代码:

复制代码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。
这就是没有命令模式的代价:操作和它的逆操作被割裂了。
命令模式不是什么新东西,但大部分人没写对
命令模式把每个操作封装成一个对象,让它自己记住「怎么做」和「怎么撤销」:
复制代码public interface Command {
void execute();
void undo();
}
最简单的文本插入命令:
复制代码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 栈变成了一句话:
复制代码public class CommandHistory {
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(); // 新操作清空 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 还是原来的位置吗?
复制代码// 第 1 步:在位置 10 插入 "hello"
execute(new InsertTextCommand(doc, 10, "hello")); // position=10// 第 2 步:在位置 5 插入 "world"
execute(new InsertTextCommand(doc, 5, "world")); // position=5// 撤销第 1 步:按 [10, 5] 删除 "hello"
undo(); // 此时 "hello" 的实际位置已经不是 10 了!
增量记录模式的致命缺陷:命令之间的状态互相依赖。 撤销 B 之后,A 记录的 position 已经不再准确。
两种解法:
方案一:快照模式。 每次操作前保存完整状态。
复制代码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); // 整个文档回滚
}
}
快照模式实现简单,不会出现状态不一致。代价是内存——大文档的快照可能几十 MB,undo 栈深 50 层就是几 GB。
方案二:逆操作模式 + 全局序列号。 每个命令不记录绝对位置,而是记录逻辑位置。
复制代码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 等实时协作文档的解法。复杂度远超状态快照,不适合小团队。
不只是编辑器——命令模式在业务系统里的应用
定时任务调度:
复制代码public class ScheduledJobInvoker {
private final BlockingQueue<JobCommand> 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,调度器一行不改。
数据库操作的补偿机制:
复制代码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 分布式事务的本质就是一串命令的链式执行 + 失败补偿。
命令的序列化——一个被低估的需求
命令模式强调「把请求封装为对象」,但很少人提这个对象能不能序列化。
复制代码// 错误: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,这三件事都做不了。
组合命令——多个操作的原子化
富文本编辑器里,用户点一下「加粗 + 斜体」,实际上是两个操作。但撤销的时候,两个操作应该一起撤销:
复制代码public class CompositeCommand implements Command {
private final List<Command> commands;
@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,用户按一次 Ctrl+Z,两个操作一起撤销。
这个设计的陷阱:组合命令的 undo 是逆序的——如果你先插入了文字再删除了图片,撤销时必须先恢复图片再删除文字。顺序搞反了,文档状态就错了。
什么时候命令模式是过度设计
一个简单的 CRUD 系统不需要命令模式。你的 UserController.updateUser() 不需要写成 UpdateUserCommand——除非你需要撤销、重做、延迟执行、事务补偿这些能力。
命令模式的价值和系统复杂度正相关。判断标准:
- 涉及 undo/redo → 必用
- 需要异步执行 / 延迟执行 → 强烈建议
- 需要审计日志(操作可回溯)→ 强烈建议
- 普通 REST API → 完全不需要