Design-Pattern-Command

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

场景

假设正在开发一款软件的GUI,界面上有许多功能不同的按钮。这些按钮有很多相似的地方,直觉上会把这些共性提取到一个按钮基类中。而每个具体按钮不同的实际功能,则需要在继承的子类中实现区分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public abstract class Button {
private int positionX;
private int positionY;

public abstract void click();
}

public class SaveButton extends Button {
private FileService fileService;

@Override
public void click() {
String savePath = getSavePath();
fileService.save(savePath);
}
}

public class DeleteButton extends Button {
@Override
public void click() {
...
}
}

但这会产生问题。随着功能增多,按钮的子类也变得越来越多。每新增一种功能就需要新增一个具体按钮子类。并且继承这种关系本身就是强耦合的,子类依赖着基类。如果基类Button发生了改动,所有的子类可能需要修改,导致不容易维护。例如上例中基类的click方法,如果不是定义为抽象方法,而是添加了部分逻辑,那么基类的click方法的改动会影响所有的子类。子类重写了父类的行为也可能破坏里氏替换原则。因此对于容易发生变动的基类来说,通过继承来扩展功能并不是个好选择。

此外,在上例中,SaveButton中调用了业务方法,使得GUI直接依赖了业务逻辑层。根据分层开发的思想,GUI层应只关注用户交互,业务逻辑应封装为独立的模块。而GUI对于业务逻辑层的直接依赖,使其需要了解业务逻辑细节,如save方法及其参数。业务逻辑的频繁变化,也会导致GUI需要协同调整。

保存功能也可能会被其他地方使用。例如通过菜单,或者通过快捷键来触发。按照上例的做法,SaveMenuSaveShortcut中,会有重复的代码,这显然不是个良好的设计。

命令模式

对于带来问题的继承,在Design-Pattern-Design-Principle中也讨论过,组合优于继承,可以使用组合代替。通过继承扩展的每个具体按钮的功能,可以改为把功能作为变量,由外部传入。这样保存按钮不再是按钮的子类,而是带着保存功能的按钮类。这种形式实际上在桥接模式Design-Pattern-Bridge中也见到过。在桥接模式中,抽象和实现互相独立。通过抽象组合实现,来避免类的组合爆炸。在本例中,如果将按钮、快捷键、菜单视为抽象,将具体触发的功能视为实现,某种程度也符合桥接模式中的语义 -- 抽象部分与实现部分分离,可以独立变化,对抽象的调用会委派给实现。

但这还没解决GUI层直接依赖业务逻辑层的问题。不过没有什么不可以通过加一层来解决。引入中间层命令GUI中只需负责调用命令的执行方法,而无需关心是哪个具体命令和执行逻辑。使得GUI和业务逻辑间的依赖关系发生了转变。

1
2
[GUI] ----依赖----> [业务逻辑]
[GUI] ----依赖----> [命令] ----依赖----> [业务逻辑]

这种间接依赖使得业务逻辑层的变动仅影响了命令层,只需要调整命令层的代码,而无需对GUI层进行改动。降低了耦合,也便于两个层独立地进行单元测试。

至此可以正式给出命令模式的定义。在命令模式中,上例中的按钮类称为发送者Sender或触发者Invoker,执行功能的接口称为命令Command,其实现类称为具体命令Concrete Command,实际执行的业务逻辑类称为接收者Receiver,用于将命令和发送者绑定的类称为客户端Client

classDiagram
    direction RL
    class Client
    
    class Invoker {
        - Command command
        + setCommand()
        + executeCommand()
    }
    
    class Command {
        << interface >>
        + execute()
    }
    Invoker --> Command
    
    class ConcreteCommand {
        - Receiver receiver
        + execute()
    }
    ConcreteCommand ..|> Command
    
    class Receiver {
        + operation(a, b, c)
    }
    ConcreteCommand --> Receiver

    class Client
    Client --> Invoker
    Client --> ConcreteCommand
    

接口与参数

命令是对操作的抽象,是对各种业务逻辑方法调用的抽象。由于不同业务逻辑方法的方法签名不同,所以如何统一接口的方法参数、返回等是个问题。

引入命令模式的一部分动机是减少不同层之间的耦合,消除GUI层对于业务逻辑层的依赖。最简单粗暴的方法便是将命令接口的执行方法定义为无参无返回的方法。把所有命令都统一后的好处是,可以十分方便地切换命令,而无需对触发者做任何代码改动。

1
2
3
public interface Command {
void execute();
}

但命令层实际调用业务逻辑层时,又需要这些参数。这些参数从哪里来?通常来说,需要客户端在创建具体命令时,把相关的上下文信息传入具体命令中。例如一个命令是另存为,客户端可以把整个GUI或者部分文本框对象传入另存为命令中(通过构造函数或者set方法),使得命令中可以获取到这些对象的属性值,作为参数传递给业务逻辑方法。所以调用业务逻辑的过程仍然存在,只是职责上由GUI层,转移到了命令层。

