在之前的命令模式中,我们可以保存所有的对系统的状态修改的命令,通过按顺序执行命令的方式,使得系统能够回滚到之前的任何一个时点,这种回滚是累积式的。
但是在某些情况下,我们并不关心这种“回放”,我们只关心如何将系统回滚到特定的某一个状态即可。备忘录模式非常像快照,我们给系统在某一时刻进行拍照,保存其所以需要的状态,从而在后续某个需要的时间点可以进行恢复。
▲ 图片来自 https://refactoring.guru/design-patterns/memento
以前面在讨论命令模式的银行账户的例子来看,我们需要保存的状态为银行账户的余额,所以需要将资金字段balance保存起来,但是这里有个问题,资金应该是银行账户BankAccount的内部字段,不应该提供给外部直接访问,因为这样会破坏封装性,但是要保存快照就必须要能访问到这个私有字段。一个解决方法是,使用内部类来实现,当然一些编程语言并不支持内部类,比如PHP。下面就来看看具体的实现。
备忘录模式的简单实现
回顾一下之前的BankAccount模型,基本实现如下:
public class BankAccount
{
private int balance;
public BankAccount(int bal)
{
balance = bal;
}
public override string ToString()
{
return $"balance:{balance}";
}
}
它包含了一个私有字段表示账户余额,和一个初始化余额的构造函数。现在需要实现存款Deposit方法,跟之前在命令模式里的返回void不同,这里返回一个Memento对象。
public Memento Deposit(int amount)
{
balance += amount;
return new Memento(balance);
}
Memento对象在后面可以用来将账户恢复到之前的某一个状态。
public void Restore(Memento m)
{
balance = m.Balance;
}
Memento对象是一个非常简单的,只是用来存储数据的只读对象,如下:
public class Memento
{
public int Balance { get; }
public Memento(int bal)
{
Balance = bal;
}
}
使用如下:
BankAccount ba = new BankAccount(100);
var m1 = ba.Deposit(50);
var m2 = ba.Deposit(25);
Console.WriteLine(ba);// 175
ba.Restore(m1); // 恢复至m1快照
Console.WriteLine(ba); //150
ba.Restore(m2); //恢复至m2快照
Console.WriteLine(ba); //175
这个实现已经足够,但仍然存在一些问题,比如无法恢复至状态的初始化状态,因为构造函数无法带有返回值,当然可以使用out关键字,但是很丑陋。
实现撤销与重做
如果我们保存BankAccount的每一次变更后的操作结果会怎么样?跟之前命令模式中用集合来保存命令一样,我们也可以使用这种方式来实现撤销Undo和重做Redo。在BankAccount中,可以保存每一次修改后的Memento对象。
public class BankAccount
{
private int balance;
private List<Memento> changes = new List<Memento>();
private int current;
public BankAccount(int bal)
{
balance = bal;
changes.Add(new Memento(bal));
}
}
可以看到,在构造函数中,对于初始值的状态,也进行了保存,这就解决了前面无法保存初始状态的问题。假如有什么问题需要恢复Reset到出厂设置,就可以用这里的状态来进行恢复。在BankAccount里,新增加了一个current字段,用来记录最新的快照在快照记录集changes里面的位置,为什么要加这个呢?难道最新的current不是change的数量减1吗?当然在撤销Undo或者回滚操作时是这样的,但是如果要执行重做Redo操作,则必须要标记一下最后的操作。
现在,修改一下Deposit方法如下:
public Memento Deposit(int amount)
{
balance += amount;
var m = new Memento(balance);
changes.Add(m);
current++;
return m;
}
当调用存款操作时,将存款后的状态保存到了changes中,并且更新了当前最新的位置current,最后返会最新的Memento状态。根据Memento进行恢复的方法如下:
public void Restore(Memento m)
{
if (m != null)
{
balance = m.Balance;
changes.Add(m);
current = changes.Count - 1;
}
}
在Restore方法中,首先判断快照是否有效,如果有效,将状态恢复到快照中保存的状态,同时,也需要将快照加入到最新的修改中。
接下来实现撤销操作Undo:
public Memento Undo()
{
if (current > 0)
{
var m = changes[--current];
balance = m.Balance;
return m;
}
return null;
}
如果当前位置大于0,则取前一个快照,然后将前一个快照的状态赋值给当前状态。如果当前已经是初始状态,则返回null,这就是在Restore里面需要判断是否为null的原因。
重做Redo的实现如下:
public Memento Redo()
{
if (current + 1 < changes.Count)
{
var m = changes[++current];
balance = m.Balance;
return m;
}
return null;
}
只有当前做过撤销Undo操作,才能执行重做Redo,所以首先要判断current的位置,只有不是最新的才能执行操作。重做就是获取当前快照的后一个快照来执行回复操作。
使用如下:
BankAccount ba = new BankAccount(100);
ba.Deposit(50);
ba.Deposit(25);
Console.WriteLine(ba);
ba.Undo();
Console.WriteLine($"Undo 1:{ba}");
ba.Undo();
Console.WriteLine($"Undo 2:{ba}");
ba.Redo();
Console.WriteLine($"Redo 2:{ba}");
输出结果如下:
balance:175
Undo 1:balance:150
Undo 2:balance:100
Redo 2:balance:150
以上实现比较完美,在实际中,为了减少内存占用或者提供性能,撤销操作的步骤数目可能是有限制的,比如只能回退30步。另外,对象还可以提供是否能够回退以及是否能够重做,这个只需要添加两个只读属性,属性里判断当前的位置current跟列表的个数即可,这种提示可以用在UI界面上来对撤销和重做图标进行是否能够操作的显示,比如处于初始状态没操作过,那不能撤销,则图标就变灰。
上面的代码中,从功能看已经能够完美满足需求。但从设计模式的准则来看,仍然有值的改进的地方,比如撤销和重做这个操作,我们不应该放在BankAccount中,这些快照状态我们也不应该保存在BankAccount中,BankAccount应该只包含账户的存取款相关逻辑,不应该包含撤销Undo和重做Redo这些,这明显违反了单一职责原则。另外对于这些快照的保存,我们应该提供扩展,比如不只可以保存到内存中,也可以保存到文本文件中等等。另外,快照我们还希望能够保存一些额外的信息,比如快照时间等等。
改进
改进的地方有两个点,一是将快照对象抽象出来,使得具体实现可以扩展,二是将快照的保存、撤销以及重做操作从BankAccount中提取出来,封装到单独的类中,使得其符合单一职责原则。
首先,定义一个IMemento接口,他包含获取实际的Memento对象,获取该快照的产生时间,以及快照名称。
public interface IMemento
{
Memento GetMemento();
string GetName();
DateTime GetDate();
}
然后定义一个具体的快照首先ConcreteMemento:
public class ConcreteMemento : IMemento
{
private Memento menento;
private DateTime date;
public ConcreteMemento(Memento m)
{
menento = new Memento(m.Balance);
date = DateTime.Now;
}
public DateTime GetDate()
{
return date;
}
public Memento GetMemento()
{
return menento;
}
public string GetName()
{
return $"date: {date}/ balance: {menento.Balance}";
}
}
可以看到,在具体的快照类里,我们实现了三个方法,实现了三个方法返回需要的值,这里也可以写为属性。Memento跟之前的结构一样,里面只有一个字段Balance。
接下来,新建一个Caretaker类,用来保存快照以及协调BankAccount实现撤销与重做操作。
public class Caretaker
{
private List<IMemento> mementos = new List<IMemento>();
private BankAccount account;
private int current;
public Caretaker(BankAccount bankAccount)
{
account = bankAccount;
this.mementos.Add(this.account.CreateSnapShot());
}
public void BackUp()
{
Console.WriteLine("Caretaker: Saving Account's state...");
this.mementos.Add(this.account.CreateSnapShot());
current++;
}
}
可以看到,在这个类的构造函数中,我们传入了需要管理的BankAccount对象,这应该是一种“组合模式”。在构造函数中,通过调用bankAccount对象的CreateSnapShot方法,过保存了BankAccount的快照状态。并将其添加到了mementos快照列表中。另外Caretaker提供了表示当前快照的current方法,这个跟之前的用法相似。Caretaker里面的BackUp方法,用来保存BankAccount对象的最新状态,这个需要主动调用。
接下来实现Undo和Redo方法,参照之前的实现,非常简单:
public void Undo()
{
if (current > 0)
{
var m = mementos[--current];
account.Restore(m);
Console.WriteLine("Caretaker: Restoring state to: " + m.GetName());
}
}
public void Redo()
{
if (current + 1 < mementos.Count)
{
var m = mementos[++current];
account.Restore(m);
Console.WriteLine("Caretaker: Redo state to: " + m.GetName());
}
}
为配合新的Caretaker,并从BankAccount中移除对于Undo和Redo的操作,BankAccount类需要做相应的修改,修改如下:
public class BankAccount
{
private int balance;
public BankAccount(int bal)
{
balance = bal;
}
public void Deposit(int amount)
{
balance += amount;
}
public void Restore(IMemento m)
{
if (m != null)
{
balance = m.GetMemento().Balance;
}
}
public IMemento CreateSnapShot()
{
return new ConcreteMemento(new Memento(balance));
}
public override string ToString()
{
return $"balance:{balance}";
}
}
可以看到,在Deposit中,没有之前的保存快照方法了,Restore方法的参数变味了IMemento接口,并提供了CreateSnapShot获取当前最新快照的方法。
现在,使用方法如下:
BankAccount bav2 = new BankAccount(100);
Caretaker c = new Caretaker(bav2);
Console.WriteLine(bav2);
bav2.Deposit(50);
c.BackUp();
Console.WriteLine(bav2);
bav2.Deposit(25);
c.BackUp();
Console.WriteLine(bav2);
c.Undo();
Console.WriteLine($"Undo 1:{bav2}");
c.Undo();
Console.WriteLine($"Undo 2:{bav2}");
c.Redo();
Console.WriteLine($"Redo 2:{bav2}");
输出结果跟上面一样,为:
balance:100
balance:150
balance:175
Undo 1:balance:150
Undo 2:balance:100
Redo 2:balance:150
在以上的实现中,可以看到,跟之前简单版本里直接将快照操作写在对象的操作方法比如Deposit、Restore里不同,我们需要手动显示调用Backup方法,后续的Undo和Redo才能实现,因为我们在Caretaker中无法访问BankAccount的具体实现细节。从另外一个角度来看,备份和撤销重做操作就应该是在一起,所以这个改进方案更加合理。
总结
备忘录模式就是处理如何将系统的当前状态以快照形式保存,并在将来某一时刻能够恢复至之前的状态。快照中保存了系统能够恢复到之前状态的所有信息。如果能够保存系统操作的每一次快照,就能够实现诸如撤销(Undo)和重做(Redo)的功能,这个功能在常见的文本编辑器,比如Word、Excel,以及图形处理软件比如PhotoShop中非常常见。
本文介绍了备忘录模式的简单实现,即在对象中用列表直接保存所有的对对象进行修改之后的状态,然后直接提供撤销和重做的实现,以将从之前保存的某个快照中恢复。这种方式简单,但是不符合单一职责原则,所以在改进的实现中,我们分离了快照的具体实现,以及从对象中抽离了快照的保存,撤销和重做到单独的类中,使得结构更加清晰明了。
参考资料