备忘录
备忘录用来记录曾经发生过的事情,使回溯历史变得切实可行。备忘录模式(Memento)则可以在不破坏元对象封装性的前提下捕获其在某些时刻的内部状态,并像历史快照一样将它们保留在元对象之外,以备恢复之用。
时光流逝
光阴似箭,岁月如梭,时间在一分一秒地不停流逝,一去不返。想必我们都做过错误的决定,最终导致糟糕的结果。然而这个世界并不存在后悔药,做出的决定如覆水难收。
然而,在计算机世界中,我们似乎可以来去自如,例如浏览器前进与后退、撤销文档修改、数据库备份与恢复、游戏存盘载入、操作系统快照恢复、手机恢复出厂设置等操作稀松平常。再深入到面向对象层面,我们知道当程序运行时一个对象的状态有可能随时发生变化,而当修改其状态时我们可以对其进行记录,如此便能够将对象恢复到任意记录的状态。备忘录模式正是采用这种理念,让历史重演。
覆水难收
为了更生动地展现备忘录模式,以使读者更容易理解,我们来模拟这样一个场景:假设某作家要写一部科幻小说,当他构思完成后打开编辑器软件开始创作的时候,必然会创建一个文档。那么我们首先来定义这个文档类Doc
package memorandum;
public class Doc {
private String title;//文章标题
private String body;//文章内容
public Doc(String title) {
this.title = title;
this.body = "";
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getBody() {
return body;
}
public void setBody(String body) {
this.body = body;
}
}
作为一个简单的Java对象(Plain Ordinary Java Object,POJO)类,文档类包括两个内部属性:文档标题title与文档内容body,它们拥有各自的get方法与set方法。可以看到,这个类实例化出的对象一定包含“文档标题”与“文档内容”两个状态,并且会在运行时随着作家对文档的修改而改变,尤其是对“文档内容”的修改,如此才能达到编辑文档的目的。接下来当然少不了作家用来修改这个文档的编辑器类
编辑器类Editor
package memorandum;
public class Editor {
private Doc doc;//文档引用
public Editor(Doc doc){
System.out.println("打开文档"+doc.getTitle());
this.doc = doc;
show();
}
public void append(String txt){
System.out.println("插入操作");
doc.setBody(doc.getBody()+txt);
show();
}
public void delete(){
System.out.println("删除操作");
doc.setBody("");
show();
}
public void save(){
System.out.println("存盘操作");
}
private void show(){//显示当前文档内容
System.out.println(doc.getBody());
System.out.println("文档结束》》》》");
}
}
我们先从最简单的功能看起,当编辑器类实例化时需要载入一个文档对象,并展示其内容。接下来是编辑器最重要的编辑功能了。我们保持以最简单的代码来模拟文档的编辑功能,从第11行开始依次有插入方法append()、删除方法delete()、存盘方法save(),以及显示文档内容方法show(),请读者仔细阅读,此处不做赘述。一切就绪,作家可以开始使用这个编辑器了,关于客户端类Client
作家开始创作并一口气写完了两章的内容,输出的文档内容让他颇有成就感。于是他决定冲杯咖啡,休息一下,并没有调用存盘方法save()便离开了计算机,一切看起来非常顺利。然而不幸的是,作家的宠物猫跳上了他的计算机键盘,不巧按下了Delete键并触发了第36行的删除操作,结果整个文档从内存中被清空了。作家5 000字的心血付之东流,不得不为自己的疏忽大意付出惨痛的代价。
破镜重圆
编辑器类提供的删除方法本来是出于软件功能的完整性而设计的,却反而给用户带来了潜在风险。所以,我们一定要避免发生这类误操作,才能带来更好的用户体验。大家一定想到了以Ctrl+Z组合键触发的撤销操作了吧。这条编辑器指令可以瞬间撤销用户的上一步操作并回退到上一个文档状态,这样不但给了用户吃后悔药的机会,还能省去用户频繁地进行存盘操作的麻烦。这种自动备忘录机制是如何实现的呢?既然可以回溯历史,就一定得定义一个历史快照类,用来记录用户每步操作后的文档状态。
历史快照类History
package memorandum;
public class History {
private String body;//用于备忘文档内容
public History(String body){
this.body = body;
}
public String getBody(){
return body;
}
}
和文档类Doc非常类似,历史快照类History也是一个POJO类,它同样封装了属性“文档内容”。可以看到构造方法中对文档内容的初始化,这样我们便可以记录文档内容的快照了。我们知道,每生成一个历史快照对象就相当于在备忘录中写下一笔记录,一个对象对应一个快照,那么由谁来生成这个快照记录呢?我们对文档类Doc进行重构,做一些快照功能上的增强
package memorandum;
public class Doc {
private String title;//文章标题
private String body;//文章内容
public Doc(String title) {
this.title = title;
this.body = "";
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getBody() {
return body;
}
public void setBody(String body) {
this.body = body;
}
public History createHistory(){
return new History(body);
}
public void restoreHistory(History history){
this.body = history.getBody();//恢复历史记录
}
}
我们加入了创建历史记录方法createHistory(),它能够生成并返回当前文档内容对应的历史快照。与之相对应历史记录的恢复方法restoreHistory(),它能够根据传入的历史快照参数将文档内容恢复到任意历史时间点。至此,文档类便具备了快照生成与恢复功能。要实现编辑器的撤销功能,我们首先得在用户进行编辑操作时对文档进行历史快照备份,如此才能恢复到任意历史时间点。下面我们对编辑器类进行重构
package memorandum;
import java.util.ArrayList;
import java.util.List;
public class Editor {
private Doc doc;//
private List<History> histories;
private int historyPosition = -1;//历史记录当前位置
public Editor(Doc doc){
System.out.println("打开文档"+doc.getTitle());
this.doc = doc;
histories = new ArrayList<>();
show();
}
public void append(String txt){
System.out.println("插入操作");
doc.setBody(doc.getBody()+txt);
show();
}
public void delete(){
System.out.println("删除操作");
doc.setBody("");
show();
}
private void backup(){
histories.add(doc.createHistory());
historyPosition++;
}
public void save(){
System.out.println("存盘操作");
}
private void show(){//显示当前文档内容
System.out.println(doc.getBody());
System.out.println("文档结束》》》》");
}
public void undo(){
//撤销操作
System.out.println(">>>撤销操作");
if (historyPosition==0){
return;
}
historyPosition--;
History history = histories.get(historyPosition);
doc.restoreHistory(history);
show();
}
public void redo(){//重作操作
//此处省略
}
}
读者可能会提出这样的疑问:既然要对元数据类(文档类Doc)的各个历史状态进行记录,为何不直接利用原型模式对元对象进行复制,而非要重新定义一个与之类似的备忘录类(历史快照类History)呢?其实这是出于对节省内存空间的考虑,譬如本例中历史快照类History只是针对“文档内容”进行记录,而不包括“文档标题”,或者其他有更大数据量的状态,所以我们没有必要对整个元对象进行完整复制而造成不必要的内存空间资源的浪费。否则,我们完全可以考虑结合备忘录模式与原型模式来记录历史快照。
历史回溯
备忘录模式就像一台时光机,让我们在软件世界里自由自在地进行时空穿梭。需要注意的是,备忘录类一定独立于元数据类而单独成类,其生成的历史记录也应该在元数据类之外进行维护,这样不但确保了元数据类的封装不被破坏,而且实现了对其内部状态历史变化的捕获与恢复。请参看备忘录模式的类结构
备忘录模式的各角色定义如下。
Originator(元):状态需要被记录的元对象类,其状态是随时可变的。既可以生成包含其内部状态的即时备忘录,也可以利用传入的备忘录恢复到对应状态。对应本章例程中的文档类Doc。
Memento(备忘录):与元对象相仿,但只需要保留元对象的状态,一个状态对应一个备忘录对象。对应本章例程中的历史快照类History。
CareTaker(看护人):历史记录的维护者,持有所有记录的历史记录,并且提供对元数据对象的恢复操作,如撤销undo()、重做redo()等,一般不提供对历史记录的修改。对应本章例程中的编辑器类Editor。
在程序运行的过程中,内存中的对象状态变幻莫测,备忘录模式能为我们捕获每一个精彩的历史瞬间,让其留存于备忘录的每一页,以便我们回溯历史,勇敢前行。备忘录模式非常简单、易懂,但读者在应用时一定要小心一些陷阱,例如在元对象状态数据量过大的情况下,或者是无限制地对元对象进行快照备份的操作,都可能会导致内存空间资源的过度耗费,使系统性能变得越来越差。这时就要看读者怎样变通了,譬如为备忘录历史记录加上容量限制,可以总是保存最近的20条记录。通过诸如此类的方式可以改善这种情况,所以读者一定要根据特定的场景进行适当的变通,保持灵活开放的思维才能更好地利用设计模式,设计出更优秀的应用程序。
Go版本代码
package memo
import "fmt"
type Doc struct {
title string
body string
}
func (d Doc) createHistory() history {
return history{d.body}
}
func (d *Doc) restoreHistory(h history) {
d.body = h.getBody()
}
type history struct {
body string
}
func (h history) getBody() string {
return h.body
}
type Editor struct {
doc Doc
histories []history
historyPosition int
}
func NewEditor(doc Doc) Editor {
fmt.Println("打开文档" + doc.title)
editor := Editor{
doc: doc,
histories: make([]history, 0),
historyPosition: -1,
}
editor.show()
return editor
}
func (e *Editor) Append(text string) {
fmt.Println("插入操作")
e.doc.body = e.doc.body + text
e.backup()
e.show()
}
func (e *Editor) Delete() {
fmt.Println("删除操作")
e.doc.body = ""
e.backup()
e.show()
}
func (e *Editor) backup() {
e.histories = append(e.histories, e.doc.createHistory())
e.historyPosition++
}
func (e *Editor) Save() {
fmt.Println("存盘操作")
}
func (e Editor) show() {
fmt.Println(e.doc)
fmt.Println("文档结束》》》》》")
}
func (e *Editor) Undo() {
//撤销操作
fmt.Println(">>>>撤销操作")
if e.historyPosition == 0 {
return
}
e.historyPosition--
h := e.histories[e.historyPosition]
e.doc.restoreHistory(h)
e.show()
}