备忘录模式
从 Ctrl+Z 撤销说起
文本编辑器需要支持 Ctrl+Z 撤销。最直接的想法是把 TextEditor 的内容字段直接暴露出去,让外部的历史记录栈保存每一步快照。但这破坏了封装——一旦 TextEditor 修改了内部字段结构,外部的历史记录代码就全部失效。
备忘录模式的解法是:让 TextEditor 自己生成"快照"对象(Memento),外部只负责存储和归还这个不透明的快照,不需要知道它内部有什么字段。状态的保存和恢复都通过 TextEditor 自己的方法完成,封装性完好无损。
🔍 定义
备忘录模式(Memento)在不破坏封装性的前提下,捕获一个对象的内部状态并保存到外部,以便在需要时恢复到之前的状态。
⚠️ 不使用备忘录存在的问题
文本编辑器需要支持撤销,但直接让外部保存状态会破坏封装:
| MementoBadExample.java |
|---|
| package com.example.behavioral.memento;
/**
* 备忘录模式 - 反例
* 问题:撤销时需要在 UndoManagerBad 里直接暴露和操作编辑器内部状态
*/
public class MementoBadExample {
public static void main(String[] args) {
TextEditorBad editor = new TextEditorBad();
UndoManagerBad undo = new UndoManagerBad();
editor.setContent("Hello");
undo.save(editor.getContent()); // ❌ 必须手动获取内部状态
editor.setContent("Hello, World");
undo.save(editor.getContent()); // ❌ 仍然直接暴露内部状态
System.out.println("当前: " + editor.getContent()); // Hello, World
// 撤销时,又得直接操作内部状态
String last = undo.pop();
editor.setContent(last); // ❌ 外部直接 set 内部状态
System.out.println("撤销后: " + editor.getContent()); // Hello
}
}
// ❌ 编辑器内部状态对外完全暴露
class TextEditorBad {
private String content = "";
public String getContent() { return content; }
public void setContent(String text) { content = text; }
}
// ❌ 撤销管理器必须知道编辑器的内部字段类型
class UndoManagerBad {
private final java.util.Deque<String> stack = new java.util.ArrayDeque<>();
public void save(String state) { stack.push(state); }
public String pop() { return stack.isEmpty() ? "" : stack.pop(); }
}
|
🏗️ 设计模式结构说明
%%{init: {'themeVariables': {'noteBkgColor': 'transparent', 'noteBorderColor': '#768390'}}}%%
classDiagram
classDef default fill:transparent,stroke:#768390
class TextEditor {
-content: String
-cursorPos: int
+type(text) void
+save() Memento
+restore(m Memento) void
}
class Memento {
-content: String
-cursorPos: int
+getContent() String
+getCursorPos() int
}
class UndoManager {
-history: Deque~Memento~
+push(m Memento) void
+pop() Memento
}
TextEditor ..> Memento
UndoManager o--> Memento
note for TextEditor "原发器(Originator)"
note for Memento "备忘录(Memento)"
note for UndoManager "负责人(Caretaker)"
TextEditor 自己创建和恢复 Memento,外部(UndoManager)只存储不读取——封装性得到保护。
💻 设计模式举例说明
| MementoExample.java |
|---|
| package com.example.behavioral.memento;
import java.util.ArrayDeque;
import java.util.Deque;
/**
* 备忘录模式 - 正例
* TextEditor 自己创建和恢复快照(Memento),UndoManager 只管存储,不接触内部状态
*/
public class MementoExample {
public static void main(String[] args) {
TextEditor editor = new TextEditor();
UndoManager history = new UndoManager();
editor.type("Hello");
history.save(editor.save()); // 保存快照 ✅
editor.type(", World");
history.save(editor.save()); // 保存快照
editor.type("!!!");
System.out.println("当前: " + editor.getContent()); // Hello, World!!!
editor.restore(history.undo()); // ✅ 撤销,恢复到"Hello, World"
System.out.println("撤销: " + editor.getContent()); // Hello, World
editor.restore(history.undo()); // ✅ 再次撤销
System.out.println("再撤销: " + editor.getContent()); // Hello
}
}
// 备忘录:封装编辑器的内部状态快照(不可变)
class Memento {
private final String content; // 对外不可见,只有 TextEditor 能访问
Memento(String content) { this.content = content; }
String getContent() { return content; } // 包私有,TextEditor 可访问
}
// 原发器:TextEditor 自己负责创建和恢复快照
class TextEditor {
private String content = "";
public void type(String text) { content += text; }
// ✅ 自己创建快照,不暴露内部状态
public Memento save() { return new Memento(content); }
// ✅ 自己恢复快照,外部只传递 Memento 对象
public void restore(Memento memento) {
if (memento != null) { content = memento.getContent(); }
}
public String getContent() { return content; }
}
// 管理者:只负责存储 Memento,不关心其内容
class UndoManager {
private final Deque<Memento> history = new ArrayDeque<>();
public void save(Memento memento) { history.push(memento); }
public Memento undo() {
if (history.isEmpty()) { System.out.println("没有可撤销的操作"); return null; }
return history.pop(); // ✅ 返回 Memento,不需要理解其内部
}
}
|
⚖️ 优缺点
优点:
- 在不破坏封装的前提下保存和恢复对象状态
- 简化原发器:不需要自己管理历史版本
- 支持撤销/重做、事务回滚
缺点:
- 频繁保存快照会消耗大量内存(尤其对象状态较大时)
- 管理者需要跟踪原发器的生命周期
🔗 与其它模式的关系
组合使用:
备忘录常与命令模式配合——命令的 undo() 通过备忘录恢复执行前的完整状态,比在命令中单独记录每个字段的"前值"更简洁。
🗂️ 应用场景
- 文本编辑器、图形编辑器的撤销/重做
- 游戏存档
- 数据库事务回滚
- Spring:事务传播机制中的
Savepoint 类似备忘录的思想
🏭 工业视角
封装原则是备忘录的核心约束
备忘录的核心要求是:只有原发器(Originator)能读写快照内容,外部持有者只能存储和传递。违反这一约束会导致外部代码意外修改"历史状态",使撤销功能产生难以复现的 Bug。
正确做法:Snapshot 类只暴露 getter,不暴露任何修改方法;原发器用语义明确的 restoreSnapshot() 替代暴露给外部的 setText():
| 标准备忘录结构(封装保护) |
|---|
| public class Snapshot {
private final String text; // 不可变,不暴露 setter
public Snapshot(String text) { this.text = text; }
public String getText() { return text; }
// 没有 setText(),外部无法篡改历史状态
}
public class InputText {
private StringBuilder text = new StringBuilder();
public Snapshot createSnapshot() { // 原发器自己创建快照
return new Snapshot(text.toString());
}
public void restoreSnapshot(Snapshot snapshot) { // 恢复专用,语义明确
this.text.replace(0, this.text.length(), snapshot.getText());
}
}
|
大对象备份的内存优化:全量 + 增量结合
当被备份对象数据量大、备份频率高时,全量快照会带来严重的内存和时间开销。工业实践通常采用两种策略:
策略一:只保存最小恢复信息(适用于顺序单步撤销):记录文本长度等轻量标记,结合当前对象状态反推历史,以牺牲灵活性换取内存。
策略二:低频全量备份 + 高频增量备份(适用于持久化场景):每次改动只记录变化部分(增量),定期做一次全量快照,恢复时找最近全量备份再顺序重放增量。
工业级参考:MySQL 备份策略
MySQL 的典型备份方案是这一策略的教科书实现:定期全量备份(mysqldump 或 xtrabackup),
配合 binlog 增量记录每次写操作。恢复到任意时间点时,先应用最近的全量备份,再重放 binlog。
这正是备忘录模式在系统架构层面的体现——Redis 的 RDB + AOF 机制与此异曲同工。