设计模式 21 · 备忘录模式
命令模式那篇讲撤销时,我们埋了个钩子:命令模式靠每个命令记住自己的逆操作来撤销,但如果对象状态很复杂、记录逆操作不方便,还有另一条路——直接给对象拍个快照,撤销时整个恢复。这条路,就是这一篇的备忘录模式(Memento)。备忘录模式解决的问题一句话:在不破坏封装的前提下,捕获一个对象的内部状态,并在需要时把它恢复到这个状态。关键词是捕获状态“恢复和不破坏封装”。最形象的类比就是游戏存档:你在打一个 Boss 前存个档,万一打输了,就读档回到存档那一刻,一切重来。这个存档就是一份备忘录——它保存了游戏在某一刻的完整状态,让你能随时回滚。我们的订单场景里,一个天然的例子是订单编辑的草稿功能:用户在后台编辑一个复杂订单(改地址、调数量、加优惠、改备注……),编辑到一半想存个草稿,过会儿如果改乱了,能恢复到草稿那一刻。这就需要在某个时间点,把订单的完整状态拍照存下来,之后能整个恢复回去。备忘录模式,就是干这件事的标准方案。这一篇和命令模式、以及第 6 篇原型模式都有呼应(它们都涉及保存状态),我会讲清它们的分工。因为它的核心思想比较直接,篇幅上会比策略、状态那些略简洁。这篇文章按这条线索展开:先看想恢复状态,却要么做不到、要么破坏封装的两难;再引出备忘录如何优雅地捕获和恢复状态;然后讲清它的三个角色、以及不破坏封装这个精妙之处;接着说它和命令、原型的分工与现实身影;最后给出适用边界。贯穿例是订单草稿。目录想恢复状态,却陷入两难备忘录模式:给状态拍快照三个角色,与不破坏封装的精妙和命令、原型的分工,以及现实身影什么时候用备忘录模式一、想恢复状态,却陷入两难看订单草稿。用户编辑订单,想在某一刻存个草稿,之后能恢复。假设订单是这样:publicclassOrder{privateStringaddress;privateintcount;privateStringcoupon;privateStringremark;// ... 一堆状态字段}要实现存草稿 恢复,你会发现自己陷入两难:做法一:把所有字段暴露出去,让外面存。外面拿到所有字段存起来,恢复时再一个个设回去:// 存草稿:把 order 的每个字段都读出来存着StringsavedAddressorder.getAddress();intsavedCountorder.getCount();StringsavedCouponorder.getCoupon();// ... 恢复时再一个个 setXxx 回去问题:这彻底破坏了封装。外面被迫知道了订单的所有内部字段,还得有权限读写它们。而且订单以后加一个字段,所有存草稿的地方都得记得同步加一行——极易遗漏,漏一个草稿就少存一个字段。订单的内部状态,本不该这样赤裸裸地摊给外面。做法二:让订单自己往外吐一个完整状态对象。好一点,但如果这个状态对象把所有字段的 getter/setter 都开放了,外面依然能随意读写订单的内部——封装还是破了。问题的两难在于:保存和恢复状态这件事,天然需要访问对象的内部;但直接把内部暴露给外面,又破坏了封装。我们真正想要的是:让订单自己负责打包它的内部状态成一个备忘录,这个备忘录对外是个黑盒(别人拿到也看不懂、改不了里面),但订单自己能从这个备忘录里还原出当时的状态。状态的打包和还原,都由订单自己做,外面只负责保管这个黑盒备忘录。这就是备忘录模式的精妙之处。二、备忘录模式:给状态拍快照备忘录模式的做法:由对象自己(原发器)创建一个备忘录来保存自己的内部状态;备忘录对外是个封闭的黑盒;需要恢复时,把备忘录交还给对象,由对象自己从里面还原状态。一个管理者负责保管这些备忘录,但不能窥探其内容。第一步,备忘录类(保存状态的黑盒):// 备忘录:保存订单某一刻的状态。对外是黑盒publicclassOrderMemento{privatefinalStringaddress;privatefinalintcount;privatefinalStringcoupon;privatefinalStringremark;// 构造和 getter 是包级私有或仅对 Order 可见 —— 外面拿不到内容OrderMemento(Stringaddress,intcount,Stringcoupon,Stringremark){this.addressaddress;this.countcount;this.couponcoupon;this.remarkremark;}StringgetAddress(){returnaddress;}// 仅 Order 能访问// ... 其余 getter 同样受限}第二步,原发器(订单)——它自己会打包和还原状态:publicclassOrder{privateStringaddress;privateintcount;privateStringcoupon;privateStringremark;// 存草稿:把当前状态打包成一个备忘录publicOrderMementosave(){returnnewOrderMemento(address,count,coupon,remark);}// 恢复:从备忘录里还原状态(由订单自己读,不破坏封装)publicvoidrestore(OrderMementomemento){this.addressmemento.getAddress();this.countmemento.getCount();this.couponmemento.getCoupon();this.remarkmemento.getRemark();}}第三步,管理者(草稿箱)——只负责保管备忘录,不看内容:publicclassDraftCaretaker{privateOrderMementodraft;// 保管草稿,但看不懂里面是什么publicvoidsave(OrderMementom){this.draftm;}publicOrderMementoget(){returndraft;}}用起来,存草稿和恢复都干净利落:Orderorder...;DraftCaretakercaretakernewDraftCaretaker();caretaker.save(order.save());// 编辑到一半,存个草稿order.setCount(99);// 继续瞎改...改乱了order.restore(caretaker.get());// 恢复到草稿那一刻,瞎改的全没了对比第一节,升级点非常清晰:订单的状态由订单自己打包成备忘录、也由它自己还原,外面(草稿箱)只拿到一个看不懂、改不了的黑盒,全程没有暴露订单的任何内部字段——封装完好无损;而存草稿 恢复这个需求,优雅地实现了。用一张图看这个拍快照 保管 还原的三方协作最清楚:图里最该记住的,是那个只有原发器能看懂、管理者只能保管不能拆开的黑盒备忘录。这就是备忘录模式和做法一最本质的区别:状态的读写权限,始终牢牢握在对象自己手里,外部永远碰不到内部字段。三、三个角色,与不破坏封装的精妙备忘录模式的角色,三个:角色本例中是谁职责原发器(Originator)Order创建备忘录保存状态、从备忘录恢复状态备忘录(Memento)OrderMemento保存原发器的内部状态,对外封闭管理者(Caretaker)DraftCaretaker保管备忘录,但不能读取/修改其内容这个模式最精妙、也最容易被做错的地方,在于**“不破坏封装”** 这四个字。它靠一个巧妙的设计实现:备忘录对原发器是开放的(原发器能读写它的全部状态),但对管理者和其他所有人是封闭的(只能持有引用、不能看内容)。在 Java 里,这通常靠访问权限来实现,常见两种手法:内部类:把Memento做成Originator的私有内部类,这样只有Originator能访问它的字段,外面连它长什么样都看不到,只能拿到一个Object或一个空接口引用。包级私有:像上面代码那样,把Memento的构造和 getter 设成包级私有(不写public),只有同包的Order能访问,外面调不了。核心思想是:管理者只是个保管箱,它保管的是一个密封的信封,能存能取,但拆不开。只有原发器持有拆信封的钥匙。这样一来,保存/恢复状态需要访问内部和不想暴露内部这对矛盾,就被巧妙地化解了——访问内部的权限,只给了对象自己。四、和命令、原型的分工,以及现实身影备忘录和前面两个模式(命令、原型)都涉及状态,容易混,理清分工:备忘录 vs 命令(第 19 篇)的撤销:两者都能实现撤销,但思路不同。命令的撤销是记住逆操作(改数量的逆操作是改回旧数量),是增量式的——记住怎么反着做一遍。备忘录的撤销是存整个快照,是全量式的——直接把整个状态存下来,恢复时整个覆盖。状态简单、逆操作好写,用命令;状态复杂、逆操作难写,用备忘录直接存快照。而且两者常配合:命令模式做撤销时,如果操作难以逆转,就在命令里存一个备忘录,undo()时恢复它。备忘录 vs 原型(第 6 篇):原型是克隆一个完整的新对象,备忘录是保存状态、之后恢复到原对象。原型的产物是个能独立使用的新对象;备忘录的产物是个只用于恢复的黑盒状态。实现上,备忘录有时会用到克隆(把状态深拷贝一份存起来),但意图不同——原型为了造新的,备忘录为了存旧的以便回滚。现实身影:数据库事务的回滚(savepoint):事务开始时的状态相当于一个备忘录,回滚就是恢复到那个状态。各种编辑器/IDE 的撤销、游戏存档、浏览器的会话恢复:都是备忘录思想——保存某一刻的完整状态,以便回到那一刻。Serializable序列化:把对象序列化成字节存起来、之后反序列化恢复,本质也是一种备忘录(把整个对象状态打包保存)。一个识别信号:凡是保存某一刻的完整状态,以便日后整个恢复回去(存档/读档、快照/回滚),就是备忘录。五、什么时候用备忘录模式适合用备忘录模式的信号:你需要保存对象某一刻的完整状态,并能在之后恢复到那个状态(存档、草稿、快照);你想实现撤销/回滚,但对象状态复杂、记逆操作不划算,直接存快照更省事;你希望在不暴露对象内部结构的前提下做这件事。不必用的信号:对象状态就一两个简单字段——那随手存一下就行,套三个角色是过度设计;撤销的逆操作很简单——那用命令模式的undo()更轻量;状态对象非常大、且要频繁存快照——注意内存开销:每个备忘录都是一份完整状态,存太多、太大会吃内存(可以考虑只存关键字段、或限制快照数量)。判断的核心还是那句话:先确认真的需要保存并整体恢复完整状态,且在意封装,备忘录才值得上。还要留意它的内存成本——快照不是免费的。小结。备忘录模式在不破坏封装的前提下,捕获对象的内部状态并支持恢复:由对象自己(原发器)把状态打包成一个黑盒备忘录,管理者只负责保管(能存取、拆不开),恢复时由对象自己从备忘录还原——访问内部的权限始终握在对象手里,靠内部类或包级私有实现这份封装。它是存档/读档“草稿/恢复”快照/回滚的标准方案。它和命令模式的撤销互补(增量记逆操作 vs 全量存快照,可配合),和原型的克隆意图不同(造新的 vs 存旧的)。用它要留意内存开销。下一篇是行为型的收尾,我们把三个相对冷门、适用面很窄的模式——中介者、访问者、解释器——放在一篇里集中讲透,重点说清它们各自解决什么问题、以及为什么大多数时候你并不需要它们。