十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

备忘录模式深度解析:从快照恢复到多agent状态管理

备忘录模式深度解析:从快照恢复到多agent状态管理 备忘录模式这个名字很多学过设计模式的人都觉得它简单无非就是“快照”加“恢复”。但真到项目里用起来很多人会发现要么不知道怎么存状态要么存了之后恢复不了要么直接内存爆掉。这篇文章我打算把备忘录模式从基础原理到实际工程里的扩展用法都过一遍包括三要素的结构设计、白箱黑箱两种实现方式、undo/redo怎么落地、Java序列化要注意什么还会结合最近多agent设计里的状态管理思路聊聊这个老模式在新场景下怎么迁移。1. 项目概述与技术定位1.1 核心需求解析你什么时候真的需要备忘录模式备忘录模式解决的根本问题是“状态的回滚”。说得直白一点它就是给对象加一个存档功能让对象可以随时回到之前某个时间点的状态。很多人一听到“备份”就想到数据库备份、Redis快照、Git版本管理备忘录模式在这个层面的确和它们思路一致但它管的范围非常窄——它只管某个对象实例的内部状态不涉及文件系统、不依赖外部存储一切维护在内存里就够了。它的经典应用场景是编辑器里的撤销、游戏里的存档读档、事务里的回滚日志以及任何一个“用户操作后可能需要反悔”的交互界面。如果你写过一个支持CtrlZ的文本框或者做过一个复杂表单的草稿恢复功能你就已经踩到备忘录模式的边界了。1.2 为什么需要备忘录模式直接暴露内部状态有多痛如果只是实现“撤销”很多人第一反应就是把对象内部的所有字段copy一份存起来。这个思路本身没错但问题在于——把字段暴露出来等于把封装性彻底丢掉了。比如你有一个Editor类内部有text、cursorPos、selection几个私有字段。为了支持撤销你很可能写出这样的代码class History { private String text; private int cursorPos; private String selection; History(Editor editor) { this.text editor.getText(); this.cursorPos editor.getCursorPos(); this.selection editor.getSelection(); } void restore(Editor editor) { editor.setText(text); editor.setCursorPos(cursorPos); editor.setSelection(selection); } }这组代码能跑但History类对Editor的内部结构知道得一清二楚只要Editor增加字段History就必须同步改。而且History拿到的是一堆零散的getter/setter对状态的边界没有清晰定义时间一长你根本分不清哪些字段需要存、哪些是临时计算出来的。备忘录模式要解决的就是“保存状态”和“不破坏封装”这个矛盾。它把状态快照和业务对象放在同一个类体系里对外只暴露一个不透明的接口由业务对象自己决定保存什么、如何恢复外部调用方根本不需要关心状态里有几个字段。2. 备忘录模式三大角色与标准实现方案2.1 发起人、备忘录、管理者三个角色各司其职备忘录模式有三个角色理解它们的分工是掌握这个模式的关键。发起人Originator是状态的拥有者。它知道自己的哪些字段需要保存也知道如何从快照中恢复。你可以把它理解为游戏里的玩家角色自带存档和读档能力。备忘录Memento是状态快照的载体它是一个不透明的对象外部只能调用它的标识方法比如获取存档名称不能修改里面的内容。管理者Caretaker负责保存和托管备忘录它只管备忘录的存放顺序和生命周期完全不知道备忘录内部存了什么。这三者的关系很像你玩一个RPG游戏玩家角色是发起人存档文件是备忘录负责自动存档、提供存档列表的系统是管理者。玩家不知道自己存档文件里具体记录了多少数据系统也不知道它只知道哪个存档对应哪个时间点。2.2 白箱实现与黑箱实现封装程度怎么取舍备忘录模式有两种经典实现方式分别叫白箱和黑箱它们的区别在于“备忘录是否对外暴露内部状态”。白箱实现也叫宽接口实现。在这种方式中Memento类会暴露所有内部状态字段Caretaker可以读取备忘录里的所有数据。实现起来非常直接但缺点也很明显——任何拿到备忘录对象的人都能看到并修改里面的数据这等于把存档文件明文摆在了桌面上封装性几乎为零。黑箱实现则用接口来收窄备忘录的可见性。发起人持有的备忘录是宽接口能读写全部状态管理者持有的备忘录是窄接口只能看到接口声明的方法完全无法触碰内部数据。要做到这一点通常把备忘录类定义为发起人的内部私有类。从工程实践看如果状态结构常年稳定、不涉及复杂域模型白箱实现完全够用而且更直观。如果状态数据属于敏感业务数据或者类结构经常调整必须用黑箱。2.3 标准实现Java代码完整走一遍我直接用Java写一个黑箱实现的标准示例这个案例就是文本编辑器的撤销功能。先定义发起人Editorpublic class Editor { private String text; private int cursorPos; private String selection; public void setState(String text, int cursorPos, String selection) { this.text text; this.cursorPos cursorPos; this.selection selection; } public void showState() { System.out.println(text: text); System.out.println(cursorPos: cursorPos); System.out.println(selection: selection); } // 创建存档宽接口内部可读写 public Memento createMemento() { return new Memento(text, cursorPos, selection); } // 读档恢复 public void restoreMemento(Memento memento) { this.text memento.text; this.cursorPos memento.cursorPos; this.selection memento.selection; } // 备忘录作为内部私有类外部只能看到窄接口 public static class Memento { private final String text; private final int cursorPos; private final String selection; private Memento(String text, int cursorPos, String selection) { this.text text; this.cursorPos cursorPos; this.selection selection; } } }再定义管理者Historyimport java.util.Stack; public class History { private StackEditor.Memento undoStack new Stack(); private StackEditor.Memento redoStack new Stack(); public void save(Editor editor) { undoStack.push(editor.createMemento()); redoStack.clear(); } public void undo(Editor editor) { if (undoStack.isEmpty()) { return; } redoStack.push(editor.createMemento()); editor.restoreMemento(undoStack.pop()); } public void redo(Editor editor) { if (redoStack.isEmpty()) { return; } undoStack.push(editor.createMemento()); editor.restoreMemento(redoStack.pop()); } }这里有个细节值得注意Memento用private修饰了构造方法外部无法直接new出来只能通过Editor.createMemento()获得。而History持有的虽然是Editor.Memento类型但它访问不了text、cursorPos这几个字段因为它们也是私有的。这就是黑箱的核心——管理者能存、能取但看的永远是黑盒。提示如果项目里用了Java模块化JPMS或者跨包结构这种内部私有类的写法可能面临访问限制。遇到这种情况可以把Memento改成Editor的protected静态内部类然后通过export声明模块导出保持封装性的同时兼顾模块边界。3. 实操过程从需求到完整代码的一个完整案例3.1 需求场景表单编辑器里的多级撤销重做只讲基础示例有点隔靴搔痒我按实际项目里的需求来推一遍。这个需求是做一个支持多级撤销和重做的表单编辑器表单里有名称、描述、标签列表、创建时间四个字段用户每编辑一个字段就产生一条历史记录可以反复撤销、重做。需要注意的是撤销操作本身不应该再被记录不然会出现“撤销了一次历史记录里多了一条”的Bug。这个看起来小的问题实际开发里踩坑的人不少。3.2 数据模型设计哪些字段需要保存哪些不需要这个表单编辑器的数据源是一个FormData对象public class FormData { private String name; private String description; private ListString tags; private LocalDateTime createdAt; }其中createdAt是用户进入页面时生成的整个编辑过程都不应该变所以它不需要保存进快照。这引出一个很关键的判断快照保存的是“可以变化的业务状态”不是“整个对象”。有些字段是常量、派生值、临时缓存它们不应该进入快照。我在实际项目里见过有人把Service层的单例对象、数据库连接池、配置对象全部塞进快照结果是每创建一个备忘录就把一批重量级对象复制了一遍内存直接爆掉。正确做法是发起人自己决定哪些字段要进快照这正是备忘录模式封装性的一个体现。3.3 最终代码实现完整跑通undo/redo闭环FormData作为发起人它的快照内部类如下public class FormData { private String name; private String description; private ListString tags; private LocalDateTime createdAt; public void edit(String name, String description, ListString tags) { this.name name; this.description description; this.tags new ArrayList(tags); this.createdAt LocalDateTime.now(); } public void display() { System.out.println(name name , description description , tags tags , createdAt createdAt); } public Memento createMemento() { return new Memento(name, description, tags); } public void restore(Memento memento) { this.name memento.name; this.description memento.description; this.tags new ArrayList(memento.tags); } public static class Memento { private final String name; private final String description; private final ListString tags; private Memento(String name, String description, ListString tags) { this.name name; this.description description; this.tags new ArrayList(tags); } } }这里有个不容易发现的坑tags是引用类型如果在createMemento()时直接把外部列表的引用赋给Memento内部的tags那么外部后续修改列表元素备忘录里的数据也会跟着变做出来的快照就是“假快照”。正确做法是在Memento构造方法里做一次拷贝new ArrayList(tags)。restore里也要做一次拷贝防止列表引用被外部篡改。管理者用双栈实现撤销重做public class FormHistory { private StackFormData.Memento undoStack new Stack(); private StackFormData.Memento redoStack new Stack(); public void record(FormData formData) { undoStack.push(formData.createMemento()); redoStack.clear(); } public void undo(FormData formData) { if (undoStack.isEmpty()) { System.out.println(没有可撤销的操作); return; } redoStack.push(formData.createMemento()); formData.restore(undoStack.pop()); System.out.println( 撤销成功 ); } public void redo(FormData formData) { if (redoStack.isEmpty()) { System.out.println(没有可重做的操作); return; } undoStack.push(formData.createMemento()); formData.restore(redoStack.pop()); System.out.println( 重做成功 ); } }测试一下public class MementoDemo { public static void main(String[] args) { FormData formData new FormData(); FormHistory history new FormHistory(); formData.edit(项目申报表, 用于年度项目申请, Arrays.asList(立项, 审批)); System.out.println(初始状态:); formData.display(); history.record(formData); formData.edit(项目申报表, 用于年度项目申请已修改, Arrays.asList(立项, 审批, 复核)); System.out.println(第一次修改后:); formData.display(); history.record(formData); formData.edit(项目申报表2024, 用于年度项目申请终版, Arrays.asList(立项, 审批, 复核, 归档)); System.out.println(第二次修改后:); formData.display(); history.record(formData); history.undo(formData); formData.display(); history.undo(formData); formData.display(); history.redo(formData); formData.display(); } }运行结果能清楚地看到状态在两次撤销和一次重做之间正确流转。注意我在edit()方法里重新生成了createdAt但恢复快照后createdAt不会回到历史值因为快照里根本没存它。这个效果正是我们需要的——时间戳是操作时间不是内容本身。3.4 实操心得什么时候分别保存快照从上面的代码可以看到record()是在每次状态变更之后调用的不是在用户每次击键时调用。如果做成每次按键都保存一条快照那么用户输入一个10个字符的单词就会产生10条历史记录撤销一次只能退回一个字母体验很差。常见的做法是防抖用户停止输入500毫秒后保存一条快照或者每次失焦、提交、切换字段时保存一条。这本质上是在“历史记录粒度”和“内存消耗”之间做平衡。4. 深入实践序列化、跨语言与多agent场景中的状态管理4.1 用Java序列化实现通用备忘录快照不再逐字段复制手写快照的缺点很明显每新增一个字段就要改Memento类。更省事的方案是用Java的序列化机制把整个对象的状态一次性打成字节数组恢复时再从字节数组反序列化回来。public class SerializableMementoT implements Serializable { private static final long serialVersionUID 1L; private final byte[] stateBytes; private SerializableMemento(byte[] stateBytes) { this.stateBytes stateBytes; } public static T extends Serializable SerializableMementoT from(T originator) throws IOException { try (ByteArrayOutputStream baos new ByteArrayOutputStream(); ObjectOutputStream oos new ObjectOutputStream(baos)) { oos.writeObject(originator); return new SerializableMemento(baos.toByteArray()); } } public T restore() throws IOException, ClassNotFoundException { try (ByteArrayInputStream bais new ByteArrayInputStream(stateBytes); ObjectInputStream ois new ObjectInputStream(bais)) { return (T) ois.readObject(); } } }这种实现能把整个对象图序列化进字节数组不用关心一个类到底有哪些字段后续加字段也不需要改快照类。但它的代价是性能开销比手动赋值大得多而且所有涉及的类必须实现Serializable接口还有序列化版本号serialVersionUID的管理问题。所以我的建议是对象字段少、变更频繁的场景用手动快照对象结构复杂、类层级深、字段经常增删的场景用序列化快照。这两种方案不是互斥的可以在同一个项目里并存。4.2 JSON快照方案跨语言协作时代的替代思路如果系统是微服务架构或者前后端需要共享状态快照Java的二进制序列化就有点脱离时代了。这时候可以把快照直接序列化成JSON字符串存储到Redis、数据库或者文件里。public class JsonMementoT { private final String jsonState; private final ClassT targetType; private JsonMemento(String jsonState, ClassT targetType) { this.jsonState jsonState; this.targetType targetType; } public static T JsonMementoT from(T originator, ObjectMapper mapper) throws JsonProcessingException { return new JsonMemento(mapper.writeValueAsString(originator), (ClassT) originator.getClass()); } public T restore(ObjectMapper mapper) throws JsonProcessingException { return mapper.readValue(jsonState, targetType); } }JSON方案的优点是状态快照是纯文本方便调试、查看、迁移跨语言能力也强。缺点是需要手动处理一些场景——比如循环引用会导致序列化失败、自定义字段需要加注解、反序列化不支持多态的话可能丢失子类信息。如果只是在一个单体应用内部做内存中的撤销操作JSON方案不占优势因为每次快照都要经历“对象转JSON字符串再转回对象”的开销。4.3 结合多agent设计备忘模式如何迁移到agent状态管理最近看到不少多agent设计的讨论里提到“主从模式”、“subagent本质上是另一种tool调用”这些说法。我仔细想了想这个思路和备忘录模式的状态管理思想有很强的对应关系。在一个主从agent协作系统里主agent负责调度子agent负责执行特定任务。子agent在执行过程中会产生自己的上下文状态比如读过的文件、生成的中间结果、筛选过的候选方案。如果子agent执行某一步失败或者主agent觉得子agent的方向不对需要让子agent回到之前某个节点重新决策这时候就需要一种机制来保存和恢复agent的执行状态。迁移备忘录模式的思路就是让子agent自身承担发起人职责明确“自己有哪些状态需要保存”把一次agent运行的临时上下文封装成快照对象主agent承担管理者职责它只管把快照存起来、在需要的时候塞回给子agent但完全不需要知道快照内部的结构。子agent暴露给主agent的只能是一个窄接口——比如“执行”和“从快照恢复”。这种设计有个非常大的好处agent的内部状态被严格封装了。主agent不需要知道子agent的memory机制、工具调用栈、搜索结果缓存具体怎么存它只需要在合适的时机调用一个统一的快照接口。将来子agent内部实现换掉、加新的上下文模块主agent完全不用改动。这跟备忘录模式黑箱实现的初衷完全一致。另外把subagent当作另类的tool调用这个视角也很值得玩味。在一个多agent系统里主agent调用子agent的方式其实和调用一个函数、一个API没有本质区别——传入参数得到结果。那么“子agent执行到一半需要回滚”就可以类比成“中间件执行失败需要回滚事务”。这里用备忘录模式保存子agent在关键决策点之前的上下文比维护一堆全局变量、临时文件要优雅得多。5. 常见陷阱、内存约束与项目中的真实经验5.1 深浅拷贝问题引用字段导致快照失效我在实操中遇到过最典型的Bug就是深浅拷贝问题。假设一个用户对象有ListString roles这个字段快照的时候直接把roles赋值给Memento后续给这个list添加角色快照里的角色也会变。这就是浅拷贝带来的“假快照”。正确的做法是在Memento的构造方法里对引用类型字段做一次深拷贝。如果引用类型内部还有可变集合或者自定义对象需要递归拷贝或序列化拷贝。Java里比较省事的做法是用Collections.unmodifiableList(new ArrayList(list))做一层保护性拷贝如果你是做纯内存的操作这样做就够了。这个坑的隐蔽性在于它不是每次都会出错而是只有当源对象在快照之后发生修改时才出现问题。很多时候测试用例恰好没有覆盖这个分支导致假快照问题被隐藏到生产环境才暴露。5.2 大状态对象的内存危机快照不是免费的午餐备忘录模式最大的隐患是内存。如果发起人的状态很重而且操作频率很高每操作一次就产生一个完整快照内存很快就撑不住。我做过一个图像处理编辑器用户的每个调整步骤都保存了一张全分辨率位图快照。用户连续调整20次之后JVM直接OutOfMemoryError。后来改成保存“差异数据”的方案——每次只保存被修改的矩形区域以及对应的操作参数撤销的时候反向应用差异内存占用减少了九成。另一个可选方案是压缩。快照内存占用通常有冗余特别是文本、JSON这类数据用压缩算法很容易压掉一半以上体积。实际操作中我会给序列化快照加一层GZIPOutputStream在存储和恢复的两个边界做压缩和解压虽然会消耗一点CPU但内存收益非常明显。5.3 双检查点策略快照保存的时机不能太迷信用户操作很多人在实现撤销功能时只保存“用户操作前的状态”。但实际操作中有些用户操作并不产生状态变化比如用户连续点击“撤销”按钮或者在一个TextField里连续输入却没有任何内容变化。如果每次都保存快照垃圾数据只会越积越多。一个更稳的方案是“差异检查”加“时间检查”先判断当前状态是否和上一次快照的状态有实质差异没有差异就不保存有差异但时间间隔很短可以合并成一条快照。这个策略在复杂表单、低代码平台里非常常用。5.4 快照数据安全备忘录内容需要加密保护吗如果备忘录保存的是敏感数据比如个人信息、支付金额、密钥那么快照本身就是一份敏感数据副本必须当作敏感数据来对待。内存中的Memento对象要避免被日志打印存储到数据库或Redis的JSON快照要做加密或脱敏处理。有人会问对象已经放在内存里了再加密是不是多此一举但如果快照被序列化到磁盘、传入消息队列或者跨线程传递就完全有可能落入不受控制的边界。所以在序列化边界上做一次加密是值得的而且这个操作成本极低。5.5 常见问题速查表问题现象排查方法推荐方案假快照撤销后状态没回到历史版本部分字段变了检查Memento里的引用字段是否被外部修改在Memento构造和restore时做深拷贝内存暴涨频繁操作后内存占用线性增长统计快照数量和单个快照大小改用差异快照、压缩快照或限制历史栈深度撤销/重做不对称撤销之后重做结果不对检查redoStack是否在每次新操作时被clear新操作产生时必须清空重做栈序列化版本冲突应用升级后旧快照无法恢复检查serialVersionUID是否变化显式声明serialVersionUID或使用兼容字段策略自定义字段丢失快速变更后旧数据恢复出来缺少字段检查反序列化是否支持多态用TypeReference或动态类型注册大对象跨线程传递快照被多个线程共享出现并发修改检查快照是否被多个Caretaker引用快照设计为不可变对象5.6 一个小技巧用快照做生产环境的故障定位除了撤销功能备忘录模式里“保存状态快照”的能力也可以在线上问题排查中发挥作用。比如某个复杂流程执行失败系统可以在每个关键节点自动保存一份发起人的快照失败时把最近几份快照序列化到日志或远程存储里这样排查问题时就能精确地知道业务对象在哪个环节进入了异常状态。这个思路有点类似“全链路追踪”是针对请求链路的而状态快照是针对业务对象的。两者结合能大大缩短线上Bug的定位时间。6. 实现对比与选型建议6.1 三种实现方案对比表维度手动快照标准实现序列化快照JSON快照实现复杂度低低中封装性强黑箱中序列化整个对象弱快照可被读取性能高低中可维护性字段变更需同步修改好好跨语言能力无无强适用场景状态稳定、性能敏感对象结构复杂、字段常变化跨系统、分布式、调试友好6.2 选型决策树根据我的经验可以按以下路径来选择实现方式如果状态只在单个JVM内、内存中操作选标准手动快照优先用黑箱实现。如果对象的字段经常增减、类层级很深选Java序列化快照。如果快照需要持久化或者需要被其他语言或服务读取选JSON快照。如果状态对象很大、操作很频繁不要直接套用任何标准实现优先考虑差异快照方案。6.3 不同编程语言场景下的注意事项对于Java之外的语言备忘录模式的实现思路是通用的但具体细节不太一样。比如C里可以用拷贝构造函数实现快照但要特别注意深拷贝问题因为C默认是浅拷贝Python里可以用copy.deepcopy做快照但要注意对象是否支持深拷贝比如包含文件句柄、Socket的对象就无法用标准方案复制前端TypeScript里则通常配合structuredClone或JSON.parse(JSON.stringify(obj))使用但后者无法处理函数、Map、Set、循环引用所以得谨慎选择。我自己在写设计模式系列的总结时都会提醒读者设计模式是“思想”不是“代码模板”。在不同的语言、不同的框架下你完全可以用不同的方式实现同一个模式只要核心原则没变——发起人保存状态、备忘录封装状态、管理者托管状态。写在最后备忘录模式在23种经典设计模式里看起来是最不起眼的那一类因为它没有工厂模式那样层出不穷的变体也没有代理模式那样广泛的应用场景。但恰恰因为它简单它反而是我在项目里用到频率最高的模式之一。所有需要“后悔药”的地方都值得考虑套用它的思路。我个人在实际操作中的体会是备忘录模式最需要花心思设计的地方不是三个类怎么写而是“什么状态应该进入快照、什么状态不应该”、以及“快照应该以什么粒度保存”。这两个问题想清楚了代码写起来其实非常顺手。最后再分享一个小技巧给快照类统一加上创建时间戳和版本号哪怕当前业务用不到这个字段也会在未来的排查和生产故障定位中派上大用场。
返回列表