跳到主要内容

命令模式-Command Pattern

序言​

相信大家都有过点外卖的经历:你只需要在 App 上选择菜品、下单,后厨就会收到一份"订单",厨师照着订单做菜,外卖小哥把做好的餐送到你手上。在整个过程中,你(调用方)并不知道后厨有多少位厨师、厨师是怎么做菜的、菜品用了什么食材。你发出的"订单"就是一个被封装起来的请求。

命令模式(Command Pattern)的思想与此类似:把一次"请求"或"动作"封装成一个独立的对象,调用方只需要"发出命令",至于命令由谁执行、如何执行,调用方一概不知。

这种模式最常见的现实类比是遥控器:每个按钮就是一个"命令",按下按钮后,电视、空调、灯光各自执行自己的动作,而遥控器本身完全不需要知道这些设备内部的工作原理。

定义​

Encapsulate a request as an object, thereby letting you parameterize clients with different requests, queue or log requests, and support undoable operations.

将一个请求封装成一个对象,从而使你可以用不同的请求对客户端进行参数化,对请求排队或记录请求日志,以及支持可撤销的操作。

把请求封装成对象之后,我们可以:

  • 将请求作为方法的参数传递(参数化客户端);
  • 将请求放入队列,按顺序依次执行(请求排队);
  • 记录请求日志,便于回溯(请求日志);
  • 记录已执行的命令,从而支持撤销(undoable operations)。

这就是命令模式的核心价值:请求的发出者(Invoker)和请求的执行者(Receiver)被彻底解耦。

结构​

让我们从一个问题开始​

假设我们正在开发一个智能家居控制系统,需要控制家里的灯光和电视。最直觉的写法是把所有控制逻辑堆在遥控器类中:

public class RemoteControl {
private Light light;
private TV tv;

// 按下按钮,通过大量的 if 判断来执行不同的动作
public void onButtonPressed(int button) {
if (button == 1) {
light.on();
} else if (button == 2) {
tv.on();
} else if (button == 3) {
tv.changeChannel(5);
}
// 每增加一个新设备,就要在这里增加一个 if 分支
// 每增加一个新的功能,同样要在这里增加一个 if 分支
// ...
}
}

这样写有几个明显的问题:

  1. RemoteControl 与 Light、TV 等具体类强耦合,而且认识所有设备的内部方法;
  2. 每增加一个设备或功能,就要修改 RemoteControl,违反开闭原则;
  3. 无法实现"撤销":当按下"打开灯光"后,要撤销成什么状态?RemoteControl 根本无从得知;
  4. 无法将"请求"排队、记录日志,或者延迟执行。

那有没有一种方式,让 RemoteControl 只负责"按下按钮",而不用关心按钮背后到底做了什么?这就是命令模式要解决的问题。

命令模式的结构​

命令模式由以下几个角色组成:

各个角色的职责如下:

  • Command(命令):声明了执行操作的接口,通常包含 execute() 方法,我们这里还增加了 undo() 用于撤销。
  • ConcreteCommand(具体命令):绑定了一个接收者对象和动作,调用接收者的方法来完成具体的操作。例如 LightOnCommand 绑定 Light,执行时调用 light.on()。
  • Receiver(接收者):真正执行业务逻辑的对象,如 Light、TV。
  • Invoker(调用者):持有命令对象并在某个时机触发命令的执行,如 RemoteControl。它不需要知道命令的具体实现。
  • Client(客户端):创建具体命令对象并设置其接收者,然后把命令交给调用者。

代码实现​

我们来实现上面提到的智能家居遥控器。

定义命令接口​

public interface Command {
void execute();

// 支持撤销操作
void undo();
}

定义接收者​

public class Light {

private boolean on = false;

public void on() {
this.on = true;
System.out.println("灯光已打开");
}

public void off() {
this.on = false;
System.out.println("灯光已关闭");
}

public boolean isOn() {
return on;
}
}
public class TV {

private int channel = 1;

public void on() {
System.out.println("电视已打开,当前频道:" + channel);
}

public void off() {
System.out.println("电视已关闭");
}

public void changeChannel(int channel) {
System.out.println("电视切换到频道:" + channel);
this.channel = channel;
}

public int getChannel() {
return channel;
}
}

