跳转到内容

命令模式(Command)

按钮一点就调一个方法,这种写法简单直接,但当你需要「刚才那步能撤销」「这些操作先排队再执行」「先记下来再统一回放」时,就会发现:操作本身如果只是方法调用,很难被当作参数传、放进队列或写进日志。命令模式做的事,就是把一次「请求」封装成一个对象——有统一的执行入口,还可以带撤销、序列化、排队,调用方只和这个对象打交道,不用关心具体是谁在执行。


很多时候我们写的是「调用方直接调接收方」:点保存就调 configService.save(),点导出就调 exportService.run()。这样写没问题,直到你遇到这些需求:

  • 撤销 / 重做:用户改了一串配置,想回到上一步,或者重做。如果每次改动只是方法调用,没有「反向操作」的抽象,就得在业务代码里到处记状态、写 if-else。
  • 排队 / 延迟执行:一堆操作不想立刻执行,要放进队列,按顺序或定时执行。方法调用是「立刻发生」的,没法直接当队列元素;你需要的是「可携带参数、可延迟执行」的单元。
  • 记录与回放:为了审计或排查问题,希望把用户做过的操作记下来,必要时能按顺序重放。如果操作只是散落的方法调用,没有统一形态,就很难序列化和回放。

这些需求的共同点是:要把「操作」当作一等公民——能传、能存、能排队、能撤销。命令模式就是通过「请求对象化」来满足这一点。


做法可以概括成四类角色:

  • Command(命令):一个接口,至少有一个 execute(),表示「执行这次请求」。有的场景还会加 undo(),表示撤销。
  • ConcreteCommand(具体命令):实现 Command,内部持有一个 Receiver(接收者) 的引用,execute() 里调的是接收者的具体方法。这样「做什么」写在命令里,「谁来做」写在接收者里。
  • Invoker(调用者):只持有一个 Command,在合适的时机(例如按钮点击、队列轮询)调用 command.execute()。它不关心命令里包的是保存配置还是发邮件。
  • Receiver(接收者):真正干活的类,提供业务方法(如 setConfigsendEmail)。命令在 execute() 里调用这些方法。

客户端负责把 Receiver 和 ConcreteCommand 组装好,把 Command 交给 Invoker。之后无论是立刻执行、放进队列,还是记到历史里再做撤销,都是在对「命令对象」做文章,而不是散落一地的 if-else。


用类图看一遍关系:Invoker 只依赖 Command 接口;ConcreteCommand 依赖 Receiver,并在 execute() 里调用 Receiver 的方法。

classDiagram
    class Command {
        <<interface>>
        +execute()* void
    }
    class ConcreteCommand {
        -receiver Receiver
        +execute() void
    }
    class Invoker {
        -command Command
        +invoke() void
    }
    class Receiver {
        +action() void
    }
    Command <|.. ConcreteCommand
    ConcreteCommand o-- Receiver
    Invoker o-- Command

调用链是:Invoker.invoke()Command.execute()Receiver.action()。多出来的这一层 Command 就是「操作」的抽象,可以替换、可以排队、可以加 undo。


public interface Command {
void execute();
}
public class Receiver {
public void action() {
System.out.println("Receiver.action");
}
}
public class ConcreteCommand implements Command {
private final Receiver receiver = new Receiver();
@Override
public void execute() {
receiver.action();
}
}
public class Invoker {
private Command command;
public void setCommand(Command command) {
this.command = command;
}
public void invoke() {
command.execute();
}
}
// 客户端
Invoker invoker = new Invoker();
invoker.setCommand(new ConcreteCommand());
invoker.invoke(); // 输出: Receiver.action

这里还没有 undo、没有队列,但「请求对象化」已经成立:Invoker 拿到的是一颗 Command,至于里面包的是打印一句话还是改配置,对 Invoker 透明。


很多后台系统有「系统参数」「运行配置」一类的页面,用户改完希望支持撤销/重做,而不是每次改错就再改回去。用命令模式可以把「一次修改」做成一个命令,执行时改配置,撤销时把旧值写回去。

5.1 配置项与配置服务(Receiver)

Section titled “5.1 配置项与配置服务(Receiver)”

先假定配置是 key-value,存在内存或库里都行,这里用内存简化。

package com.example.demo.command.config;
import lombok.Data;
@Data
public class ConfigItem {
private String key;
private String value;
}
package com.example.demo.command.config;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class ConfigService {
private final Map<String, String> store = new ConcurrentHashMap<>();
public String get(String key) {
return store.get(key);
}
public void set(String key, String value) {
store.put(key, value);
}
}

ConfigService 就是 Receiver:真正改配置的是它。

5.2 命令接口与「设置配置」命令

Section titled “5.2 命令接口与「设置配置」命令”

命令接口里加上 undo(),方便做撤销。

package com.example.demo.command.config;
public interface ConfigCommand {
void execute();
void undo();
}

「设置某 key 为某 value」是一个具体命令。执行时先记下旧值,再调用 ConfigService.set;撤销时把旧值写回去。

package com.example.demo.command.config;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class SetConfigCommand implements ConfigCommand {
private final ConfigService configService;
private String key;
private String value;
private String previousValue; // 用于 undo
public SetConfigCommand bind(String key, String value) {
this.key = key;
this.value = value;
return this;
}
@Override
public void execute() {
previousValue = configService.get(key);
configService.set(key, value);
}
@Override
public void undo() {
if (previousValue != null) {
configService.set(key, previousValue);
}
}
}

