命令模式的原理

命令模式的英文翻译是 Command Design Pattern。在 GoF 的《设计模式》一书中是这么定义的:

The command pattern encapsulates a request as an object, thereby letting us parameterize other objects with different requests, queue or log requests, and support undoable operations.

翻译成中文就是:命令模式将请求(命令)封装为一个对象,这样可以使用不同的请求参数化其他对象(将不同请求依赖注入到其他对象),并能够支持请求(命令)的排队执行、记录日志、撤销等(附加控制)功能。

对于 GoF 给出的定义,我这里再进一步解读一下。

落实到编码实现,命令模式用的最核心的实现手段,是将函数封装成对象。我们知道,C 语言支持函数指针,我们可以把函数当作变量传递来传递去。但是,在大部分编程语言中,函数没法儿作为参数传递给其他函数,也没法儿赋值给变量。借助命令模式,我们可以将函数封装成对象。

具体来说就是,命令对象需要将动作和接收者包进对象中。这个对象只暴露出一个 execute() 方法,当此方法被调用时,接收者就会进行这些动作。从外面看,其他对象不知道究竟哪个接收者进行了哪些动作,只知道如果调用了暴露的 execute() 方法,请求的目的就能达到。从实现的角度来说,它类似回调。

  1. // 命令接口
  2. public interface Command {
  3. // execute 方法内部定义了要执行的动作
  4. void execute();
  5. }
  6. // 打开电灯的命令实现
  7. public class LightOnCommand implements Command{
  8. // 命令内部持有一个电灯,相当于接收者
  9. private Light light;
  10. public LightOnCommand(Light light) {
  11. this.light = light;
  12. }
  13. @Override
  14. public void execute() {
  15. light.on();
  16. }
  17. }

当我们把函数封装成对象之后,对象就可以存储下来,方便控制执行。所以,命令模式的主要作用和应用场景,是用来控制命令的执行,比如,异步、延迟、排队执行命令、撤销重做命令、存储命令、给命令记录日志等等,这才是命令模式能发挥独一无二作用的地方。

命令模式的实战讲解

下面,我们结合一个具体的例子来解释一下。假设我们正在开发一个类似《天天酷跑》这样的手游。通常,游戏客户端和服务器间的数据交互是比较频繁的,为了节省网络连接建立的开销,客户端和服务器之间一般采用长连接的方式来通信。客户端发送给服务器的请求,一般都包括两部分内容:指令和数据。其中,指令我们也可以叫作事件,数据是执行这个指令所需的数据。服务器在接收到客户端的请求后,会解析出指令和数据,并根据指令的不同,执行不同的处理逻辑。对于这样的一个业务场景,一般有两种架构实现思路。

常用的一种实现思路是利用多线程。通过一个主线程来接收客户端发来的请求。每当接收到一个请求之后,就从一个专门用来处理请求的业务线程池中,捞出一个空闲线程来处理。另一种实现思路是在一个线程内轮询接收请求和处理请求。第二种方式尽管无法利用多线程的优势,但对于 IO 密集型的业务来说,它避免了多线程不停切换对性能的损耗,并且避免了多线程编程 Bug 比较难调试的缺点,也算是开发中比较常见的架构模式了。

我们接下来就重点讲一下第二种实现方式。整个手游后端服务器轮询获取客户端发来的请求,获取到请求之后,借助命令模式,把请求包含的数据和处理逻辑封装为命令对象,并存储在内存队列中。然后,再从队列中取出一定数量的命令来执行。执行完成后,再开始新一轮轮询。具体的示例代码如下:

  1. public interface Command {
  2. void execute();
  3. }
  4. public class GotDiamondCommand implements Command {
  5. // 省略成员变量
  6. public GotDiamondCommand(/*数据*/) {
  7. //...
  8. }
  9. @Override
  10. public void execute() {
  11. // 执行相应的逻辑
  12. }
  13. }
  14. //GotStartCommand/HitObstacleCommand/ArchiveCommand类省略
  15. public class GameApplication {
  16. private Queue<Command> queue = new LinkedList<>();
  17. public void mainloop() {
  18. while (true) {
  19. // ...省略从epoll或select中获取数据,并封装成Request的逻辑,
  20. List<Request> requests = new ArrayList<>();
  21. for (Request request : requests) {
  22. Event event = request.getEvent();
  23. Command command = null;
  24. if (event.equals(Event.GOT_DIAMOND)) {
  25. command = new GotDiamondCommand(/*数据*/);
  26. } else if (event.equals(Event.GOT_STAR)) {
  27. command = new GotStartCommand(/*数据*/);
  28. } else if (event.equals(Event.HIT_OBSTACLE)) {
  29. command = new HitObstacleCommand(/*数据*/);
  30. } else if (event.equals(Event.ARCHIVE)) {
  31. command = new ArchiveCommand(/*数据*/);
  32. } // ...一堆else if...
  33. queue.add(command);
  34. }
  35. Command command = queue.poll();
  36. command.execute();
  37. }
  38. }
  39. }

命令模式 VS 策略模式

你可能会觉得,命令模式跟策略模式、工厂模式非常相似啊,那它们的区别在哪里呢?实际上,每个设计模式都应该由两部分组成:第一部分是应用场景,即这个模式可以解决哪类问题;第二部分是解决方案,即这个模式的设计思路和具体的代码实现。如果我们只关注解决方案这一部分,甚至只关注代码实现,就会产生大部分模式看起来都很相似的错觉。

实际上,设计模式之间的主要区别还是在于设计意图,也就是应用场景。单纯地看设计思路或者代码实现,有些模式确实很相似,比如策略模式和工厂模式。

之前讲策略模式时,我们有讲到,策略模式包含策略的定义、创建和使用三部分,从代码结构上来,它非常像工厂模式。它们的区别在于,策略模式侧重“策略”或“算法”这个特定的应用场景,用来解决根据运行时状态从一组策略中选择不同策略的问题,而工厂模式侧重封装对象的创建过程,这里的对象没有任何业务场景的限定,可以是策略,但也可以是其他东西。从设计意图上来,这两个模式完全是两回事儿。

接下来,我们再来看命令模式跟策略模式的区别。你可能会觉得,命令的执行逻辑也可以看作策略,那它是不是就是策略模式了呢?实际上,这两者有一点细微的区别。在策略模式中,不同的策略具有相同的目的、不同的实现、互相之间可以替换。比如,BubbleSort、SelectionSort 都是为了实现排序的。而在命令模式中,不同的命令具有不同的目的,对应不同的处理逻辑,并且互相之间不可替换。