定义具体命令​

每个命令都绑定一个接收者,execute() 调用接收者的方法,undo() 执行反向操作:

public class LightOnCommand implements Command {

private final Light light;

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

@Override
public void execute() {
light.on();
}

@Override
public void undo() {
light.off();
}
}
public class LightOffCommand implements Command {

private final Light light;

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

@Override
public void execute() {
light.off();
}

@Override
public void undo() {
light.on();
}
}

对于"切换频道"这种需要记住历史状态的操作,我们可以在命令内部保存上一个状态,从而实现撤销:

public class TVChangeChannelCommand implements Command {

private final TV tv;
private final int channel;
private int lastChannel;

public TVChangeChannelCommand(TV tv, int channel) {
this.tv = tv;
this.channel = channel;
}

@Override
public void execute() {
// 记录执行前的频道,用于撤销
this.lastChannel = tv.getChannel();
tv.changeChannel(channel);
}

@Override
public void undo() {
tv.changeChannel(lastChannel);
}
}

电视的开与关也是一对命令,逻辑与灯光命令一致:

public class TVOnCommand implements Command {

private final TV tv;

public TVOnCommand(TV tv) {
this.tv = tv;
}

@Override
public void execute() {
tv.on();
}

@Override
public void undo() {
tv.off();
}
}
public class TVOffCommand implements Command {

private final TV tv;

public TVOffCommand(TV tv) {
this.tv = tv;
}

@Override
public void execute() {
tv.off();
}

@Override
public void undo() {
tv.on();
}
}

定义调用者(遥控器)​

RemoteControl 持有命令对象,用户按下按钮时触发命令执行,同时记录上一条命令以便撤销:

public class RemoteControl {

private final Command[] onCommands;
private final Command[] offCommands;
private Command lastCommand;

public RemoteControl() {
onCommands = new Command[7];
offCommands = new Command[7];

// 用"空命令"填充所有插槽,避免空指针判断
Command noCommand = new NoCommand();
for (int i = 0; i < 7; i++) {
onCommands[i] = noCommand;
offCommands[i] = noCommand;
}
lastCommand = noCommand;
}

public void setCommand(int slot, Command onCommand, Command offCommand) {
onCommands[slot] = onCommand;
offCommands[slot] = offCommand;
}

public void pressOn(int slot) {
onCommands[slot].execute();
lastCommand = onCommands[slot];
}

public void pressOff(int slot) {
offCommands[slot].execute();
lastCommand = offCommands[slot];
}

public void pressUndo() {
lastCommand.undo();
}
}

这里我们用到了一个 NoCommand,它是一个"空对象",什么都不做。这样可以避免在遥控器初始化时对每个插槽做空值判断:

public class NoCommand implements Command {

@Override
public void execute() {
// 什么都不做
}

@Override
public void undo() {
// 什么都不做
}
}

NoCommand 正是**空对象模式(Null Object Pattern)**的典型应用,在行为型模式中我们会单独介绍它。

客户端使用​

public class Main {

public static void main(String[] args) {
// 接收者:家里的设备
Light livingRoomLight = new Light();
TV tv = new TV();

// 具体命令:把设备动作封装成命令
Command lightOn = new LightOnCommand(livingRoomLight);
Command lightOff = new LightOffCommand(livingRoomLight);
Command tvOn = new TVOnCommand(tv);
Command tvOff = new TVOffCommand(tv);
Command tvToChannel5 = new TVChangeChannelCommand(tv, 5);

// 调用者:遥控器,绑定命令到插槽
RemoteControl remote = new RemoteControl();
remote.setCommand(0, lightOn, lightOff);
remote.setCommand(1, tvOn, tvOff);

// 使用遥控器
System.out.println("=== 按下插槽0的开按钮 ===");
remote.pressOn(0);

System.out.println("=== 按下插槽1的开按钮 ===");
remote.pressOn(1);

System.out.println("=== 按下插槽0的开按钮(再开一次) ===");
remote.pressOn(0);

System.out.println("=== 执行一次性命令:电视切到5频道 ===");
tvToChannel5.execute();

System.out.println("=== 撤销上一次操作 ===");
remote.pressUndo();

System.out.println("=== 再次撤销 ===");
remote.pressUndo();
}
}