而对于方法的返回值,某些场景中,触发者需要根据命令的执行结果来决定是否弹出警告。那也可以将执行方法的返回改成boolean,或者一个自定义的CommonResponse类,在其中包含执行结果和错误信息等。由于这部分信息对触发者来说是通用的,不会增加代码耦合度。不过这也会使得那些不关心命令执行情况的触发者强行关注。

1
2
3
4
5
6
7
8
9
public interface Command {
CommonResponse execute();
}

@Getter
public class CommonResponse {
private boolean success;
private String errorMessage;
}

命令模式的额外功能

上文一直在强调命令模式的解耦作用。但当将操作功能封装为对象时,操作本身便可以进行存储、组合、排序。这些特性常用于实现撤销操作。例如可以用一个栈保存触发过的操作。当需要撤销最近的操作时,则从栈顶弹出命令,并根据具体命令执行撤销。由于撤销和具体命令直接相关,通常也定义在具体命令中。撤销的实现方式有多种,例如状态快照在执行命令前,保存当前状态,在执行撤销时恢复成命令执行前保存的状态即可。也可以在撤销中编写逆向操作,例如命令的功能是打开某个功能,则撤销的逻辑就是关闭这个功能。git中每次commit,记录了增量信息,撤销时就可以反向应用这些变化量,推断出commit前的状态,通过逆向操作来撤销。逆向操作方案并不是总是可行,这需要根据当前状态,和最近一次执行的命令,可以推断出命令执行前的状态。当某个命令是把字体颜色改为红色,由于无法推断改之前是什么颜色,所以无法使用这种方式。因此逆向操作通用性不如状态快照,但相较于每次执行保存状态会更节省空间。实际也可以根据命令的复杂程度,组合使用不同的撤销实现方式

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
public interface Command {
void execute();
void undo();
}

public class UpgradeCommand implements Command {
private States backupStates;
@Override
public void execute() {
backupStates = getStates();
upgrade();
}

@Override
public void undo() {
// 通过状态快照实现撤销
restoreState(backupStates)
}
}

public class SaveCommand implements Command {
private FileService fileService;
@Override
public void execute() {
String savePath = getSavePath();
fileService.save(savePath);
}

@Override
public void undo() {
// 通过逆向操作实现撤销
String savePath = getSavePath();
fileService.delete(savePath)
}
}

利用命令可以储存和排序特性,还可以支持类似MQ的功能。例如将命令对象放入一个优先级队列(PriorityQueue)中,根据任务的优先级决定执行的顺序。再结合线程池或任务调度框架,消费命令队列中的任务。

至此可以回顾最开头的定义。

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

笔者最初读了很多遍也没搞懂在说什么。要理解这段定义,最重要的是弄清楚其中的核心概念“请求”。这里的请求,实际就是前文多次提到的操作,在代码中体现为对某个业务逻辑类方法的调用、参数以及相关上下文信息。而转化的过程,就是把对业务逻辑的方法调用,封装成了一个命令,也是定义中的包含与请求相关的所有信息的独立对象。基于这些核心概念的解释,后续也就容易理解了。

左右脑互搏

在笔者实际开发中,几乎没有遇到过命令模式的场景。大部分的代码逻辑都是接受到某个请求,直接调用对应的业务逻辑类。好像没有考虑过中间加一层,或者把请求封装为对象。

上文提到了在没有应用命令模式前,代码会直接依赖业务逻辑层,导致“高耦合”。而实际开发中,业务逻辑进行了抽象。调用方依赖的是相对稳定的接口,不关心其具体实现。并且也不需要经常切换接口实现类。这种高耦合似乎也没那么耦合,没有到必须加一层,牺牲便利性来降低耦合的程度。因此在降低耦合这件事情上,命令模式不存在明显优势。

那么使用命令模式,更多是想借助其额外功能,也就是可以对操作进行记录、存储、排序的特性。当需要支持命令撤销、组合以及队列的功能,并且命令具有多个不同的实现类,才会考虑使用命令模式。而实际上,这些功能也有很多的替代方案,例如Spring@Transactional,以及MQ

在日常开发中,更多依赖框架提供的抽象。只有在少数特定场景下,才会考虑使用命令模式。纵观网上众多教程,使用的案例不是编辑器(撤销),就是GUI(解耦),遥控器(命令组合),几乎没有更现代的案例。这也从侧面说明了命令模式使用范围的局限。

总结

命令模式中的主要角色包括:发送者、接收者、命令接口和实现、客户端。

命令模式通过新增了命令层,将对业务逻辑方法的调用封装成了命令对象,解耦了发送者与接收者。

命令接口的执行方法通常是个无参无返回的方法,也可以使用通用的入参和返回。

命令模式天然地支持命令的记录、组合、排序,最常见用来实现操作撤销。


Design-Pattern-Command
http://dracoyus.github.io/2025/06/10/Design-Pattern-Command/
作者
DracoYu
发布于
2025年6月10日
许可协议