写在前面
Hello,我是易元,这篇文章是我学习设计模式时的笔记和心得体会。如果其中有错误,欢迎大家留言指正!
1.需求背景
假设我们正在开发一个轻量级的文本编辑器,编辑器的核心功能非常简单:输入、编辑文本。随着用户使用时间的增长,发现了一个极为严重的缺陷:写作的过程中,经常需要调整思路,可能会删除掉一大段文字,但完成后,发现之前删除的版本比现在的更好;或者多次修改后,希望能退回到某个特定版本。目前我们开发的编辑器并没有提供撤销操作。
因此,一个非常核心的需求被提出来:为文本编辑器增加撤销功能,允许用户回退到之前的编辑状态。
更具体的说,我们需要实现以下目标:
用户在修改或输入时,进行内容的保存。
用户可以执行撤销的操作,以便恢复到上一个版本。
可以进行多次撤销。
这个需求背景经常遇到,例如:绘图软件、代码编辑器、游戏存档功能等 都存在类似的功能-在某个时间点保存对象的状态,在需要的时候恢复到该状态。
2.使用常规编码实现
根据上文设定的需求-增加撤销功能,首先我们先尝试使用比较直接、常规的方式进行实现。这个阶段,暂时不考虑引入任何特定的设计模式,而是专注于功能的快速实现。
核心思路:当文本内容发生变化时,就将当前的状态保存,当用户需要撤销时,就从保存的历史记录中取出最近的一次状态,进行恢复。
具体的代码实现
SimpleTextEditor
public class SimpleTextEditor {
/**
* 当前文本内容
*/
private String currentText;
/**
* 存储历史文本状态列表
*/
private List<String> history;
public SimpleTextEditor() {
// 初始化时,文本内容为空
this.currentText = "";
// 初始化历史记录列表
this.history = new ArrayList<>();
}
/**
* 修改文本内容
*
* @param text
*/
public void setText(String text) {
// 在修改文本之前,存入历史记录
this.history.add(text);
// 更新当前文本内容
this.currentText = text;
}
/**
* 撤销操作
*/
public void undo() {
if (!history.isEmpty()) {
// 从历史记录中移除最近一次的状态,并讲其设置为当前文本
this.currentText = history.remove(history.size() - 1);
System.out.println("撤销成功,当前文本: " + this.currentText);
}
else {
System.out.println("没有可撤销的操作!");
}
}
}
@Test
public void test_simpleTextEditor() {
SimpleTextEditor editor = new SimpleTextEditor();
editor.setText("这是第一段内容。");
editor.setText("这是第二段内容。");
editor.setText("这是最后一段内容。");
System.out.println();
System.out.println("--------------------------------------------------------------");
System.out.println();
System.out.println("开始撤销操作");
System.out.println();
editor.undo();
editor.undo();
editor.undo();
editor.undo();
System.out.println();
System.out.println("--------------------------------------------------------------");
System.out.println();
editor.setText("重新开始编辑内容。");
}
代码说明:
SimpleTextEditor
类中有两个核心成员变量:
currentText(String 类型)
:用于存储编辑器当前显示的文本内容。
history(List 类型)
:字符车列表,用于保存每次文本变化的状态,每次调用setText
方法时,旧的currentText
会被添加到history
列表的末尾。
setText(String text)
方法:
当用户输入新的文本时,调用该方法。
将
text
添加到history
列表中。将
currentText
更新为传入的text
。
undo()
方法:
该方法主要实现了撤销功能
首先检查
history
列表是否为空,若为空,则说明没有历史状态可以恢复,并打印一条提示信息。如果
history
不为空,则从列表的末尾取出一个元素(最近的一次保存的状态),并将其赋值给currentText
。同时将该值从history
列表中移除。
test_simpleTextEditor()
方法:
- 类中我们创建了一个
SimpleTextEditor
实例,模拟了一系列用户操作,即多次编辑文本,多次撤销。
该实现方式非常直观,对于简单的使用场景,用户可以编辑文本,并在需要的时候回退之前的版本,但该实现也潜藏着一定的问题和局限性。
3.常规实现存在的问题
我们通过 SimpleTextEditor
类 实现了一个具备基本撤销功能的文本编辑器,实现方式简单直接,但在更复杂的场景或需求变化时,会出现一些问题,以下我们进行深入分析
问题点一:状态存储和编辑器类紧密耦合
在 SimpleTextEditor
中,history
列表是编辑器类的私有成员,意味着状态的保存和管理逻辑被封装在了 SimpleTextEditor
内部,存在以下弊端:
-
职责不单一:
SimpleTextEditor
不仅要负责文本的编辑,还要负责历史状态的记录和恢复,当一个类承担过多的指责时,维护将变的复杂。 -
状态表示的局限性:目前我们仅保存了文本内容,若未来编辑器需要支持更复杂的状态,例如:光标位置、字体样式等,那么
history
列表的类型就需要修改,可能变为一个包含多个属性的复杂对象,这将直接修改SimpleTextEditor
的核心代码,增加了出错的风险。
问题点二:破坏封装性,暴露太多细节
虽然 history
私有,但为了实现撤销,SimpleTextEditor
必须知道如何创建和恢复状态。如果状态变的非常复杂,那么 SimpleTextEditor
内部就要了解这个负责对象的所有内部细节,确保能够正确的保存和恢复,这破坏了状态对象的本身的封装性,理想情况下,状态对象应该自己负责管理自己的数据,而不是让外部类来操控其内部。
问题点三:难以扩展新的状态管理机制
假设我们想引入重做功能,当前代码实现中,我们需要在 SimpleTextEditor
中再增加一个列表类存储被撤销的状态。并在 undo
和 setText
方法中维护这个新的列表,使得其越来越庞大。
一个具体扩展需求带来的问题
假设产品经理提出新需求:希望在用户撤销时,不仅能够恢复文本,还能要将光标位置恢复。
在当前的 SimpleTextEditor
中,我们需要:
-
修改
SimpleTextEditor
类,增加一个cursorPosition
成员变量(标识光标位置) -
修改
history
列表的类型,不再是String
,而是一个包含了两个成员变量的类TextState
。 -
修改
setText
方法,保存历史时,需要创建一个TextState
对象,并保存当前的文本和光标位置。 -
修改
undo
方法,恢复文本时,恢复光标位置。
在仅仅增加一个光标位置的状态,就需要对 SimpleTextEditor
多处进行修改,如果未来还要增加字体、颜色等,修改文本将会更多,耦合度也会越来越高。备忘录模式提供了一种优雅的方式来捕获一个对象的内部状态,并在该对象之外保存,以后就可以将该对象的恢复到原先保存的状态,同时又不会破坏对象的封装性。
4.备忘录模式
什么是备忘录模式?
备忘录模式是一种行为设计模式,它允许在不暴露对象实现细节的情况下捕获和恢复一个对象的内部状态,直观的讲,就是给对象拍个快照,把快照交给一个保管员存起来,需要的时候,对象可以从保管员那里取回之前的快照,然后用这个快照恢复到当时的状态。
备忘录模式的三个核心角色
- 原发器(Originator)
-
需要保存其状态的业务对象。
-
负责创建一个包含当前内部状态快照的备忘录对象。
-
负责使用备忘录对象来恢复到某个之前的状态。
-
原发器决定哪些状态需要保存在备忘录中,以及如何从备忘录中恢复状态。
- 备忘录(Memento)
-
存储原发器的内部状态
-
核心特性:备忘录保护其内容不被原发器之外的任何对象访问,备忘录对于 负责人来说是不透明的,负责人只能持有它、传递它,但不能修改或者查看其内部状态,而对于原发器来说,备忘录是透明的,原发器可以访问备忘录中所有数据,以便恢复自身状态。
-
提供两个接口:
-
宽接口:供原发器使用,允许访问备忘录中存储的所有状态数据,以便进行保存和恢复,这个接口通常只有原发器可见。
-
窄接口:供负责人使用,通常只允许负责人获取备忘录的句柄,但不能访问备忘录的内部状态,这样保证了负责人不会意外修改或依赖备忘录的内部结构。
-
- 负责人(Caretaker)
-
也称为保管员,负责保存备忘录对象。
-
当需要保存状态时,想原发器请求一个备忘录,并将其保存起来。
-
当恢复状态时,从存储中取出来,并将其传递给原发器。
-
不关心备忘录存储了什么内容,只知道这个备忘录对应着原发器的某个历史状态
类比游戏存档
在大型单机游戏中,经常会通关打Boss,在打之前通常情况下会先 存档,这个存档文件就相当于是一个备忘录,它记录的当时角色的等级、装备、位置等所有关键信息,如果打Boss失败,可以直接读档,游戏就会恢复到之前存档时的状态。
在这个过程中:
-
游戏本身(或者说角色的状态)就是原发器(Originator)
-
读档文件 就是备忘录(Memento)
-
玩家护着游戏中的存档管理器就是负责人或者保管员(Caretaker),它并不需要知道存档文件中具体是如何记录角色数据,只知道这里有一个有效的存档。
备忘录模式的结构与协作
三个角色间的协作流程大致如下:
- 保存状态:
-
负责人决定需要保存原发起的当前状态。
-
负责人请求原发器创建一个备忘录。
-
原发器创建一个备忘录对象,并将自己的当前状态信息复制到备忘录中。
-
原发器将新创建的备忘录返回给负责人。
-
负责人将受到的备忘录存储,例如:压入一个栈中。
- 恢复状态:
-
负责人决定需要将原发器恢复到之前的某个状态。
-
负责人从其存储中取出一个之前保存的备忘录,例如:从栈顶弹出一个备忘录。
-
负责人将这个备忘录对象传递给原发器。
-
原发器从备忘录中读取状态信息,并重置自身内部状态。
Java中,实现备忘录对原发器透明,对负责人不透明的特性,有以下几种方式
内部类实现:将备忘录类做为原发器类的一个内部类(可以是私有内部类)。这样,原发器可以访问备忘录的所有成员(包含私有成员),而外部的负责人由于无法直接访问私有内部类的成员(或者只能通过一个非常受限的公共接口访问)。
接口隔离:定义一个标记接口(空接口)作为备忘录的窄接口,负责人持有的备忘录类型是这个标记接口,而备忘录则实现这个标记接口,并提供额外的方法(宽接口)供原发器访问。原发器可以将备忘录对象向下转型为实际类型类访问宽接口。这种方式需要原发器和备忘录在同一个包下,或者备忘录的宽接口方法是包级私有的。
包级私有:将原发器和备忘录放置在同一个包中,备忘录的获取状态方法设置为包级私有,负责人则放置在不同与备忘录的包中。
5.使用设计模式进行重构
使用备忘录模式对之前文本编辑器进行重构,主要目标是解决高度耦合、职责不清晰、封装性被破坏和扩展性差的问题。
重构为四个主要类:
-
TextEditor(Originator)
:用于担任原发器,负责创建备忘录,读取备忘录中的状态重置自身。 -
Memento
:接口,用于标识负责人内部的备忘录列表类型 -
EditorMemento(Memento)
:Memento
接口实现类,TextEditor
的私有静态内部类,实际的备忘录,存储TextEditor
实时状态副本,对负责人不透明。 -
EditorHistory(Caretaker)
:担任负责人,保存和管理
重构步骤
第一步:定义 Memento 接口(Memento)
public interface Memento {
}
代码说明
Memento
主要用于实现宽窄接口,做为标识存在,不定义任何接口
第二步:修改原发器类(TextEditor)
public class TextEditor {
private String currentText;
public TextEditor() {
this.currentText = "";
}
public void setCurrentText(String text) {
System.out.println("设置文本: " + text);
this.currentText = text;
}
public String getCurrentText() {
return currentText;
}
public EditorMemento save() {
System.out.println("保存状态到备忘录: " + currentText + "");
return new EditorMemento(this.currentText);
}
public void restore(Memento memento) {
if (memento != null) {
EditorMemento memento1 = (EditorMemento) memento;
this.currentText = memento1.getText();
System.out.println("从备忘录恢复状态: " + this.currentText);
}
else {
System.out.println("无法恢复,备忘录为空!");
}
}
private static class EditorMemento implements Memento {
private final String text;
private EditorMemento(String text) {
this.text = text;
}
public String getText() {
return text;
}
}
}
代码说明
-
EditorMemento
(私有内部类):实现Memento
接口,定义text
成员变量,用于存储currentText
实时状态信息。 -
currentText(String)
:存储当前文本内容 -
setText(String text)
:修改文本内容的方法,与之前SimpleTextEditor
不同,该方法不在负责将旧状态存入历史记录。 -
save()
:创建并返回一个Memento
对象,该对象包含了一个TextEditor
当前的currentText
状态。实际返回Memento
具体实现类(TextEditor
内部私有类) -
restore(Memento memento)
:接收一个Memento
对象,并转换为EditorMemento
,获取text
,重置自身currentText
。
第三步:定义负责人类(EditorHistory)
public class EditorHistory {
private final Stack<Memento> history = new Stack<>();
private final TextEditor editor;
public EditorHistory(TextEditor editor) {
this.editor = editor;
}
public void recordPriorStateAndSetText(String newText) {
history.push(editor.save());
System.out.println("负责人: 在文本修改前,保存了状态: " + editor.getCurrentText() + "");
editor.setCurrentText(newText);
}
public void undoToPreviousState() {
if (history.isEmpty()) {
System.out.println("负责人: 没有可撤销的状态!");
return;
}
Memento pop = history.pop();
System.out.println("负责人: 取出备忘录,请求编辑器恢复到修改前的状态。");
editor.restore(pop);
}
}
代码说明:
-
history(Stack)
:使用Stack
来存储备忘录对象,后进先出。 -
editor(TextEditor)
:对原发器TextEditor
的引用,负责人需要请求原发器创建备忘录(editor.save())
或从备忘录恢复状态(editor.restore(memento))
。 -
EditorHistory(TextEditor editor)
:构造函数,传入原发器实例。 -
recordPriorStateAndSetText(String newText)
:模拟用户编辑文本并触发保存的流程,调用editor.save()
来获取编辑器当前的状态,并将这个状态压入history
栈,调用editor.setText(newText)
更新编辑器的文本。 -
undoToPreviousState()
:模拟用户实现撤销操作,从history
栈顶弹出一个备忘录,然后调用editor.restore(memento)
将编辑器恢复到该状态。
第四步:编写测试类
@Test
public void textEditor() {
TextEditor textEditor = new TextEditor();
EditorHistory history = new EditorHistory(textEditor);
history.recordPriorStateAndSetText("这是第一段文本内容!");
System.out.println("=================================================");
history.recordPriorStateAndSetText("这是第二段文本内容!");
System.out.println("=================================================");
history.recordPriorStateAndSetText("这是最后的文本内容!");
System.out.println("=================================================");
System.out.println();
System.out.println("=================================================");
System.out.println("执行 撤销操作 ");
System.out.println("=================================================");
System.out.println();
history.undoToPreviousState();
System.out.println("=================================================");
history.undoToPreviousState();
System.out.println("=================================================");
history.undoToPreviousState();
System.out.println("=================================================");
}
代码说明:
-
创建
TextEditor
实例,并通过 有参构造 将TextEditor
对象传入EditorHistory
中。 -
模拟用户编辑、撤回一系列文本内容
6.分析重构代码
我们将状态的保存和恢复逻辑从 TextEditor
中分离出来,由 EditorMemento
负责封装状态,由 EditorHistory
负责管理状态历史,TextEditor
只关心如何创建备忘录和如何从备忘录恢复,不再关心历史记录的具体存储方式。
重构后的优势:
- 封装性得到增强
TextEditor
的内部状态现在通过EditorMemento
来进行快照,而EditorMemento
是Memento
的实现类,且做为了TextEditor
私有内部类,TextEditor
可访问EditorMemento
中的所有私有成员变量,而EditorHistory
则无法访问EditorMemento
。
- 职责更单一
-
TextEditor
:只专注 文本的编辑和管理,只需要知道如何创建备忘录和恢复某个历史记录的状态,不再操心历史记录如何存储、何时存储、撤销逻辑等。 -
EditorMemento
:唯一职责就是存储原发器在某个时间点的状态快照。 -
EditorHistory
:只负责管理备忘录的历史记录。
- 降低耦合
-
TextEditor
与EditorHistory
耦合度大大降低,TextEditor
甚至不知道EditorHistory
的存在,只与EditorMemento
交互。 -
EditorHistory
与Memento
的具体实现类也是解耦的,只要TextEditor
提供一个Memento
对象EditorHistory
就能工作,不需要关注具体实现类。
在重构后的代码中实现之前提出的扩展需求(希望用户在撤销时,不仅能恢复文本,还能恢复当时的光标位置)
-
在常规实现中:需要修改
SimpleTextEditor
,增加currentPosition
成员变量,修改history
列表的类型为List<TextState>
(包含文本和光标位置),并修改setText
和undo
方法类处理这个新的TextState
对象。 -
对于重构后的代码中:
- 修改
TextEditor
:
-
增加
currentPosition
成员变量。 -
在内部类
EditorMemento
中增加currentPosition
成员变量。 -
修改
restore(Memento memento)
方法,恢复文本内容时,重置光标位置
EditorHistory
则不需要修改任何内容。
- 修改
7.长话短说
核心思想
备忘录模式通过在不破坏对象封装性的前提下,捕获并保存对象内部状态,使对象可以恢复到某个时间点的状态。
主要角色
-
原发器(Originator):保存和恢复状态 的对象。
-
备忘录(Memento):存储原发器内部状态的载体
-
责任人(Caretaker):保存和管理备忘录的历史记录
使用场景
1.撤销/重做功能:入文本编辑器等。 2.事物回滚:数据库操作失败时回滚到之前的数据 3.状态快照: 需要记录对象历史状态的场景
使用思路
1.定义状态范围:明确需要保存的属性内容 2.设计备忘录:封装状态存储,限制访问权限。 3.实现状态保存和恢复:原发器开发生成备忘录和恢复接口 4.管理历史记录:负责存储备忘录列表
注意事项
-
性能问题:频繁保存大对象状态可能导致内存的消耗。
-
深拷贝和浅拷贝:确保备忘录保存的是对象状态的独立副本。
-
封装保护:备忘录对象应仅对原发器提供内部的访问。
-
生命周期管理:过期的备忘录信息及时清理,防止内存泄漏。