输出结果:

=== 按下插槽0的开按钮 ===
灯光已打开
=== 按下插槽1的开按钮 ===
电视已打开,当前频道:1
=== 按下插槽0的开按钮(再开一次) ===
灯光已打开
=== 执行一次性命令:电视切到5频道 ===
电视切换到频道:5
=== 撤销上一次操作 ===
电视切换到频道:1
=== 再次撤销 ===
灯光已关闭

可以看到,RemoteControl 自始至终都没有依赖 Light、TV 这些具体的类,它只和 Command 接口打交道。新增加一个设备(比如空调),只需要新增一个接收者类和几个具体命令类,RemoteControl 一行代码都不用改——这就是命令模式带来的解耦与扩展性。

在开源框架中的应用​

Java Swing:Action 接口​

Java Swing 中,javax.swing.Action 就是命令模式的典型应用。Action 继承了 ActionListener,把"动作"封装成对象,同时携带图标、文本、是否可用等属性:

public interface Action extends ActionListener {

// 一些属性名常量,如 NAME、SHORT_DESCRIPTION、SMALL_ICON 等
public static final String DEFAULT = "Default";

public Object getValue(String key);

public void putValue(String key, Object value);

public void setEnabled(boolean b);

public boolean isEnabled();

...
}

AbstractAction 提供了默认实现,我们自定义命令时只需要继承它:

public class SaveAction extends AbstractAction {

public SaveAction() {
// 通过 putValue 设置按钮的显示文本、图标、提示信息
putValue(NAME, "保存");
putValue(SMALL_ICON, new ImageIcon("save.png"));
putValue(SHORT_DESCRIPTION, "保存当前文件");
}

@Override
public void actionPerformed(ActionEvent e) {
// 执行真正的保存逻辑
saveFile();
}

private void saveFile() {
System.out.println("文件已保存");
}
}

一个 Action 对象可以被多个按钮、菜单项共用,当 setEnabled(false) 时,所有关联的按钮和菜单项会同步变灰——这正是命令对象被"参数化"与"复用"的体现。

Java UndoManager:源码视角的命令栈管理​

Swing 的 javax.swing.undo.UndoManager 是命令模式 + 备忘录模式在 JDK 源码中的标准结合。整个 javax.swing.undo 包以"命令"为核心建模:UndoableEdit 就是命令接口,AbstractUndoableEdit 是命令的默认实现,CompoundEdit 是复合命令,UndoManager 是命令的管理者(调用者)。

命令接口:UndoableEdit​

// javax.swing.undo.UndoableEdit(源码)
public interface UndoableEdit {
// 撤销这个编辑
public void undo() throws CannotUndoException;

// 重做这个编辑
public void redo() throws CannotRedoException;

// 该命令是否可以撤销/重做
public boolean canUndo();
public boolean canRedo();

// 尝试把新编辑与当前编辑合并(如连续输入字符合并为一个编辑)
public boolean addEdit(UndoableEdit anEdit);

// 用新编辑替换当前编辑
public boolean replaceEdit(UndoableEdit anEdit);
...
}

命令管理者:UndoManager 的命令栈​

UndoManager 内部用一个 Vector 保存已执行的命令,用 indexOfNextAdd 记录"下一次添加命令的位置"(也是撤销/重做的游标):

// javax.swing.undo.UndoManager(源码骨架)
public class UndoManager extends AbstractUndoableEdit implements UndoableEditListener {

// 命令栈:保存所有已执行的命令(UndoableEdit)
protected Vector<UndoableEdit> edits = new Vector<>();
// 下一次添加命令的位置;也是撤销游标
protected int indexOfNextAdd = 0;

// 添加一条命令:如果存在 redo 分支则先丢弃,再插入命令栈
public synchronized boolean addEdit(UndoableEdit anEdit) {
// 丢弃游标之后的所有命令(新命令会让 redo 分支失效)
trimEdits(indexOfNextAdd, edits.size() - 1);
// 尝试与上一条命令合并(例如连续键入合并为一次"键入"命令)
if (indexOfNextAdd > 0) {
UndoableEdit lastEdit = edits.elementAt(indexOfNextAdd - 1);
if (lastEdit.addEdit(anEdit)) {
return true; // 合并成功,不新增命令
}
anEdit.replaceEdit(lastEdit);
}
edits.add(anEdit); // 入栈
indexOfNextAdd++;
return true;
}

// 撤销:游标前移,调用对应命令的 undo()
public synchronized void undo() {
UndoableEdit edit = edits.elementAt(--indexOfNextAdd);
edit.undo();
}

// 重做:调用游标处命令的 redo(),游标后移
public synchronized void redo() {
UndoableEdit edit = edits.elementAt(indexOfAdd++);
edit.redo();
}

public synchronized boolean canUndo() {
return indexOfNextAdd > 0;
}
...
}

