今天研究了设计模式里的“命令模式”,起因是自己写的基于Vue的webapp上,需要实现一个允许用户编辑数据时undo/redo的功能。
一开始以为这个实现起来应该很简单,只要用一个列表保存好每次变化的一些参数,再在需要的时候执行出来就是redo,反向执行出来就是undo。但实际开始写之后却发现问题不断,自己的类的拆分不佳,导致多余的代码很多,而且越写自己也越感觉混乱了(大概自己还是太菜了吧)。
于是上网查阅这种undo/redo功能的实现的思路,许多文章提到了“命令模式”(command design pattern),遂研究之。
许多的文章中提到的命令模式,其讲述十分完备,其中已经引入了大量此设计模式特有的概念,如invoker, receiver, client之类,对于初学者来说有点不太友好(因为实在是太抽象了)。
在这种情形下,我认为,即使看再多的关于命令模式的教程,对于现在的我来说也很想难完全理解并运用其中的所有知识来重构自己的程序。而且如果完全地照搬那些设计模式,不一定是最适合自己的应用场景的。本着循序渐进的原则,我先抓住命令模式中最重要的一点:对操作或事件的封装,开始了边编码学习的过程。
用命令模式设计一个程序,可以对一个字符串的任意位置进行增加一个字符或删除一个字符的操作,并可以通过调用相应方法,进行逐步的undo和redo操作。
因为Java有更完整的面向对象的特性,我打算Java中来完成这个小课题,顺便学习命令模式。这样,能够更直观地感受到一个设计模式中的抽象是怎么来的。而之前在前端写的undo/redo半成品,只有少量的逻辑可以移用过来,基本上其他的代码都重写了。
在web前端,结合Vue这样的数据双向绑定的前端框架,可以很快开发出很精简且互动性很强的demo,更适合做大量的测试;在Java这边,要想做同样的可视化测试,还得用swing包,并且手写不少的代码。所以这次我就在main方法中就简单的命令行程序来做测试了。
上面的课题,描述了一种很简单的对字符串的操作,即:在任意位置,增加或删除一个字符。据此我们可以知道,这个操作大致可以抽象为如下的类:
// 这里仅仅是一个用于说明的类,之后被重构
public class StringModifyCommand {
// 操作所针对的目标字符串
private String target;
// 增加的或删除的字符
private String content;
// 增加或删除所在的位置
private int index;
// 本次操作的具体类型:增加或删除
private StringModifyType type;
private enum StringModifyType{
INSERT, DELETE
}
}
我们还需要一个命令对象的管理类CommandManager,它会维护一个命令对象的列表,并可以在被调用undo或redo操作时返回相应位置上的命令对象。
于是得到以下代码:
import java.util.ArrayList;
class CommandManager {
private ArrayList<Command> commands = new ArrayList<>();
private int cursor = 0;
void record(Command c){
// record方法不是单纯地添加一个新命令到列表的末端。如果此时用户已经undo了几步,再进行操作时,就会冲掉cursor之后的记录。所以需要移除这些记录。
for (int i = cursor; i < commands.size(); i++) {
commands.remove(commands.size() - 1);
}
cursor++;
commands.add(c);
}
private Command get(int i){
return commands.get(i);
}
// 当外界需要undo时调用此方法,取出一个Command对象
Command getUndoCommand(String data){
if(cursor == 0){
System.out.println("You cannot undo more.");
return null;
}
Command command = get(--cursor);
// 这里的update方法,意在使命令对象在调用undo或redo之前,有机会更新其自身拥有的数据。最新的数据由CommandManager对象从外界获得,再传递给命令对象
command.update(data);
return command;
}
// 当外界需要redo时调用此方法,取出一个Command对象。和上面的区别在于cursor的移动方法不同。
Command getRedoCommand(String data){
if(cursor == commands.size()){
System.out.println("You cannot redo more.");
return null;
}
Command command = get(cursor++);
command.update(data);
return command;
}
}
写到这里的时候,我又去网上学习了一些资料,了解到命令模式的另一个精髓:将Command定义为一个接口,在接口中规定execute()方法。一个命令类通过实现execute()方法,规定该命令被调用时应该做哪些事。于是有了以下的接口:
public interface Command {
Object execute();
// 但仅仅是execute还不够,这次的课题需要把一个命令倒过来执行(undo),所以也需要留下下面这个接口
Object undo();
// 以及用于更新命令对象中数据的接口
void update(Object o);
}
有了上面的接口,我们可以实现两个命令类,增加字符命令(InsertCommand)和删除字符命令(DeleteCommand)。在本例中,两种命令之间正好成undo/redo的关系,在target, index, index等参数相同的情况下,insert的undo就是delete,并且用同样的参数就可以达到需要的结果。
import java.util.HashMap;
public class InsertCommand implements Command{
private String target;
private String content;
private int index;
InsertCommand(String target){
this.target = target;
}
@Override
public Object execute() {
// modifyString为工具方法,用以实现添加或删除target字符串中的某一位置上的一个字符
// 为了方便,这里我的type参数(第二个参数)已经不是枚举类型了
return StringUtil.modifyString(target, "insert", content, index);
}
@Override
public Object undo() {
return StringUtil.modifyString(target, "delete", content, index);
}
@Override
public void update(Object o) {
this.target = (String) o;
}
// 针对content和index这两个参数的setter
void setDetail(HashMap<String, Object> detail) {
content = (String) detail.get("content");
index = (int) detail.get("index");
}
}
// 和上面基本一致
import java.util.HashMap;
public class DeleteCommand implements Command{
private String target;
private String content;
private int index;
DeleteCommand(String target){
this.target = target;
}
@Override
public Object execute() {
return StringUtil.modifyString(target, "delete", content, index);
}
@Override
public Object undo() {
return StringUtil.modifyString(target, "insert", content, index);
}
@Override
public void update(Object o) {
this.target = (String) o;
}
void setDetail(HashMap<String, Object> detail) {
content = (String) detail.get("content");
index = (int) detail.get("index");
}
}
// 字符串工具类
class StringUtil {
static String modifyString(String target, String type, String content, int index) {
StringBuilder sb = new StringBuilder(target);
if (type.equals("insert")) {
sb.insert(index, content);
} else if (type.equals("delete")) {
sb.delete(index, content.length() + index);
}
return sb.toString();
}
}
本次的测试方法为:假设有一个字符串的初值为"apple"。使用Scanner类读取用户输入,用户输入形如"insert a 2"这样的命令时,则在字符串"apple"的第2个位置上插入字符"a",而输入"undo"或"redo",则会尝试回退或重做一次对字符串"apple"的修改。每次输入,控制台都会打印一次"apple"字符串的当前值。因此,测试类编写如下:
import java.util.HashMap;
import java.util.Scanner;
public class CommandManagerTest {
private static String data = "apple";
public static void main(String[] args) {
CommandManager cm = new CommandManager();
Scanner scanner = new Scanner(System.in);
while(scanner.hasNext()){
String input = scanner.nextLine();
switch (input) {
case "undo": {
// 从Manager中取一个Command对象
Command c = cm.getUndoCommand(data);
if (c != null) {
// 执行
data = (String) c.undo();
}
break;
}
case "redo": {
Command c = cm.getRedoCommand(data);
if (c != null) {
data = (String) c.execute();
}
break;
}
default: {
HashMap<String, Object> parsed = parseInput(input);
String type = (String) parsed.get("type");
String content = (String) parsed.get("content");
int index = (int) parsed.get("index");
if (type.equals("insert")) {
// 向Manager中记录命令对象
InsertCommand c = new InsertCommand(data);
c.setDetail(parsed);
cm.record(c);
} else if (type.equals("delete")) {
DeleteCommand c = new DeleteCommand(data);
c.setDetail(parsed);
cm.record(c);
}
data = StringUtil.modifyString(data, type, content, index);
break;
}
}
printData();
}
}
private static void printData(){
System.out.println(data);
}
private static HashMap<String, Object> parseInput(String input){
String[] params = input.split("\\s");
String type = params[0];
String content = params[1];
int index = Integer.parseInt(params[2]);
HashMap<String, Object> result = new HashMap<>();
result.put("type", type);
result.put("content", content);
result.put("index", index);
return result;
}
}
以下是一组测试,已经实现了基本功能:
insert a 2 // 输出apaple
undo //输出apple
redo //输出apaple
redo //已经没有可以redo的记录了,所以会输出 You cannot redo more.\n apaple