欢迎你来读这篇博客,这篇博客主要是关于备忘录模式。
其中包括备忘录模式的核心思想、适用场景、优缺点、撤销与恢复、快照管理、与命令模式/原型模式/状态模式/事件溯源的区别,以及 Java 后端开发中配置版本回滚的完整案例。
序言
在软件开发中,我们经常会遇到一个很朴素但很重要的需求:
当前状态可能会被修改,但我希望必要时能恢复到之前的某个状态。
比如:
- 文本编辑器撤销上一步;
- 表单编辑时恢复草稿;
- 配置中心修改后回滚;
- 工作流节点状态恢复;
- 游戏存档和读档;
- 审批单修改前保存快照;
- 后台管理配置变更保留历史版本;
- 订单复杂操作前保存状态,以便失败后补偿。
这些场景背后的共同点是:
需要保存对象某一时刻的状态,并在将来恢复。
备忘录模式就是为了解决这类问题:
在不破坏对象封装性的前提下,捕获并保存对象内部状态,以便以后恢复到该状态。
简单说:
备忘录模式就是给对象拍一张“状态快照”。
对象后面怎么变都没关系,只要快照还在,就可以回到过去。
当然,别幻想什么都能回到过去。代码里的状态可以恢复,线上误删数据就不一定了。所以备忘录模式能救的是对象状态,不是凌晨三点没备份的你。
正文
chapter 1:什么是备忘录模式
备忘录模式,英文是 Memento Pattern,属于行为型设计模式。
它的定义是:
在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以便以后将对象恢复到原先保存的状态。
这句话里面有三个关键点:
- 保存对象内部状态;
- 不破坏对象封装;
- 以后可以恢复状态。
备忘录模式一般包含三个角色:
- Originator 发起人:需要保存和恢复状态的对象。
- Memento 备忘录:保存发起人某一时刻的状态。
- Caretaker 管理者:负责保存备忘录,但不应该修改或理解备忘录内部状态。
结构如下:
1 2 3 4 5 6 7 8 9 10
| Originator │ createMemento() ▼ Memento
Caretaker │ save(memento) │ get() ▼ Memento
|
发起人负责创建快照,也负责根据快照恢复自己。
管理者负责保存快照,但不应该直接修改快照内容。
chapter 2:为什么需要备忘录模式
假设我们有一个文章编辑器。
1 2 3 4 5 6 7 8
| public class ArticleEditor {
private String title;
private String content;
private String author; }
|
用户编辑文章时,希望支持撤销。
如果没有备忘录模式,可能会在外部直接保存字段:
1 2 3
| String oldTitle = editor.getTitle(); String oldContent = editor.getContent(); String oldAuthor = editor.getAuthor();
|
然后恢复时:
1 2 3
| editor.setTitle(oldTitle); editor.setContent(oldContent); editor.setAuthor(oldAuthor);
|
这种方式的问题是:
- 外部对象需要知道编辑器内部有哪些字段;
- 如果字段增加,外部保存逻辑也要改;
- 封装性被破坏;
- 状态保存和恢复逻辑分散;
- 容易漏字段;
- 复杂对象恢复很麻烦。
备忘录模式的做法是:
1
| ArticleMemento memento = editor.createMemento();
|
恢复时:
1
| editor.restore(memento);
|
外部管理者只保存 memento,不关心里面到底有什么字段。
这样对象状态的保存和恢复都由对象自己控制。
chapter 3:备忘录模式解决的核心问题
备忘录模式主要解决的是:
如何在不暴露对象内部细节的前提下,保存并恢复对象状态。
它特别强调“封装性”。
不好的做法是让外部知道对象内部字段:
1 2 3 4
| oldTitle = editor.getTitle(); oldContent = editor.getContent(); oldStatus = editor.getStatus(); oldTags = editor.getTags();
|
好的做法是让对象自己创建快照:
1
| ArticleMemento memento = editor.createMemento();
|
外部只管保存快照。
对象自己知道如何恢复:
1
| editor.restore(memento);
|
这就是备忘录模式和普通 DTO 复制的区别。
chapter 4:备忘录模式的核心角色
1. Originator 发起人
发起人是状态真正所属的对象。
它负责:
- 创建备忘录;
- 从备忘录恢复状态;
- 维护自己的业务状态。
例如:
1 2 3 4
| ArticleEditor ConfigDocument GameRole WorkflowContext
|
2. Memento 备忘录
备忘录保存发起人某一时刻的状态。
它通常应该是不可变对象。
例如:
1 2 3 4 5 6 7 8
| public class ArticleMemento {
private final String title;
private final String content;
private final String author; }
|
3. Caretaker 管理者
管理者负责保存备忘录。
例如:
- 历史栈;
- 版本列表;
- 数据库快照表;
- Redis 快照;
- 文件存档。
它不应该修改备忘录内部状态。
1 2 3 4
| public class History {
private final Deque<ArticleMemento> snapshots = new ArrayDeque<>(); }
|
chapter 5:最简单的备忘录模式示例
先写一个文章编辑器。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| public class ArticleEditor {
private String title;
private String content;
private String author;
public ArticleEditor(String title, String content, String author) { this.title = title; this.content = content; this.author = author; }
public void update(String title, String content, String author) { this.title = title; this.content = content; this.author = author; }
public ArticleMemento createMemento() { return new ArticleMemento(title, content, author); }
public void restore(ArticleMemento memento) { this.title = memento.getTitle(); this.content = memento.getContent(); this.author = memento.getAuthor(); }
public void print() { System.out.println("title = " + title + ",content = " + content + ",author = " + author); } }
|
备忘录对象:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| public class ArticleMemento {
private final String title;
private final String content;
private final String author;
public ArticleMemento(String title, String content, String author) { this.title = title; this.content = content; this.author = author; }
public String getTitle() { return title; }
public String getContent() { return content; }
public String getAuthor() { return author; } }
|
历史管理者:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| import java.util.ArrayDeque; import java.util.Deque;
public class ArticleHistory {
private final Deque<ArticleMemento> history = new ArrayDeque<>();
public void save(ArticleMemento memento) { history.push(memento); }
public ArticleMemento undo() { if (history.isEmpty()) { throw new IllegalStateException("No history memento"); }
return history.pop(); } }
|
客户端:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| public class MementoDemo {
public static void main(String[] args) { ArticleEditor editor = new ArticleEditor( "设计模式", "今天开始写设计模式", "SuperMario" );
ArticleHistory history = new ArticleHistory();
history.save(editor.createMemento());
editor.update( "设计模式:备忘录模式", "备忘录模式用于保存和恢复对象状态", "SuperMario" );
editor.print();
editor.restore(history.undo());
editor.print(); } }
|
这个例子体现了备忘录模式的基本流程:
- 修改前保存快照;
- 修改对象状态;
- 需要撤销时恢复快照。
chapter 6:为什么备忘录对象最好不可变
备忘录对象保存的是历史状态。
历史状态一旦创建,最好不要再被修改。
所以备忘录对象一般设计成不可变对象:
1 2 3 4 5 6 7 8
| public class ArticleMemento {
private final String title;
private final String content;
private final String author; }
|
不建议提供 setter:
1 2 3
| public void setTitle(String title) { this.title = title; }
|
原因很简单:
快照如果能被修改,就不再是可靠快照。
如果管理者或其他对象可以修改备忘录,恢复时就可能恢复到一个被污染的状态。
这就像你存档之后,游戏还偷偷改了存档文件。读档时发现自己从勇者变成村口 NPC,心态当场爆炸。
chapter 7:备忘录模式和封装性
备忘录模式非常强调封装。
理想情况下,Caretaker 不应该知道备忘录里保存了什么。
它只负责保存和取出。
1 2
| history.save(editor.createMemento()); editor.restore(history.undo());
|
管理者不应该这样写:
1 2
| memento.setTitle("xxx"); memento.setContent("yyy");
|
也不应该根据备忘录内部字段做复杂业务判断。
因为备忘录内部结构属于发起人对象。
外部只保存,不解释。
在 Java 中,如果想更强地保护封装,可以把备忘录定义为发起人内部类。
chapter 8:使用内部类保护备忘录
可以把备忘录定义成 ArticleEditor 的静态内部类。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| public class ArticleEditor {
private String title;
private String content;
private String author;
public ArticleEditor(String title, String content, String author) { this.title = title; this.content = content; this.author = author; }
public void update(String title, String content, String author) { this.title = title; this.content = content; this.author = author; }
public Memento createMemento() { return new Memento(title, content, author); }
public void restore(Memento memento) { this.title = memento.title; this.content = memento.content; this.author = memento.author; }
public static class Memento {
private final String title;
private final String content;
private final String author;
private Memento(String title, String content, String author) { this.title = title; this.content = content; this.author = author; } } }
|
这里 Memento 的构造方法是 private。
外部无法随便创建备忘录。
只能通过:
创建。
这样可以更好地保护快照创建逻辑。
不过 Java 中内部类字段如果暴露 getter,外部仍然可以读到。
是否需要完全隐藏,要看业务场景。
chapter 9:支持多次撤销
前面的历史管理者只支持撤销到上一个状态。
如果要支持多次撤销,可以使用栈。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| import java.util.ArrayDeque; import java.util.Deque;
public class MementoHistory<T> {
private final Deque<T> undoStack = new ArrayDeque<>();
public void save(T memento) { undoStack.push(memento); }
public T pop() { if (undoStack.isEmpty()) { throw new IllegalStateException("No memento"); }
return undoStack.pop(); }
public boolean isEmpty() { return undoStack.isEmpty(); }
public int size() { return undoStack.size(); } }
|
使用:
1 2 3 4 5 6 7 8 9 10 11 12
| MementoHistory<ArticleMemento> history = new MementoHistory<>();
history.save(editor.createMemento());
editor.update("v1", "content v1", "Mario");
history.save(editor.createMemento());
editor.update("v2", "content v2", "Mario");
editor.restore(history.pop()); editor.restore(history.pop());
|
每次修改前先保存状态。
撤销时从栈顶取出最近一次快照。
chapter 10:支持撤销和重做
如果要支持撤销和重做,需要两个栈:
- undoStack:撤销栈;
- redoStack:重做栈。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| import java.util.ArrayDeque; import java.util.Deque;
public class UndoRedoHistory<T> {
private final Deque<T> undoStack = new ArrayDeque<>();
private final Deque<T> redoStack = new ArrayDeque<>();
public void save(T memento) { undoStack.push(memento);
redoStack.clear(); }
public T undo(T currentMemento) { if (undoStack.isEmpty()) { throw new IllegalStateException("No undo memento"); }
redoStack.push(currentMemento);
return undoStack.pop(); }
public T redo(T currentMemento) { if (redoStack.isEmpty()) { throw new IllegalStateException("No redo memento"); }
undoStack.push(currentMemento);
return redoStack.pop(); } }
|
使用思路:
1 2 3 4 5 6 7 8 9
| history.save(editor.createMemento());
editor.update(...);
ArticleMemento previous = history.undo(editor.createMemento()); editor.restore(previous);
ArticleMemento next = history.redo(editor.createMemento()); editor.restore(next);
|
这里有一个细节:
撤销前要把当前状态放入 redoStack,重做时才能回来。
这就是很多编辑器撤销/重做的基本思想。
chapter 11:备忘录模式案例背景:配置版本回滚
前面的文章编辑器适合理解原理。
下面讲一个 Java 后端更常见的场景:配置版本回滚。
假设我们做一个配置管理系统。
配置包括:
- 配置 key;
- 配置 value;
- 描述;
- 是否启用;
- 更新时间;
- 更新人。
每次修改配置前,都要保存一份快照。
如果新配置有问题,可以回滚到某个历史版本。
这就是备忘录模式非常典型的后端落地。
chapter 12:定义配置文档 ConfigDocument
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93
| import java.time.LocalDateTime;
public class ConfigDocument {
private final String configKey;
private String configValue;
private String description;
private boolean enabled;
private String updatedBy;
private LocalDateTime updatedAt;
public ConfigDocument(String configKey, String configValue, String description, boolean enabled, String updatedBy) { if (configKey == null || configKey.isBlank()) { throw new IllegalArgumentException("configKey can not be blank"); }
this.configKey = configKey; this.configValue = configValue; this.description = description; this.enabled = enabled; this.updatedBy = updatedBy; this.updatedAt = LocalDateTime.now(); }
public ConfigSnapshot createSnapshot(String snapshotBy) { return new ConfigSnapshot( configKey, configValue, description, enabled, updatedBy, updatedAt, snapshotBy, LocalDateTime.now() ); }
public void restore(ConfigSnapshot snapshot, String operator) { if (!this.configKey.equals(snapshot.getConfigKey())) { throw new IllegalArgumentException("snapshot configKey not match"); }
this.configValue = snapshot.getConfigValue(); this.description = snapshot.getDescription(); this.enabled = snapshot.isEnabled(); this.updatedBy = operator; this.updatedAt = LocalDateTime.now(); }
public void update(String configValue, String description, boolean enabled, String operator) { this.configValue = configValue; this.description = description; this.enabled = enabled; this.updatedBy = operator; this.updatedAt = LocalDateTime.now(); }
public String getConfigKey() { return configKey; }
public String getConfigValue() { return configValue; }
public String getDescription() { return description; }
public boolean isEnabled() { return enabled; }
public String getUpdatedBy() { return updatedBy; }
public LocalDateTime getUpdatedAt() { return updatedAt; } }
|
这个类是 Originator。
它负责:
注意:
1 2
| createSnapshot() restore()
|
这两个方法都在 ConfigDocument 内部。
外部不需要知道如何复制和恢复字段。
chapter 13:定义配置快照 ConfigSnapshot
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70
| import java.time.LocalDateTime;
public class ConfigSnapshot {
private final String configKey;
private final String configValue;
private final String description;
private final boolean enabled;
private final String originalUpdatedBy;
private final LocalDateTime originalUpdatedAt;
private final String snapshotBy;
private final LocalDateTime snapshotAt;
public ConfigSnapshot(String configKey, String configValue, String description, boolean enabled, String originalUpdatedBy, LocalDateTime originalUpdatedAt, String snapshotBy, LocalDateTime snapshotAt) { this.configKey = configKey; this.configValue = configValue; this.description = description; this.enabled = enabled; this.originalUpdatedBy = originalUpdatedBy; this.originalUpdatedAt = originalUpdatedAt; this.snapshotBy = snapshotBy; this.snapshotAt = snapshotAt; }
public String getConfigKey() { return configKey; }
public String getConfigValue() { return configValue; }
public String getDescription() { return description; }
public boolean isEnabled() { return enabled; }
public String getOriginalUpdatedBy() { return originalUpdatedBy; }
public LocalDateTime getOriginalUpdatedAt() { return originalUpdatedAt; }
public String getSnapshotBy() { return snapshotBy; }
public LocalDateTime getSnapshotAt() { return snapshotAt; } }
|
这个类是 Memento。
它保存配置某一刻的状态。
字段全部是 final,对象不可变。
chapter 14:定义快照仓库 ConfigSnapshotRepository
真实项目中,快照通常要持久化到数据库。
这里用内存模拟。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Optional;
public class ConfigSnapshotRepository {
private final List<ConfigSnapshot> snapshots = new ArrayList<>();
public void save(ConfigSnapshot snapshot) { snapshots.add(snapshot);
System.out.println("保存配置快照,configKey = " + snapshot.getConfigKey() + ",snapshotAt = " + snapshot.getSnapshotAt()); }
public Optional<ConfigSnapshot> findLatest(String configKey) { return snapshots.stream() .filter(snapshot -> snapshot.getConfigKey().equals(configKey)) .max(Comparator.comparing(ConfigSnapshot::getSnapshotAt)); }
public List<ConfigSnapshot> findAll(String configKey) { return snapshots.stream() .filter(snapshot -> snapshot.getConfigKey().equals(configKey)) .sorted(Comparator.comparing(ConfigSnapshot::getSnapshotAt).reversed()) .toList(); } }
|
这个类是 Caretaker 的一种实现。
它负责保存快照和查询快照。
它不负责修改快照。
chapter 15:定义配置服务 ConfigService
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
| import java.util.HashMap; import java.util.Map;
public class ConfigService {
private final Map<String, ConfigDocument> configStore = new HashMap<>();
private final ConfigSnapshotRepository snapshotRepository;
public ConfigService(ConfigSnapshotRepository snapshotRepository) { this.snapshotRepository = snapshotRepository; }
public void createConfig(String key, String value, String description, boolean enabled, String operator) { ConfigDocument document = new ConfigDocument( key, value, description, enabled, operator );
configStore.put(key, document);
System.out.println("创建配置,key = " + key + ",value = " + value); }
public void updateConfig(String key, String value, String description, boolean enabled, String operator) { ConfigDocument document = getConfig(key);
ConfigSnapshot snapshot = document.createSnapshot(operator);
snapshotRepository.save(snapshot);
document.update(value, description, enabled, operator);
System.out.println("更新配置,key = " + key + ",value = " + value); }
public void rollbackLatest(String key, String operator) { ConfigDocument document = getConfig(key);
ConfigSnapshot snapshot = snapshotRepository.findLatest(key) .orElseThrow(() -> new IllegalStateException("No snapshot found, key = " + key));
document.restore(snapshot, operator);
System.out.println("回滚配置,key = " + key + ",value = " + document.getConfigValue()); }
public ConfigDocument getConfig(String key) { ConfigDocument document = configStore.get(key);
if (document == null) { throw new IllegalArgumentException("config not found, key = " + key); }
return document; } }
|
这个服务中:
- 修改配置前先保存快照;
- 然后更新配置;
- 回滚时取最新快照;
- 通过
document.restore(snapshot) 恢复配置。
chapter 16:客户端使用配置回滚
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| public class ConfigMementoDemo {
public static void main(String[] args) { ConfigSnapshotRepository snapshotRepository = new ConfigSnapshotRepository();
ConfigService configService = new ConfigService(snapshotRepository);
configService.createConfig( "order.timeout.seconds", "30", "订单超时时间", true, "admin" );
configService.updateConfig( "order.timeout.seconds", "60", "订单超时时间调整为 60 秒", true, "admin" );
configService.updateConfig( "order.timeout.seconds", "120", "订单超时时间调整为 120 秒", true, "admin" );
ConfigDocument current = configService.getConfig("order.timeout.seconds");
System.out.println("当前配置值:" + current.getConfigValue());
configService.rollbackLatest("order.timeout.seconds", "rollback-admin");
ConfigDocument afterRollback = configService.getConfig("order.timeout.seconds");
System.out.println("回滚后配置值:" + afterRollback.getConfigValue()); } }
|
输出类似:
1 2 3 4 5 6 7 8
| 创建配置,key = order.timeout.seconds,value = 30 保存配置快照,configKey = order.timeout.seconds 更新配置,key = order.timeout.seconds,value = 60 保存配置快照,configKey = order.timeout.seconds 更新配置,key = order.timeout.seconds,value = 120 当前配置值:120 回滚配置,key = order.timeout.seconds,value = 60 回滚后配置值:60
|
这就是备忘录模式在后端配置回滚中的典型应用。
chapter 17:为什么修改前保存快照
在配置更新里,一般是在修改前保存快照。
1 2 3 4 5
| ConfigSnapshot snapshot = document.createSnapshot(operator);
snapshotRepository.save(snapshot);
document.update(...);
|
这样快照保存的是“旧状态”。
如果新配置有问题,可以恢复旧状态。
如果修改后再保存快照,那保存的是新状态,不适合做回滚。
当然,不同场景也可以同时保存修改前和修改后:
1 2
| beforeSnapshot afterSnapshot
|
这更适合审计和变更对比。
chapter 18:备忘录模式和审计日志
备忘录和审计日志看起来有点像,但目标不一样。
| 对比项 |
备忘录 |
审计日志 |
| 核心目的 |
恢复状态 |
记录谁在什么时候做了什么 |
| 保存内容 |
对象状态快照 |
操作行为、操作人、时间、结果 |
| 是否用于回滚 |
是 |
不一定 |
| 是否要求完整状态 |
通常需要 |
不一定 |
| 示例 |
配置快照 |
管理员修改配置日志 |
配置系统里,两者可以同时存在。
例如:
不要把审计日志当成备忘录。
如果日志只写了:
1
| admin 修改了配置 order.timeout.seconds
|
那是没法恢复配置值的。
chapter 19:备忘录模式和命令模式的区别
备忘录模式和命令模式都能支持撤销,但它们的角度不同。
| 对比项 |
备忘录模式 |
命令模式 |
| 核心目的 |
保存和恢复对象状态 |
将请求封装成对象 |
| 撤销方式 |
恢复之前的状态快照 |
执行 undo 或补偿命令 |
| 关注点 |
状态 |
操作 |
| 典型对象 |
Memento |
Command |
| 示例 |
恢复配置旧版本 |
撤销取消订单命令 |
备忘录模式适合:
命令模式适合:
有些场景两者可以结合。
例如文本编辑器:
- 命令模式封装每次编辑操作;
- 备忘录模式保存编辑前后的文本状态。
chapter 20:备忘录模式和原型模式的区别
备忘录模式和原型模式都和复制对象状态有关。
但目的不同。
| 对比项 |
备忘录模式 |
原型模式 |
| 核心目的 |
保存状态以便恢复 |
复制对象创建新对象 |
| 关注点 |
历史状态 |
对象创建 |
| 是否强调封装 |
强调 |
不一定 |
| 是否有管理者 |
通常有 Caretaker |
通常有原型工厂或客户端 |
| 示例 |
保存配置快照 |
复制报表模板 |
原型模式是:
备忘录模式是:
复制只是手段,恢复才是备忘录模式的目的。
chapter 21:备忘录模式和状态模式的区别
状态模式关注对象行为随状态变化而变化。
备忘录模式关注保存和恢复状态。
| 对比项 |
备忘录模式 |
状态模式 |
| 核心目的 |
保存和恢复对象状态 |
根据状态改变对象行为 |
| 关注点 |
状态快照 |
状态行为 |
| 是否需要历史 |
通常需要 |
不一定 |
| 示例 |
回滚配置版本 |
订单 CREATED/PAID/SHIPPED 状态流转 |
订单状态机适合状态模式。
订单修改前保存快照适合备忘录模式。
chapter 22:备忘录模式和事件溯源的区别
事件溯源,Event Sourcing,是另一种保存历史的方式。
它不直接保存对象快照,而是保存导致状态变化的一系列事件。
例如订单:
1 2 3 4
| OrderCreated OrderPaid OrderShipped OrderCancelled
|
通过重放事件,可以还原状态。
备忘录模式保存的是状态快照:
对比如下:
| 对比项 |
备忘录模式 |
事件溯源 |
| 保存内容 |
状态快照 |
状态变化事件 |
| 恢复方式 |
直接恢复快照 |
重放事件恢复 |
| 存储成本 |
快照可能较大 |
事件较小但数量多 |
| 历史解释能力 |
一般 |
很强 |
| 实现复杂度 |
较低 |
较高 |
| 典型场景 |
配置回滚、草稿恢复 |
金融账本、审计强系统 |
如果只是做配置版本回滚,备忘录模式就够了。
如果需要完整追溯每次状态变化的业务含义,可以考虑事件溯源。
别为了一个配置回滚引入完整事件溯源。用牛刀杀鸡还嫌鸡不够架构,这就过了。
chapter 23:备忘录模式和数据库快照表
在后端项目中,备忘录经常落地为数据库快照表。
例如配置快照表:
1 2 3 4 5 6 7 8 9 10
| config_snapshot id config_key config_value description enabled original_updated_by original_updated_at snapshot_by snapshot_at
|
每次修改配置前插入一条快照记录。
回滚时选择某个快照,把配置表恢复到该状态。
这种设计非常常见。
它就是备忘录模式的持久化版本。
chapter 24:Spring Boot 中落地备忘录模式
在 Spring Boot 项目中,可以这样分层:
1 2 3 4 5 6
| Controller -> ConfigApplicationService -> ConfigRepository -> ConfigSnapshotRepository -> ConfigDocument -> ConfigSnapshot
|
示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| @Service public class ConfigApplicationService {
private final ConfigRepository configRepository;
private final ConfigSnapshotRepository snapshotRepository;
public ConfigApplicationService(ConfigRepository configRepository, ConfigSnapshotRepository snapshotRepository) { this.configRepository = configRepository; this.snapshotRepository = snapshotRepository; }
@Transactional(rollbackFor = Exception.class) public void updateConfig(UpdateConfigCommand command) { ConfigDocument document = configRepository.findByKey(command.getConfigKey()) .orElseThrow(() -> new IllegalArgumentException("config not found"));
ConfigSnapshot snapshot = document.createSnapshot(command.getOperator());
snapshotRepository.save(snapshot);
document.update( command.getConfigValue(), command.getDescription(), command.isEnabled(), command.getOperator() );
configRepository.save(document); }
@Transactional(rollbackFor = Exception.class) public void rollback(RollbackConfigCommand command) { ConfigDocument document = configRepository.findByKey(command.getConfigKey()) .orElseThrow(() -> new IllegalArgumentException("config not found"));
ConfigSnapshot snapshot = snapshotRepository.findById(command.getSnapshotId()) .orElseThrow(() -> new IllegalArgumentException("snapshot not found"));
document.restore(snapshot, command.getOperator());
configRepository.save(document); } }
|
这个结构中:
ConfigDocument 是发起人;
ConfigSnapshot 是备忘录;
ConfigSnapshotRepository 是管理者的一部分;
ConfigApplicationService 负责编排用例和事务。
chapter 25:UpdateConfigCommand 示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| public class UpdateConfigCommand {
private final String configKey;
private final String configValue;
private final String description;
private final boolean enabled;
private final String operator;
public UpdateConfigCommand(String configKey, String configValue, String description, boolean enabled, String operator) { this.configKey = configKey; this.configValue = configValue; this.description = description; this.enabled = enabled; this.operator = operator; }
public String getConfigKey() { return configKey; }
public String getConfigValue() { return configValue; }
public String getDescription() { return description; }
public boolean isEnabled() { return enabled; }
public String getOperator() { return operator; } }
|
回滚命令:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| public class RollbackConfigCommand {
private final String configKey;
private final Long snapshotId;
private final String operator;
public RollbackConfigCommand(String configKey, Long snapshotId, String operator) { this.configKey = configKey; this.snapshotId = snapshotId; this.operator = operator; }
public String getConfigKey() { return configKey; }
public Long getSnapshotId() { return snapshotId; }
public String getOperator() { return operator; } }
|
这里也能看到命令模式和备忘录模式可以组合使用。
命令表达“修改配置”和“回滚配置”这两个操作。
备忘录保存配置的历史状态。
chapter 26:快照应该保存全量还是增量
备忘录模式落地时,一个常见问题是:
快照保存全量状态,还是只保存变化字段?
1. 全量快照
全量快照保存对象完整状态。
优点:
- 恢复简单;
- 逻辑清晰;
- 不依赖其他版本;
- 查询某个版本方便。
缺点:
2. 增量快照
增量快照只保存变化字段。
优点:
缺点:
- 恢复复杂;
- 需要合并多个增量;
- 容易出错;
- 查询历史版本更麻烦。
3. 实践建议
普通配置、表单、草稿、规则对象,优先全量快照。
只有对象非常大、变更非常频繁时,再考虑增量快照。
不要一开始就为了省一点存储,把恢复逻辑搞成考古现场。
chapter 27:快照数量如何控制
备忘录模式可能产生大量快照。
如果每次修改都保存一份,长期运行后快照表会越来越大。
常见控制策略:
1. 限制最近 N 个版本
例如每个配置只保留最近 20 个快照。
2. 按时间清理
例如只保留 90 天内的快照。
1
| snapshot_at >= now - 90 days
|
3. 关键版本永久保存
普通修改版本定期清理,但发布版本永久保存。
4. 手动标记版本
用户可以标记某个版本为“稳定版本”,不被清理。
5. 冷数据归档
历史快照归档到低成本存储。
实践中要结合业务重要性和存储成本。
chapter 28:快照恢复时要注意什么
恢复快照不是简单复制字段。
真实项目中要考虑很多问题。
1. 快照是否属于当前对象
必须校验:
1 2 3
| if (!configKey.equals(snapshot.getConfigKey())) { throw new IllegalArgumentException("snapshot not match"); }
|
否则可能拿错快照。
2. 当前对象是否允许回滚
有些状态不允许回滚。
例如已经发布到外部系统的配置,可能需要审批后回滚。
3. 回滚本身是否也要生成快照
通常建议回滚前也保存当前状态。
这样回滚之后还能再恢复回来。
4. 是否需要记录审计日志
回滚是高风险操作,必须记录:
- 谁回滚;
- 回滚到哪个版本;
- 回滚前状态;
- 回滚原因;
- 操作时间。
5. 是否需要通知下游
配置回滚后,可能需要通知应用刷新缓存。
例如:
- 发布配置变更事件;
- 刷新本地缓存;
- 通知网关;
- 通知业务服务。
备忘录模式只负责状态保存和恢复。
这些工程问题还要额外处理。
chapter 29:复杂对象快照中的深拷贝问题
如果对象内部包含可变集合或引用对象,创建快照时要注意深拷贝。
错误示例:
1 2 3 4 5 6 7 8
| public class RuleConfig {
private List<String> rules;
public RuleSnapshot createSnapshot() { return new RuleSnapshot(rules); } }
|
如果 RuleSnapshot 直接保存 rules 引用,那么原对象后续修改 rules,快照也会被影响。
正确做法:
1 2 3
| public RuleSnapshot createSnapshot() { return new RuleSnapshot(List.copyOf(rules)); }
|
快照中也要防止被修改:
1 2 3 4 5 6 7 8 9 10 11 12
| public class RuleSnapshot {
private final List<String> rules;
public RuleSnapshot(List<String> rules) { this.rules = List.copyOf(rules); }
public List<String> getRules() { return rules; } }
|
快照必须是稳定的历史记录。
不要让它和原对象共享可变内部状态。
chapter 30:备忘录模式的优点
1. 支持状态恢复
对象可以恢复到历史状态。
2. 不破坏封装
状态保存和恢复由对象自己完成,外部不需要知道内部细节。
3. 简化撤销和回滚
非常适合撤销、重做、配置回滚、草稿恢复。
4. 历史状态可管理
可以通过 Caretaker 保存多个快照。
5. 职责清晰
Originator 管状态,Memento 存状态,Caretaker 管历史。
chapter 31:备忘录模式的缺点
1. 可能占用大量内存或存储
如果对象很大,快照很多,成本会很高。
2. 深拷贝复杂
复杂对象创建快照时要考虑引用对象和集合。
3. 快照管理复杂
需要考虑快照数量、清理策略、版本选择、权限控制。
4. 恢复逻辑可能有业务限制
不是所有状态都能随便恢复。
5. 容易和审计日志混淆
快照用于恢复,日志用于记录行为,两者不是一回事。
chapter 32:适用场景
备忘录模式适合以下场景。
1. 需要撤销或重做
例如文本编辑器、图形编辑器、表单编辑。
2. 需要保存草稿
例如文章草稿、配置草稿、流程草稿。
3. 需要版本回滚
例如配置中心、规则引擎、模板管理。
4. 需要保存对象历史状态
例如审批单、工作流上下文、复杂表单。
5. 不希望暴露对象内部状态
外部只保存备忘录,不直接访问对象字段。
chapter 33:不适合使用的场景
以下场景不适合使用备忘录模式。
1. 对象状态很小且不需要恢复
没有恢复需求,就没必要保存快照。
2. 对象非常大且变化频繁
快照成本可能太高。
3. 状态不可逆或恢复意义不大
例如短信已经发出、外部支付已经完成。
4. 更适合事件溯源的场景
如果业务需要完整追踪每次变化原因和事件流,事件溯源可能更合适。
5. 只需要审计,不需要恢复
那用审计日志即可,不一定要快照。
chapter 34:真实项目中的实践建议
1. 修改前保存快照
大多数回滚场景都应该在修改前保存旧状态。
2. 快照对象尽量不可变
字段 final,集合使用不可变副本。
1 2
| List.copyOf(list) Map.copyOf(map)
|
3. 快照恢复要校验归属
不要让 A 对象恢复 B 对象的快照。
4. 回滚前也保存当前状态
这样可以支持“回滚后再恢复”。
5. 快照表要有清理策略
不要无限增长。
6. 区分快照和审计日志
快照用于恢复,审计用于追踪行为。
7. 大对象考虑增量快照或压缩
但不要过早复杂化。
8. 分布式配置回滚要通知缓存刷新
快照恢复只是数据库状态变了,应用缓存也要刷新。
9. 回滚操作要做权限控制
不是所有人都应该能回滚生产配置。
10. 不要把备忘录当万能后悔药
外部系统动作、真实资金动作、消息发送这类行为,不一定能靠快照恢复。
chapter 35:完整案例代码汇总
配置文档
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93
| import java.time.LocalDateTime;
public class ConfigDocument {
private final String configKey;
private String configValue;
private String description;
private boolean enabled;
private String updatedBy;
private LocalDateTime updatedAt;
public ConfigDocument(String configKey, String configValue, String description, boolean enabled, String updatedBy) { if (configKey == null || configKey.isBlank()) { throw new IllegalArgumentException("configKey can not be blank"); }
this.configKey = configKey; this.configValue = configValue; this.description = description; this.enabled = enabled; this.updatedBy = updatedBy; this.updatedAt = LocalDateTime.now(); }
public ConfigSnapshot createSnapshot(String snapshotBy) { return new ConfigSnapshot( configKey, configValue, description, enabled, updatedBy, updatedAt, snapshotBy, LocalDateTime.now() ); }
public void restore(ConfigSnapshot snapshot, String operator) { if (!this.configKey.equals(snapshot.getConfigKey())) { throw new IllegalArgumentException("snapshot configKey not match"); }
this.configValue = snapshot.getConfigValue(); this.description = snapshot.getDescription(); this.enabled = snapshot.isEnabled(); this.updatedBy = operator; this.updatedAt = LocalDateTime.now(); }
public void update(String configValue, String description, boolean enabled, String operator) { this.configValue = configValue; this.description = description; this.enabled = enabled; this.updatedBy = operator; this.updatedAt = LocalDateTime.now(); }
public String getConfigKey() { return configKey; }
public String getConfigValue() { return configValue; }
public String getDescription() { return description; }
public boolean isEnabled() { return enabled; }
public String getUpdatedBy() { return updatedBy; }
public LocalDateTime getUpdatedAt() { return updatedAt; } }
|
配置快照
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70
| import java.time.LocalDateTime;
public class ConfigSnapshot {
private final String configKey;
private final String configValue;
private final String description;
private final boolean enabled;
private final String originalUpdatedBy;
private final LocalDateTime originalUpdatedAt;
private final String snapshotBy;
private final LocalDateTime snapshotAt;
public ConfigSnapshot(String configKey, String configValue, String description, boolean enabled, String originalUpdatedBy, LocalDateTime originalUpdatedAt, String snapshotBy, LocalDateTime snapshotAt) { this.configKey = configKey; this.configValue = configValue; this.description = description; this.enabled = enabled; this.originalUpdatedBy = originalUpdatedBy; this.originalUpdatedAt = originalUpdatedAt; this.snapshotBy = snapshotBy; this.snapshotAt = snapshotAt; }
public String getConfigKey() { return configKey; }
public String getConfigValue() { return configValue; }
public String getDescription() { return description; }
public boolean isEnabled() { return enabled; }
public String getOriginalUpdatedBy() { return originalUpdatedBy; }
public LocalDateTime getOriginalUpdatedAt() { return originalUpdatedAt; }
public String getSnapshotBy() { return snapshotBy; }
public LocalDateTime getSnapshotAt() { return snapshotAt; } }
|
快照仓库
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Optional;
public class ConfigSnapshotRepository {
private final List<ConfigSnapshot> snapshots = new ArrayList<>();
public void save(ConfigSnapshot snapshot) { snapshots.add(snapshot);
System.out.println("保存配置快照,configKey = " + snapshot.getConfigKey() + ",snapshotAt = " + snapshot.getSnapshotAt()); }
public Optional<ConfigSnapshot> findLatest(String configKey) { return snapshots.stream() .filter(snapshot -> snapshot.getConfigKey().equals(configKey)) .max(Comparator.comparing(ConfigSnapshot::getSnapshotAt)); }
public List<ConfigSnapshot> findAll(String configKey) { return snapshots.stream() .filter(snapshot -> snapshot.getConfigKey().equals(configKey)) .sorted(Comparator.comparing(ConfigSnapshot::getSnapshotAt).reversed()) .toList(); } }
|
配置服务
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
| import java.util.HashMap; import java.util.Map;
public class ConfigService {
private final Map<String, ConfigDocument> configStore = new HashMap<>();
private final ConfigSnapshotRepository snapshotRepository;
public ConfigService(ConfigSnapshotRepository snapshotRepository) { this.snapshotRepository = snapshotRepository; }
public void createConfig(String key, String value, String description, boolean enabled, String operator) { ConfigDocument document = new ConfigDocument( key, value, description, enabled, operator );
configStore.put(key, document);
System.out.println("创建配置,key = " + key + ",value = " + value); }
public void updateConfig(String key, String value, String description, boolean enabled, String operator) { ConfigDocument document = getConfig(key);
ConfigSnapshot snapshot = document.createSnapshot(operator);
snapshotRepository.save(snapshot);
document.update(value, description, enabled, operator);
System.out.println("更新配置,key = " + key + ",value = " + value); }
public void rollbackLatest(String key, String operator) { ConfigDocument document = getConfig(key);
ConfigSnapshot snapshot = snapshotRepository.findLatest(key) .orElseThrow(() -> new IllegalStateException("No snapshot found, key = " + key));
document.restore(snapshot, operator);
System.out.println("回滚配置,key = " + key + ",value = " + document.getConfigValue()); }
public ConfigDocument getConfig(String key) { ConfigDocument document = configStore.get(key);
if (document == null) { throw new IllegalArgumentException("config not found, key = " + key); }
return document; } }
|
客户端
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| public class ConfigMementoDemo {
public static void main(String[] args) { ConfigSnapshotRepository snapshotRepository = new ConfigSnapshotRepository();
ConfigService configService = new ConfigService(snapshotRepository);
configService.createConfig( "order.timeout.seconds", "30", "订单超时时间", true, "admin" );
configService.updateConfig( "order.timeout.seconds", "60", "订单超时时间调整为 60 秒", true, "admin" );
configService.updateConfig( "order.timeout.seconds", "120", "订单超时时间调整为 120 秒", true, "admin" );
ConfigDocument current = configService.getConfig("order.timeout.seconds");
System.out.println("当前配置值:" + current.getConfigValue());
configService.rollbackLatest("order.timeout.seconds", "rollback-admin");
ConfigDocument afterRollback = configService.getConfig("order.timeout.seconds");
System.out.println("回滚后配置值:" + afterRollback.getConfigValue()); } }
|
chapter 36:一句话总结
备忘录模式的本质是:
在不破坏对象封装性的前提下,保存对象某一时刻的状态,并在需要时恢复到该状态。
它特别适合:
- 撤销;
- 重做;
- 草稿保存;
- 配置回滚;
- 版本快照;
- 游戏存档;
- 表单恢复;
- 复杂对象状态恢复。
备忘录模式最关键的不是“复制字段”,而是:
谁有权创建快照,谁有权恢复状态,谁只负责保管快照。
好的备忘录模式像可靠存档:保存稳定、恢复准确、边界清楚。
坏的备忘录模式像截图备份数据库:看起来有历史,真要恢复时只能干瞪眼。
参考资料
- Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides. Design Patterns: Elements of Reusable Object-Oriented Software.
- Robert C. Martin. Agile Software Development, Principles, Patterns, and Practices.
- Joshua Bloch. Effective Java.
- Martin Fowler. Patterns of Enterprise Application Architecture.
- Eric Evans. Domain-Driven Design: Tackling Complexity in the Heart of Software.
- Refactoring Guru: Memento Pattern.
- SourceMaking: Memento Design Pattern.
启示录
富贵岂由人,时会高志须酬。
能成功于千载者,必以近察远。