Post

设计模式:命令模式

模式所解决的问题

在前面的文章中,我们讨论过很多的设计模式,比如说 迭代器模式工厂模式,还有 观察者模式。这些模式都在试图解决代码中的一个核心问题——依赖。什么是依赖呢?就好比一个模块 A 强依赖于模块 B,那么我们在使用模块 A 的同时需要把模块 B 也给搞清楚,还需要弄清楚 A 与 B 之间的关系。当一个系统中的模块数量逐渐增多,并且这些模块都相互依赖,那么我们就需要花更多的时间来维护,而且代码可读性降低,以及添加新功能和模块也变的更加困难。

可能你会说,模块之间本身就需要有依赖和关联啊,这在任何一个应用程序中都是必须的。没错,但是对于一个模块来说,存在着使用者和维护者,对于维护者来说,他需要对这个模块非常了解,了解这个模块和其他模块之间的关系,出了问题也要及时定位到问题,及时修改。但对于使用者来说,他仅仅关心如何才能使用这个模块来达到自己的目的,至于这个模块内部如何实现,如何依赖,他并不关心,如果一个模块能够拿来就用,并且效果和这个模块的名字以及描述一模一样,那就是最好的。

通常来说,降低依赖的一个常规操作就是加一层抽象,这层抽象位于使用者和模块之间,它表示的是一类模块,而不是单一的一个模块。这层抽象定义了某类模块的一些共性,当给到使用者一个模块时,使用者可以把这个模块当成是这个抽象,因而不去关心这个模块是什么,仅仅依据抽象来使用即可。

说了这么多,现在我们来看一个场景,假设我们去餐馆点餐,从点餐到菜品开始制作,这中间的过程是怎么样的呢?不管是点餐员过来帮你点餐,还是通过 APP 点餐,你并没有跟后厨直接交流。看似跟后厨直接点餐是最快捷的方式,其实不然,如果后厨既需要帮你点餐,又要给你介绍菜品,还要制作菜品,先不考虑忙不忙的过来,这种一人兼并数职,一个环节出会影响到其他环节,就好比你点完了菜中途想再增加些菜,那么不好意思,要等后厨忙完才行。所以点餐员或者点餐软件必不可少,这样后厨只需要专注在菜品制作上,你中途想加菜也完全不受影响。

由此一来,下面三个类就是必不可少的了

1
2
3
4
5
6
// Customer
class Client {}

class Waitress {}

class Chef {}

那么这就足够了吗?对于小餐馆来说,也许够了,但是规模稍大一点的餐馆就不行,酒店就更不行了。因为酒店里不止一类厨师,有炒菜的,有做凉菜的,有做面点的,还有做糕点的,再往细了说,每个类别的厨师群体中,又有不同的领域,不同领域中细化到个人,每个人擅长的技能又不一样。当一个点餐员拿到用户的需求后,他如何知道谁才是最合适的人呢?

很明显,现在的问题存在与厨师与点餐员之间,我们需要在点餐员与厨师之间加一层抽象,你可以把它看成是一个统一化的工具,帮助点餐员更好、更快捷地工作。就像下面这样:

1
2
3
4
5
6
7
8
// Customer
class Client {}

class Waitress {}

class Command {}

class Chef {}

加的这个东西就是我们今天要说的主角——命令。

命令模式

通过前面的例子,我们大概理解了什么是命令模式,它具体解决的是什么样的问题。但是到具体的一些细节,我们并没有提及,这里我们就挨个来分析。

之前我们说过的 依赖反转原则——高层模块不要依赖于底层模块,高层模块和底层模块应该通过抽象来实现依赖,而例子对应的实现中,我们并没有引入任何的抽象,这一点就需要进行补充,修改后如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Customer
class Client {}

class Waitress {}

interface Command {}

class TiramisuCommand implements Command {}

class KungPaoChickenCommand implements Command {}

class BraisedPeanut implements Command {}

interface Chef {}

class ChineseChef implements Chef {}

class DessertChef implements Chef {}

class AppetizerChef implements Chef {}

剩下的就是在这些抽象中定义共性的行为了,比如说 Command 对应的菜品,任何一个出现在菜单上的菜品都可以下单,对应来说任何一个命令都是可以被执行的。而任何一个 Chef 都会制作菜品,不管制作的菜品是什么。另外,Command 负责将菜品和厨师关联起来,这样 Chef 只会收到与他相关的菜品制作要求。对于点餐员 Waitress 来说就更简单了,只需要将客户的要求归拢起来,帮助用户下单即可,于是就有以下的修改:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// Customer
class Client {}

class Waitress {
  List<Command> customerRequests;

  public RemoteControl() {
    customerRequests = new ArrayList<>();
  }

  public void setCommand(int slot, Command request) {
    customerRequests.add(request);
  }

  public void makeRequest(int slot) {
    customerRequests[slot].execute();
  }
}

interface Command {
  public void execute();
}

class TiramisuCommand implements Command {
  DessertChef chef;

  TiramisuCommand(DessertChef chef) {
    this.chef = chef
  }

  public void execute() {
    chef.cook();
  }
}

class KungPaoChickenCommand implements Command {
  ChineseChef chef;

