设计模式预计阅读 17 分钟6474 字3711 次浏览

设计模式修炼之路 ㉑ 备忘录模式——不破坏封装的存档术

不破坏封装的存档术、宽窄接口、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

这个模式解的是一个"既要又要"的矛盾:存档必须读取角色的状态(不然存什么),恢复必须写回角色的状态(不然怎么还原)——但一旦把读写开放出去,封装就没了。

解法是釜底抽薪:**打包和恢复这两个动作,都由角色亲自动手。**外界全程只搬运一个黑盒,从头到尾没碰到任何字段。类比游戏存档:存档时是你自己(角色)把状态写进存档卡,读档时也是你自己照着卡恢复——仓库管理员只负责把卡收好,他根本看不懂卡上写的是什么(也不想看懂)。

二、优点与缺点

优点:

  1. 封装完好无损。外界拿不到字段,连存档器自己都读不懂快照内容——角色加十个字段,存档器一行代码不用改。
  2. 状态恢复的完整性有保证。快照是角色亲手打包的,里面装了什么、怎么还原,只有角色知道,不会出现"漏抄一个字段"的残废存档。
  3. 职责干净。角色管打包还原,管理器管保管调度,各自单一职责。
  4. 快照可复用。存档槽可以做成列表——多存档位、自动存档、时间机器,都是管理者层面的小扩展。

缺点:

  1. 内存开销。每份快照都是一份状态的完整拷贝,状态一大多存几份,内存就顶不住(⑱ 反面教材 BadEditor 的快照栈就是教训——这是"高频快照 + 大状态"的组合陷阱)。
  2. 黑盒的代价:管理者想展示"存档详情"给用户看?做不到,内容被锁死了。需要展示就得额外开口子,封装的口子一开就收不回。
  3. 类数量增加。每个需要存档的原发器都得配一个备忘录类。
  4. 只适合"全量快照"语义:想只撤销"半步"(只回滚血量不回滚金币),备忘录原生不支持——粒度问题要靠拆多个备忘录类解决。

三、什么时候用

判断标准:需要把对象状态恢复到过去某个时点,且对象状态复杂/含私有细节,不能或不该用"逆操作"来表达。

  • 游戏存档:角色状态、地图进度、任务进度——本文场景
  • 事务保存点:数据库 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) 条关系线,来一个新人,全员改代码。把网状依赖收拢成星型——所有人只认识群主一个——中介者模式,下一篇见。

花

花开空白

开发者 · 西安

相关文章

评论(0)

还没有评论,来抢沙发吧

发表评论