备忘录模式 (Memento) —— 游戏存档,随时回档
前言
在深入探讨备忘录模式之前,想向大家推荐一个非常棒的开源项目:design-patterns-23。这个项目用最现代的技术栈重写了 23 种设计模式,非常适合实战学习,还配套了在线交互演示站,可以边读文章边动手玩。本文的实战案例灵感也来源于此。
1. 备忘录模式是什么?
备忘录模式(Memento Pattern),也称为快照模式(Snapshot Pattern),是一种行为型设计模式。它允许在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以便以后当需要时能将该对象恢复到原先保存的状态。
简单来说,备忘录模式就是给你的对象吃一颗“后悔药”。
1.1 核心概念
备忘录模式包含三个核心角色:
- Originator(发起人):记录当前时刻的内部状态,负责定义哪些属于备份范围的状态,负责创建和恢复备忘录数据。
- Memento(备忘录):负责存储 Originator 对象的内部状态,在需要的时候提供这些内部状态给 Originator。
- Caretaker(管理者):对备忘录进行管理,保存和提供备忘录,但不能对备忘录的内容进行访问或者修改。
1.2 为什么我们需要它?
场景: 想象你在写文档,写了一大段突然发现写偏了,想回到十分钟前的状态。或者你在玩 RPG 游戏,打 Boss 前存了个档,打输了读档重来。
痛点: 如果想恢复状态,最直接的方法是让对象提供 getState() 和 setState()。但是:
- 破坏封装:为了备份,你必须把对象内部所有的私有属性(包括一些不该暴露的中间状态)都暴露给外部。
- 职责混乱:由外部类(比如 Client)来负责记录对象的状态,会导致 Client 代码极其臃肿,且 Client 必须知道对象内部的细节。
解决方案: 备忘录模式通过将状态的存储和恢复逻辑封装在 Memento 和 Originator 内部,外部 Caretaker 只负责保管“盒子”,而无法窥探盒子内部的细节(即“黑箱”操作),从而完美解决了上述问题。
2. 备忘录模式的两种实现:白箱 vs 黑箱
备忘录模式的核心在于如何保证封装性。根据备忘录对外部的开放程度,分为“白箱”和“黑箱”两种实现。
2.1 白箱备忘录(White-box Memento)
白箱模式下,备忘录类(Memento)对所有类都是可见的。虽然实现简单,但它破坏了封装性,因为 Caretaker 可以随意修改 Memento 中的状态。
代码示例:
// 备忘录:简单的 POJO,状态对外暴露
public class Memento {
private String state;
public Memento(String state) {
this.state = state;
}
public String getState() {
return state;
}
public void setState(String state) {
this.state = state;
}
}
// 发起人
public class Originator {
private String state;
public void setState(String state) {
this.state = state;
}
public String getState() {
return state;
}
public Memento saveStateToMemento() {
return new Memento(state);
}
public void getStateFromMemento(Memento memento) {
this.state = memento.getState();
}
}
// 管理者
public class Caretaker {
private Memento memento;
public void setMemento(Memento memento) {
this.memento = memento;
}
public Memento getMemento() {
return memento;
}
}缺点:Caretaker 获取到 Memento 后,可以随意修改 state,这可能会导致 Originator 恢复到一个错误的状态。
2.2 黑箱备忘录(Black-box Memento)—— 推荐做法
黑箱模式是备忘录模式的标准实现。它通过将 Memento 定义为 Originator 的私有内部类,或者使用接口标记,使得只有 Originator 能访问 Memento 的内部状态,而 Caretaker 只能看到一个空的接口(标识接口)。
代码结构:
- 定义一个窄接口
IMemento,对外提供(Caretaker 只能看到这个)。 Originator内部定义私有静态内部类Memento实现IMemento,存储实际状态。
代码实现:
// 1. 窄接口:对外只是一个标识,没有任何方法
public interface IMemento {
}
// 2. 发起人:包含内部类
public class Originator {
private String state;
public void setState(String state) {
this.state = state;
System.out.println("当前状态:" + state);
}
// 创建备忘录:返回的是接口,但实际上是内部类实例
public IMemento save() {
return new Memento(state);
}
// 恢复备忘录
public void restore(IMemento memento) {
if (!(memento instanceof Memento)) {
throw new IllegalArgumentException("Unknown memento class");
}
this.state = ((Memento) memento).state;
}
// 私有内部类:真正的备忘录,只有 Originator 能访问
private static class Memento implements IMemento {
private String state;
private Memento(String state) {
this.state = state;
}
}
}
// 3. 管理者
public class Caretaker {
// 只能持有 IMemento 接口,无法访问内部属性
private IMemento memento;
public void saveMemento(IMemento memento) {
this.memento = memento;
}
public IMemento retrieveMemento() {
return this.memento;
}
}
// 4. 客户端
public class Client {
public static void main(String[] args) {
Originator originator = new Originator();
Caretaker caretaker = new Caretaker();
// 状态 1
originator.setState("State #1");
// 状态 2
originator.setState("State #2");
// 存档
caretaker.saveMemento(originator.save());
// 状态 3
originator.setState("State #3");
// 读档
originator.restore(caretaker.retrieveMemento());
System.out.println("恢复后的状态:" + originator.save()); // 这里只是为了验证,实际 save 返回的是对象
}
}优点:
- 安全性高:
Caretaker拿到的是IMemento接口,无法访问state,也无法修改。 - 封装性好:
Memento的具体实现细节完全被隐藏在Originator内部。
3. 实战案例:文本编辑器撤销功能
我们来模拟一个更复杂的场景:一个简单的文本编辑器,支持输入文本、修改光标位置,并支持无限步撤销(Undo)。
3.1 需求分析
- 编辑器属性:
content(文本内容),cursorPosition(光标位置)。 - 操作:输入文字,移动光标。
- 撤销:Ctrl+Z,回到上一步。
3.2 代码实现
import java.util.Stack;
// 1. 备忘录接口
interface EditorMemento {
}
// 2. 编辑器 (Originator)
class TextEditor {
private StringBuilder content = new StringBuilder();
private int cursorPosition = 0;
public void append(String text) {
content.insert(cursorPosition, text);
cursorPosition += text.length();
print();
}
public void delete() {
if (cursorPosition > 0 && content.length() > 0) {
content.deleteCharAt(cursorPosition - 1);
cursorPosition--;
print();
}
}
// 创建快照
public EditorMemento save() {
return new Snapshot(content.toString(), cursorPosition);
}
// 恢复快照
public void restore(EditorMemento memento) {
Snapshot snapshot = (Snapshot) memento;
this.content = new StringBuilder(snapshot.content);
this.cursorPosition = snapshot.cursorPosition;
print();
}
private void print() {
System.out.println("当前内容: [" + content + "], 光标: " + cursorPosition);
}
// 私有内部类实现备忘录
private static class Snapshot implements EditorMemento {
private final String content;
private final int cursorPosition;
private Snapshot(String content, int cursorPosition) {
this.content = content;
this.cursorPosition = cursorPosition;
}
}
}
// 3. 历史记录管理者 (Caretaker)
class History {
private Stack<EditorMemento> undoStack = new Stack<>();
private TextEditor editor;
public History(TextEditor editor) {
this.editor = editor;
}
// 执行操作前先备份
public void backup() {
undoStack.push(editor.save());
}
// 撤销
public void undo() {
if (!undoStack.isEmpty()) {
EditorMemento memento = undoStack.pop();
editor.restore(memento);
} else {
System.out.println("无法撤销:已经是初始状态");
}
}
}
// 4. 测试客户端
public class EditorClient {
public static void main(String[] args) {
TextEditor editor = new TextEditor();
History history = new History(editor);
System.out.println("--- 开始编辑 ---");
// 操作1
history.backup();
editor.append("Hello");
// 操作2
history.backup();
editor.append(" World");
// 操作3:删除一个字符
history.backup();
editor.delete();
System.out.println("\n--- 开始撤销 ---");
// 第一次撤销:恢复删除前
System.out.print("Undo 1: ");
history.undo();
// 第二次撤销:恢复 World 输入前
System.out.print("Undo 2: ");
history.undo();
// 第三次撤销:恢复 Hello 输入前
System.out.print("Undo 3: ");
history.undo();
}
}输出结果:
--- 开始编辑 ---
当前内容: [Hello], 光标: 5
当前内容: [Hello World], 光标: 11
当前内容: [Hello Worl], 光标: 10
--- 开始撤销 ---
Undo 1: 当前内容: [Hello World], 光标: 11
Undo 2: 当前内容: [Hello], 光标: 5
Undo 3: 当前内容: [], 光标: 04. 源码中的备忘录模式
4.1 Java Servlet API
在 Java Web 开发中,HttpSession 某种程度上扮演了 Caretaker 的角色,而 session 中存储的对象状态(Attribute)就是 Memento。当我们在不同的请求之间恢复用户状态时,就是在应用备忘录模式的思想(虽然实现上更多是 Map 存储)。
4.2 Spring Web Flow
Spring Web Flow 是 Spring 的一个子项目,用于管理复杂的 Web 流程。其中的 StateManageableMessageContext 接口就涉及到了状态的管理和恢复。
4.3 数据库事务 (Transaction)
数据库的事务管理是备忘录模式最硬核的应用。
Savepoint:SQL 中的保存点,就是一个 Memento。ROLLBACK TO Savepoint:回滚到保存点,就是 restore。- Undo Log:数据库底层的 Undo Log 记录了事务修改前的旧值,用于事务回滚,这也是备忘录模式的一种底层实现形式。
5. 备忘录模式优缺点与避坑指南
5.1 优点
- 提供了一种状态恢复机制:让用户可以方便地回到历史状态(后悔药)。
- 实现了信息的封装:使得用户不需要关心状态的保存细节,由 Originator 自己完成,不需要暴露内部成员变量。
5.2 缺点
- 资源消耗大:这是备忘录模式最大的痛点。如果对象的状态数据很大(例如一张高清图片),或者状态改变很频繁,那么保存大量的历史状态会占用巨量的内存。
- 管理成本高:需要维护历史列表,还要考虑何时清理旧的历史记录。
5.3 避坑与优化
- 增量存储:不要每次都全量备份。可以只记录发生变化的属性(Delta),或者使用命令模式(Command Pattern)记录操作指令,通过逆向操作来恢复(Time Machine 机制)。
- 限制历史记录数量:使用有界栈或循环队列,只保留最近的 N 次操作(例如 Photoshop 默认只保留 50 步历史)。
- 软引用/弱引用:对于非关键的备忘录,可以使用 Java 的
SoftReference,在内存不足时自动回收,防止 OOM。
6. 总结
备忘录模式是“时间机器”的核心。在需要“撤销”、“回滚”、“恢复现场”的场景下,它是不二之选。
- 如果你需要保存对象的全部状态,且不希望破坏封装,用黑箱备忘录。
- 如果状态数据很大,请慎用,或考虑增量备份。
7. 实验实操
在 design-patterns-web 项目中,你可以体验一个支持历史快照的文本编辑器。 这个 Demo 完美复刻了备忘录模式的核心功能:
- 创建快照:输入一段文字后,点击 "Save Snapshot" 按钮,系统会创建一个包含当前文本和时间戳的备忘录对象。
- 时光倒流:在右侧的 "History" 列表中点击任意一个历史记录,编辑器内容会瞬间恢复到那个时刻的状态。
- 封装性:你可以观察到,虽然我们能看到历史列表,但无法直接修改快照中的内容,保证了历史数据的安全性。 截图建议:先输入并保存几个不同版本的文本,然后点击其中一个旧版本的快照进行恢复。截取此时的编辑器界面,重点包含编辑器中的文字内容和右侧高亮选中的历史记录项。