设计模式修炼之路 ㉑ 备忘录模式——不破坏封装的存档术
不破坏封装的存档术、宽窄接口、Git 与 Savepoint、命令+备忘录合订
目录
行为型第八站。
⑱ 命令模式讲撤销时我们走了"逆操作"路线:撤销插入就是删除、撤销删除就是插回。轻巧,但有个前提——每个操作都必须可逆。
游戏角色可没有"逆操作":血量、蓝量、金币、装备、坐标……打 Boss 团灭了,想把角色恢复到进本之前,怎么办?难道把所有字段 getter/setter 全开放,让存档代码自己抄一份?——那封装就死透了,而且外挂狂喜。
备忘录模式就是为这个困境而生的:不破坏封装的前提下,把内部状态存下来、再原样恢复。
一、备忘录模式是什么
GoF 的原始定义:
Without violating encapsulation, capture and externalize an object's internal state so that the object can be restored to this state later. (在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以便以后需要时将该对象恢复到原先保存的状态。)
定义第一句就是"不破坏封装"——这是备忘录模式和"暴露字段随便抄"的分界线,也是它全部技巧所在。
三个角色:
| 角色 | 干什么 | 本文对应 |
|---|---|---|
| Originator(原发器) | 状态的主人:打包快照、从快照恢复 | GameRole |
| Memento(备忘录) | 状态快照的容器:对外是个黑盒 | RoleMemento |
| Caretaker(管理者) | 保管备忘录:只存不读、只搬运不拆箱 | RoleStateCaretaker |
这个模式解的是一个"既要又要"的矛盾:存档必须读取角色的状态(不然存什么),恢复必须写回角色的状态(不然怎么还原)——但一旦把读写开放出去,封装就没了。
解法是釜底抽薪:**打包和恢复这两个动作,都由角色亲自动手。**外界全程只搬运一个黑盒,从头到尾没碰到任何字段。类比游戏存档:存档时是你自己(角色)把状态写进存档卡,读档时也是你自己照着卡恢复——仓库管理员只负责把卡收好,他根本看不懂卡上写的是什么(也不想看懂)。
二、优点与缺点
优点:
- 封装完好无损。外界拿不到字段,连存档器自己都读不懂快照内容——角色加十个字段,存档器一行代码不用改。
- 状态恢复的完整性有保证。快照是角色亲手打包的,里面装了什么、怎么还原,只有角色知道,不会出现"漏抄一个字段"的残废存档。
- 职责干净。角色管打包还原,管理器管保管调度,各自单一职责。
- 快照可复用。存档槽可以做成列表——多存档位、自动存档、时间机器,都是管理者层面的小扩展。
缺点:
- 内存开销。每份快照都是一份状态的完整拷贝,状态一大多存几份,内存就顶不住(⑱ 反面教材 BadEditor 的快照栈就是教训——这是"高频快照 + 大状态"的组合陷阱)。
- 黑盒的代价:管理者想展示"存档详情"给用户看?做不到,内容被锁死了。需要展示就得额外开口子,封装的口子一开就收不回。
- 类数量增加。每个需要存档的原发器都得配一个备忘录类。
- 只适合"全量快照"语义:想只撤销"半步"(只回滚血量不回滚金币),备忘录原生不支持——粒度问题要靠拆多个备忘录类解决。
三、什么时候用
判断标准:需要把对象状态恢复到过去某个时点,且对象状态复杂/含私有细节,不能或不该用"逆操作"来表达。
- 游戏存档:角色状态、地图进度、任务进度——本文场景
- 事务保存点:数据库
SAVEPOINT/ROLLBACK TO,长事务打点回滚 - 编辑器/IDE 状态恢复:窗口布局、断点集、打开的文件列表
- 版本控制:每次 commit 都是一次备忘录
- 草稿箱/定时快照:表单自动保存、图文编辑器的历史版本
反面对照:操作可逆且高频(文本编辑的打字删除)——用 ⑱ 命令模式的增量撤销更轻;只是想跨进程传输对象——那是序列化,不需要"黑盒";状态就两三个字段——直接 copy 个 record 就完事了。
四、实现方式:游戏角色的存档与读档
4.1 反面教材:字段裸奔
先看最直觉的存档做法——把字段全开放出去,让存档代码自己抄:
public class BadGameRole {
private int hp = 100;
private int mp = 80;
private int gold = 500;
public void fightBoss() {
hp = 10;
mp = 20;
gold = 1200;
}
public void display(String tag) {
System.out.println(" [角色] " + tag + ":HP=" + hp + ", MP=" + mp + ", 金币=" + gold);
}
// 为了让外界能存档/读档,被迫开放所有字段的读写
// 代价一:存档代码必须认识每个字段,角色以后加字段它就漏抄
// 代价二:任何代码都能 setHp(99999)——外挂就是这么诞生的
public int getHp() { return hp; }
public void setHp(int hp) { this.hp = hp; }
public int getMp() { return mp; }
public void setMp(int mp) { this.mp = mp; }
public int getGold() { return gold; }
public void setGold(int gold) { this.gold = gold; }
}
存档方"人肉抄写":
int savedHp = bad.getHp();
int savedMp = bad.getMp();
int savedGold = bad.getGold();
bad.fightBoss();
bad.setHp(savedHp);
bad.setMp(savedMp);
bad.setGold(savedGold);
功能能用,三宗罪却在输出里现了形:存档代码必须逐字段认识角色(角色加个"装备列表",这里必漏);恢复靠人肉抄写(抄错一个字段,存档就是残废);setter 全公开——setHp(99999) 一行,外挂诞生。
4.2 正解:角色亲手打包,管理者只管搬运
第一步,原发器 + 备忘录。关键设计:RoleMemento 做成 GameRole 的静态内部类,字段和构造全是 private——嵌套类之间可以互访私有成员,但外部类(包括管理者和任何代码)统统摸不到:
// 原发器:拥有需要保存的状态,自己负责"打包快照"和"从快照恢复"
public class GameRole {
private int hp = 100;
private int mp = 80;
private int gold = 500;
public void fightBoss() {
hp = 10; // 打得很惨
mp = 20;
gold = 1200; // 但捡了点钱
}
// 打包当前状态:只有自己能创建和读取备忘录
public RoleMemento createMemento() {
return new RoleMemento(hp, mp, gold);
}
// 从快照恢复:只有自己读得到备忘录里的字段
public void restoreMemento(RoleMemento memento) {
this.hp = memento.hp;
this.mp = memento.mp;
this.gold = memento.gold;
}
public void display(String tag) {
System.out.println(" [角色] " + tag + ":HP=" + hp + ", MP=" + mp + ", 金币=" + gold);
}
// 备忘录:静态内部类,字段 private、构造 private
// 管理者和外界拿在手里也只是个黑盒——看得见对象,摸不到内容
public static class RoleMemento {
private final int hp;
private final int mp;
private final int gold;
private RoleMemento(int hp, int mp, int gold) {
this.hp = hp;
this.mp = mp;
this.gold = gold;
}
@Override
public String toString() {
return "快照 [HP=" + hp + ", MP=" + mp + ", 金币=" + gold + "]";
}
}
}
第二步,管理者。它干的就是存和还,内容与它无关:
// 管理者:只负责保管和交还备忘录,不看内容、不乱动
// 它持有 RoleMemento 引用,但字段和构造全是 private——想读想改都没门
public class RoleStateCaretaker {
private GameRole.RoleMemento memento;
public void saveMemento(GameRole.RoleMemento memento) {
this.memento = memento;
System.out.println(" [管理器] 存档完成:" + memento);
}
public GameRole.RoleMemento retrieveMemento() {
System.out.println(" [管理器] 交还存档:" + memento);
return memento;
}
}
第三步,测试——存档、打 Boss、团灭、读档:
public class MementoTest {
public static void main(String[] args) {
System.out.println("========== 反面教材:字段裸奔 ==========");
BadGameRole bad = new BadGameRole();
bad.display("战斗前");
int savedHp = bad.getHp();
int savedMp = bad.getMp();
int savedGold = bad.getGold();
bad.fightBoss();
bad.display("战斗后");
bad.setHp(savedHp);
bad.setMp(savedMp);
bad.setGold(savedGold);
bad.display("人肉读档");
bad.setHp(99999);
bad.display("外挂之后");
System.out.println();
System.out.println("========== 正解:备忘录模式 ==========");
GameRole role = new GameRole();
RoleStateCaretaker caretaker = new RoleStateCaretaker();
role.display("战斗前");
caretaker.saveMemento(role.createMemento());
role.fightBoss();
role.display("战斗后");
System.out.println(" [剧情] 团灭了,读档重打——");
role.restoreMemento(caretaker.retrieveMemento());
role.display("读档后");
}
}
运行输出:
========== 反面教材:字段裸奔 ==========
[角色] 战斗前:HP=100, MP=80, 金币=500
[角色] 战斗后:HP=10, MP=20, 金币=1200
[角色] 人肉读档:HP=100, MP=80, 金币=500
[角色] 外挂之后:HP=99999, MP=80, 金币=500
========== 正解:备忘录模式 ==========
[角色] 战斗前:HP=100, MP=80, 金币=500
[管理器] 存档完成:快照 [HP=100, MP=80, 金币=500]
[角色] 战斗后:HP=10, MP=20, 金币=1200
[剧情] 团灭了,读档重打——
[管理器] 交还存档:快照 [HP=100, MP=80, 金币=500]
[角色] 读档后:HP=100, MP=80, 金币=500
4.3 三处细节咂摸一下
**细节一:打包和恢复都是角色亲手做的。**createMemento 里读字段、restoreMemento 里写字段的代码都在 GameRole 内部——外界从头到尾没接触过任何字段。所以角色以后加"装备列表""坐标""Buff 集合",只需要改这两个方法,管理者和所有调用方零改动。对比反面教材"加字段 → 全世界的存档代码跟着改",这就是"封装守住了"的实际含义。
**细节二:静态内部类 + private,是"宽窄接口"的 Java 惯用实现。**备忘录模式的理论里有个双接口概念:Originator 拿宽接口(读写全部内容),Caretaker 拿窄接口(只能持有引用)。Java 里不需要真写两个接口——嵌套类天然互访私有成员(编译器会合成桥方法),而外部任何类都摸不到 private。于是同一个 RoleMemento,在 GameRole 眼里是透明的,在 Caretaker 眼里是黑盒。"看得见摸不着",编译器站岗。
**细节三:管理者为什么必须"不读内容"?**不是技术不能,是制度不许。Caretaker 的职责是"什么时候存、存多久、存哪个槽位"——一旦它开始读快照内容做展示、做判断,它就懂了业务,黑盒就废了:角色改字段,管理器也要跟着改。窄接口不是能力限制,是防腐层。真要展示存档信息?让 GameRole 提供 describe(Memento) 之类的受控方法,绕回角色自己身上。
五、在框架里找它
**Git——世界上最大的备忘录系统。**每次 commit 是一次 createMemento(把工作区状态打包成不可变的快照对象),.git 仓库是 Caretaker(只负责存和取,快照内容按内容寻址、谁也别想手改),git restore / git checkout 是 restoreMemento。"写代码前先 commit 一版"和"打 Boss 前先存个档",是同一个求生智慧。
**JDBC Savepoint——数据库事务的存档点。**长事务里 Connection.setSavepoint("sp1") 打一个存档点,后面操作出错 conn.rollback(sp1) 只回滚到打点处、不毁掉之前的成果——正是"多存档位 + 定点读档"的工业级实现。打点就是快照,回滚就是恢复。
**IDE 本地历史。**IntelliJ 的 Local History、VS Code 的 Timeline:你每次保存、甚至每次失去焦点,IDE 都悄悄存一份文件快照——误删了代码、refactor 翻车了,右键 Local History 一秒回到五分钟前。这类功能敢做成"全自动",底气就是快照的打包/恢复全在 IDE 内部闭环,外界(包括你)只负责挑时间点。
浏览器后退栈、操作系统的系统还原点、手机云备份——所有"回到过去"的按钮,背后都是同一个模式。
一句话总结这一节:凡是"把现在存起来,以便将来后悔"的地方,备忘录模式都在默默服务。
六、撤销问题的完整拼图:命令 + 备忘录
至此,"后悔药"的两种配方集齐了,合上账本:
| 命令模式 ⑱ | 备忘录模式 ㉑ | |
|---|---|---|
| 撤销原理 | 逆操作回放(撤销插入=删除) | 快照回滚(整个状态倒回) |
| 存储 | 每条命令只存增量,轻 | 每份快照存全量,重 |
| 前提 | 操作必须可逆 | 无要求,什么状态都能存 |
| 粒度 | 单步撤销,天生精细 | 一次跳一个存档点 |
| 高频操作 | 优秀(Ctrl+Z 随便按) | 内存会爆炸 |
有个旧账顺便翻了:⑱ 的反面教材 BadEditor 用"每步存全文快照"实现撤销,被批得体无完肤——它错不在快照思路,错在场景错配:文本编辑是"高频操作 + 可逆动作",正该走命令的增量路线;而本篇游戏存档是"低频打点 + 不可逆状态",正该走备忘录的全量路线。同一味药,用对场景是仙丹,用错场景是毒药。
实战里两者常常并肩:游戏每进一个存档点打一份备忘录(大颗粒),存档点内的每步操作记命令(细颗粒)——读档回大点,小撤销用命令,各司其职。
写在最后
备忘录模式一句话总结:角色亲手打包黑盒快照,管理者只管保管搬运;封装滴水不漏,后悔药随取随用。
下一篇 ㉒ 中介者模式——角色存完档,去聊天室放松一下。聊天室里有个老问题:用户 A 想发消息给 B 和 C,B 想回 A,C 想私聊 A……每个用户都得直接持有其他所有用户的引用,N 个人就是 N×(N-1) 条关系线,来一个新人,全员改代码。把网状依赖收拢成星型——所有人只认识群主一个——中介者模式,下一篇见。