命令模式:把请求封装成对象

命令模式,说白了就是把「你要做什么」这件事,变成一个对象。你想想看,在代码里我们经常要调用某个方法,比如 light.on()fan.off()。但如果我想把这些操作排队、记录、甚至撤销呢?直接调用方法就做不到了。

我个人习惯把命令模式理解成「点餐」。你去餐厅,不会直接冲进厨房炒菜吧?你会告诉服务员你要什么,服务员把订单传给厨师。这个订单,就是命令对象。它把「谁来做」和「怎么做」给解耦了。

命令模式的核心角色

先看看这几个角色,我当年学的时候总觉得有点绕,其实捋清楚了就很简单:

  • 命令接口(Command):定义执行操作的统一接口,通常就一个 execute() 方法。
  • 具体命令(ConcreteCommand):实现命令接口,绑定一个接收者,在 execute() 里调用接收者的具体方法。
  • 接收者(Receiver):真正干活的对象,比如灯、风扇、音响。
  • 调用者(Invoker):持有命令对象,在某个时机调用 execute()。它不知道命令具体怎么执行。
  • 客户端(Client):创建具体命令,绑定接收者,然后交给调用者。

核心思想:请求的发起者和请求的执行者之间,隔了一层命令对象。这一层,给了你无限的操作空间——队列、日志、撤销、宏命令,全都能做。

先看一个最简单的例子

我记得刚学命令模式时,第一个例子就是控制电灯。咱们也从这个开始:

// 命令接口
public interface Command {
    void execute();
}

// 接收者:电灯
public class Light {
    public void on() {
        System.out.println("灯亮了");
    }
    public void off() {
        System.out.println("灯灭了");
    }
}

// 具体命令:开灯
public class LightOnCommand implements Command {
    private Light light;
    public LightOnCommand(Light light) {
        this.light = light;
    }
    public void execute() {
        light.on();
    }
}

// 具体命令:关灯
public class LightOffCommand implements Command {
    private Light light;
    public LightOffCommand(Light light) {
        this.light = light;
    }
    public void execute() {
        light.off();
    }
}

// 调用者:遥控器
public class RemoteControl {
    private Command slot;
    public void setCommand(Command command) {
        this.slot = command;
    }
    public void pressButton() {
        slot.execute();
    }
}

// 客户端使用
public class Client {
    public static void main(String[] args) {
        Light light = new Light();
        Command lightOn = new LightOnCommand(light);
        Command lightOff = new LightOffCommand(light);

        RemoteControl remote = new RemoteControl();
        remote.setCommand(lightOn);
        remote.pressButton();  // 输出:灯亮了

        remote.setCommand(lightOff);
        remote.pressButton();  // 输出:灯灭了
    }
}

你看,遥控器(调用者)根本不知道按下去的是开灯还是关灯。它只知道调用 execute()。这就是解耦。

撤销操作:让时光倒流

命令模式最实用的功能之一就是撤销。怎么做?很简单——给命令接口加一个 undo() 方法。

我在项目中做过一个文本编辑器,用户输入、删除、格式化,每一步都是一个命令对象。撤销就是把命令对象从历史栈里弹出来,调用它的 undo()

public interface Command {
    void execute();
    void undo();
}

public class LightOnCommand implements Command {
    private Light light;
    public LightOnCommand(Light light) {
        this.light = light;
    }
    public void execute() {
        light.on();
    }
    public void undo() {
        light.off();  // 撤销开灯,就是关灯
    }
}

// 带撤销的遥控器
public class RemoteControlWithUndo {
    private Command command;
    private Command lastCommand;  // 记录上一步操作

    public void setCommand(Command command) {
        this.command = command;
    }
    public void pressButton() {
        command.execute();
        lastCommand = command;
    }
    public void pressUndo() {
        if (lastCommand != null) {
            lastCommand.undo();
        }
    }
}

小技巧:如果命令的撤销逻辑比较复杂,可以在具体命令内部保存执行前的状态。比如调整音量时,把旧音量存下来,撤销时恢复回去。

宏命令:一键执行一组操作

宏命令就是把多个命令打包成一个。你想想看,智能家居里「离家模式」要关灯、关空调、关电视、锁门——这就是一个宏命令。

实现方式很简单:让宏命令本身也实现 Command 接口,内部维护一个命令列表。

public class MacroCommand implements Command {
    private Command[] commands;

    public MacroCommand(Command[] commands) {
        this.commands = commands;
    }

    public void execute() {
        for (Command cmd : commands) {
            cmd.execute();
        }
    }

    public void undo() {
        // 撤销时倒序执行,你想想为什么?
        for (int i = commands.length - 1; i >= 0; i--) {
            commands[i].undo();
        }
    }
}

// 使用
Command[] partyCommands = {lightOn, stereoOn, fanOn};
MacroCommand partyMacro = new MacroCommand(partyCommands);
remote.setCommand(partyMacro);
remote.pressButton();  // 一键开启派对模式

注意:撤销宏命令时,一定要倒序执行每个子命令的 undo。比如先开灯再开风扇,撤销时应该先关风扇再关灯。顺序错了,结果可能不对。

命令模式在真实项目中的应用

我曾经参与过一个交易系统的开发。每个交易请求(买入、卖出、撤单)都被封装成命令对象。这样做有几个好处:

  • 请求排队:命令对象可以放进队列,按顺序执行。
  • 日志记录:每个命令执行前后都写日志,方便审计和回放。
  • 事务支持:如果一组命令中某个失败了,可以批量撤销已执行的部分。
  • 延迟执行:命令可以存储起来,在特定时间再执行。

说白了,只要你的系统里有「操作」这个概念,并且这些操作需要被管理、记录、回放,命令模式就是你的不二之选。

命令模式的优缺点

优点 缺点
将请求的发起者和执行者解耦 每增加一个操作就要增加一个具体命令类
支持撤销、重做、队列、日志等扩展 命令类数量可能膨胀
可以组合多个命令形成宏命令 如果命令状态复杂,undo 实现可能麻烦
符合开闭原则,新增命令不影响现有代码 ——

核心知识结构图

下面这张图把命令模式的整体脉络画出来了,我建议你多看几遍:

客户端 Client 创建命令 调用者 Invoker 调用 execute() «接口» Command +execute() +undo() ConcreteCommand1 持有 receiver 引用 ConcreteCommand2 持有 receiver 引用 MacroCommand 持有命令列表 接收者 Receiver(Light, Fan, Stereo...)

从这张图可以看得很清楚:客户端负责组装,调用者只管触发,命令对象是中间人,接收者才是真正干活的。宏命令本身也是一个具体命令,只不过它内部又包含了一组子命令。

避坑指南

我曾经在一个项目中过度使用命令模式,每个按钮点击都创建一个新命令对象。结果呢?内存里堆满了命令对象,GC 压力巨大。后来我加了一个命令池,复用那些无状态或状态可重置的命令对象,问题才解决。

还有一次,我在实现撤销时忘了保存接收者的完整状态。用户撤销一个「批量修改」操作后,只恢复了部分数据,导致数据不一致。嗯,这里要注意:如果接收者的状态比较复杂,最好在命令执行前做一次快照,或者用备忘录模式配合使用。

总结一下:命令模式把「做什么」和「谁来做」拆开,给了你操作请求的完全控制权。撤销、宏命令、队列、日志——这些高级功能都是建立在这个基础之上的。我个人觉得,它是设计模式里性价比最高的几个之一,值得你花时间吃透。


公众号:蓝海资料掘金营,微信 deep3321