你写的撤销功能,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<OrderMemento> 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 个设计模式用漫画 + 答题的方式讲,目前正在开发中。如果你觉得这类内容有意思,搜一下「爪爪代码冒险记」,或者等我后面的文章。

相关推荐
Gopher_HBo1 小时前
请求绑定与校验
后端
笨鸟飞不快2 小时前
RocketMQ Lite Topic 流程梳理与设计解读
后端·rocketmq
步行cgn2 小时前
MyBatis 查询结果字段为 null?一文讲透下划线命名与驼峰映射问题
后端
技术长镜头2 小时前
别再只会“加索引”:从磁盘页到 B+Tree,彻底理解 MySQL 索引的设计与运行
后端·mysql
生锈的键盘2 小时前
gRPC 多路复用全链路拆解:从客户端到服务端,中间隔着 ELB 和 Nginx 到底是怎么玩的?
后端
laity172 小时前
python发光表白爱心(从零到一实现)
前端·后端
用户921080262862 小时前
2. Java 基础该怎么学,先抓业务开发真正用得上的部分
后端
AI老猿博士2 小时前
SpringBoot自动配置揭秘
后端
程序员爱钓鱼2 小时前
Go for 循环详解
后端·面试·go