  KungPaoChickenCommand(ChineseChef chef) {
    this.chef = chef
  }

  public void execute() {
    chef.cook();
  }
}

class BraisedPeanutCommand implements Command {
  AppetizerChef chef;

  BraisedPeanutCommand(AppetizerChef chef) {
    this.chef = chef
  }

  public void execute() {
    chef.cook();
  }
}

interface Chef {
  public void cook();
}

class ChineseChef implements Chef {
  public void cook() {}
}

class DessertChef implements Chef {
  public void cook() {}
}

class AppetizerChef implements Chef {
  public void cook() {}
}

最后,我们还需要在客户端将这些东西组合起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class RestaurantOrderSystem {
  Waitress waitress;

  public static void initialSetting() {
    waitress = new Waitress();

    ChineseChef chineseChef = new ChineseChef();
    DessertChef dessertChef = new DessertChef();
    AppetizerChef appetizerChef = new AppetizerChef();

    KungPaoChickenCommand kungPaoChickenCommand = new KungPaoChickenCommand(chineseChef);
    TiramisuCommand tiramisuCommand = new TiramisuCommand(dessertChef);
    BraisedPeanutCommand braisedPeanutCommand = new BraisedPeanutCommand(appetizerChef);

    waitress.setCommand(0, kungPaoChickenCommand);
    waitress.setCommand(1, tiramisuCommand);
    waitress.setCommand(2, braisedPeanutCommand);

    // waitress.makeRequest(0);
    // waitress.makeRequest(1);
    // waitress.makeRequest(2);
  }
}

命令模式对应到的类关系图如下:

这里面,点餐员对应的就是 Invoker,也就是命令的发起者,厨师对应的就是 Receiver,也就是命令的接受者,负责命令在执行过程中的各种具体细节。

当然,上面的例子中,我们也仅仅是呈现了一个命令模式的大致框架,其实可以根据实际的情况添加各种的细节,比如下面这些:

  • 除了 Receiver(也就是 Chef),每个命令其实还可以包括更多的细节,对应到我们的例子中,每个命令代表的是一道菜品,那么一些菜品会有一些选项,比如牛排需要几分熟、菜需要少油少盐、加辣、加配菜等等。命令虽说是 Waitress 下的,但实际的创建者是顾客,因而这里并没有增加 WaitressCommand 之间的复杂度。
  • Invoker(也就是 Waitress)在执行命令时也可以传入一些信息,表示用户的一些额外请求(不在 Command 的自带选项中)。
  • Receiver 当中的细节我们都隐去了,一个命令的执行可能很复杂,也可能很简单,但这不是命令模式的重点,Receiver 只需要提供对应的接口给到 Command 就行。

命令模式的实际应用

命令模式的核心就是将不同的任务或事物统一封装起来,这样呈现给外界的都是一个个统一类型的对象,让类之间的关系变得更加清晰,降低了模块和模块之间的耦合性。虽说仅仅是简单的封装,但是这在实际中却大有用途。

第一个是关于操作系统的例子,试想一下,一个任务过来,操作系统是如何将这个任务分配到指定的线程的呢?根据职责划分吗,比如一些线程专门负责网络请求,另外一些线程负责程序的编译,还有一些线程负责内存的管理,这样职责清晰,不是很好吗?这其实会造成资源的浪费,比如当网络模块忙碌时,网络模块的资源就会不足,任务就会拥塞,此时其他模块处的资源却非常的充裕,但却不能拿来使用。

更好一点的做法是通过任务队列来实现,每一个进入任务队列的任务都被包装成 Command,所有的线程都从任务队列中取这些包装好的 Command。不管是什么样的任务,对任务队列来说都是一样的,任务队列其实只做着一件事情——将任务根据到达先后排序,线程在这个过程中也只做一件事情——当线程空闲,如果队列中还有任务,则将任务从队列中取出。这样,通过命令模式的封装,整个任务的分配这一环节就变的清晰了。

另一个例子是关于日志的,我们知道当系统因为某些故障崩溃,当系统重启后,就需要恢复这段时间系统丢失的数据,而日志在这之中起到关键作用。可这里的问题是,日志如何打才好呢?如果将涉及到的所有数据都放到日志里,那么日志体量将变得巨大,而且有些操作是通过网络进行的(比如去到其他机器上更改数据库),本地根本就无法直接获取到相关的数据。

这里比较好的做法是记录操作,每个系统通常都会有记录点(check point),这表明这个记录点之前的数据,系统都已经持久化完毕了,也就说明系统只要恢复记录点后面的数据即可,最为直接的操作就是直接将相关的操作通过 Command 包装起来,序列化存储到日志中,当系统恢复时,将这些 Command 取出,因为日志已经保证了顺序,因此我们挨个执行一遍就好了。当然了,Command 中可以再添加两个方法,一个是 store(),另一个是 load(),整个 Command 接口就变成了下面这样:

1
2
3
4
5
interface Command {
  public void execute();
  public void store();
  public void load();
}

store() 负责的是将相关恢复需要的部分序列化存储起来,load() 则是相反,将实际可执行的对象从序列化后的字符串中恢复出来,有了这两个方法,系统就可以借助命令模式来恢复丢失的数据了。

This post is licensed under CC BY 4.0 by the author.