备忘录模式的原理与实现

备忘录模式,也叫快照(Snapshot)模式,英文翻译是 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”,程序会撤销上一次输入的文本,也就是从内存文本中将上次输入的文本删除掉。示例如下所示:

  1. >hello
  2. >:list
  3. hello
  4. >world
  5. >:list
  6. helloworld
  7. >:undo
  8. >:list
  9. hello

我们通过代码来实现下上面的功能,我写了一种实现思路,如下所示:

  1. public class InputText {
  2. @Getter
  3. @Setter
  4. private StringBuilder text = new StringBuilder();
  5. public void append(String input) {
  6. text.append(input);
  7. }
  8. }
  9. public class SnapshotHolder {
  10. private Stack<InputText> snapshots = new Stack<>();
  11. public InputText popSnapshot() {
  12. return snapshots.pop();
  13. }
  14. public void pushSnapshot(InputText inputText) {
  15. InputText deepClonedInputText = new InputText();
  16. deepClonedInputText.setText(inputText.getText());
  17. snapshots.push(deepClonedInputText);
  18. }
  19. }
  20. public class ApplicationMain {
  21. public static void main(String[] args) {
  22. InputText inputText = new InputText();
  23. SnapshotHolder snapshotsHolder = new SnapshotHolder();
  24. Scanner scanner = new Scanner(System.in);
  25. while (scanner.hasNext()) {
  26. String input = scanner.next();
  27. if (input.equals(":list")) {
  28. System.out.println(inputText.getText());
  29. } else if (input.equals(":undo")) {
  30. InputText snapshot = snapshotsHolder.popSnapshot();
  31. inputText.setText(snapshot.getText());
  32. } else {
  33. snapshotsHolder.pushSnapshot(inputText);
  34. inputText.append(input);
  35. }
  36. }
  37. }
  38. }

实际上,备忘录模式的实现很灵活,也没有很固定的实现方式,在不同的业务需求、不同编程语言下,代码实现可能都不大一样。上面的代码基本上已经实现了最基本的备忘录的功能。但是,如果我们深究一下的话,还有一些问题要解决,那就是前面定义中提到的第二点:要在不违背封装原则的前提下,进行对象的备份和恢复。而上面的代码并不满足这一点,主要体现在下面两方面:

  • 第一,为了能用快照恢复 InputText 对象,我们在 InputText 类中定义了 setText() 函数,但这个函数有可能会被其他业务使用,所以,暴露不应该暴露的函数违背了封装原则;

  • 第二,快照本身是不可变的,理论上讲,不应该包含任何 set() 等修改内部状态的函数,但在上面的代码实现中,“快照“这个业务模型复用了 InputText 类的定义,而 InputText 类本身有一系列修改内部状态的函数,所以,用 InputText 类来表示快照违背了封装原则。

针对以上问题,我们对代码做两点修改。其一,定义一个独立的类(Snapshot 类)来表示快照,而不是复用 InputText 类。这个类只暴露 get() 方法,没有 set() 等任何修改内部状态的方法。其二,在 InputText 类中,我们把 setText() 方法重命名为 restoreSnapshot() 方法,用意更加明确,只用来恢复对象。按照这个思路,我们对代码进行重构。重构后的代码如下所示:

  1. public class InputText {
  2. @Getter
  3. private StringBuilder text = new StringBuilder();
  4. public void append(String input) {
  5. text.append(input);
  6. }
  7. public Snapshot createSnapshot() {
  8. return new Snapshot(text.toString());
  9. }
  10. public void restoreSnapshot(Snapshot snapshot) {
  11. this.text.replace(0, this.text.length(), snapshot.getText());
  12. }
  13. }
  14. public class Snapshot {
  15. @Getter
  16. private String text;
  17. public Snapshot(String text) {
  18. this.text = text;
  19. }
  20. }
  21. public class SnapshotHolder {
  22. private Stack<Snapshot> snapshots = new Stack<>();
  23. public Snapshot popSnapshot() {
  24. return snapshots.pop();
  25. }
  26. public void pushSnapshot(Snapshot snapshot) {
  27. snapshots.push(snapshot);
  28. }
  29. }
  30. public class ApplicationMain {
  31. public static void main(String[] args) {
  32. InputText inputText = new InputText();
  33. SnapshotHolder snapshotsHolder = new SnapshotHolder();
  34. Scanner scanner = new Scanner(System.in);
  35. while (scanner.hasNext()) {
  36. String input = scanner.next();
  37. if (input.equals(":list")) {
  38. System.out.println(inputText.toString());
  39. } else if (input.equals(":undo")) {
  40. Snapshot snapshot = snapshotsHolder.popSnapshot();
  41. inputText.restoreSnapshot(snapshot);
  42. } else {
  43. snapshotsHolder.pushSnapshot(inputText.createSnapshot());
  44. inputText.append(input);
  45. }
  46. }
  47. }
  48. }

实际上,上面的代码实现就是典型的备忘录模式的代码实现,也是很多书籍(包括 GoF 的《设计模式》)中给出的实现方法。

除了备忘录模式,还有一个跟它很类似的概念,“备份”,它在我们平时的开发中更常听到。那备忘录模式跟“备份”有什么区别和联系呢?实际上,这两者的应用场景很类似,都应用在防丢失、恢复、撤销等场景中。它们的区别在于,备忘录模式更侧重于代码的设计和实现,备份更侧重架构设计或产品设计

如何优化内存和时间消耗?

前面我们只是简单介绍了备忘录模式的原理和经典实现,现在我们再继续深挖一下。如果要备份的对象数据比较大,备份频率又比较高,那快照占用的内存会比较大,备份和恢复的耗时会比较长。这个问题该如何解决呢?

不同的应用场景下有不同的解决方法。比如,我们前面举的那个例子,应用场景是利用备忘录来实现撤销操作,而且仅仅支持顺序撤销,也就是说,每次操作只能撤销上一次的输入,不能跳过上次输入撤销之前的输入。在具有这样特点的应用场景下,为了节省内存,我们不需要在快照中存储完整的文本,只需要记录少许信息,比如在获取快照时当下的文本长度,用这个值结合 InputText 类对象存储的文本来做撤销操作。

我们再举一个例子。假设每当有数据改动,我们都需要生成一个备份,以备之后恢复。如果需要备份的数据很大,这样高频率的备份,不管是对存储(内存或者硬盘)的消耗,还是对时间的消耗,都可能是无法接受的。想要解决这个问题,我们一般会采用“低频率全量备份”和“高频率增量备份”相结合的方法。

全量备份就是把所有的数据“拍个快照”保存下来。所谓“增量备份”指的是记录每次操作或数据变动。当我们需要恢复到某一时间点的备份的时候,如果这一时间点有做全量备份,我们直接拿来恢复就可以了。如果这一时间点没有对应的全量备份,我们就先找到最近的一次全量备份,然后用它来恢复,之后执行此次全量备份跟这一时间点之间的所有增量备份,也就是对应的操作或者数据变动。这样就能减少全量备份的数量和频率,减少对时间、内存的消耗了。