走进 Gang of Four 设计模式:备忘录模式

发布时间:2026/7/21 8:17:54
走进 Gang of Four 设计模式:备忘录模式 走进 Gang of Four 设计模式备忘录模式说明不仅告诉你怎么用更告诉你为什么这样设计前置知识Java 面向对象基础封装、内部类、序列化、不可变对象概念 设计模式分类总览GoF 23 种设计模式可按两个维度交叉分类类/对象处理方式 ×创建型/结构型/行为型目的。维度创建型Creational结构型Structural行为型Behavioral类Class通过继承复用Factory Method工厂方法Adapter类适配器Interpreter解释器Template Method模板方法对象Object通过组合/聚合复用Abstract Factory抽象工厂Builder建造者Prototype原型Singleton单例Adapter对象适配器Bridge桥接Composite组合Decorator装饰Facade外观Flyweight享元Proxy代理Chain of Resp.责任链Command命令Iterator迭代器Mediator中介者Memento备忘录Observer观察者State状态Strategy策略Visitor访问者 目录模式概述 · 15 问深度分析框架源码实战分析JDK Calendar 的状态导出与导入Spring Web Flow 分布式表单回退MyBatis / JDBC Savepoint 事务回滚深度追问Why / How / Trade-off / Evolution / Modern Practice总结参考文献1. 模式概述 · 15 问深度分析Q1: 为什么需要这个模式它解决了什么问题在软件开发中我们经常需要记录一个对象在某个时刻的状态以便在未来某个时刻将其恢复比如撤销/重做功能、游戏存档、事务回滚等。它解决的核心问题如何在不破坏对象封装性的前提下捕获、保存和恢复一个对象的内部状态。Q2: 如果不用这个模式会有什么缺陷如果不使用备忘录模式通常的做法是让外部类直接读取并保存目标对象的属性。这会带来以下致命缺陷破坏封装性目标对象必须暴露其内部的私有属性提供getXxx()和setXxx()方法以便外部能够读取和恢复。高耦合度负责保存状态的外部模块必须紧紧依赖目标对象的内部结构。一旦目标对象的属性发生变化所有相关的保存、恢复逻辑全部要修改。职责不清目标对象不仅要负责自己的核心业务逻辑还要暴露大量细节给外部来应付状态管理。// ❌ 不使用备忘录——破坏封装外部直接读写内部状态publicclassGameCharacter{publicinthp;// 被迫公开publicintmp;// 被迫公开publicintlevel;// 被迫公开// 外部代码可以直接修改这些属性}Q3: 核心思想是什么一句话如何概括核心思想通过引入一个专门的备忘录对象来存储状态并将该对象的访问权限严格限制在状态拥有者内部从而在不暴露内部细节的情况下实现状态的备份与恢复。一句话概括传达状态而不暴露细节留住瞬间以备来日重现。Q4: 包含哪些角色每个角色的职责是什么角色英文职责描述发起人Originator拥有内部状态的核心业务对象。负责创建含有其当前状态的备忘录对象并能使用备忘录恢复其内部状态。备忘录Memento存储发起人内部状态的内部对象。关键点它对发起人是宽接口允许读取所有状态对外部是窄接口不暴露任何状态细节。负责人Caretaker负责保存好备忘录但不能对备忘录的内容进行操作或检查。它只负责传递备忘录比如用栈实现撤销历史。Q5: 它们之间如何协作调用流程是怎样的保存状态流程Caretaker向Originator发出备份请求。Originator内部读取当前状态创建一个Memento实例并将状态注入其中。Originator将这个Memento返回给Caretaker。Caretaker将Memento存入其内部的集合/栈中。恢复状态流程Caretaker从集合/栈中取出需要的Memento。Caretaker将Memento传回给Originator。Originator从Memento中读取状态覆盖自己当前的内部属性完成恢复。Q6: 为什么要这样设计每个角色存在的意义是什么Originator 的存在意义它是业务主体。只有它自己最清楚哪些状态需要备份哪些不需要。Memento 的存在意义它是状态的保险箱。它隔绝了外部对状态的窥探确保数据在传输和存储过程中不被篡改。Caretaker 的存在意义它是历史的保管员。由于Originator应该专注于业务不该操心什么时候备份、一共备份了多少次、怎么管理历史队列等问题Caretaker把这部分历史管理的脏活累活接了过来。Q7: 为什么使用接口、抽象类、组合而不是其他方式组合CompositionCaretaker通过组合持有Memento的集合来管理历史记录这比继承更灵活可以动态决定保存多少个历史版本。接口/内部类Interface/Inner Class为了实现对 Originator 开放对 Caretaker 隐藏的双重权限控制Java 中通常将Memento实现为Originator的私有内部类或者让Memento实现一个没有任何方法的标记接口Narrow Interface暴露给Caretaker。Q8: 这种设计符合哪些面向对象原则单一职责原则 (SRP)Originator负责业务和状态生成Caretaker负责历史记录的管理与维护职责分离。开闭原则 (OCP)如果我们要改变状态的存储方式例如把Caretaker的内存存储改为持久化到数据库不需要修改Originator的代码。迪米特法则 (LoD / 最少知识原则)Caretaker完全不知道Memento里存了什么它只负责拿着这个对象做到了最少知道。Q9: 它有哪些优点和局限性会带来哪些额外成本优点完美的封装性状态恢复的细节被屏蔽在Originator内部。简化了 Originator状态存储的责任被转移到了Caretaker。局限性与额外成本高内存消耗主要成本如果Originator的内部状态非常庞大且用户频繁操作每一次备份都会创建一个巨大的Memento对象极易导致 JVM 堆内存暴增甚至引发 OOM。时间开销频繁的创建对象和复制属性会带来一定的 CPU 性能开销。Q10: 它最适合哪些场景哪些场景不应该使用最佳适用场景文本编辑器、画图软件需要支持Ctrl Z撤销和Ctrl Y重做的场景。游戏存档关卡节点的快照保存如单机游戏的快速存档/快速读档。数据库事务回滚在执行一系列操作前保存快照失败时执行rollback。绝对不该使用的场景超大对象且频繁变动比如视频剪辑软件中的视频帧数据、高频交易系统中的全量内存数据。状态不确定或包含外部不可控资源比如状态中包含网络 Socket 连接、文件流句柄等这些是无法通过简单快照复制来恢复的。Q11: 它与容易混淆的其他设计模式有什么区别和联系与命令模式 (Command)联系它们经常结合使用来实现撤销功能。区别命令模式通过记录操作步骤增量命令来实现撤销反向执行undo()而备忘录模式通过记录**状态快照全量数据**来实现撤销。与原型模式 (Prototype)联系都可以用来复制一个对象的状态。区别原型模式是为了克隆出一个独立的、可供外部后续使用的同类对象而备忘录模式克隆对象纯粹是为了在未来某个时刻把状态还给原对象且强调对外部的隐藏。与状态模式 (State)区别状态模式是根据当前状态改变对象的运行时行为行为随状态变备忘录模式是为了保存和恢复状态状态的备份。Q12: 框架中有哪些典型应用JDK -java.util.Date与CalendarCalendar.getInstance()可以获取时间状态虽然不是标准备忘录但其状态的导出与导入思想一致。Spring Web Flow / Spring Session在处理多步骤表单向导时Spring 会在 Session 中保存每一步的用户输入状态允许用户点击上一步回退这正是备忘录模式的分布式体现。MyBatis - 事务管理与二级缓存在执行 SQL 报错需要回滚Rollback时底层的连接和事务状态恢复就应用了备忘录思想。Q13: 如何从零实现一个最小可运行版本以下实现利用私有内部类和窄接口来保证绝对的封装importjava.util.Stack;// 1. 窄接口给 Caretaker 看的没有任何方法interfaceMemento{}// 2. 发起人角色classOriginator{privateStringstate;publicvoidsetState(Stringstate){this.statestate;System.out.println(当前状态改变为: this.state);}// 创建备忘录内部状态打包进私有内部类publicMementosaveStateToMemento(){returnnewRoleMemento(state);}// 从备忘录恢复publicvoidgetStateFromMemento(Mementomemento){if(mementoinstanceofRoleMemento){this.state((RoleMemento)memento).getState();System.out.println(状态已恢复为: this.state);}}// 真正的备忘录私有内部类只有 Originator 能访问privatestaticclassRoleMementoimplementsMemento{privatefinalStringstate;publicRoleMemento(Stringstate){this.statestate;}publicStringgetState(){returnstate;}}}// 3. 负责人角色classCaretaker{privatefinalStackMementohistorynewStack();publicvoidpush(Mementomemento){history.push(memento);}publicMementopop(){returnhistory.isEmpty()?null:history.pop();}}// 4. 测试运行publicclassMain{publicstaticvoidmain(String[]args){OriginatororiginatornewOriginator();CaretakercaretakernewCaretaker();originator.setState(状态 #1满血);caretaker.push(originator.saveStateToMemento());// 存档originator.setState(状态 #2残血);caretaker.push(originator.saveStateToMemento());// 存档originator.setState(状态 #3阵亡);// 恢复到 状态 #2originator.getStateFromMemento(caretaker.pop());// 恢复到 状态 #1originator.getStateFromMemento(caretaker.pop());}}Q14: 如何在现有项目中识别可以使用该模式的地方历史记录硬编码代码中出现了类似oldValue1、oldValue2、backupXxx这样定义在核心类里的临时变量。大面积的 Getter/Setter 暴露发现某个类提供了密密麻麻的属性 get 方法而调用这些 get 方法的目的纯粹是为了在外部临时存一下后面再 set 回来。复杂的撤销回滚逻辑业务包含多步交互如复杂的审批流、多页填写表单且需要高频支持返回上一步。Q15: 如果重新设计这个模式在今天会有哪些改进结合 Java 16 Record不可变性可以用Record来定义Memento。状态一旦生成便不可修改彻底杜绝了并发篡改风险。函数式编程与快照利用Function或Supplier让Caretaker通过高阶函数或 Lambda 延迟触发状态恢复。事件溯源Event Sourcing在微服务架构中备忘录模式升级为事件溯源模式。不保存状态快照而是将发生的所有事件持久化到 Kafka 或 EventStore 中。需要恢复到任意时刻时从零开始重新播放Replay这些事件。2. 框架源码实战分析2.1 JDK Calendar 的状态导出与导入在 Java 早期Java 8 之前java.util.Calendar是处理日期和时间的核心类。由于Calendar是一个可变的Mutable巨型对象内部维护了一个包含多个时间字段的数组在进行复杂的时间计算或高并发传递时极易发生状态被污染的情况。虽然它没有完全遵循 GoF 的三角色定义但它通过getTime()和setTime(Date)完美实现了状态的导出备份与导入恢复。发起人与备忘录的融合在Calendar中Calendar自身充当了发起人Originator而java.util.Date对象则充当了备忘录Memento。Calendar 内部状态publicabstractclassCalendarimplementsSerializable,Cloneable,ComparableCalendar{// 这是 Calendar 的内部状态一个长达 17 位的字段数组存储年、月、日、时、分、秒等SuppressWarnings(ProtectedField)protectedintfields[];// 另一个核心状态从 Epoch 开始算起的毫秒数protectedlongtime;}创建备忘录保存状态publicfinalDategetTime(){// 内部通过当前毫秒数 time 实例化一个全新的 Date 对象返回returnnewDate(getTimeInMillis());}分析这里的Date对象本质上就是一个状态快照。它只持有一个long类型的毫秒数对外部而言它是一个轻量级的、代表某一特定时刻的备忘录。从备忘录恢复恢复状态publicfinalvoidsetTime(Datedate){// 恢复状态将备忘录Date中保存的毫秒数重新赋值给 Calendar 的内部属性 timesetTimeInMillis(date.getTime());}在调用setTimeInMillis后Calendar内部会触发invalidate()并在下一次读取时重新计算fields[]数组从而让整个庞大的对象彻底回滚到当初Date所记录的那一刻。2.2 Spring Web Flow 分布式表单回退在多步骤表单如银行开户向导、机票预订场景中用户随时可能点击上一步。Spring Web Flow 框架为了支持这种服务器端的回退Undo引入了FlowExecutionImpl发起人、FlowExecutionSnapshot备忘录和SnapshotGroup负责人。备忘录角色快照定义publicabstractclassFlowExecutionSnapshotimplementsSerializable{// 抽象备忘录用于代表某个特定步骤State下的流程状态publicabstractbyte[]marshal()throwsIOException;}在默认实现SerializedFlowExecutionSnapshot中Spring 会将当前流程的上下文包括用户在前几页输入的 Scope 变量、当前走到了哪一步等直接序列化为字节数组byte[]存起来。负责人角色快照管理器KeyedViewportFlowExecutionSnapshotGroup充当了负责人Caretaker它内部维护了一个 Map 来管理这些快照classKeyedViewportFlowExecutionSnapshotGroupimplementsFlowExecutionSnapshotGroup,Serializable{// 负责人持有的备忘录群Key 通常是步骤IDsnapshotIdprivatefinalMapSerializable,FlowExecutionSnapshotsnapshotsnewLinkedHashMap();// 保存备忘录publicvoidaddSnapshot(Serializableid,FlowExecutionSnapshotsnapshot){this.snapshots.put(id,snapshot);// 如果超过了最大保存步数例如限制只能回退3步则移除最老的快照if(maxSnapshots0snapshots.size()maxSnapshots){IteratorSerializableitsnapshots.keySet().iterator();it.next();it.remove();}}// 获取备忘录publicFlowExecutionSnapshotgetSnapshot(Serializableid){returnsnapshots.get(id);}}发起人执行恢复当用户在前端点击上一步时请求携带着历史的snapshotId到达后端由FlowExecutionImplFactory执行反序列化publicFlowExecutionrestoreFlowExecution(FlowExecutionSnapshotsnapshot,...){// 将备忘录中的 byte[] 数据反序列化重新还原出原来的 FlowExecution 状态return((SerializedFlowExecutionSnapshot)snapshot).unmarshal(...);}源码分析Spring 完美还原了标准的 GoF 备忘录模式甚至考虑到了内存边界通过限制maxSnapshots避免 OOM。2.3 MyBatis / JDBC Savepoint 事务回滚在 MyBatis 中开启一个事务执行多条 SQL最后发生异常触发SqlSession.rollback()时底层就是利用了数据库连接的Savepoint保存点机制——这是数据库和持久层框架中最典型的备忘录模式。JDBC 中的备忘录与负责人在 JDBC 标准中java.sql.Connection充当了负责人Caretaker它负责创建和持有保存点。java.sql.Savepoint接口则是备忘录Memento。MyBatis 的 JdbcTransaction 封装publicclassJdbcTransactionimplementsTransaction{protectedConnectionconnection;Overridepublicvoidrollback()throwsSQLException{if(connection!null!connection.getAutoCommit()){connection.rollback();// 回滚到最近保存点}}}Spring 管理嵌套事务的 Savepoint 机制Spring 配合 MyBatis 处理嵌套事务时的源码摘自org.springframework.jdbc.datasource.JdbcTransactionObjectSupportpublicclassJdbcTransactionObjectSupportimplementsSavepointManager{// 创建备忘录存档点OverridepublicObjectcreateSavepoint()throwsTransactionException{ConnectionHolderconHoldergetConnectionHolderForSavepoint();try{// 让 Connection 创建一个 Savepoint 备忘录returnconHolder.getConnection().setSavepoint();}catch(SQLExceptionex){thrownewNestedTransactionNotSupportedException(Could not create JDBC savepoint,ex);}}// 恢复到指定的备忘录状态回滚到特定存档点OverridepublicvoidrollbackToSavepoint(Objectsavepoint)throwsTransactionException{ConnectionHolderconHoldergetConnectionHolderForSavepoint();try{// 负责人 Connection 接收备忘录对象 (Savepoint)conHolder.getConnection().rollback((Savepoint)savepoint);}catch(SQLExceptionex){thrownewTransactionSystemException(Could not roll back to JDBC savepoint,ex);}}}源码分析总结发起人数据库底层的事务状态包含当前的 Data Page 缓存、锁信息、Undo Log 指针。备忘录java.sql.Savepoint实例代表了数据库在某个指令执行前的一个快照指针。负责人Spring 的TransactionManager或 MyBatis 的事务处理器。在嵌套循环中先调用createSavepoint()拿到备忘录一旦内层业务抛出异常便将备忘录丢还给Connection执行rollbackToSavepoint。从顶级框架中学到的备忘录的物理形式是多样的在Calendar中它是一个普通的Java 对象 (Date)在 Spring Web Flow 中它被转化为字节数组 (byte[])在 MyBatis/JDBC 事务中它是一个指向底层系统状态的指针对象 (Savepoint)。窄接口的实践java.sql.Savepoint接口内部几乎没有任何业务方法只有getSavepointId()和getSavepointName()。这完美契合了迪米特法则——对外部Caretaker而言它只是一个没有任何秘密的黑盒。3. 深度追问3.1 Why为什么解决哪个根本矛盾备忘录模式解决的根本矛盾是对象状态的完全备份需求与面向对象封装性原则信息隐藏之间的冲突。矛盾的一方业务需要将对象的内部状态完整导出并保存以便后续撤销或回滚这意味着外部必须能够看见这些状态。矛盾的另一方面向对象的核心思想是封装对象的内部实现细节、核心私有属性应该对外部严格隐藏否则会导致高耦合和脆弱的系统架构。如果直接暴露属性就破坏了封装如果不暴露属性外部就无法备份。备忘录模式通过引入一个对拥有者透明对外部是黑盒的中间介质完美调和了这一矛盾。3.2 How怎么做对象关系与交互机制它通过**双重接口Dual Interface的权限控制机制和三方解耦**的交互关系来解决问题。核心机制双重接口窄接口Narrow Interface负责人Caretaker看到的接口。通常没有任何方法标记接口或者只暴露极少元数据。确保 Caretaker 只能拿着它而无法窥探或篡改其中的状态。宽接口Wide Interface发起人Originator看到的接口。由于备忘录通常作为发起人的私有内部类存在发起人可以直接跨越private限制读取和写入该内部类的所有属性。交互机制时序逻辑Caretaker 发起备份指令。Originator 内部 new 出一个实现了窄接口的私有内部类实例Memento将自身状态 copy 进去。Originator 将这个窄接口对象返回给 Caretaker 存入队列。需要回滚时Caretaker 将窄接口对象丢给 Originator。Originator 内部强转为具体的内部类提取数据完成自愈。3.3 Trade-off代价牺牲了什么换取什么牺牲了代价内存与存储空间高成本每次备份都是对状态的全量快照。如果对象体量大、高频操作极易引发 JVM 内存暴增。运行期性能频繁的对象实例化、深度克隆或属性拷贝会带来明显的 CPU 开销。换取了收益绝对的封装性保证发起人对象的内部秘密永远不被外界知晓。职责的单一性状态如何排队、如何持久化、何时触发撤销这些历史管理的复杂逻辑全部被剥离到了 Caretaker 中Originator 只专注于纯粹的业务。3.4 Evolution演化从简单设计到变体阶段一原始的硬编码设计Naïve Design最早的代码中撤销功能通过在业务类内部写一堆oldXxx变量来实现。当状态超过两三个或者需要支持多步撤销时这种设计彻底崩溃。阶段二GoF 经典的备忘录模式引入三个标准角色利用 Java 的private inner class内部类实现了双重接口权限控制。阶段三现代衍生变体增量备忘录Incremental Memento不再保存全量快照而是计算并保存当前状态与上一个状态的Diff差异。降低了内存消耗提高了恢复时的计算复杂度。序列化备忘录Serialized Memento将状态转化为 JSON/Protobuf 字节流用于跨网络、跨进程的状态恢复。3.5 Modern Practice现代实践被取代还是重生不可变模型Immutable Architecture的兴起现代 Java 16 (Record) 极力推崇状态不可变。每次操作产生一个全新的、包含了新状态的对象。“撤销变成了简单的引用切换。不再需要专门写一个Memento类因为不可变对象本身就是天然的、安全的备忘录”。事件溯源Event Sourcing在微服务架构中备忘录模式演变成了事件溯源。传统的备忘录存的是状态State事件溯源存的是导致状态改变的事件Events。当系统需要恢复到任意时刻时只需从零开始重新重放Replay这些事件。Spring 的声明式事务在 Spring 环境下直接使用Transactional。Spring 利用 AOP 在底层无感地管理着数据库连接的Savepoint。开发者不再需要显式地编写创建快照、保存快照、在catch块中恢复快照的备忘录模板代码。结论备忘录模式的原版形式私有内部类在现代单体应用开发中逐渐退居幕后但在底层框架设计如文本编辑器内核、图形渲染引擎中依然是不二之选而在宏观架构层它的核心思想通过不可变数据结构、Redux 状态管理、事件溯源等新技术获得了重组和新生。4. 总结核心要点备忘录模式是后悔药机制通过 Originator → Memento → Caretaker 三角色实现状态的安全备份与恢复核心机制是双重接口窄接口给 Caretaker空接口和宽接口给 Originator可读写内部状态与命令模式的本质区别备忘录存快照全量命令存步骤增量物理形式多样可以是普通对象Date、字节数组Spring Web Flow、指针Savepoint现代演进不可变数据、事件溯源、声明式事务一句话备忘录模式就是存档——打 Boss 前先保存挂了可以读档重来。它让你能在不破坏封装的前提下把对象的此刻完整封存。5. 参考文献[1] GAMMA E, HELM R, JOHNSON R, et al. Design Patterns: Elements of Reusable Object-Oriented Software[M]. Boston: Addison-Wesley, 1994.[2] SPRING. Spring Web Flow Documentation[EB/OL]. https://spring.io/projects/spring-web-flow, 2024.[3] Oracle. Java Calendar / Date Documentation[EB/OL]. https://docs.oracle.com/javase/8/docs/api/java/util/Calendar.html, 2024.[4] MyBatis. MyBatis 3 Transaction[EB/OL]. https://mybatis.org/mybatis-3/, 2024.