这段源码清楚地展示了命令模式的三大价值:

  1. 操作被封装成命令对象:每次编辑都是 UndoableEdit 实例,被存进 edits 栈——没有命令对象就无法"把操作排进队列";
  2. 调用者统一管理:UndoManager 作为调用者,只知道对 UndoableEdit 调 undo()/redo(),完全不关心命令内部的业务(是"插入文本"还是"删除文件");
  3. 命令支持合并与替换:addEdit/replaceEdit 让相邻命令可以合并,这正是"把请求参数化为对象"后带来的额外能力。

复合命令:CompoundEdit​

CompoundEdit 本身也是一个 UndoableEdit(命令),它内部聚合一组子命令,end() 之后对外表现为一条可撤销的命令,undo() 时按逆序撤销全部子命令:

// javax.swing.undo.CompoundEdit(源码骨架)
public class CompoundEdit extends AbstractUndoableEdit {

protected Vector<UndoableEdit> edits = new Vector<>();
private boolean inProgress = true; // 收集子命令期间为 true

// 收集阶段:把子命令加入复合命令
public void addEdit(UndoableEdit anEdit) {
if (inProgress) {
edits.addElement(anEdit);
}
}

// 结束收集:复合命令"定型"
public void end() {
if (inProgress) {
inProgress = false;
// 尝试合并子命令……
}
}

// 撤销复合命令:逆序遍历子命令逐个撤销
public void undo() {
super.undo();
int i = edits.size();
while (i-- > 0) {
UndoableEdit edit = edits.elementAt(i);
edit.undo();
}
}
}

这是**宏命令(Macro Command)**的标准实现:"一次选中多行删除"这种复合操作,在编辑器内部被拆成多个原子命令收集进 CompoundEdit,用户按一次撤销,全部原子操作一起撤销。

命令模式为 UndoManager 提供了基础:只有把"操作"封装成对象,才能把操作放进栈中、记录状态、批量撤销/重做。

Flowable:把命令模式用到极致的工作流引擎​

如果说哪个开源框架把命令模式贯彻得最彻底,那一定是工作流引擎 Flowable(以及它的前身 Activiti)。Flowable 的官方架构文档开篇就写道:

Every API method is transformed into a Command instance. This Command instance is passed into the CommandExecutor which, amongst others, makes it pass a stack of CommandInterceptors. These interceptors have various responsibilities, including handling transactions.

(每个 API 方法都会被转换为一个 Command 实例。该 Command 实例被传递给 CommandExecutor,后者会让它通过一系列 CommandInterceptors。这些拦截器有着各种职责,包括处理事务。)

也就是说,我们在业务代码里调用的每一个引擎 API——runtimeService.startProcessInstanceByKey(...)、taskService.complete(...)、repositoryService.deploy(...)——在引擎内部都会被包装成一个具体的 Command 对象,然后交给 CommandExecutor 执行。

Flowable 命令模式的结构​

  • Command<T>:命令接口,只声明一个 execute(CommandContext): T 方法,所有引擎操作都实现该接口;
  • StartProcessInstanceCmd、CompleteTaskCmd 等:引擎内置的具体命令类,位于 org.flowable.engine.impl.cmd 包下,分别对应"发起流程实例""完成任务"等操作;
  • CommandExecutor:命令执行器,负责把命令对象交给拦截器链执行;
  • CommandInterceptor:命令拦截器接口,多个拦截器首尾相连组成一条拦截器链——这正是命令模式与责任链模式结合的最佳范例;
  • CommandInvoker:拦截器链的末端节点,真正调用 command.execute(commandContext);
  • CommandContext:命令上下文,承载本次命令执行的数据库会话、变量作用域等运行期信息,由链上的拦截器创建并在命令间传递。

