命令 Command

命令模式是一种行为设计模式,它可将请求转换为一个包含与请求相关的所有信息的独立对象。该转换让你能根据不同的请求将方法参数化、延迟请求执行或将其放入队列中,且能实现可撤销操作。

命令模式的本质是对命令进行封装,将发出命令的责任和执行命令的责任分离开。


为什么要使用?

命令模式的对象职责:

将一个请求封装为一个对象,从而可以参数化具体不同请求、队列或日志请求的其他对象,并支持可撤销的操作。命令模式是将一组操作封装在对象中而设计的。简单来说就是为了将函数方法封装为对象以方便传输。

👉 只关心具体的命令和动作,不想知道具体的接收者是谁以及如何操作。

🎈 命令模式将发送者与接收者解耦,让发送者只提供命令而不必知道命令到底是如何完成的。

👉 为了方便统计跟踪行为操作。

🎈 使用命令模式能够便携地记录数据的排序、序列化、跟踪、日志记录等操作。在一些需要读取大量数据的场景中,使用命令模式来读取上下文信息,能避免内存溢出的风险。

👉 为了围绕命令的维度来构建功能。

🎈 能够自由组合相关的命令,完成一系列的组合功能;还能避免使用者需要了解大量的代码实现逻辑,起到了隐藏代码逻辑的作用。

❓ 例如,在 Java 中使用 GUI。对于一个页面内的许多 Button,它们虽然都是点击,但却包含许多相同或不同的命令操作(打开、关闭 …)。我们要如何处理这些命令呢?在每个 Button 所在类中实现?这样会导致代码混乱且难以修改。同时也会出现很多重复代码,如,点击操作往往有对应的快捷键操作,它们实现逻辑一样但所用的 Listener 却不同。

⁉️ 在面向对象设计中提倡关注点分离。所以在处理图形化这方面的问题时,我们往往是将图形界面和业务逻辑进行分层。一个 GUI 对象传递一些参数来调用一个业务逻辑对象。这个过程通常被描述为一个对象发送请求给另一个对象。

❗ 在命令模式中,命令对象负责连接不同的 GUI 和业务逻辑对象。GUI 对象无需了解业务逻辑对象是否获得了请求,也无需了解其对请求进行处理的方式。GUI 对象触发命令即可,命令对象会自行处理所有细节工作。(命令的执行操作中通常没有参数,如何将请求的详情发送给接收者呢?可以使用数据对命令进行预先配置,或者让其能够自行获取数据。)

✅ 现在我们再使用 Button 时,则不需要在类中实现具体的逻辑操作。只需要在 Button 类中添加一个成员变量来存储对于命令对象的引用,并在点击后执行该命令即可。我们需要为每个可能的操作实现一系列命令类,并且根据 Button 所需行为将命令和 Button 连接起来。


模式结构

  • 发送者(Sender)——亦称“调用者 (Invoker)”——类负责对请求进行初始化,其中必须包含一个成员变量来存储对于命令对象的引用。发送者触发命令,而不向接收者直接发送请求。注意,发送者并不负责创建命令对象:它通常会通过构造函数从客户端处获得预先生成的命令。
  • 命令(Command)接口通常仅声明一个执行命令的方法。
  • 具体命令(Concrete Commands)会实现各种类型的请求。具体命令自身并不完成工作,而是会将调用委派给一个业务逻辑对象。但为了简化代码,这些类可以进行合并。接收对象执行方法所需的参数可以声明为具体命令的成员变量。你可以将命令对象设为不可变,仅允许通过构造函数对这些成员变量进行初始化。
  • 接收者(Receiver)类包含部分业务逻辑。几乎任何对象都可以作为接收者。绝大部分命令只处理如何将请求传递到接收者的细节,接收者自己会完成实际的工作。
  • 客户端(Client)会创建并配置具体命令对象。客户端必须将包括接收者实体在内的所有请求参数传递给命令的构造函数。此后,生成的命令就可以与一个或多个发送者相关联了。

命令模式的类图:


模式实现

该示例使用命令模式实现复制和粘贴功能。让调用者 Invoker 根据传入的命令 Command,执行命令。具体的命令 CopyPaste 分别去调用接收者 ControlConsole(变量 controlString 表示控制台中的字符串,可被用户复制;变量 copy 表示剪切板的值,可被用户粘贴)的具体逻辑实现。相当于把调用者发出的命令和控制台接收者执行的命令中的命令(复制、粘贴)封装成对象,作为这两个类沟通的桥梁。

示例程序的类图

代码实现

发送者
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package example;

/** 调用者 */
public class Invoker {
/** 命令 */
private Command command;

/**
* 绑定命令
* @param command
*/
public void setCommand(Command command) {
this.command = command;
}

/** 调用命令 */
public void executeCommand() {
if (command != null) {
command.execute();
}
}
}
命令
1
2
3
4
5
6
7
package example;

