命令模式、JS中的命令模式、撤销和重做

关于命令模式的一些内容,包括命令模式的意义和实现,在Java中通过函数接口来简化命令模式,以及在JS等方便的语言中的使用,最后补充一个可视化的例子(虽然还没补)。

  • 命令模式是什么
  • 通过传递方法来简化
  • 在JS中使用命令模式
  • 使用命令模式做撤销和重做
  • 一个看起来生动形象的例子

命令模式

我们先从别的地方摘抄一个定义过来:

命令模式(Command Pattern)是一种数据驱动的设计模式,它属于行为型模式。请求以命令的形式包裹在对象中,并传给调用对象。调用对象寻找可以处理该命令的合适的对象,并把该命令传给相应的对象,该对象执行命令。 –菜鸟教程

命令模式看起来并不是很让人头晕,但是不知道怎么从头说起。我们可以对比一下从没有使用命令模式到使用了命令模式的区别:

假设我们现在界面上有几个按钮:


暂时跳过


主要的操作在于,如果我们直接调用不同的方法,这种情况下行为的请求者和行为的实现者产生了一种紧耦合的关系,在进行扩展的时候会产生一些麻烦(具体的麻烦我们后面再讨论)。那么我们为了解决紧耦合带来的问题,可以做的就是使用一些办法来解耦,命令模式就是一种很好的方式。在命令模式中,行为请求者并不直接和行为的实现者进行交流,而是通过一个Invoker来操作。如下是一个没有使用命令模式的例子:

1
2
3
4
5
6
7
8
9
10
11
class DemoA {
public static void main(String[] args) {
Tv tv = new Tv();
Light light = new Light();

tv.turnOn();
light.turnOn();
tv.turnOff();
light.turnOff();
}
}

这个是一个简单的例子,主要的操作就是开电视关电视,开灯关灯。我们可以看看如果将这个例子改成用命令模式会变成什么样:

1
2
3
4
5
6
7
8
9
10
11
12
13
class DemoB {
public static void main(String[] args) {
Tv tv = new Tv();
Light light = new Light();

Invoker invoker = new Invoker();
invoker.addCommand(new TurnOnTvAction(tv));
invoker.addCommand(new TurnOnLightAction(light));
invoker.addCommand(new TurnOffTvAction(tv));
invoker.addCommand(new TurnOffLightAction(light));
invoker.invokeAll();
}
}

我们使用了一个Invoker来间接进行操作,并且使用了几个对象来封装对对象的操作,每一种行为我们都定义了对应的对象,它们会实现同样的接口,我们把它记为Command,它有一个execute方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
interface Command {
void execute();
}

class TurnOnTvAction implements Command{
private Tv tv;

public TurnOnTvAction(Tv tv) {
this.tv = tv;
}

@override
public void execute() {
tv.turnOn();
}
}

class TurnOnLightAction implements Command{
private Light light;

public TurnOnLightAction(Light light) {
this.light = light;
}

@override
public void execute() {
light.turnOn();
}
}

// ……

我们把所有的要进行的行为包装成了对象,然后让它实现Command接口,而Command接口有一个execute方法,在我们封装的行为对象中,我们把实际要操作的对象在构造函数中获得,然后在execute方法中做实际的调用就可以了。我们原来有两个类分别有两个方法,对每个方法进行封装就变成了四个类。据说这是命令模式的一个缺点,我们搞出了太多的类来。那么我们再看看Invoker干了什么,它可能是长这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Invoker {
private Queue<Command> commandQueue;

public Invoker() {
commandQueue = new LinkedList<>();
}

public void addCommand(Command command) {
commandQueue.offer(command);
}

public void invokeAll() {
whlie(!commandQueue.isEmpty()) {
commandQueue.poll().execute();
}
}
}

使用函数接口简化

既然构造太多的类是一个麻烦,我们可以用一些办法来解决这个问题。既然Java是一个面向对象的语言,那方法也可以是一个对象,我们可以用Java8提供的函数接口来做这件事情,直接将方法作为参数传递,就可以在一些情况下避免多余的封装了。这里我们的关灯和开灯都是没有参数的,我们可以用Supplier来取代我的自己定义的Command接口。但是Supplier要求要有一个返回值,如果我们不需要返回值,可以自定义一个函数接口,但是我们已经有一个Command了,把它改改就可以:

1
2
3
4
@FunctionalInterface
interface Command {
void execute();
}

那么这个时候我们的开灯关灯等各种方法,都可以作为这个函数接口的一个实现,而通过函数接口,我们可以无需为方法封装一个对象,直接使用双冒号将它的方法引用传入即可,此时的Invoker不需要改动,因为我们依旧通过Command接口的execute方法来执行实际的方法。

而我们的Demo也就成了这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
class DemoC {
public static void main(String[] args) {
Tv tv = new Tv();
Light light = new Light();

Invoker invoker = new Invoker();
invoker.addAction(tv::turnOn);
invoker.addAction(light::turnOn);
invoker.addAction(tv::turnOff);
invoker.addAction(light::turnOff);
invoker.execAll();
}
}

通过函数接口,我们可以不再为每一个行为构造一个新的类,只需要这些行为都满足接口定义的方法格式要求就可以了。如果所有的行为符合Java中提供的几个常用的函数接口的格式,我们还可以把自己定义的接口省去。不过如果在不同的行为要求不同数量的参数,以及可能会有返回值的情况,我们就需要进行更多的额外的操作了。

一个不太一样的例子

那么这个命令模式需要的内容我们基本上都有了,再看看一个不太一样的例子,毕竟我们只在一个按顺序调用的例子里面用命令模式好像太过凑数了。我们可能会遇到给界面上的按钮添加事件的情况。

除此之外,既然我们需要按顺序执行这些方法,我们可以不需要先添加再全部执行的操作,只需要直接执行即可。

下面是一个新的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class DemoD {
public static void main(String[] args) {

//此处通过各种神奇的方式获得一个全局的上下文context。

Tv tv = context.getTv("theTv");
Light light = context.getLight("theLight");
Button button1 = context.getButton("button1");
Button button2 = context.getButton("button2");
Button button3 = context.getButton("button3");
Button button4 = context.getButton("button4");

button1.setOnClick(tv::turnOn);
button2.setOnClick(light::turnOn);
button3.setOnClick(tv::turnOff);
button4.setOnclick(light::turnOff);
}
}

我们同样可以对它使用命令模式,此时我们改为命令模式的操作实际上是绑定点击事件的操作,关灯开灯只是这个操作的一个参数而已。看看main方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class DemoE {
public static void main(String[] args) {

Tv tv = context.getTv("theTv");
Light light = context.getLight("theLight");
Button button1 = context.getButton("button1");
Button button2 = context.getButton("button2");
Button button3 = context.getButton("button3");
Button button4 = context.getButton("button4");

Invoker invoker = new Invoker();
invoker.execCommand(new BindTurnOnTvAction(button1, tv));
invoker.execCommand(new BindTurnOnLightAction(button2, light));
invoker.execCommand(new BindTurnOffTvAction(button3, tv));
invoker.execCommand(new BindTurnOffLightAction(button4, light));
}
}

我们不需要先把操作存起来一并执行,可以直接给Invoker来一个execCommand方法,它接受方法并马上执行方法。

1
2
3
4
5
6
7
8
public class Invoker {

//我们暂时也移除掉了队列,后面实现撤销重做可能还是需要一个队列的。

public void execAction(Command command) {
command.execute();
}
}

通过这种方式,我们需要这样的类来实现Command接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class BindTurnOnTvAction {

private Button button;
private Tv tv;

public BindTurnOnTvAction(Button button, Tv tv) {
this.button = button;
this.tv = tv;
}

public void execute() {
button.setOnClick(tv::turnOn);
}
}

这时候我们又双叒叕需要想一想怎么样通过函数接口来取代这种定义操作了。这时我们已经有一个传给setOnClick方法的函数接口了,和原来的Command是一样的,我们把它改名为Event:

1
2
3
4
@FunctionalInterface
interface Event {
void execute();
}

