ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

你写的撤销功能,99% 是伪 Memento——Undo 不是存个备份那么简单

你写的撤销功能,99% 是伪 Memento——Undo 不是存个备份那么简单 你写的撤销功能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 模式拉进来每次用户操作包装成一个 CommandCommand 执行时各自创建 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 个设计模式用漫画 答题的方式讲目前正在开发中。如果你觉得这类内容有意思搜一下「爪爪代码冒险记」或者等我后面的文章。
返回列表