这里用 bind(key, value) 在每次使用前绑定参数,避免每个 key 都写一个 Command 类。若你的配置是强类型、字段很多,也可以做成不同的 ConcreteCommand 类。

Invoker 持有一个「已执行命令」的历史栈,执行时 push,撤销时 pop 并调用 undo();重做可以再做一个「重做栈」,这里只做撤销。

package com.example.demo.command.config;
import org.springframework.stereotype.Component;
import java.util.ArrayDeque;
import java.util.Deque;
@Component
public class ConfigCommandInvoker {
private final Deque<ConfigCommand> history = new ArrayDeque<>();
public void execute(ConfigCommand command) {
command.execute();
history.push(command);
}
public void undo() {
if (history.isEmpty()) return;
ConfigCommand cmd = history.pop();
cmd.undo();
}
}

把「设置配置」和「撤销」都变成对 Invoker 的调用,业务代码只和 Command、Invoker 打交道。

package com.example.demo.command.config;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/config")
@RequiredArgsConstructor
public class ConfigController {
private final ConfigCommandInvoker invoker;
private final SetConfigCommand setConfigCommand;
@PostMapping("/set")
public String set(@RequestParam String key, @RequestParam String value) {
invoker.execute(setConfigCommand.bind(key, value));
return "ok";
}
@PostMapping("/undo")
public String undo() {
invoker.undo();
return "ok";
}
}

注意:每次 set 最好用新命令实例或确保 bind 每次调用都是新参数,避免并发下复用一个 Command 实例导致状态错乱。上面用的是单例 SetConfigCommandbind,多线程下可以改为工厂里每次 new SetConfigCommand(key, value) 再交给 Invoker。

这样你就得到了一个「可撤销的配置修改」:每次修改都是一条命令,Invoker 负责执行和撤销,和具体改哪个 key、改成了什么值解耦。


另一类常见需求是:有一批「操作」不想立刻执行,要丢进队列,由后台线程按顺序执行。例如定时导出报表、延迟发通知、批量清理缓存。这些都可以抽象成 Command,队列里存的是命令对象,消费者从队列里取出来执行 execute()

下面用内存队列 + 单线程消费做一个简化版,实际项目里可以换成 RabbitMQ、Redis 等。

命令接口仍然是一个 execute(),无参即可,参数在构造或 set 时绑在具体命令里。

package com.example.demo.command.task;
public interface TaskCommand {
void execute();
}

例如「发一封邮件」和「写一条本地日志」(模拟耗时任务):

package com.example.demo.command.task;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
public class SendEmailTask implements TaskCommand {
private final JavaMailSender mailSender;
private String to;
private String subject;
private String text;
public SendEmailTask bind(String to, String subject, String text) {
this.to = to;
this.subject = subject;
this.text = text;
return this;
}
@Override
public void execute() {
SimpleMailMessage msg = new SimpleMailMessage();
msg.setTo(to);
msg.setSubject(subject);
msg.setText(text);
mailSender.send(msg);
log.info("email sent to {}", to);
}
}
package com.example.demo.command.task;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class LogAuditTask implements TaskCommand {
private String message;
public LogAuditTask bind(String message) {
this.message = message;
return this;
}
@Override
public void execute() {
log.info("[AUDIT] {}", message);
}
}

用一个阻塞队列接命令,一个后台线程不断取命令并执行。这里用 Spring 的 @PostConstruct 启动消费者,实际也可以用 @Async 或独立线程池。

package com.example.demo.command.task;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
@Slf4j
@Component
@RequiredArgsConstructor
public class TaskQueueInvoker {
private final BlockingQueue<TaskCommand> queue = new LinkedBlockingQueue<>();
public void submit(TaskCommand command) {
queue.offer(command);
}
@PostConstruct
public void startConsumer() {
Thread t = new Thread(() -> {
while (true) {
try {
TaskCommand cmd = queue.take();
cmd.execute();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
} catch (Exception e) {
log.error("task execution failed", e);
}
}
}, "task-consumer");
t.setDaemon(true);
t.start();
}
}

Service 或 Controller 里不再直接调邮件或写日志,而是把「发邮件」「记审计」封装成 TaskCommand,交给 TaskQueueInvoker.submit()

package com.example.demo.command.task;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class NotificationService {
private final TaskQueueInvoker invoker;
private final SendEmailTask sendEmailTask;
private final LogAuditTask logAuditTask;
public void scheduleEmail(String to, String subject, String text) {
invoker.submit(sendEmailTask.bind(to, subject, text));
}
public void scheduleAuditLog(String message) {
invoker.submit(logAuditTask.bind(message));
}
}

这样,操作被封装成命令对象,执行时机由队列和消费者决定,调用方只关心「提交什么命令」,符合命令模式里 Invoker 与 Command 解耦的用法。若要换成 MQ,只需把 queue.offer 改成发消息,消费者里从 MQ 取消息再反序列化成 Command 执行即可。


命令模式把「请求」变成对象,带来几件事:参数化(同一个 Invoker 可以执行不同命令)、排队/延迟(队列里存的是命令)、撤销/重做(命令带 undo,由 Invoker 维护历史)、记录与回放(命令可序列化后再执行)。在 Spring Boot 里,Receiver 通常是 Service,ConcreteCommand 用 @Component 或工厂创建,Invoker 可以是 Controller 里的调用对象、也可以是队列消费者。如果你手头有「一堆操作想统一排队或支持撤销」的代码,不妨抽成 Command 试试,结构会清晰不少。