命令模式(Command)
按钮一点就调一个方法,这种写法简单直接,但当你需要「刚才那步能撤销」「这些操作先排队再执行」「先记下来再统一回放」时,就会发现:操作本身如果只是方法调用,很难被当作参数传、放进队列或写进日志。命令模式做的事,就是把一次「请求」封装成一个对象——有统一的执行入口,还可以带撤销、序列化、排队,调用方只和这个对象打交道,不用关心具体是谁在执行。
一、问题出在哪儿?
Section titled “一、问题出在哪儿?”很多时候我们写的是「调用方直接调接收方」:点保存就调 configService.save(),点导出就调 exportService.run()。这样写没问题,直到你遇到这些需求:
- 撤销 / 重做:用户改了一串配置,想回到上一步,或者重做。如果每次改动只是方法调用,没有「反向操作」的抽象,就得在业务代码里到处记状态、写 if-else。
- 排队 / 延迟执行:一堆操作不想立刻执行,要放进队列,按顺序或定时执行。方法调用是「立刻发生」的,没法直接当队列元素;你需要的是「可携带参数、可延迟执行」的单元。
- 记录与回放:为了审计或排查问题,希望把用户做过的操作记下来,必要时能按顺序重放。如果操作只是散落的方法调用,没有统一形态,就很难序列化和回放。
这些需求的共同点是:要把「操作」当作一等公民——能传、能存、能排队、能撤销。命令模式就是通过「请求对象化」来满足这一点。
二、思路:请求变成对象
Section titled “二、思路:请求变成对象”做法可以概括成四类角色:
- Command(命令):一个接口,至少有一个
execute(),表示「执行这次请求」。有的场景还会加undo(),表示撤销。 - ConcreteCommand(具体命令):实现 Command,内部持有一个 Receiver(接收者) 的引用,
execute()里调的是接收者的具体方法。这样「做什么」写在命令里,「谁来做」写在接收者里。 - Invoker(调用者):只持有一个 Command,在合适的时机(例如按钮点击、队列轮询)调用
command.execute()。它不关心命令里包的是保存配置还是发邮件。 - Receiver(接收者):真正干活的类,提供业务方法(如
setConfig、sendEmail)。命令在execute()里调用这些方法。
客户端负责把 Receiver 和 ConcreteCommand 组装好,把 Command 交给 Invoker。之后无论是立刻执行、放进队列,还是记到历史里再做撤销,都是在对「命令对象」做文章,而不是散落一地的 if-else。
三、结构长什么样?
Section titled “三、结构长什么样?”用类图看一遍关系: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 透明。
五、配置变更的撤销与重做
Section titled “五、配置变更的撤销与重做”很多后台系统有「系统参数」「运行配置」一类的页面,用户改完希望支持撤销/重做,而不是每次改错就再改回去。用命令模式可以把「一次修改」做成一个命令,执行时改配置,撤销时把旧值写回去。
5.1 配置项与配置服务(Receiver)
Section titled “5.1 配置项与配置服务(Receiver)”先假定配置是 key-value,存在内存或库里都行,这里用内存简化。
package com.example.demo.command.config;
import lombok.Data;
@Datapublic 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;
@Servicepublic 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@RequiredArgsConstructorpublic 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 类。
5.3 调用者:带历史栈的 Invoker
Section titled “5.3 调用者:带历史栈的 Invoker”Invoker 持有一个「已执行命令」的历史栈,执行时 push,撤销时 pop 并调用 undo();重做可以再做一个「重做栈」,这里只做撤销。
package com.example.demo.command.config;
import org.springframework.stereotype.Component;import java.util.ArrayDeque;import java.util.Deque;
@Componentpublic 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(); }}5.4 在 Controller 里用起来
Section titled “5.4 在 Controller 里用起来”把「设置配置」和「撤销」都变成对 Invoker 的调用,业务代码只和 Command、Invoker 打交道。
package com.example.demo.command.config;
import lombok.RequiredArgsConstructor;import org.springframework.web.bind.annotation.*;
@RestController@RequestMapping("/api/config")@RequiredArgsConstructorpublic 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 实例导致状态错乱。上面用的是单例 SetConfigCommand 加 bind,多线程下可以改为工厂里每次 new SetConfigCommand(key, value) 再交给 Invoker。
这样你就得到了一个「可撤销的配置修改」:每次修改都是一条命令,Invoker 负责执行和撤销,和具体改哪个 key、改成了什么值解耦。
六、异步任务队列
Section titled “六、异步任务队列”另一类常见需求是:有一批「操作」不想立刻执行,要丢进队列,由后台线程按顺序执行。例如定时导出报表、延迟发通知、批量清理缓存。这些都可以抽象成 Command,队列里存的是命令对象,消费者从队列里取出来执行 execute()。
下面用内存队列 + 单线程消费做一个简化版,实际项目里可以换成 RabbitMQ、Redis 等。
6.1 任务命令接口与简单实现
Section titled “6.1 任务命令接口与简单实现”命令接口仍然是一个 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@RequiredArgsConstructorpublic 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@Componentpublic 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); }}6.2 队列 + 单线程 Invoker
Section titled “6.2 队列 + 单线程 Invoker”用一个阻塞队列接命令,一个后台线程不断取命令并执行。这里用 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@RequiredArgsConstructorpublic 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(); }}6.3 业务侧只负责组命令、投递
Section titled “6.3 业务侧只负责组命令、投递”Service 或 Controller 里不再直接调邮件或写日志,而是把「发邮件」「记审计」封装成 TaskCommand,交给 TaskQueueInvoker.submit()。
package com.example.demo.command.task;
import lombok.RequiredArgsConstructor;import org.springframework.stereotype.Service;
@Service@RequiredArgsConstructorpublic 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 试试,结构会清晰不少。