我们需要让调用Invoker的操作变成这样的形式:

1
invoker.execAction(button::setOnClick, tv::turnOn);

表示将第二个参数作为第一个参数的参数,并执行第一个参数。所以不要在意第二个参数,它只是碰巧是一个方法。这个时候我们需要来一个这样的Command

1
2
3
4
@FunctionalInterface
interface Command {
void execute(Event event);
}

然后Invoker:

1
2
3
4
5
class Invoker {
public void execAction(Command command, Event event) {
command.execute(event);
}
}

于是我们又顺利变成了命令模式。虽然我们用它改造了两个例子,但是我们还没有体现出它的好处,那么这个时候我们可以通过添加打日志功能来体现一下这个好处。(虽然很鸡肋,因为方法虽然可以实现函数接口,但给它添加可以给人看的属性又是一波麻烦的操作了)

1
2
3
4
5
6
class Invoker {
public void execAction(Command command, Event event) {
logger.log(String.format("exec command %s, args: %s", command, event));
command.execute(event);
}
}

通过命令模式我们可以达到这样的效果:如果我们需要打日志,不需要修改原来的方法,在原来的方法中添加,而是把它放在Invoker或者自己为行为定义的类中。这种方式耦合较低,对添加统一的功能比较友好。

在JS中

不过我觉得在Java中还是不够灵活,如果我们在JS里面要实现类似的功能就会轻松很多,因为方法真的也是一个对象,不用绕弯弯了。我们把上面一个例子稍微改一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Invoker() {}
Invoker.prototype.execAction = (command) => {
command();
}

function Demo() {
const tv = new Tv();
const light = new Light();

const invoker = new Invoker();
invoker.execAction(tv.turnOn.bind(tv));
invoker.execAction(light.turnOn.bind(light));
invoker.execAction(tv.turnOff.bind(tv));
invoker.execAction(light.turnOff.bind(light));
}

如果我们只需要简单地分离调用操作,这样就可以了。对于按钮绑定事件的例子,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function Invoker() {}
Invoker.prototype.setClickEvent = (element, event) => {
element.onclick = event;
}

function Demo() {
const tv = new Tv();
const light = new Light();

const button1 = new Button();
const button2 = new Button();
const button3 = new Button();
const button4 = new Button();

const invoker = new Invoker();
invoker.setClickEvent(button1, tv.turnOn.bind(light));
invoker.setClickEvent(button2, light.turnOn.bind(light));
invoker.setClickEvent(button3, tv.turnOff.bind(tv));
invoker.setClickEvent(button4, light.turnOff.bind(light));
}

实现的形式可以有一些不同,但主要操作都是将行为的请求者和行为的执行者解耦,我们通过添加一个Invoker来作为它们之间通信的桥梁,从而在扩展一些神奇的操作的时候不会因为紧耦合写出糟糕的代码。

实现撤销和重做

如果我们需要撤销或者重做一些操作,那么我们需要记录这些操作步骤,这时候就体现出好处了。当然撤销是个比较复杂的问题,我们需要把原来操作的副作用消去。这里可能有挺多的实现办法,但我们这里不讨论这个办法,先认为它有一个撤销的办法。

那么事情就会变成这样子,我们修改一下上一个例子中的Invoker:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Invoker {
private class Pair {
private Command command;
private Event arg;
}

private Stack<Pair> execStack;
private Stack<Pair> cancelStack;

public Invoker() {
execStack = new Stack<>();
cancelStack = new Stack<>();
}

public void execAction(Command command, Event event) {
command.execute(event);
cancelStack = new Stack<>();
}

public void cancel() {
if (!execStack.empty()) {
Pair pair = execStack.pop();
Command command = getReverseCommand(pair.command);
command.execute(pair.arg);
cancelStack.push(pair);
}
}

public void redo() {
if (!cancelStack.empty()) {
Pair pair = cancelStack.pop();
pair.command.execute(pair.arg);
execStack.push(pair);
}
}
}

唔,就先这样吧。

一个例子

我准备在这里放一个,按钮和电视机的例子。