多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

深入理解备忘录模式中的Caretaker看护者:职责边界与实战案例

深入理解备忘录模式中的Caretaker看护者:职责边界与实战案例 在做文档编辑器、游戏服务端、配置中心这类业务时我经常要处理一个看似简单却很容易失控的需求把对象某个时刻的状态保存下来等用户操作出错、流程中断或需要回退时再把它恢复回去。最直观的方案是在业务类里直接复制一份字段快照可一旦字段增多、历史版本变多代码就会变成一团乱麻。后来我把这类逻辑拆成“发起人、备忘录、看护者”三个角色才真正体会到真正难管理的不只是快照本身更是那个负责保管快照的看护者也就是 Caretaker。这篇文章想围绕The Caretakers这个主题展开完整讲清楚备忘录模式下“看护者”角色的职责边界、常见误区和实战写法。我会用 Java 和 Python 各做一个可运行的案例覆盖文本编辑器撤销历史、游戏存档读档两个典型场景并给出常见问题排查和工程建议。适合正在学习设计模式的后端开发者也适合想把状态回滚逻辑重构得干净一点的业务开发同学。1. 背景与核心概念1.1 The Caretakers 在软件设计中指什么Caretaker 直译是“看护者”“负责人”。在 GoF 的《设计模式》中它并不是一个单独的设计模式而是**备忘录模式Memento Pattern**中的核心角色之一。备忘录模式由三个角色组成Originator发起人需要被保存状态的对象它负责创建快照也能根据快照恢复到指定状态。Memento备忘录保存发起人内部状态的数据载体对外部隐藏实现细节。Caretaker看护者负责持有备忘录对象并在需要的时候把它交给发起人进行恢复。很多人学备忘录模式时会把目光放在“如何创建快照”上却低估了看护者角色的重要性。实际上真正决定一个状态恢复系统是否好用的往往是看护者的设计它什么时候保存快照、保存多少份、如何管理历史版本、如何避免外部意外修改快照。这些问题的答案都集中在 “The Caretakers” 身上。1.2 一个直观的业务场景假设你在做一个网页端的文档编辑器用户输入一段文字编辑器内部维护了内容和光标位置。用户点击“撤销”系统需要恢复到上一次输入结束时的状态。如果用户进行了十次输入系统要能一次一次回退。如果不做任何抽象编辑器类里可能既要处理输入逻辑又要维护历史内容列表还要负责状态覆盖。类会越来越膨胀而且“内容快照”这种细节一旦暴露给其他组件后续想改成增量保存、数据库序列化都很难。引入备忘录模式之后编辑器只负责“创建快照”和“从快照恢复”历史列表交给看护者去管。这样职责被拆开了后续替换存储方案也不影响业务逻辑。1.3 为什么需要看护者而不是直接保存对象有同学可能会问既然要保存状态我直接把对象序列化到 Redis 或者数据库行不行当然可以但从设计模式的角度看看护者存在的意义是隔离业务对象与状态存储机制。举个例子游戏角色可以存档。如果把存档逻辑直接写在 Game 类里Game 类既要管血量、装备、等级又要管存档文件读写、存档槽位管理显然不合理。正确的做法是Game 类提供save()方法生成一个内存快照GameSaveManager 作为看护者去负责把快照保存到文件、数据库或者对象存储。这样 Game 类不需要知道自己被存到了哪里只需关注“生成快照”和“恢复快照”。2. 环境准备与版本说明本文包含完整代码示例先说清楚运行环境。我在本地使用以下环境验证JDK 17Java 示例使用record、instanceof pattern等现代语法如果你使用 JDK 8需要手动改成传统写法。Maven 3.8仅为演示工程结构示例代码不依赖任何第三方库直接用javac也能编译运行。Python 3.10使用dataclass和类型注解Python 3.7 以上基本可以运行。开发工具IDEA 或 VS Code 均可。需要说明的是版本号并不是硬性要求重点是演示配置和编码思路。如果你的环境是 JDK 8 或 Python 3.8对应调整语法即可设计模式的代码本身不依赖高版本特性。Java 示例工程结构如下caretaker-java/ ├── src/main/java/com/example/caretaker/ │ ├── Memento.java │ ├── TextEditor.java │ ├── History.java │ └── Demo.javaPython 示例就一个文件caretaker-python/ └── game_save.py3. 核心原理拆解3.1 备忘录模式三角色的职责划分角色英文核心职责现实类比发起人Originator创建快照、从快照恢复状态游戏角色、文档编辑器备忘录Memento保存发起人的内部状态并对外隐藏细节存档文件、操作记录看护者Caretaker保存和提供备忘录不修改备忘录内容存档管理器、撤销历史栈这里最容易被忽略的是看护者只负责保存和取出不应该修改备忘录内部的数据。也就是说看护者是一个“仓库管理员”不是“内容编辑器”。如果看护者可以随意改动快照内容那快照的可靠性就无从谈起了。3.2 窄接口与宽接口在实际设计时备忘录通常被设计成两种访问方式窄接口对外暴露给看护者和客户端的是一个尽量小的接口只看得到“这是一个备忘录”看不到具体字段。宽接口只有发起人才能真正访问备忘录的完整数据。Java 示例中为了让代码简单直观我直接定义了一个不可变的Memento类字段都是final外部无法修改。如果要加强封装可以拆成两个接口类似下面这样public interface Memento { } public interface EditorMemento extends Memento { String getContent(); int getCursorPosition(); }看护者持有Memento类型而发起人内部使用EditorMemento类型。这样看护者能保存快照但不能访问具体内容安全性更好。不过这种写法会增加一定复杂度普通业务项目如果对封装要求不高直接使用不可变类也是可以的。3.3 深拷贝与引用拷贝的问题备忘录保存的“状态”本质上是一组数据。对于基本类型和不可变对象直接复制值没有风险但对于列表、Map 等可变对象直接赋值传递的是引用后续修改对象内容可能导致历史快照被动态改变。这也是状态恢复系统最常见的一个坑。比如保存游戏存单时直接把inventory列表放进备忘录玩家后续拾取新装备时旧存档里的列表也跟着变了。正确做法是复制一份新的列表Java 里可以用new ArrayList(list)Python 里可以用deepcopy或者转成不可变类型。4. 完整实战案例Java 文本编辑器撤销历史4.1 功能设计我们实现一个非常简化但完整的文本编辑器撤销功能TextEditor作为发起人内部维护两样状态content和cursorPosition。Memento作为备忘录保存这两个字段并且不可变。History作为看护者使用栈结构保存历史备忘录。Demo模拟用户输入、撤销的全过程。每次用户输入后编辑器创建一份快照交给看护者。用户点击撤销时看护者取出栈顶快照编辑器根据快照恢复状态。4.2 完整代码先看Memento.java// 文件路径src/main/java/com/example/caretaker/Memento.java package com.example.caretaker; /** * 备忘录保存编辑器某个时刻的状态。 * 字段使用 final保证不可变。 */ public class Memento { private final String content; private final int cursorPosition; public Memento(String content, int cursorPosition) { this.content content; this.cursorPosition cursorPosition; } public String getContent() { return content; } public int getCursorPosition() { return cursorPosition; } }然后是TextEditor.java// 文件路径src/main/java/com/example/caretaker/TextEditor.java package com.example.caretaker; /** * 发起人负责创建快照和从快照恢复。 */ public class TextEditor { private String content ; private int cursorPosition 0; public void write(String text) { content text; cursorPosition content.length(); } public void setCursor(int position) { if (position 0 || position content.length()) { throw new IllegalArgumentException(非法的光标位置 position); } this.cursorPosition position; } public Memento save() { return new Memento(content, cursorPosition); } public void restore(Memento memento) { this.content memento.getContent(); this.cursorPosition memento.getCursorPosition(); } Override public String toString() { return TextEditor{content content , cursor cursorPosition }; } }接着是看护者History.java// 文件路径src/main/java/com/example/caretaker/History.java package com.example.caretaker; import java.util.ArrayDeque; import java.util.Deque; /** * 看护者只负责保存和取出备忘录不关心备忘录内容。 */ public class History { private final DequeMemento undoStack new ArrayDeque(); public void push(Memento memento) { undoStack.push(memento); } public Memento pop() { if (undoStack.isEmpty()) { throw new IllegalStateException(没有可撤销的历史记录); } return undoStack.pop(); } public int size() { return undoStack.size(); } }最后是演示入口Demo.java// 文件路径src/main/java/com/example/caretaker/Demo.java package com.example.caretaker; public class Demo { public static void main(String[] args) { TextEditor editor new TextEditor(); History history new History(); // 第一次输入保存快照 editor.write(Hello); history.push(editor.save()); System.out.println(输入 Hello editor); // 第二次输入保存快照 editor.write(, CSDN); history.push(editor.save()); System.out.println(输入 , CSDN editor); // 用户移动光标 editor.setCursor(3); System.out.println(移动光标后 editor); // 第一次撤销 editor.restore(history.pop()); System.out.println(撤销一次 editor); // 第二次撤销 editor.restore(history.pop()); System.out.println(再撤销一次 editor); System.out.println(历史栈剩余 history.size()); } }4.3 编译运行与预期输出在项目根目录执行javac -d out src/main/java/com/example/caretaker/*.java java -cp out com.example.caretaker.Demo预期输出输入 HelloTextEditor{contentHello, cursor5} 输入 , CSDNTextEditor{contentHello, CSDN, cursor11} 移动光标后TextEditor{contentHello, CSDN, cursor3} 撤销一次TextEditor{contentHello, CSDN, cursor11} 再撤销一次TextEditor{contentHello, cursor5} 历史栈剩余0注意撤销之后光标位置也一并恢复了。这就是快照的价值它不是简单恢复文本而是把整个编辑器状态回退到历史时刻。如果只保存文本、不保存光标位置用户体验就会差很多。5. 完整实战案例Python 游戏存档系统5.1 功能设计第二个案例用 Python 实现一个游戏存档系统场景更接近真实项目Game是发起人维护等级、分数、背包物品。GameMemento是备忘录使用dataclass(frozenTrue)保证不可变。GameSaveManager是看护者管理多个存档槽位。玩家可以随时保存也可以从某个槽位读档。这个示例重点演示可变对象背包列表在快照时需要复制避免存档被后续游戏行为意外修改。5.2 完整代码# 文件路径caretaker-python/game_save.py from dataclasses import dataclass dataclass(frozenTrue) class GameMemento: level: int score: int inventory: tuple class Game: 发起人游戏角色本体。 def __init__(self, level1, score0, inventoryNone): self.level level self.score score self.inventory inventory if inventory is not None else [] def add_score(self, points: int): self.score points def pickup(self, item: str): self.inventory.append(item) def next_level(self): self.level 1 def save(self) - GameMemento: # 注意inventory 要复制不能直接把原列表放进去 return GameMemento( levelself.level, scoreself.score, inventorytuple(self.inventory) ) def restore(self, memento: GameMemento): self.level memento.level self.score memento.score self.inventory list(memento.inventory) def __str__(self): return fGame(level{self.level}, score{self.score}, inventory{self.inventory}) class GameSaveManager: 看护者管理存档槽位。 def __init__(self): self.slots {} def save_to_slot(self, slot_name: str, memento: GameMemento): self.slots[slot_name] memento def load_from_slot(self, slot_name: str) - GameMemento: if slot_name not in self.slots: raise KeyError(f存档槽 {slot_name} 不存在) return self.slots[slot_name] def list_slots(self): return list(self.slots.keys()) if __name__ __main__: game Game() manager GameSaveManager() # 玩家玩了一会儿获得一些装备 game.next_level() game.add_score(100) game.pickup(wooden_sword) print(当前状态, game) # 保存到 slot_1 manager.save_to_slot(slot_1, game.save()) # 继续玩获得新装备 game.next_level() game.add_score(50) game.pickup(health_potion) print(继续游戏, game) # 玩家阵亡读档回到刚才 game.restore(manager.load_from_slot(slot_1)) print(读档后, game)5.3 运行结果在命令行执行python game_save.py预期输出当前状态 Game(level2, score100, inventory[wooden_sword]) 继续游戏 Game(level3, score150, inventory[wooden_sword, health_potion]) 读档后 Game(level2, score100, inventory[wooden_sword])可以看到读档后等级、分数、背包都完整恢复到保存时刻。这里如果把tuple(self.inventory)改成self.inventory那么存档里的inventory和当前游戏对象共享同一个列表玩家后续拾取health_potion时存档也会跟着多出这件道具数据就被污染了。这是一种典型的状态覆盖问题。6. 进阶从单次撤销到多级撤销第二个案例如果要做成真正的游戏存档通常不会只有一个槽位。而文本编辑器要做到多级撤销本质就是在看护者内部使用一个栈结构每次操作前 push撤销时 pop。如果还要支持“重做redo”可以再加一个redoStack。当用户执行新操作时清空 redo 栈当用户撤销时把弹出的备忘录压入 redo 栈。这样看护者的职责仍然是“保管快照”只是多了一个栈的管理逻辑。在更复杂的框架中常把备忘录模式和命令模式结合使用。命令模式负责封装“用户操作”备忘录模式负责保存“操作前的状态”。看护者此时不直接保存业务对象而是保存一条条命令每条命令都携带自己的状态快照。这样代码结构更清晰但底层思路没有变化发起人负责状态交换看护者负责生命周期管理。7. 常见问题与排查思路问题现象常见原因解决思路撤销后部分字段没有还原快照漏字段或者使用了浅拷贝检查快照包含的字段可变对象做深拷贝保存历史过多内存暴涨每个操作都保存完整状态限制历史数量合并快照或使用增量快照快照被外部意外修改备忘录暴露了可变引用使用不可变对象字段加 final列表返回副本多线程环境下状态错乱看护者没有做并发控制对看护者加锁或使用线程安全的栈结构读取旧版本序列化数据失败类字段变更导致的兼容性问题定义版本号迁移旧数据展开来说漏字段最简单的排查方式是打印保存前后对象的所有字段对比哪些字段没有恢复。常见漏点包括缓存、临时标记位、UI 状态。浅拷贝导致历史被改Java 示例中String是不可变的所以没有风险但如果状态里有List、Map等可变类型就必须复制一份。内存管理每次输入都保存全文快照短文本没问题长文本或高频操作就会出现性能问题。可以限制栈深度比如最多保存 50 条超过后移除最旧的记录。并发安全Spring 项目中如果看护者被多个线程共享可以用ConcurrentLinkedDeque或者在读写时加锁。最简单的是把 History 的访问包在synchronized方法里。8. 最佳实践与工程建议从“能跑”到“工程质量靠谱”状态恢复系统需要在几个层面做优化。8.1 快照粒度要按业务场景设计不是任何时候都需要保存完整快照。文本编辑器里输入一个字符就保存一个完整快照内存压力很大可以只在用户停止输入一定时间后保存或者每 N 个操作合并成一个版本。游戏存档也一样频繁自动保存会导致磁盘开销通常采用定时保存 关键节点保存两种策略。8.2 快照尽量设计为不可变对象Java 中把字段设为final集合返回时使用Collections.unmodifiableListPython 中可以用dataclass(frozenTrue)或者用tuple替代list。不可变快照能减少大量“共享引用”引发的问题。8.3 持久化时考虑版本兼容如果把快照序列化到数据库或 Redis类结构后续会增加字段、删除字段。建议在快照对象里增加version字段恢复时根据版本做数据迁移。否则线上发布新版本后老存档可能直接读不出来。8.4 看护者不感知业务细节看护者最好只依赖Memento类型而不感知TextEditor或Game的内部字段。如果需要把快照写入数据库可以封装独立的仓储层。这样当项目从内存快照切换到 Redis 快照时只需要替换看护者的存储实现完全不改动发起人代码。8.5 恢复时需要做合法性校验旧版本数据、脏数据、被篡改的数据都可能被传给restore方法。恢复之前要检查字段范围比如光标位置不能超过文本长度、等级不能小于 1、分数不能为负数。不做校验容易出现运行时异常或逻辑错乱。9. 总结与学习路线这篇文章从 The Caretakers 这个角色切入完整梳理了备忘录模式的三个核心角色并用 Java 文本编辑器和 Python 游戏存档两个实战案例演示了看护者如何保存和提供快照发起人如何创建和恢复状态。重点是搞清楚职责边界看护者只管保管不修改快照备忘录管数据封装发起人管状态交换。如果你是在校学生下一步可以把这两个代码改成支持多级撤销重做的版本给编辑器增加 redo 功能或者给游戏增加多个存档槽位。如果你是在做后端业务可以继续研究命令模式、原型模式以及 Redis 序列化快照、MySQL 存储历史版本等实践了解增量快照和全量快照的取舍。状态恢复看起来是个小功能但设计好坏直接影响系统的可维护性和数据可靠性。动手把你手头的历史记录代码拆一拆比只看不练有效得多。如果这篇教程对你有帮助建议收藏备用。
返回列表