保存快照和撤销功能的实现方案——备忘录模式总结
1、前言
本模式用的不是特别多,知道即可,本文主要是平时的读书笔记的整理
2、出现的动机和概念
备忘录模式——也叫 Memo 模式,或者快照模式等,顾名思义就是实现历史记录的作用,比如可以实现游戏关卡的角色复活,任务进度保存,命令的撤销,以及系统的快照留存记录等功能。
备忘录模式的用意是在不破坏封装的条件下,将一个对象的状态捕捉(Capture),并外部化存储,从而可以在将来合适的时候把这个对象还原到存储时的状态(undo/rollback)。
很简单的概念,可以联系Git,还有数据库事务处理等,它们都有版本记录,操作回滚的逻辑,这些都可以基于备忘录模式,搭配其他模式来优雅的实现。
一句话:当业务需求是让对象返回之前的某个历史性的状态的时候,就应该使用备忘录模式加以封装。
2.1、什么叫破坏了封装性
假如保存某个对象 A 的当前状态 A1,那么 RD 自然不是撑得没事干,肯定是为了未来能回滚或者查看对象 A 的这个当前状态 A1。自然的,外部的类(对象)就一定要能够自由的访问 A 的内部状态(即有一段代码 B 需要依赖 A 的内部结构),否则连保存什么都不知道,那还保存个什么劲儿呢。那么问题来了,如果稍不注意,就会把 B 分散在系统的各个角落,导致系统对 A 恢复操作的管理日益杂乱,增大开发和维护成本。这就叫破坏了封装性。
2.2、如何防止关键对象的封装性遭到破坏
答案很明显,就是使用备忘录模式加以设计。
3、由投色子游戏引出
游戏会有玩家复活功能,或者关卡进度恢复的功能,如果你不提供这样的功能,肯定没人玩。但是游戏的状态是非常关键的数据,必须要封装得当,不能让别人随意访问。
下面看一个投色子的游戏机例子,玩家先充钱(200起步)才能玩,且按下投掷按钮,让机器来摇色子:
1、点数为1,玩家赢100块钱
2、为2,输200块钱
3、为6,玩家不赢钱,但是可以得到一个礼物,礼物里分为两类:
-
纪念意义的礼物,不值钱
-
可以积累换积分,兑换钱的vip礼物
4、玩家没钱了,游戏结束
5、如果玩家不想结束当前游戏,则可以充钱恢复到最初状态。
我们用 User 代表玩家,Memo 代表备忘录,Game 代表游戏机。
3.1、备忘录类和单一职责原则
Memo 代表备忘录类,是备忘录模式的核心类。顾名思义,它只有一个功能——负责保存和恢复目标对象的状态,比如创建快照,恢复快照。而到底什么时候创建快照,什么时候恢复快照,Memo 类并不关心。
在例子中,Memo 表示玩家的状态,注意该类和代表玩家类的类(User)都必须在一个包下面。
import java.util.ArrayList; import java.util.List; /** * 特别要注意,各个属性和方法的 包 权限,它和用户类需在一个包下 */ public class Memo { /** * 代表用户的钱,为包访问权限 */ int money; /** * 代表用户的礼物,为包访问权限 */ ArrayList gifts; /** * 包访问权限的构造器——这是一个宽接口 */ Memo(int money) { this.money = money; this.gifts = new ArrayList<>(); } // 窄接口,获取用户的当前状态下的钱 public int getMoney() { return money; } /** * 宽接口,保存礼物 */ void addGift(String gift) { this.gifts.add(gift); } /** * 宽接口,获取当前用户持有的所有礼物 */ List getGifts() { return (List) this.gifts.clone(); } }
一定注意 Memo 类的成员权限,这非常重要:
1、构造器在包外无法被访问,只有本包内的类可以访问,生成 Memo 实例
2、addGift 方法也是只有同一个包下的类能访问——给用户保存所得的礼物,外部包的类无法改变 Memo(备忘录)的数据
3、只有 getMoney 是 public,虽然只有它能被外界随意访问,但叫窄接口
3.1.1、Java 类成员的访问权限
权限 |
访问限制的说明 |
---|---|
public |
任何类 |
protected |
同一个包的类,或者该类的子类 |
无,也叫默认权限,或者包权限 |
同一个包的类 |
private |
该类自己 |
3.1.2、宽接口和窄接口
所谓的宽,窄,要明白针对谁说的——它们都是面向的备忘录 Memo,即宽接口是说其他类调用了该方法,那么就能获得 or 修改 Memo 的所有快照中的数据,这就是所谓的宽的意思。而窄接口,是说其他类调用了该方法,那么只能获得当前快照中的数据,这就是所谓的窄。
在 Memo 类中,只有 public int getMoney() 方法是窄接口,只有它可以被外部的类访问,而修改状态的宽接口们,不可以被外部的类访问。
3.1.3、Java 拷贝
Memo 类里,对 List getGifts 方法的返回值进行了 clone,其中,ArrayList 默认给重写了 clone 方法,但是是浅拷贝的,需要注意。
/** * Returns a shallow copy of this <tt>ArrayList</tt> instance. (The * elements themselves are not copied.) * * @return a clone of this <tt>ArrayList</tt> instance */ public Object clone() { try { ArrayList<?> v = (ArrayList<?>) super.clone(); v.elementData = Arrays.copyOf(elementData, size); v.modCount = 0; return v; } catch (CloneNotSupportedException e) { // this shouldn't happen, since we are Cloneable throw new InternalError(e); } }
3.2、和 Memo 类同包的用户类 User(生成者类)
User类——需要被保存状态以便恢复的那个对象。而如何恢复和保存快照的逻辑,它不 care,是前面的 Memo 类负责。
简单的规则就是,只要玩家没有输光了钱,它就可以一直玩下去
import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Random; public class User { private static final String[] GIFT_NAME = {"手机", "扫地机器人", "圆珠笔"}; private Random random = new Random(); private int money; // 玩家的钱 private List gifts = new ArrayList<>(); public User(int money) { this.money = money; } public int getMoney() { return money; } public void playGame() { int dice = random.nextInt(6) + 1; // [1-6] switch (dice) { case 1: this.money += 100; System.out.println("money + 100"); break; case 2: this.money -= 200; System.out.println("money -100"); break; case 6: String gift = getGift(); System.out.println("gitf is " + gift); break; default: System.out.println("平局"); } } // 保存快照 public Memo captureState() { // 保存当前余额 Memo memo = new Memo(this.money); Iterator iterator = this.gifts.iterator(); // 保存当前全部礼物(只保存 VIP 礼物) while (iterator.hasNext()) { String g = (String) iterator.next(); if (g.startsWith("VIP")) { memo.addGift(g); } } return memo; } // 恢复快照 public void restoreState(Memo memo) { this.money = memo.getMoney(); this.gifts = memo.getGifts(); } // 模拟随机生成一个礼物给用户,该方法不应该放这里的,为了演示 private String getGift() { String prefix = ""; if (random.nextBoolean()) { prefix = "VIP: "; } return prefix + GIFT_NAME[random.nextInt(GIFT_NAME.length)]; } @Override public String toString() { return "[ money = " + this.money + ", gifts = " + this.gifts + " ]"; } }
captureState 方法用来保存玩家的当前状态(拍摄快照),并把快照返回给调用者,这个调用者就是接下来要实现的管理类。类比拍照,captureState 方法拍下当前玩家的快照,并保存到 Memo 类中。
restoreState 相反就是撤销(回滚,恢复)的操作。
3.3、包外的管理者类 Main——何时保存/恢复快照
Main 类会初始化一个 User 实例,代表一个玩家,在玩家游戏的过程中,由 Main 类决定何时保存 User 快照,何时恢复 User 快照。具体的保存和恢复策略以及存储的位置,是 Memo 这个备忘录类实现的。
如果玩家运气好,会赢钱(礼物),并保存当前快照以便于未来恢复到这个状态。如果运气不好,输光了,玩家会马上买筹码,此时系统自动调用恢复快照的方法,让玩家恢复到死亡之前的状态。
import com.dashuai.D10Memo.memo.Memo; import com.dashuai.D10Memo.memo.User; public class Main { public static void main(String[] args) { // 玩家开始游戏,初始化一个用户实例,代表该玩家 User user = new User(100); System.out.println("玩家111,买了100筹码,开始游戏"); // 保存玩家初始化的状态,这是最早的恢复点 Memo memo = user.captureState();
for (int i = 1; i <= 10; i++) { System.out.println("用户的当前状态:" + user); System.out.println("------第 " + i + " 局"); user.playGame(); System.out.println("该局结束后,当前用户的金钱 = " + user.getMoney()); if (user.getMoney() > memo.getMoney()) { System.out.println("赢了很多啊,值得保存一下游戏进度"); memo = user.captureState(); System.out.println("保存完毕!"); } else if (user.getMoney() <= 0) { System.out.println("输光了,复活时间内,用户马上买筹码,为其复活,恢复到游戏结束前的状态"); user.restoreState(memo); } } } }玩家111,买了100筹码,开始游戏 用户的当前状态:[ money = 100, gifts = [] ] ------第 1 局 平局 该局结束后,当前用户的金钱 = 100 用户的当前状态:[ money = 100, gifts = [] ] ------第 2 局 平局 该局结束后,当前用户的金钱 = 100 用户的当前状态:[ money = 100, gifts = [] ] ------第 3 局 平局 该局结束后,当前用户的金钱 = 100 用户的当前状态:[ money = 100, gifts = [] ] ------第 4 局 平局 该局结束后,当前用户的金钱 = 100 用户的当前状态:[ money = 100, gifts = [] ] ------第 5 局 money + 100 该局结束后,当前用户的金钱 = 200 赢了很多啊,值得保存一下游戏进度 保存完毕! 用户的当前状态:[ money = 200, gifts = [] ] ------第 6 局 平局 该局结束后,当前用户的金钱 = 200 用户的当前状态:[ money = 200, gifts = [] ] ------第 7 局 money -200 该局结束后,当前用户的金钱 = 0 输光了,复活时间内,用户马上买筹码,为其复活,恢复到游戏结束前的状态 用户的当前状态:[ money = 200, gifts = [] ] ------第 8 局 gitf is VIP: 圆珠笔 该局结束后,当前用户的金钱 = 200 用户的当前状态:[ money = 200, gifts = [] ] ------第 9 局 平局 该局结束后,当前用户的金钱 = 200 用户的当前状态:[ money = 200, gifts = [] ] ------第 10 局 gitf is VIP: 手机 该局结束后,当前用户的金钱 = 200
1、Main 作为包外的类,就是所谓的管理者类,它管理 User(生成者类) 和 Memo (备忘录类),前者用来表示要保存的对象,后者表示如何保存的逻辑和保存的地点。
2、由于管理者类Main在包外,故 Main 不能直接访问 Memo 类的构造器,无法直接生成备忘录,保证了备忘录的封装完整,Main 只能通过调用 User 类的 public 的 getMoney 方法获取当前玩家的金钱,不能随意改变玩家的余额,保证了安全性。
3、由管理者类——Main 决定,何时拍摄玩家的快照或者何时恢复这个快照。具体的拍照和恢复策略,是 Memo——备忘录类本身实现。
4、备忘录模式的标准类图和角色
1、生成者——对应了示例的 User 类,是需要被保存状态以便恢复的那个对象
2、备忘录——Memo 类,该对象由生成者创建,主要用来保存生成者的内部状态
3、管理者——Main 类,负责管理在适当的时间保存/恢复生成者对象的状态。
备忘录角色有如下责任:
1)将生成者对象的内战状态存储。备忘录可以根据生成者对象的状态判断来决定存储多少生成者对象的内部状态
2)备忘录可以保护其内容不被生成者对象之外的任何对象所读取
5、备忘录模式的应用场景
如果一个对象需要保存状态并可通过undo或rollback等操作恢复到以前的状态时,可以使用Memento模式
具体说,如果一个类需要保存它的对象的状态(相当于生成者角色),可以
1、设计一个类,该类只是用来保存上述对象的状态(相当于 Memo 角色)
2、需要的时候,管理者角色要求生成者返回一个Memo并加以保存
3、undo或rollback时,通过管理者保存的Memo对象,恢复生成者的状态
6、多个备忘录的情景
之前的例子,Main 这个管理者类只保存了一个 memo,如果在Main集成数组或者list,则可以实现历史访问点的快照,便于恢复各个时间点的状态。
7、Memo 备忘录类的有效期问题
如果在内存中保存 memo,那么程序结束,就没用了。此时可把 memo 保存到数据库或者文件里序列化。但是到底保存多久又是个新问题,需要结合具体业务涉及。
8、划分管理者和生成者角色的意义
为什么要这么麻烦呢,直接全部实现在备忘录类不得了么。
因为,管理者角色的职责是决定何时保存生成者的快照,何时撤销。另一方面,生成者角色的职责是代表被保存和恢复的那个对象,他生成备忘录角色对象和使用接受到的备忘录角色对象恢复自己的状态。
这样就实现了职责分离。如下当需求变动:
1、撤销一次的操作,变更为撤销多次时
2、变更拍摄快照保存到内存为保存到数据库,or 文件时
都不需要反复修改生成者角色的代码了,这个生成者是实现关键业务逻辑的类,保证封装的稳定性。
9、经常和备忘录模式搭配的其他模式
备忘录模式常常与命令模式和迭代模式一同使用。比如命令模式实现撤销操作,可以搭配备忘录模式
10、备忘录模式的优缺点
1、优点
把被存储的状态放在外面——Memo角色,不和关键对象(生成者角色)混在一起,维护了各自的内聚和封装性。
能提供快照和恢复功能
2、缺点
如果连接数据库或者文件,可能拍摄快照和恢复的动作比较耗时
11、序列化和备忘录模式
Java中,能使用序列化机制实现对象的状态保存,因此可以搭配序列化机制实现备忘录模式,参看:Java对象序列化全面总结
欢迎关注
dashuai的博客是终身学习践行者,大厂程序员,且专注于工作经验、学习笔记的分享和日常吐槽,包括但不限于互联网行业,附带分享一些PDF电子书,资料,帮忙内推,欢迎拍砖!