你写的撤销功能,99% 是伪 Memento——Undo 不是存个备份那么简单
几乎每个业务系统都有"撤销"需求,但大多数实现跟 Memento 模式没什么关系。它们只是"存个 JSON 快照到数据库",然后在用户点撤销时把旧数据覆盖回去。
这个做法能跑,但它不是 Memento。Memento 的核心不是"备份",是封装状态的访问边界——让 Originator 自己管自己的状态,外部(Caretaker)只负责存取一个黑盒,不能偷看里面装了什么。
这个区别在工程里会直接决定你的撤销系统能走多远。
一个真实的踩坑场景
三年前我接手一个电商后台的订单编辑系统。需求很简单:运营改完订单信息后,可以撤销回上一步。
当时的实现是:每次编辑前,把整个 OrderDTO 序列化成 JSON 字符串,塞进order_undo_log表。撤销时反序列化,覆盖当前数据。
看起来没毛病,直到运营提了个需求:"撤销的时候能不能只恢复部分字段?比如只改回收货地址,但保留我刚改的价格。"
做不了。因为快照是整个 DTO 的,粒度太粗。你想拆成字段级快照?可以,但 JSON 字符串里各个字段纠缠在一起,外部系统(Caretaker)根本不知道哪个字段对应什么语义。
更隐蔽的问题在后面:OrderDTO 加了新字段,旧快照反序列化回来,新字段是 null。运营撤销后提交,null 覆盖了别人刚填的数据。这就是 Caretaker 越界访问内部状态结构的代价——它根本不该知道 OrderDTO 长什么样。
Memento 的真正结构
Memento 模式三个角色:
- Originator(发起人):拥有需要保存的状态,负责创建和恢复 Memento。
- Memento(备忘录):封装状态,对除 Originator 外的所有对象隐藏内部细节。
- Caretaker(管理者):负责保存 Memento,不能操作或检查其内容。
关键约束:Caretaker 只能拿到一个 Opaque 的令牌,不能拆包看内容。
用 Java 写,大概是这个结构:
```java // Memento 是内部类或包私有,外部只能拿到接口 public interface OrderMemento { // 空接口,外部没有任何方法可调用 }
public class OrderEditor { private String address; private BigDecimal price; private String remark;
// 创建备忘录:只有 Originator 知道自己怎么存 public OrderMemento save() { return new Snapshot(address, price, remark); } // 恢复:只有 Originator 知道自己怎么恢复 public void restore(OrderMemento memento) { Snapshot s = (Snapshot) memento; this.address = s.address; this.price = s.price; this.remark = s.remark; } // Memento 实现是私有的,外部完全不可见 private static class Snapshot implements OrderMemento { final String address; final BigDecimal price; final String remark; Snapshot(String a, BigDecimal p, String r) { this.address = a; this.price = p; this.remark = r; } }}
// Caretaker 只管存和取,绝不拆开看 public class UndoManager { private final Deque stack = new ArrayDeque<>();
public void push(OrderMemento m) { stack.push(m); } public OrderMemento pop() { return stack.pop(); } public boolean isEmpty() { return stack.isEmpty(); }} ```
这个结构里,UndoManager根本不知道OrderMemento里面有什么。它就是一个黑盒管理员。如果哪天OrderEditor内部状态重构了——比如把address拆成province/city/detail——UndoManager一行代码不用改。
这就是封装的力量。JSON 快照方案牺牲了封装,换来了"简单",但在长期演进中付出了十倍代价。
跟数据库快照、Event Sourcing 的区别
很多人把 Memento 和数据库快照混为一谈,其实它们解决的是不同层面的问题。
数据库快照是持久层的备份机制,关注的是"数据丢了怎么恢复"。它的受众是 DBA 和运维,粒度通常是整张表或整个库,不涉及业务对象的封装边界。
Memento是领域层的撤销机制,关注的是"用户在当前会话里的操作怎么回退"。它的受众是业务对象本身,粒度是单个对象的状态封装,核心约束是 Caretaker 不能越界。
Event Sourcing是另一种思路:不存状态,只存事件。撤销不是"恢复旧状态",而是"追加一个逆向事件"。这个方案更强大(可以 replay、可以审计),但复杂度也高一个数量级。Memento 是"拍照片",Event Sourcing 是"记日记"。选哪个取决于你的撤销需求有多复杂——如果只是简单的单步/多步撤销,Memento 够用了;如果需要完整的历史追溯和分支回放,再考虑 Event Sourcing。
三个工程化陷阱
1. Memento 内存爆炸
如果每次状态变更都存一个完整快照,高频操作下内存很快撑爆。解决思路:
- 增量 Memento:只存变更的字段,不是整个对象。但这会打破"黑盒"原则——Caretaker 需要知道哪些字段变了。折中方案是 Originator 内部做增量计算,对外仍然输出一个统一的黑盒。
- 快照 + 操作日志:每 N 步存一个完整快照,中间用 Command 模式记录操作。撤销时先找最近快照,再 replay 逆向操作。
- 惰性复制:利用不可变数据结构(persistent data structure),新旧状态共享未变更的部分,物理上只复制变更的分支。
2. 深拷贝 vs 引用泄露
Memento 存的是对象引用还是深拷贝?如果存引用,Originator 后续修改会污染 Memento。如果存深拷贝,大对象性能堪忧。
没有银弹。我的习惯是:值对象(String、Integer、不可变 BigDecimal)直接存引用;集合和自定义对象必须深拷贝。Java 里可以用clone()、CopyOnWriteArrayList、或者 Jackson 序列化后再反序列化(笨但稳)。
3. 多 Originator 的交叉恢复
一个 Caretaker 管多个 Originator(比如一个表单里有订单编辑器和客户编辑器),撤销栈是统一的。用户点了撤销,应该恢复哪个 Originator 的状态?
这种场景需要把 Command 模式拉进来:每次用户操作包装成一个 Command,Command 执行时各自创建 Memento。撤销栈里存的是 Command 对象,pop 出来就知道该调用哪个 Originator 的 restore。
```java public interface EditCommand { void execute(); void undo(); }
public class ChangePriceCommand implements EditCommand { private final OrderEditor editor; private OrderMemento backup; private final BigDecimal newPrice;
public void execute() { backup = editor.save(); // 执行前存快照 editor.setPrice(newPrice); } public void undo() { editor.restore(backup); }} ```
Memento 管"怎么存状态",Command 管"什么时候存、存谁的"。两者配合,才能搭一个工业级的撤销系统。
什么时候该用 Memento
不是有撤销需求就必须上 Memento。判断标准:
- 状态的内部结构可能变化 →用,封装隔离变化
- Caretaker 不应知道状态细节(安全/权限原因)→用,黑盒机制天然适合
- 需要多级撤销且状态对象很大 →考虑增量 Memento 或 Command 组合
- 只是简单的单字段编辑,且状态结构极稳定 → 直接存旧值可能更轻量,不必硬套模式
设计模式不是炫技,是在约束条件下做 trade-off。Memento 的约束是"封装状态访问",如果你的场景不需要这个约束,强上模式反而是过度设计。
我在做一个用卡皮巴拉讲设计模式的微信小程序「爪爪代码冒险记」,23 个设计模式用漫画 + 答题的方式讲,目前正在开发中。如果你觉得这类内容有意思,搜一下「爪爪代码冒险记」,或者等我后面的文章。