备忘录模式,也叫快照(Snapshot)模式,英文翻译是 Memento Design Pattern
这个模式的定义主要表达了两部分内容。一部分是,存储副本以便后期恢复。这一部分很好理解。另一部分是,要在不违背封装原则的前提下,进行对象的备份和恢复实现撤销(Undo)和恢复(Redo)操作。这部分不太好理解
- 为什么存储和恢复副本会违背封装原则?
- 备忘录模式是如何做到不违背封装原则的?
备忘录设计模式涉及三个主要组件:
- 发起人(Originator):这是希望保存状态的对象。它可以创建一个备忘录(Memento)对象来保存其当前状态,并可以使用备忘录来恢复先前的状态。
- 备忘录(Memento):这个对象存储了发起人的内部状态。它应该有一个足够的状态,以便发起人可以恢复到之前的状态。备忘录类通常具有私有的访问权限,仅发起人可以访问其内部状态。
- 负责人(Caretaker):这个对象负责保存和恢复备忘录。它不应该对备忘录的内容进行任何操作,只是将备忘录传递给发起人。
使用 Java 实现的简单备忘录设计模式的示例:
// 发起人类
class Originator {
private String state;
public void setState(String state) {
this.state = state;
}
public String getState() {
return state;
}
// 创建备忘录
public Memento saveStateToMemento() {
return new Memento(state);
}
// 恢复状态
public void getStateFromMemento(Memento memento) {
state = memento.getState();
}
}
// 备忘录类
class Memento {
private final String state;
public Memento(String state) {
this.state = state;
}
public String getState() {
return state;
}
}
// 负责人类
class Caretaker {
private final List<Memento> mementoList = new ArrayList<>();
public void add(Memento state) {
mementoList.add(state);
}
public Memento get(int index) {
return mementoList.get(index);
}
}
public class Main {
public static void main(String[] args) {
Originator originator = new Originator();
Caretaker caretaker = new Caretaker();
// 设置状态并保存到备忘录
originator.setState("State1");
caretaker.add(originator.saveStateToMemento());
originator.setState("State2");
caretaker.add(originator.saveStateToMemento());
// 从备忘录中恢复状态
originator.getStateFromMemento(caretaker.get(0));
System.out.println("恢复的状态: " + originator.getState()); // 输出:恢复的状态: State1
originator.getStateFromMemento(caretaker.get(1));
System.out.println("恢复的状态: " + originator.getState()); // 输出:恢复的状态: State2
}
}
这个例子演示了如何使用备忘录设计模式保存和恢复对象的状态。创建了一个 Originator
类来存储状态并创建备忘录对象。Memento
类用于保存 Originator
的状态。Caretaker
类负责保存和恢复备忘录对象。
展示了如何在需要时从备忘录中恢复对象的状态。这种设计模式在撤销和重做操作时非常有用,因为它允许恢复到先前的状态,而不需要修改或者重新实现对象的行为。
假设希望你编写一个小程序,可以接收命令行的输入。用户输入文本时,程序将其追加存储在内存文本中;用户输入“:list”,程序在命令行中输出内存文本的内容;用户输入“:undo”,程序会撤销上一次输入的文本,也就是从内存文本中将上次输入的文本删除掉。
用例子来解释一下这个需求,如下所示:
>hello
>:list
hello
>world
>:list
helloworld
>:undo
>:list
hello
// 文本缓冲区类,用于存储文本并管理撤销操作
class TextBuffer {
private StringBuilder text;
public TextBuffer() {
text = new StringBuilder();
}
// 向文本缓冲区追加新文本
public void append(String newText) {
text.append(newText);
}
// 获取文本缓冲区的内容
public String getText() {
return text.toString();
}
// 从历史记录恢复文本
public void restoreText(String previousText) {
text = new StringBuilder(previousText);
}
}
public class Main {
public static void main(String[] args) {
TextBuffer textBuffer = new TextBuffer();
// 搞一个list,当做栈来使用记录历史记录
List<String> history = new ArrayList<>();
Scanner scanner = new Scanner(System.in);
while (true) {
System.out.print("请输入命令:");
String input = scanner.nextLine();
// 列出当前文本缓冲区的内容
if (":list".equals(input)) {
System.out.println(textBuffer.getText());
}
// 撤销上一次输入的文本
else if (":undo".equals(input)) {
if (!history.isEmpty()) {
history.remove(history.size() - 1);
textBuffer.restoreText(history.isEmpty() ? "" : history.get(history.size() - 1));
} else {
System.out.println("无法撤销,没有更早的历史记录。");
}
}
// 将用户输入的文本追加到文本缓冲区
else {
history.add(textBuffer.getText());
textBuffer.append(input);
}
}
}
}
实际上备忘录模式的实现很灵活,也没有很固定的实现方式,在不同的业务需求、不同编程语言下,代码实现可能都不大一样。上面的代码基本上已经实现了最基本的备忘录的功能。List集合中存储的历史记录本质就是一个个的备忘录。
但是,如果深究一下的话,还有一些问题要解决:
- 第一,使用 List<String> 记录历史,扩展性很差,一旦需要记录更多内容,则必须修改原始代码。
- 第二,集合中的备忘信息不具备封装性,有被篡改的风险。
其一,定义一个独立的类(Memento类)来表示备忘录,而不是使用 String 类或者其他。这个类只暴露 get() 方法,没有 set() 等任何修改内部状态的方法。
其二,在 TextBuffer类中,把 setText() 方法重命名为 restoreFromMemento() 方法,用意更加明确,只用来恢复对象。
对代码进行重构。重构之后的代码如下所示:
class TextBuffer {
private StringBuilder text;
public TextBuffer() {
text = new StringBuilder();
}
public void append(String newText) {
text.append(newText);
}
public String getText() {
return text.toString();
}
public Memento saveToMemento() {
return new Memento(text.toString());
}
public void restoreFromMemento(Memento memento) {
text = new StringBuilder(memento.getSavedText());
}
// 备忘录
static class Memento {
private final String savedText;
public Memento(String savedText) {
this.savedText = savedText;
}
public String getSavedText() {
return savedText;
}
}
}
public class Main {
public static void main(String[] args) {
TextBuffer textBuffer = new TextBuffer();
List<TextBuffer.Memento> history = new ArrayList<>();
Scanner scanner = new Scanner(System.in);
while (true) {
System.out.print("请输入命令:");
String input = scanner.nextLine();
if (":list".equals(input)) {
System.out.println(textBuffer.getText());
} else if (":undo".equals(input)) {
if (!history.isEmpty()) {
history.remove(history.size() - 1);
textBuffer.restoreFromMemento(history.isEmpty() ? new TextBuffer.Memento("") : history.get(history.size() - 1));
} else {
System.out.println("无法撤销,没有更早的历史记录。");
}
} else {
history.add(textBuffer.saveToMemento());
textBuffer.append(input);
}
}
}
}
上面的代码实现就是典型的备忘录模式的代码实现,也是很多书籍(包括 GoF 的《设计模式》)中给出的实现方法。
事实上,很多场景下使用第一种方式就能满足绝大部分需求,要知道设计模式不是万金油有时候简单的需求使用简单的编码就可以实现,很多时候不必要为了扩展而扩展,为了设计而设计,编码简单也是很重要的。
备份优化
前面只是简单介绍了备忘录模式的原理和经典实现,现在再继续深挖一下。如果要备份的对象数据比较大,备份频率又比较高,那快照占用的内存会比较大,备份和恢复的耗时会比较长。这个问题该如何解决呢?
不同的应用场景下有不同的解决方法。比如前面举的那个例子,应用场景是利用备忘录来实现撤销操作,而且仅仅支持顺序撤销,也就是说,每次操作只能撤销上一次的输入,不能跳过上次输入撤销之前的输入。在具有这样特点的应用场景下,为了节省内存,不需要在快照中存储完整的文本,只需要记录少许信息,比如在获取快照当下的文本长度,用这个值结合 InputText 类对象存储的文本来做撤销操作。
假设每当有数据改动,都需要生成一个备份,以备之后恢复。如果需要备份的数据很大,这样高频率的备份,不管是对存储(内存或者硬盘)的消耗,还是对时间的消耗,都可能是无法接受的。想要解决这个问题,一般会采用**“低频率全量备份”和“高频率增量备份”**相结合的方法。
全量备份就不用讲了,它跟上面的例子类似,就是把所有的数据“拍个快照”保存下来。所谓“增量备份”,指的是记录每次操作或数据变动。
当需要恢复到某一时间点的备份的时候,如果这一时间点有做全量备份,直接拿来恢复就可以了。如果这一时间点没有对应的全量备份,就先找到最近的一次全量备份,然后用它来恢复,之后执行此次全量备份跟这一时间点之间的所有增量备份,也就是对应的操作或者数据变动。这样就能减少全量备份的数量和频率,减少对时间、内存的消耗。
备忘录模式也叫快照模式,具体来说,就是在不违背封装原则的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以便之后恢复对象为先前的状态。这个模式的定义表达了两部分内容:一部分是,存储副本以便后期恢复;另一部分是,要在不违背封装原则的前提下,进行对象的备份和恢复。
备忘录模式的应用场景也比较明确和有限,主要是用来防丢失、撤销、恢复等。它跟平时常说的“备份”很相似。两者的主要区别在于,备忘录模式更侧重于代码的设计和实现,备份更侧重架构设计或产品设计。
对于大对象的备份来说,备份占用的存储空间会比较大,备份和恢复的耗时会比较长。针对这个问题,不同的业务场景有不同的处理方式。比如,只备份必要的恢复信息,结合最新的数据来恢复;再比如,全量备份和增量备份相结合,低频全量备份,高频增量备份,两者结合来做恢复。