CategoryResourceRepost/极客时间专栏/设计模式之美/设计模式与范式:行为型/70 | 备忘录模式:对于大对象的备份和恢复,如何优化内存和时间的消耗?.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

203 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="70 | 备忘录模式:对于大对象的备份和恢复,如何优化内存和时间的消耗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c5/11/c516c58085171935bcf176471c63b711.mp3"></audio>
上两节课我们学习了访问者模式。在23种设计模式中访问者模式的原理和实现可以说是最难理解的了特别是它的代码实现。其中用Single Dispatch来模拟Double Dispatch的实现思路尤其不好理解。不知道你有没有将它拿下呢如果还没有弄得很清楚那就要多看几遍、多自己动脑经琢磨一下。
今天,我们学习另外一种行为型模式,备忘录模式。这个模式理解、掌握起来不难,代码实现比较灵活,应用场景也比较明确和有限,主要是用来防丢失、撤销、恢复等。所以,相对于上两节课,今天的内容学起来相对会比较轻松些。
话不多说,让我们正式开始今天的学习吧!
## 备忘录模式的原理与实现
备忘录模式也叫快照Snapshot模式英文翻译是Memento Design Pattern。在GoF的《设计模式》一书中备忘录模式是这么定义的
>
Captures and externalizes an objects internal state so that it can be restored later, all without violating encapsulation.
翻译成中文就是:在不违背封装原则的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以便之后恢复对象为先前的状态。
在我看来,这个模式的定义主要表达了两部分内容。一部分是,存储副本以便后期恢复。这一部分很好理解。另一部分是,要在不违背封装原则的前提下,进行对象的备份和恢复。这部分不太好理解。接下来,我就结合一个例子来解释一下,特别带你搞清楚这两个问题:
- 为什么存储和恢复副本会违背封装原则?
- 备忘录模式是如何做到不违背封装原则的?
假设有这样一道面试题,希望你编写一个小程序,可以接收命令行的输入。用户输入文本时,程序将其追加存储在内存文本中;用户输入“:list”程序在命令行中输出内存文本的内容用户输入“:undo”程序会撤销上一次输入的文本也就是从内存文本中将上次输入的文本删除掉。
我举了个小例子来解释一下这个需求,如下所示:
```
&gt;hello
&gt;:list
hello
&gt;world
&gt;:list
helloworld
&gt;:undo
&gt;:list
hello
```
怎么来编程实现呢你可以打开IDE自己先试着编写一下然后再看我下面的讲解。整体上来讲这个小程序实现起来并不复杂。我写了一种实现思路如下所示
```
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&lt;InputText&gt; snapshots = new Stack&lt;&gt;();
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(&quot;:list&quot;)) {
System.out.println(inputText.getText());
} else if (input.equals(&quot;:undo&quot;)) {
InputText snapshot = snapshotsHolder.popSnapshot();
inputText.setText(snapshot.getText());
} else {
snapshotsHolder.pushSnapshot(inputText);
inputText.append(input);
}
}
}
}
```
实际上,备忘录模式的实现很灵活,也没有很固定的实现方式,在不同的业务需求、不同编程语言下,代码实现可能都不大一样。上面的代码基本上已经实现了最基本的备忘录的功能。但是,如果我们深究一下的话,还有一些问题要解决,那就是前面定义中提到的第二点:要在不违背封装原则的前提下,进行对象的备份和恢复。而上面的代码并不满足这一点,主要体现在下面两方面:
- 第一为了能用快照恢复InputText对象我们在InputText类中定义了setText()函数,但这个函数有可能会被其他业务使用,所以,暴露不应该暴露的函数违背了封装原则;
- 第二快照本身是不可变的理论上讲不应该包含任何set()等修改内部状态的函数但在上面的代码实现中“快照“这个业务模型复用了InputText类的定义而InputText类本身有一系列修改内部状态的函数所以用InputText类来表示快照违背了封装原则。
针对以上问题我们对代码做两点修改。其一定义一个独立的类Snapshot类来表示快照而不是复用InputText类。这个类只暴露get()方法没有set()等任何修改内部状态的方法。其二在InputText类中我们把setText()方法重命名为restoreSnapshot()方法,用意更加明确,只用来恢复对象。
按照这个思路,我们对代码进行重构。重构之后的代码如下所示:
```
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&lt;Snapshot&gt; snapshots = new Stack&lt;&gt;();
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(&quot;:list&quot;)) {
System.out.println(inputText.toString());
} else if (input.equals(&quot;:undo&quot;)) {
Snapshot snapshot = snapshotsHolder.popSnapshot();
inputText.restoreSnapshot(snapshot);
} else {
snapshotsHolder.pushSnapshot(inputText.createSnapshot());
inputText.append(input);
}
}
}
}
```
实际上上面的代码实现就是典型的备忘录模式的代码实现也是很多书籍包括GoF的《设计模式》中给出的实现方法。
除了备忘录模式,还有一个跟它很类似的概念,“备份”,它在我们平时的开发中更常听到。那备忘录模式跟“备份”有什么区别和联系呢?实际上,这两者的应用场景很类似,都应用在防丢失、恢复、撤销等场景中。它们的区别在于,备忘录模式更侧重于代码的设计和实现,备份更侧重架构设计或产品设计。这个不难理解,这里我就不多说了。
## 如何优化内存和时间消耗?
前面我们只是简单介绍了备忘录模式的原理和经典实现,现在我们再继续深挖一下。如果要备份的对象数据比较大,备份频率又比较高,那快照占用的内存会比较大,备份和恢复的耗时会比较长。这个问题该如何解决呢?
不同的应用场景下有不同的解决方法。比如我们前面举的那个例子应用场景是利用备忘录来实现撤销操作而且仅仅支持顺序撤销也就是说每次操作只能撤销上一次的输入不能跳过上次输入撤销之前的输入。在具有这样特点的应用场景下为了节省内存我们不需要在快照中存储完整的文本只需要记录少许信息比如在获取快照当下的文本长度用这个值结合InputText类对象存储的文本来做撤销操作。
我们再举一个例子。假设每当有数据改动,我们都需要生成一个备份,以备之后恢复。如果需要备份的数据很大,这样高频率的备份,不管是对存储(内存或者硬盘)的消耗,还是对时间的消耗,都可能是无法接受的。想要解决这个问题,我们一般会采用“低频率全量备份”和“高频率增量备份”相结合的方法。
全量备份就不用讲了,它跟我们上面的例子类似,就是把所有的数据“拍个快照”保存下来。所谓“增量备份”,指的是记录每次操作或数据变动。
当我们需要恢复到某一时间点的备份的时候,如果这一时间点有做全量备份,我们直接拿来恢复就可以了。如果这一时间点没有对应的全量备份,我们就先找到最近的一次全量备份,然后用它来恢复,之后执行此次全量备份跟这一时间点之间的所有增量备份,也就是对应的操作或者数据变动。这样就能减少全量备份的数量和频率,减少对时间、内存的消耗。
## 重点回顾
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
备忘录模式也叫快照模式,具体来说,就是在不违背封装原则的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以便之后恢复对象为先前的状态。这个模式的定义表达了两部分内容:一部分是,存储副本以便后期恢复;另一部分是,要在不违背封装原则的前提下,进行对象的备份和恢复。
备忘录模式的应用场景也比较明确和有限,主要是用来防丢失、撤销、恢复等。它跟平时我们常说的“备份”很相似。两者的主要区别在于,备忘录模式更侧重于代码的设计和实现,备份更侧重架构设计或产品设计。
对于大对象的备份来说,备份占用的存储空间会比较大,备份和恢复的耗时会比较长。针对这个问题,不同的业务场景有不同的处理方式。比如,只备份必要的恢复信息,结合最新的数据来恢复;再比如,全量备份和增量备份相结合,低频全量备份,高频增量备份,两者结合来做恢复。
## 课堂讨论
今天我们讲到备份在架构或产品设计中比较常见比如重启Chrome可以选择恢复之前打开的页面你还能想到其他类似的应用场景吗
欢迎留言和我分享你的想法。如果有收获,也欢迎你把这篇文章分享给你的朋友。