/** 命令接口 */
public interface Command {
/** 执行命令 */
void execute();
}
具体命令
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package example;

/** 复制命令 */
public class Copy implements Command {
/** 具体实现命令逻辑的接收者 */
private ControlConsole console;

public Copy(ControlConsole console) {
this.console = console;
}

/** 执行命令 */
@Override
public void execute() {
console.copyConsole();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package example;

/** 粘贴命令 */
public class Paste implements Command {
/** 具体实现命令逻辑的接收者 */
private ControlConsole console;

public Paste(ControlConsole console) {
this.console = console;
}

/** 执行命令 */
@Override
public void execute() {
console.pasteConsole();
}
}
接收者
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
package example;

/** 控制台接收者,实现具体业务逻辑 */
public class ControlConsole {
/** 保存输出到控制台的字符串,用于复制 */
private StringBuffer controlString = new StringBuffer("");
/** 剪切板 */
private String copy = "";

/** 复制控制台内容 */
public void copyConsole() {
copy = "{剪切板的内容}<<" + controlString.toString() + ">>";
}

/** 粘贴到控制台 */
public void pasteConsole() {
System.out.println(copy);
}

/**
* 更新控制台字符串
* @param s 输出在控制台的字符串
*/
public void setControlString(String s) {
controlString.append(s);
}
}

代码测试

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
import example.*;

/** 测试命令模式 */
public class Test {
public static void main(String[] args) {
String input = "Hello world!";
// 创建接收者
ControlConsole console = new ControlConsole();
// 为命令绑定接收者(具体命令逻辑实现)
Command copy = new Copy(console);
Command paste = new Paste(console);

Invoker invoker = new Invoker();
System.out.println("控制台无字符,执行copy操作:");
invoker.setCommand(copy);
invoker.executeCommand();
invoker.setCommand(paste);
invoker.executeCommand();

System.out.println("\n------------------- 分割线 -------------------\n");
System.out.println("控制台字符为:" + input);
console.setControlString(input);
System.out.println("控制台有字符,执行copy操作:");
invoker.setCommand(copy);
invoker.executeCommand();
invoker.setCommand(paste);
invoker.executeCommand();
}
}

输出结果

1
2
3
4
5
6
7
8
控制台无字符,执行copy操作:
{剪切板的内容}<<>>

------------------- 分割线 -------------------

控制台字符为:Hello world!
控制台有字符,执行copy操作:
{剪切板的内容}<<Hello world!>>

常用场景和解决方案

  • 需要通过操作来参数化对象时。例如,在图形化页面中点击获取下列列表的同时还能点击菜单项。
  • 想要将操作放入队列、按顺序执行脚本操作或者执行一些远程操作命令时。
  • 实现操作回滚功能的场景时,命令模式能够更好地记录命令操作顺序和相关上下文。
  • Shell 脚本类似命令模式的调用者 Invoker,脚本命令如 cat 就等同于命令 Command,而 bash shell 就是作为接收者 Receiver 来具体实现执行命令的。

模式的优缺点

优点 缺点
单一职责原则。你可以解耦触发和执行操作的类。 代码可能会变得更加复杂,因为你在发送者和接收者之间增加了一个全新的层次。
开闭原则。你可以在不修改已有客户端代码的情况下在程序中创建新的命令。 不同的接收者需要实现重复的命令。
你可以实现撤销和恢复功能。 当命令中涉及对象状态变化时,可能导致不同的结果出现。
你可以实现操作的延迟执行。
你可以将一组简单命令组合成一个复杂命令。

拓展知识

  • 命令模式并不仅限于操作系统的命令,在实际的业务开发中,可能是对应的一组复杂的代码调用逻辑。
  • 你可以同时使用命令和备忘录模式来实现“撤销”。在这种情况下,命令用于对目标对象执行各种不同的操作,备忘录用来保存一条命令执行前该对象的状态。
  • 命令和策略模式看上去很像,因为两者都能通过某些行为来参数化对象。但是,它们的意图有非常大的不同。
    • 你可以使用命令来将任何操作转换为对象。操作的参数将成为对象的成员变量。你可以通过转换来延迟操作的执行、将操作放入队列、保存历史命令或者向远程服务发送命令等。
    • 另一方面,策略通常可用于描述完成某件事的不同方式,让你能够在同一个上下文类中切换算法。


🔙 设计模式

📌最后:希望本文能够给您提供帮助,文章中有不懂或不正确的地方,请在下方评论区💬留言!

🔗参考文献:

🌐 设计模式 –refactoringguru

▶️ bilibili-趣学设计模式;黄靖锋. –拉勾教育

📖 图解设计模式 /(日)结城浩著;杨文轩译. –北京:人民邮电出版社,2017.1