命令模式:把请求封装成对象
命令模式,说白了就是把「你要做什么」这件事,变成一个对象。你想想看,在代码里我们经常要调用某个方法,比如 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 实现可能麻烦 |
| 符合开闭原则,新增命令不影响现有代码 | —— |
核心知识结构图
下面这张图把命令模式的整体脉络画出来了,我建议你多看几遍:
从这张图可以看得很清楚:客户端负责组装,调用者只管触发,命令对象是中间人,接收者才是真正干活的。宏命令本身也是一个具体命令,只不过它内部又包含了一组子命令。
避坑指南
我曾经在一个项目中过度使用命令模式,每个按钮点击都创建一个新命令对象。结果呢?内存里堆满了命令对象,GC 压力巨大。后来我加了一个命令池,复用那些无状态或状态可重置的命令对象,问题才解决。
还有一次,我在实现撤销时忘了保存接收者的完整状态。用户撤销一个「批量修改」操作后,只恢复了部分数据,导致数据不一致。嗯,这里要注意:如果接收者的状态比较复杂,最好在命令执行前做一次快照,或者用备忘录模式配合使用。
总结一下:命令模式把「做什么」和「谁来做」拆开,给了你操作请求的完全控制权。撤销、宏命令、队列、日志——这些高级功能都是建立在这个基础之上的。我个人觉得,它是设计模式里性价比最高的几个之一,值得你花时间吃透。
公众号:蓝海资料掘金营,微信 deep3321