概述
上两篇文章,我们学习了访问者模式的原理与实现、以及为什么支持双分派编程语言不需要访问者模式。
本章,学习另外一种行为型模式,备忘录模式。这个模式理解、掌握起来不难,代码实现比较灵活,应用场景也比较明确和有效,主要是用来防丢失、撤销、恢复等。
备忘录模式的原理与实现
备忘录模式,也叫快照模式,应为翻译为 Memento Design Pattern。在 GoF 的《设计模式》中,备忘录模式是这么定义的:
Captures and externalizes an object's internal state so that it can be restored later,all without violating encapsulation.
翻译成中文:在不违背封装原则的前提下,捕获一个对象的内部状态,并在该对象之外保持这个状态,以便之后恢复对象为先前的状态。
这个模式的定义表达了两部分的内容。
- 一部分是,存储副本以便后期恢复。
- 另一部分是,要在不违背封装原则的前提下,进行对象的备份和恢复。
第二部分不太好理解,下面结合一个例子来解释下,你需要搞清楚两个问题:
- 为什么存储副本和恢复副本会违背封装原则。
- 备忘录模式是如何做到不违背封装原则的?
假设有这样一个面试题,希望你编写一个小程序,可以接收命令输入。用户输入文本时,程序将追加存储在内存文本中;用户输入 ":list",程序在命令行中输出文本的内容;用户输入 ":undo",程序会撤销上一次输入的文本,也就是从内存文本中将上一次输入的文本删除。
bash
>hello
>:list
hello
>world
>:list
helloword
>:undo
>:list
hello
怎么编程实现呢?从整体上来看,这个程序实现起来并不复杂。下面是我写的一种实现思路。
java
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<InputText> snapshots = new Stack<>();
public InputText popSnapshot() {
return snapshots.pop();
}
public void pushSnapshot(InputText inputText) {
snapshots.push(inputText);
}
}
public class Application {
public static void main(String[] args) {
InputText inputText = new InputText();
SnapshotHolder snapshotHolder = new SnapshotHolder();
Scanner scanner = new Scanner(System.in);
while (scanner.hasNext()) {
String input = scanner.next();
if (input.equals(":list")) {
System.out.println(inputText.getText());
} else if (input.equals(":undo")) {
InputText snapshot = snapshotHolder.popSnapshot();
inputText.setText(snapshot.getText());
} else {
snapshotHolder.pushSnapshot(inputText);
inputText.append(input);
}
}
}
}
实际上备忘录模式很灵活,也没有固定的实现方式,在不同的业务需求、不同编程语言下,代码实现可能不一样。上面的代码基本上已经实现了最基本的备忘录的功能。但是,如果深究一下的话,还有一些问题要解决,那就是定义中的第二部分:要在不违背封装原则的前提下,进行对象的备份和恢复。而上面的代码并不满足这一点,主要体现在两个方面:
- 第一,为了能快照恢复
InputText
对象,我们在InputText
类中定义了setText()
函数,这个函数有可能会被其他业务使用,所以,暴露不应该暴露的函数违背了封装原则。 - 第二,快照本身是不可变的,理论上将,不应该包含 set() 等修改内部状态的函数,但在上面的代码实现中,"快照" 这个业务模式复用了
InputText
类的定义,而InputText
类本身有一些列修改内部状态的函数,所以,用InputText
来表示快照违背了封装原则。
针对以上问题,对代码做两点修改。
- 其一,定义一个独立的类(
Snapshot
)来表示快照,而不是复用InputText
类。这个类只暴露get()
方法。 - 其二,在
InputText
中,我们把setText()
方法重命名为restoreSnapshot()
方法,用意更加明确,只用来恢复对象。
按照这个思路,对代码进行重构。
java
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 text;
}
}
public class SnapshotHolder {
private Stack<Snapshot> snapshots = new Stack<>();
public Snapshot popSnapshot() {
return snapshots.pop();
}
public void pushSnapshot(Snapshot snapshot) {
snapshots.push(snapshot);
}
}
public class Application {
public static void main(String[] args) {
InputText inputText = new InputText();
SnapshotHolder snapshotHolder = new SnapshotHolder();
Scanner scanner = new Scanner(System.in);
while (scanner.hasNext()) {
String input = scanner.next();
if (input.equals(":list")) {
System.out.println(inputText.getText());
} else if (input.equals(":undo")) {
Snapshot snapshot = snapshotHolder.popSnapshot();
inputText.restoreSnapshot(snapshot);
} else {
snapshotHolder.pushSnapshot(inputText.createSnapshot());
inputText.append(input);
}
}
}
}
实际上,上面的代码就是典型的备忘录模式的代码实现。
除了备忘录模式,还有一个和它很类似的概念,"备份",它在我们平时的开发中经常听到。那备忘录模式和 "备份" 有什么区别呢?实际上,这两者的应用场景很类似,都应用在防丢失、恢复、撤销等场景中。它们的区别在于,备忘录模式更侧重于代码设计和实现,备份更侧重架构设计或产品设计。
如何优化内存和时间消耗?
前面只是简单介绍了备忘录模式的原理和经典实现,现在再继续深挖下。如果要备份的对象数据比较大,备份的频率又比较高,那快照占用的内存会比较大,备份和恢复的耗时会比较长。这个问题该如何解决呢?
不同的应用场景有不同的解决办法。比如,前面的例子,应用场景就是利用备忘录来实现撤销操作,而且仅仅支持顺序撤销 ,也就是说,每次操作只能撤销上一次的输入,不能跳过上次输入撤销之前的输入。在具有这样特点的应用场景下,为了节省内存,我们不需要在快照中存储完整的文本,只需要记录少许信息,比如在获取快照当下的文本长度,用这个值结合 InputText
类对象存储的文本来做撤销操作。
再举一个例子。假设每当有数据改动,都需要生成一个备份,以备之后恢复。如果需要备份的数据很大,这样高频率的备份,不管是对存储的消耗,还是对时间的消耗,都可能是无法接受的。想要解决这个问题,一般会采用 "低频率全量备份" 和 "高频率增量备份" 相结合的方法。
全量备份和上面的例子类似,就是把所有的数据 "拍个快照" 保存下来。所谓 "增量备份",指的是记录每次操作或数据变动。
当我们需要恢复到某一时间点的备份时,如果这一时间点有做全量备份,我们直接拿来恢复即可。如果这一时间点没有对应的全量备份,就先找到最近一次的全量备份,然后用它来恢复,之后执行此次全量备份跟这一时间点之间的所有增量备份,也就是对应的操作或者数据变动。这样就能减少全量备份的数据和频率,减少对时间、内存的消耗。
总结
备忘录模式也叫快照模式。具体来说,就是在不违背封装原则的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以便之后恢复对象为先前的状态。这个模式的定义表达了两部分内容:一部分是,存储副本以便后期恢复;另一部分是,要在不违背封装原则的前提下,进行对象的备份和恢复。
备忘录模式的应用场景也比较明确和有效,主要是用来防丢失、撤销、恢复等。它跟平时我们说的 "备份" 很相似。两者的主要区别在于,备忘录模式更侧重于代码的设计和实现,"备份" 更侧重架构设计或产品设计。
对于大对象的备份来说,备份占用的存储空间会比较大,备份和恢复的耗时会比较长。针对这个问题,不同的业务场景有不同的处理方式。
- 比如,只备份必要的恢复信息,结合最新的数据来恢复。
- 再比如,全量备份和增量备份相结合,低频全量备份,高频增量备份,两种结合来恢复。