自定义命令:把业务包装进引擎​

引擎不仅自己使用命令模式,还允许我们把自定义业务逻辑包装成命令,通过 ManagementService#executeCommand 提交给引擎执行,从而让自定义代码自动获得拦截器链提供的横切能力(事务、日志等):

import org.flowable.common.engine.api.management.ManagementService;
import org.flowable.common.engine.impl.interceptor.Command;
import org.flowable.common.engine.impl.interceptor.CommandContext;

public class CustomQueryCommand implements Command<List<CustomTask>> {

@Override
public List<CustomTask> execute(CommandContext commandContext) {
// 在命令上下文中执行数据库查询,共享引擎事务
return commandContext.getDbSqlSession()
.selectList("selectCustomTasks", new HashMap<>());
}
}

客户端提交命令:

ManagementService managementService = processEngine.getManagementService();

// 把自定义命令交给引擎执行,自动获得事务、日志等拦截器能力
List<CustomTask> tasks = managementService.executeCommand(new CustomQueryCommand());

拦截器链:命令模式 × 责任链​

Flowable 的 CommandExecutor 内部维护一条拦截器链,典型的链形如下:

LogInterceptor -> TransactionInterceptor -> ... -> CommandInvoker
  • LogInterceptor(日志拦截器):负责记录命令的执行,方便追踪引擎行为;
  • TransactionInterceptor(事务拦截器):负责开启、提交、回滚事务,保证命令的原子性;
  • CommandInvoker(命令调用器):链条末端,真正调用命令的 execute();
  • 此外还有命令计数器等拦截器,用于引擎的监控与统计。

这样设计的好处:命令(业务逻辑)与执行环境(事务、日志、监控)彻底解耦。新增一种横切能力,只需要在链上插入一个拦截器,所有引擎命令都会自动获得该能力;而新增一种业务操作,只需要新增一个命令类。这完美印证了命令模式的核心价值——把"请求"封装成对象之后,请求的发出、排队、事务化、日志化就都成为可能。

适用场景​

  • 当需要将操作参数化,例如按钮、菜单项、快捷键对应不同的动作时,可以使用命令模式。
  • 当需要实现撤销/重做功能时,可以将每次操作封装为命令,并把已执行的命令保存在栈中。
  • 当需要实现请求的排队、延迟执行或记录日志时,可以把命令放入队列,或记录命令的执行历史。
  • 当需要将一组操作组合成一个"宏命令"(一次执行多个动作)时,命令模式可以配合组合模式实现。
  • 当系统需要在不同时刻指定、排列和执行请求时,命令模式提供了请求与执行完全解耦的机制。

与其他设计模式的关系​

  • 命令模式经常与**备忘录模式(Memento)**结合:命令负责记录"做了什么",备忘录负责保存"执行前的状态",两者配合可以更方便地实现撤销功能(如 UndoManager 内部对状态快照的处理)。
  • 命令模式可以用**组合模式(Composite)**实现"宏命令":一个 CompositeCommand 内部聚合多个 Command,执行时依次调用每个子命令的 execute()。
  • 命令模式中常见的 NoCommand 空对象,就是**空对象模式(Null Object)**的应用。
  • 命令的 undo() 记录历史状态,与备忘录模式、状态模式都有交叉应用场景。

总结​

命令模式通过把"请求"封装为对象,实现了请求发送者与请求执行者的完全解耦:

  • 调用者(Invoker)只依赖 Command 接口,不知道命令背后是哪个接收者、执行了什么逻辑;
  • 新增功能只需要新增命令类和接收者类,无需修改调用者,符合开闭原则;
  • 命令对象可以被存储、排队、记录、撤销,支持宏命令和撤销/重做等高级功能。

命令模式的缺点是:每新增一个操作就需要新增一个命令类,命令类数量会快速膨胀;同时,如果命令对象的创建和组装过于分散,也会增加系统的理解成本。因此,命令模式更适合操作种类多、需要撤销/重做/宏命令支持的场景,比如编辑器、遥控器、菜单系统、事务管理等。