# 备忘录模式 对于大对象的备份和恢复,如何优化内存和时间的消耗 ## 定义 备忘录模式,也叫快照(Snapshot)模式,英文翻译是 Memento Design Pattern。在 GoF 的《设计模式》一书中,备忘录模式是这么定义的: > Captures and externalizes an object’s internal state so that it can be restored later, all without violating encapsulation. 翻译成中文就是:在不违背封装原则的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以便之后恢复对象为先前的状态 核心就是2点: - 存储副本以便后期恢复 - 在不违背封装原则的前提下,进行对象的备份和恢复 ## 如何实现 - 为什么存储和恢复副本会违背封装原则? - 备忘录模式是如何做到不违背封装原则的? 编写一个小程序,可以接收命令行的输入。用户输入文本时,程序将其追加存储在内存文本中;用户输入“:list”,程序在命令行中输出内存文本的内容;用户输入“:undo”,程序会撤销上一次输入的文本,也就是从内存文本中将上次输入的文本删除掉。 ``` >hello >:list hello >world >:list helloworld >:undo >:list hello ``` 简易版本 ``` public class InputText { private StringBuilder text = new StringBuilder(); public String getText() { return text.toString(); } public void append(String input) { text.append(input); } public void setText(String text) { this.text.replace(0, this.text.length(), text); } } public class SnapshotHolder { private Stack snapshots = new Stack<>(); public InputText popSnapshot() { return snapshots.pop(); } public void pushSnapshot(InputText inputText) { InputText deepClonedInputText = new InputText(); deepClonedInputText.setText(inputText.getText()); snapshots.push(deepClonedInputText); } } public class ApplicationMain { public static void main(String[] args) { InputText inputText = new InputText(); SnapshotHolder snapshotsHolder = new SnapshotHolder(); Scanner scanner = new Scanner(System.in); while (scanner.hasNext()) { String input = scanner.next(); if (input.equals(":list")) { System.out.println(inputText.toString()); } else if (input.equals(":undo")) { InputText snapshot = snapshotsHolder.popSnapshot(); inputText.setText(snapshot.getText()); } else { snapshotsHolder.pushSnapshot(inputText); inputText.append(input); } } } } ``` 问题分析:仔细想想遵循备忘录模式吗(要在不违背封装原则的前提下,进行对象的备份和恢复)? - 为了实现快照恢复功能,在 InputText 类中定义了 `setText` 方法,这个方法名字很普通,就是一个设置文本的方法,而且还是公有方法,外部很容易误调用,也不符合封装原则 - 快照应该是不可变的,所以不需要对外暴露 set 等用于修改内部状态的方法。且上面的实现复用了 InputText 这个类,用于充当快照类。InputText 同时存在一系列修改内部状态的方法,违背封装原则 改进: - 单独抽取 SnapShot 类,用于快照数据的保存,对外只暴露 get 方法,没有 set 方法去修改内部状态 - Input 类中,把 setText 改为针对特定场景的 resetSnapShot 方法,标识特定场景,只用于恢复快照数据 ``` public class InputText { private StringBuilder text = new StringBuilder(); public String getText() { return text.toString(); } public void append(String input) { text.append(input); } public Snapshot createSnapshot() { return new Snapshot(text.toString()); } public void restoreSnapshot(Snapshot snapshot) { this.text.replace(0, this.text.length(), snapshot.getText()); } } public class Snapshot { private String text; public Snapshot(String text) { this.text = text; } public String getText() { return this.text; } } public class SnapshotHolder { private Stack snapshots = new Stack<>(); public Snapshot popSnapshot() { return snapshots.pop(); } public void pushSnapshot(Snapshot snapshot) { snapshots.push(snapshot); } } public class ApplicationMain { public static void main(String[] args) { InputText inputText = new InputText(); SnapshotHolder snapshotsHolder = new SnapshotHolder(); Scanner scanner = new Scanner(System.in); while (scanner.hasNext()) { String input = scanner.next(); if (input.equals(":list")) { System.out.println(inputText.toString()); } else if (input.equals(":undo")) { Snapshot snapshot = snapshotsHolder.popSnapshot(); inputText.restoreSnapshot(snapshot); } else { snapshotsHolder.pushSnapshot(inputText.createSnapshot()); inputText.append(input); } } } } ``` 上面的代码实现就是典型的备忘录模式的代码实现,也是很多书籍(包括 GoF 的《设计模式》)中给出的实现方法。 ## QA 备忘录模式,还有一个跟它很类似的概念,“备份”,有何异同? 两者的应用场景很类似,都应用在防丢失、恢复、撤销等场景中。备忘录模式更侧重于代码的设计和实现,备份更侧重架构设计或产品设计 ## 优化内存和时间消耗 上面的实现存在问题:如果要备份的对象数据比较大,备份频率又比较高,那快照占用的内存会比较大,备份和恢复的耗时会比较长。这个问题该如何解决呢? 不同的应用场景下有不同的解决方法。比如,我们前面举的那个例子,应用场景是利用备忘录来实现撤销操作,而且仅仅支持顺序撤销,也就是说,每次操作只能撤销上一次的输入,不能跳过上次输入撤销之前的输入。在具有这样特点的应用场景下,为了节省内存,我们不需要在快照中存储完整的文本,只需要记录少许信息,比如在获取快照当下的文本长度,用这个值结合 InputText 类对象存储的文本来做撤销操作。 我们再举一个例子。假设每当有数据改动,我们都需要生成一个备份,以备之后恢复。如果需要备份的数据很大,这样高频率的备份,不管是对存储(内存或者硬盘)的消耗,还是对 时间的消耗,都可能是无法接受的。想要解决这个问题,我们一般会采用“**低频率全量备份**”和“**高频率增量备份**”相结合的方法。 全量备份就不用讲了,它跟我们上面的例子类似,就是把所有的数据“拍个快照”保存下来。所谓“增量备份”,指的是记录每次操作或数据变动。 当我们需要恢复到某一时间点的备份的时候,如果这一时间点有做全量备份,我们直接拿来恢复就可以了。如果这一时间点没有对应的全量备份,我们就先找到最近的一次全量备份, 然后用它来恢复,之后执行此次全量备份跟这一时间点之间的所有增量备份,也就是对应的操作或者数据变动。这样就能减少全量备份的数量和频率,减少对时间、内存的消耗。