1. 从"存档功能"说起:备忘录模式到底在解决什么问题
做了这么多年开发,我越来越觉得设计模式本质上是把日常编程中反复出现的"套路"沉淀下来,给它起个名字,方便大家交流。备忘录模式(Memento Pattern)就是其中一个特别贴近生活的套路——你玩过单机游戏吧?打Boss前先存个档,死了之后读档重来,这就是备忘录模式最朴素的原型。
在写业务代码的时候,"撤销"和"回滚"这两个需求几乎无处不在。文本编辑器里的Ctrl+Z,数据库里的事务回滚,Evenything软件里的操作撤销,甚至我们日常填写表单时不小心删掉一段内容想恢复,背后的逻辑都指向同一个问题:如何在保证对象内部封装不被破坏的前提下,把对象的状态保存下来,并在需要的时候恢复回去。
很多刚入行的同学第一反应是:直接把对象里的字段全部暴露出来,要保存的时候拷贝一份不就行了?听起来很合理,但实际做起来问题很大。第一,你把内部字段全部公开,意味着对象封装彻底失效,外部代码可以随便改动内部状态,整天提心吊胆;第二,当你需要保存的状态有几十个字段,再加上集合、嵌套对象的时候,"保存状态"和"恢复状态"这组动作看似简单,但在真实的业务场景里,隐藏的细节和坑远比你想象的多。接下来我从三个方面详细拆解。
1.1 三个角色各司其职
标准的备忘录模式由三个角色构成:
-
发起人(Originator):就是那个状态需要被保存的对象。它知道自己哪些内部状态是核心状态,负责创建备忘录来记录当下的状态快照,也负责接收一个备忘录,把内部状态恢复到备忘录中记录的那个时间点。关键字:
createMemento()和restore(Memento m)。 -
备忘录(Memento):用来装状态快照的容器。它本身是一个"数据类",主要保存发起人在某个时刻的内部状态。这里有个设计关键点:备忘录的数据字段在对发起人自己暴露的范围内要足够多,要能覆盖所有需要恢复的状态;但在对外部其他对象(尤其是负责管理备忘录的Caretaker)暴露的范围内,必须尽量少——好的设计是让Caretaker完全看不到备忘录的内部数据,只能把它当一个"黑盒"传来传去。
-
管理者(Caretaker):负责保管备忘录对象,但不读备忘录里的数据内容。它只知道"我手里有一堆历史快照",在需要的时候把某一个快照交给发起人去恢复。Caretaker在游戏里就像是存档管理界面,在编辑器里就像是历史记录栈。
事务的回滚思想其实和备忘录模式是一脉相承的——MySQL的undo log、Git的版本库、Docker镜像的分层存储,本质上都在围绕"保存过去的状态,以便回到过去"这个核心问题展开。
1.2 为什么不能直接复制对象?
有人会问:我写个clone()方法,或者用反射把所有字段深拷贝一份,不也能实现吗?为什么非要搞一个Memento对象出来?
这个问题问得很好,我们来做一次正反对比。
先说clone()方案。Java的Cloneable接口是一个标记接口,它不强制你做深拷贝还是浅拷贝,完全取决于你怎么实现。如果你只是把引用类型字段原样复制(浅拷贝),那么原始对象和克隆对象共享同一个内部对象,你改了其中一个,另一个也跟着变,存档就形同虚设。如果要做深拷贝,你就得手动处理每一个嵌套对象,而且一旦对象结构发生变化(新增字段、调整层级),clone代码马上要同步修改,维护成本直线上升。
再说不加封装、直接Copy字段的方案。比如:
java复制// 这种做法看起来很直接,但问题很大
GameState state = new GameState();
state.setHp(player.getHp());
state.setMp(player.getMp());
state.setItems(new ArrayList<>(player.getItems()));
问题在哪儿?第一,你把Player的内部状态暴露给了外部调用方,外部代码不仅能看到、还能改动这些数据,这就是封装破坏;第二,如果有人在这个逻辑里加了一个状态字段却忘了在存档/读档里处理,它是一个专属的、只服务于发起人对象的"快照契约",它的核心价值在于帮助发起人自身完成状态管理,不是为了在其他模块里流转。
所以,用备忘录模式来"存档",本质上是把"游戏玩家"(外部业务方)和"游戏角色状态"(内部数据)隔离,玩家(Caretaker)只负责说"给我存档"和"给我读档",至于状态里面到底有什么、怎么恢复,是角色自己(Originator)的事情。
1.3 模式的价值边界与适用信号
备忘录模式不是一个"银弹",它的优势和劣势都很明显。我在实际项目中判断要不要用,通常会问自己三个问题:
- 这个对象的状态真的需要快照吗? 如果每次操作后状态都会大变,且用户/系统确有回滚需求,那才值得做。
- 状态的粒度有多大? 如果对象非常庞大、字段非常多,每次全量快照的开销会非常恐怖,这时候就要考虑只保存增量变化,或者把大对象拆分后再快照。
- Caretaker是否真的不需要读取备忘录内容? 如果业务上必须由外部逻辑基于快照内容做判断(比如"血量低于30%时自动读档"),那就说明这种读取需求应该被"翻译"成Originator自己对外提供的方法,而不是让外部直接读Memento内部数据。
只有当这三个问题都能给出满意的答案,我才会在代码里正式引入这套模式。在后面的实操部分,你会看到我用一个完整的Java示例来推演整个过程。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手实现:三种"存档"方案对比与核心代码
理论说完了,接下来上代码。这一节我会用Java写一个"游戏角色存档"的完整示例,并且给出三种不同实现风格的对比——从最简单的嵌套类实现,到有接口隔离的宽松实现,再到基于序列化的快照方案。每一种方案都对应不同的场景,我会逐个解释它们的取舍。
2.1 方案一:经典嵌套类实现(最推荐)
这是GoF书里的经典做法:把Memento定义成Originator的私有静态嵌套类。Java的访问控制机制保证了只有外部类可以访问嵌套类的私有成员,其他任何类都无法感知Memento的内部结构。
先定义发起人角色,这里用一个游戏角色:
java复制public class GameRole {
private int hp; // 血量
private int mp; // 蓝量
private int level; // 等级
private List<String> inventory; // 背包物品
public GameRole(int hp, int mp, int level, List<String> inventory) {
this.hp = hp;
this.mp = mp;
this.level = level;
this.inventory = new ArrayList<>(inventory);
}
// 核心业务:打Boss,打败了就能升级,打输了血量会扣到很低
public void fightBoss(boolean win) {
if (win) {
level++;
hp = 100;
mp = 100;
inventory.add("Boss掉落物-" + level);
} else {
hp = Math.max(1, hp - 80);
mp = Math.max(0, mp - 50);
}
}
// 保存当前状态
public Memento save() {
return new Memento(hp, mp, level, inventory);
}
// 恢复历史状态
public void restore(Memento memento) {
this.hp = memento.hp;
this.mp = memento.mp;
this.level = memento.level;
// 注意这里要重新拷贝,避免外部引用共享
this.inventory = new ArrayList<>(memento.inventory);
}
public void showStatus() {
System.out.println("等级:" + level + ",血量:" + hp + ",蓝量:" + mp + ",背包:" + inventory);
}
// 私有嵌套类:只有GameRole自己能用内部数据
private static class Memento {
private final int hp;
private final int mp;
private final int level;
private final List<String> inventory;
Memento(int hp, int mp, int level, List<String> inventory) {
this.hp = hp;
this.mp = mp;
this.level = level;
this.inventory = new ArrayList<>(inventory);
}
}
}
注意到几个关键细节:Memento的构造函数是私有的,只有外层类能调用;inventory字段用new ArrayList<>()做防御性复制,防止外部对原始列表的修改污染快照;而且Memento一旦创建,它的字段在生命周期内不可变(final)。
再写管理者角色:
java复制public class Caretaker {
private Stack<GameRole.Memento> history = new Stack<>();
public void save(GameRole role) {
history.push(role.save());
}
public void undo(GameRole role) {
if (!history.isEmpty()) {
role.restore(history.pop());
} else {
System.out.println("没有可回退的存档了");
}
}
}
这里我用Stack来管理历史快照,天然支持"撤销-重做"的后进先出语义。当然,具体用Stack、List还是数组,取决于业务需要——如果你要支持"任意时间点跳转",用List就够了;如果只是简单的单步撤销,Stack最合适。
外层调用代码:
java复制public class MementoDemo {
public static void main(String[] args) {
List<String> initItems = new ArrayList<>(Arrays.asList("草药", "铁剑"));
GameRole role = new GameRole(100, 100, 1, initItems);
Caretaker caretaker = new Caretaker();
// 开打前满状态,存档
caretaker.save(role);
System.out.println("--- BOSS战前 ---");
role.showStatus();
// 打输了,状态跌落谷底
role.fightBoss(false);
System.out.println("--- BOSS战败后 ---");
role.showStatus();
// 后悔了,读档重来
caretaker.undo(role);
System.out.println("--- 读档后 ---");
role.showStatus();
// 二番战,打赢了
role.fightBoss(true);
System.out.println("--- 二番战胜利 ---");
role.showStatus();
}
}
跑一次程序,输出结果就非常直观了:存档时状态完好,战败后状态惨淡,读档后恢复了开战前的满状态。整个过程里,Caretaker从头到尾不知道Memento内部存了多少个字段、每个字段是什么含义,它只是在"取存单、交存单"而已。
2.2 方案二:接口隔离实现(适用跨模块场景)
嵌套类方案虽好,但它有个前提:Memento只在发起人内部使用。如果你的系统是多模块的,比如一个独立的"存档中心"要存储各种类型对象的快照,那么你不可能让Memento作为某个具体发起人的私有成员——你需要一套统一的类型定义。
这时候可以用接口隔离。典型做法是定义两个接口:
java复制// 宽接口:给发起人用,能读取所有内部状态
public interface IWideMemento {
int getHp();
int getMp();
int getLevel();
List<String> getInventory();
}
// 窄接口:给管理者用,只是一个"不透明"的标记
public interface INarrowMemento {
}
然后发起人的Memento同时实现两个接口:
java复制public class GameRole {
private int hp;
private int mp;
private int level;
private List<String> inventory;
// ... 业务方法省略 ...
public INarrowMemento save() {
return new RoleMemento(hp, mp, level, inventory);
}
public void restore(INarrowMemento memento) {
if (!(memento instanceof IWideMemento)) {
throw new IllegalArgumentException("非法的备忘录类型");
}
IWideMemento wide = (IWideMemento) memento;
this.hp = wide.getHp();
this.mp = wide.getMp();
this.level = wide.getLevel();
this.inventory = new ArrayList<>(wide.getInventory());
}
private static class RoleMemento implements IWideMemento, INarrowMemento {
private final int hp;
private final int mp;
private final int level;
private final List<String> inventory;
RoleMemento(int hp, int mp, int level, List<String> inventory) {
this.hp = hp;
this.mp = mp;
this.level = level;
this.inventory = new ArrayList<>(inventory);
}
@Override
public int getHp() { return hp; }
@Override
public int getMp() { return mp; }
@Override
public int getLevel() { return level; }
@Override
public List<String> getInventory() { return new ArrayList<>(inventory); }
}
}
这样设计之后,外部Caretaker的代码只需要面向INarrowMemento编程,它根本看不到getHp()这些方法,完美实现"黑盒"。而发起人自己通过instanceof IWideMemento来确认这个备忘录是自己生成的,然后安全地读取内部数据。
这种方式的优点是灵活,适合在模块之间传对象;缺点是需要定义额外的接口,代码量稍多。而且有一个坑要提醒你:getInventory()返回的时候一定要做防御性复制,否则外部拿到ArrayList引用之后就能随意添加删除元素,快照就被污染了。
2.3 方案三:序列化快照(快但粗糙)
如果你不想手写字段拷贝,也不想定义一堆接口,还有一个粗暴的方案——利用Java的序列化/反序列化来做深拷贝快照。
java复制public class GameRole implements Serializable {
private static final long serialVersionUID = 1L;
private int hp;
private int mp;
private int level;
private List<String> inventory;
// ... 业务方法 ...
public byte[] save() {
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos)) {
oos.writeObject(this);
return baos.toByteArray();
} catch (IOException e) {
throw new RuntimeException("存档失败", e);
}
}
public void restore(byte[] snapshot) {
try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(snapshot))) {
GameRole copy = (GameRole) ois.readObject();
this.hp = copy.hp;
this.mp = copy.mp;
this.level = copy.level;
this.inventory = new ArrayList<>(copy.inventory);
} catch (IOException | ClassNotFoundException e) {
throw new RuntimeException("读档失败", e);
}
}
}
序列化方案的优点很突出:不用一个个字段手写赋值,对象结构变了只要字段名不变基本不需要改存档代码。但缺点同样致命:
- 性能差,尤其是大对象频繁快照时,序列化/反序列化极其耗时。
- 依赖实现细节,所有字段必须是可序列化的,含有非序列化对象就报错。
- 不透明,
byte[]数组对Caretaker完全不可读,连调试都困难。 - 版本兼容问题,字段变更时serialVersionUID不一致就会反序列化失败。
我的建议是:小项目、原型验证、或者操作频率极低的场景可以用序列化方案图个方便;生产环境的高频操作还是老老实实用嵌套类方案,或者用JSON/Protobuf这类结构化格式做快照,至少可读性和兼容性都有保障。
3. 实操难点与避坑指南
有了可运行的代码雏形,接下来要在真实项目里落地,还会遇到很多教科书上不讲的坑。我挑几个我自己踩过、也在Code Review里经常见到的,逐一说透。
3.1 深拷贝是最大的坑
这一点必须要反复强调。很多同学在实现Memento的时候,简单地把发起人里所有字段直接赋值给备忘录,以为"快照完成了"。但Java里的引用类型字段,赋值拷贝的只是引用地址。
举个例子,发起人里有一个List<String> inventory,你在创建备忘录时直接写this.inventory = inventory,那么备忘录和发起人实际上共享同一个List对象。后续如果业务代码往这个List里加了新物品,等于同时修改了"未来时间点"的备忘录内容——存档被篡改了,历史记录被污染了,撤销功能就全乱了。
正确做法:备忘录创建时,所有可变引用类型字段必须做深拷贝;恢复时,同样要用拷贝后的值来赋值,不能让外部引用和内部引用指向同一个可变对象。这里有一个比较隐蔽的点是容器套容器的情况,比如List<Map<String, List<Integer>>>,只做一层new ArrayList<>(原list)还不够,里面的Map和List仍然是共享引用,必须逐层深拷贝。
那么问题来了:如果对象层级很深,手写深拷贝会累死人。实操中有几个替代方案:
- 用序列化做深拷贝(上文方案三);
- 用对象映射工具如MapStruct、Dozer做深拷贝;
- 用JSON序列化(如Jackson、Gson)把对象转成字符串再parse回来;
- 自己写深拷贝工具类,用反射+循环处理集合和嵌套对象。
每种方案都有权衡,序列化深拷贝性能最差,JSON方案丢失对象类型信息,反射方案代码复杂。我个人的经验是:如果对象结构不复杂,手写深拷贝最直白可控;如果对象经常变来变去,优先选JSON方案,因为结构变了不用大改逻辑。
3.2 备忘录的访问权限控制
嵌套类实现和接口隔离实现,都要格外注意Memento的访问权限。原则永远是:对外尽可能封闭,对内尽可能开放。
如果在代码里不小心把Memento定义成public class RoleMemento,那么外部调用方虽然拿不到Memento对象(因为Caretaker没有暴露它的引用),但只要它通过某种方式拿到对象引用,就能直接调用Memento的getter看到内部状态——这违反备忘录模式"黑盒传输"的核心思想。更危险的是,如果Memento字段是可变的(比如没有用final),外部还能直接改它的值,让"历史"变得不可信。
实操建议:
- Memento字段一律
private final; - 构造函数保持私有(嵌套类方案自然保证);
- 不用生成public getter,除非有明确的读需求且调用方可信;
- 如果用接口隔离,窄接口里一个方法都不要定义,它就是空的标记接口。
3.3 快照粒度和存储开销的平衡
备忘录模式最容易被忽视的问题就是内存和性能。想象一下:一个聊天应用的消息列表,对象里可能包含了最近几百条消息的完整数据,每条消息还有图片、视频链接、表情包。如果每次用户发一条消息就做一次全量快照,内存会迅速被撑爆,而且创建快照的时间也很长。
业界常用的几个优化策略:
-
增量快照:不是每次都保存全量状态,而是记录"从上次存档到这次存档之间的变化"。比如文本编辑器里的撤销栈,往往只保存每次敲入的字符,而不是保存整个文档。
-
压缩存储:如果快照是字符串或者字节数组,可以在存入Caretaker时做压缩(比如GZIP),读取时再解压。以时间换空间。
-
快照数量上限:Caretaker里给历史记录设置容量上限,超出上限就丢弃最老的快照(类似LRU)。像Python的
deque(maxlen=N)就是干这个的。 -
懒保存:不立即创建快照,而是在真正需要回退时才临时构建上一个状态。这在某些场景下能省下大量无谓的开销。
3.4 状态的版本兼容
随着业务迭代,发起人对象可能会加字段、改字段类型、删字段。这时候,旧的备忘录里保存的状态如何兼容新的发起人?
比如你保存了一份历史快照到数据库里,上线新版本后,发起人多了个weapon字段,而旧快照里没有这个值。恢复时该怎么处理?常见的方案:
- 给Memento增加一个
version字段,恢复时检查版本号,做字段映射和默认值填充; - 给Memento增加迁移方法,
upgrade()负责把旧版本的数据转换为新版本; - 采用宽容读取原则:恢复时,快照里没有的字段用发起人的当前值兜底,快照里多余的字段忽略。
这三个策略可以组合使用。如果你用JSON做快照格式,那么天然就支持"容忍新增字段"(因为反序列化时未知字段默认忽略);如果字段被重命名,可能还需要在反序列化时配置别名。
4. 应用场景与实战效果评估
备忘录模式的应用场景比你想象的要广泛得多。我把自己在项目中用过、以及业界比较经典的使用场景整理了一下,方便大家对照判断自己手头的需求是否适用。
4.1 五个典型应用场景
1. 编辑器类软件的撤销/重做
这是备忘录模式最经典的应用。IDE、Word、Photoshop、画图板,它们的撤销栈本质上是"状态历史记录栈"。每次操作前把状态丢入栈,Ctrl+Z就是弹栈恢复。更复杂的实现会设置多个检查点(checkpoint),允许用户跳回任意历史版本。
2. 游戏存档/读档
单机游戏和手机游戏里的存档功能几乎必用,技术方案也多样化。我之前做过一款Roguelike手游,存档不仅包括角色属性、背包、地图,还包括随机种子和敌人的状态,恢复时必须全部还原。最开始用序列化方案,一次存档2MB,用户每次进地牢都要读档,体验很差。后来改用二进制协议,只保存核心字段+增量变化,存档体积缩小到200KB,读档从800ms降到150ms。
3. 表单设计的暂存与回退
在OA系统、报表系统、低代码平台的表单设计器里,用户经常要调整字段布局、绑定数据源。如果设计器没有撤销功能,用户误删一个控件就只能重新添加,非常痛苦。用备忘录模式,每次操作前把整个表单的结构保存一份,撤销时直接恢复,这是表单类产品体验提升的重要一环。
4. 数据库事务的"回滚日志"
MySQL的undo log、Redis的AOF、Git的commit,这些底层系统都用了类似备忘录的思想。它们不会把所有版本的数据全量存储,而是记录"如何撤销这个操作",比如更新前的行记录。这与备忘录模式中增量快照的思路完全一致。
5. 分布式系统中的补偿与快照
在微服务架构中,某个长流程事务跨了多个服务,如果中途某个环节失败,需要回滚之前已经执行的步骤。与其一个服务一个服务地发回滚指令,不如对核心实体做状态快照,失败时直接恢复快照。这种方式虽然不是严格意义的备忘录模式,但思想是相通的。
4.2 避免把模式用歪的警示
任何设计模式都不能为了用而用。在实际开发中,我见过很多"病态的备忘录模式":
- 把Caretaker和Originator合并成一个类,自己存自己存快照、自己恢复,结果就是状态管理逻辑全堆在一个巨型类里,类越来越难维护。
- 把备忘录对象对外暴露出getter,让业务层可以直接读快照的内容来做逻辑判断。这样做短期可能方便,长期会破坏封装,导致以后想改内部状态结构时,所有引用点都要跟着改。
- 对超大量对象做频繁全量快照,内存一下子撑爆。比如一个实时报表的服务,每次刷新报表数据前都把整个报表对象保存一遍,跑了一小时内存直接OOM。合理做法是保存"变更前的差异",而不是快照全量。
一旦发现自己的实现偏离了"发起人自己管状态、备忘录黑盒化、管理者只管保存"这三条原则,就要停下来重新审视设计。
4.3 模式对比:和原型模式、命令模式怎么区分?
不少同学容易把备忘录模式和原型模式搞混。两者确实很像——都需要复制对象。但它们的意图不同:
- 原型模式的核心是复用:通过克隆一个原型对象来快速创建新对象,重点是"生成新对象"。
- 备忘录模式的核心是恢复:保存旧状态,目的是未来某时刻能把发起人还原到旧状态,重点是"回到过去"。
另外,跟命令模式的对比也很有价值。命令模式把"请求"封装成对象,配合历史命令列表也能实现撤销——但它的撤销粒度在"命令"级别,恢复方式是执行反向命令(比如"删除"的反向命令是"插入");备忘录模式的撤销粒度在"状态"级别,恢复方式是直接还原快照。在复杂的编辑器中,两者常组合使用:命令记操作、备忘录记状态,两套机制互为补充。
5. 常见问题与排查技巧实录
备忘录模式代码量不大,但运行时出现问题往往隐蔽又棘手。我把实操中容易踩到的问题整理成一张速查表,并给出排查思路。
| 症状 | 可能原因 | 排查思路 | 解决方案 |
|---|---|---|---|
| 撤销后对象部分字段没恢复 | 备忘录创建时漏拷了新增字段 | 检查Originator新增字段是否同步更新了save/restore方法 | 用工具类或JSON快照避免手写字段遗漏 |
| 撤销后修改了备忘录内容 | 深拷贝没做,引用类型共享了同一个List/Map | 检查Memento里的集合字段是否通过new ArrayList包了一层 | 所有可变字段做深拷贝 |
| 内存持续增长直到OOM | 历史记录无上限,或者快照过于庞大 | 监控内存占用,查看Caretaker里快照数量 | 设置栈上限或压缩快照,改用增量存储 |
| 恢复时报类转换异常 | 使用接口隔离方案时,传入了其他类型实现的Memento | 检查restore方法里的instanceof判断 | 增加类型校验,失败时抛异常并打印日志 |
| 序列化存档反序列化失败 | 类新增/删除字段后serialVersionUID不一致 | 看异常堆栈,确认是哪个类的问题 | 显式声明serialVersionUID,或改用JSON方案 |
| 快照内容可被外部修改 | Memento字段未设为final且没有防御性复制 | 检查Memento代码,看getter是否返回原始引用 | 字段设为private final,getter返回copy |
| 撤销栈越界 | 栈设计不严谨,没有处理空栈情况 | 检查undo方法是否判断isEmpty | 在pop前判空,或提供默认兜底状态 |
从上面的表格可以总结出几个排查思路:一旦出现撤销后行为异常,优先怀疑深拷贝和字段遗漏;一旦出现内存问题,优先怀疑快照粒度和历史记录容量;一旦出现类型转换问题,优先怀疑接口隔离方案的类型校验。
排查的时候,我习惯先在Memento里临时加一个timestamp字段,并在save和restore方法里打印日志。这样能清晰看到"什么时候存的档、存的哪个值、什么时候恢复的、恢复到哪个值",问题定位速度快很多。排查完再去掉这个临时字段。
提示:关于
save/restore方法里的日志,建议使用参数化日志(如Slf4j的占位符),避免字符串拼接产生的性能开销。生产环境日志级别调成DEBUG,排查问题时动态打开。
6. 记忆技巧与再思考
最后分享一个我总结的"三句话记忆法",方便你在面试或者做技术方案时快速回忆起备忘录模式:
发起人生成快照,快照对外不可见,管理者只存不解。
这三句话对应模式的三个角色和三条核心约束,把代码结构回想起来之后,细节自然就跟着出来了。
在实际项目里,我还发现一个值得思考的现象:备忘录模式往往不是单独出现的,它经常和其他模式组合使用。例如:
- 和命令模式组合:命令负责封装操作,备忘录负责记录状态,撤销时先按命令栈弹栈,再用对应备忘录恢复状态。
- 和原型模式组合:Caretaker需要保存多种类型对象的快照时,可以结合原型模式按类型克隆存档,而不是为每个类型单独写一套Memento类。
- 和策略模式组合:选择全量快照、增量快照、还是压缩快照,可以由一个"快照策略"接口来统一管理,方便运行时动态切换。
组合使用的场景往往才是真实业务中更有价值的部分。就像"撤销"功能,如果只是单步撤销,用备忘录模式就够了;但遇到多步撤销、跨会话恢复、历史版本对比,就需要引入更多辅助机制。作为开发者的核心能力,不是会背几种模式的定义,而是能根据业务复杂度组合运用合适的工具。
这个内容后续我还会再扩展一下:比如用TypeScript在Node.js服务里实现一版带过期清理的备忘录池,或者把快照方案扩展到Redis里做分布式状态回滚。欢迎有类似需求的朋友一起聊聊你们在项目里是怎么处理状态回滚的。
