本文是【GoF设计模式】系列第15篇,更多内容欢迎关注公众号:咖啡八杯

前言
为什么需要命令模式?
想象一个文本编辑器的撤销功能:用户输入了一段文字,然后按 Ctrl+Z 撤销。最直觉的写法是在每个操作方法里保存历史状态:
java
class TextEditor {
private StringBuilder content = new StringBuilder();
private List<String> history = new ArrayList<>();
public void insertText(String text, int position) {
history.add(content.toString()); // 保存当前状态
content.insert(position, text);
}
public void undo() {
if (!history.isEmpty()) {
content = new StringBuilder(history.remove(history.size() - 1));
}
}
}
这种写法很快就会失控:每加一种操作(删除、替换、格式化)就要在对应方法里手动保存历史,TextEditor 既要管业务逻辑又要管撤销状态,职责混乱。操作一多,历史管理代码散落在各处,谁也不敢碰这块代码。
命令模式解决的就是这个"把操作本身变成可以存储、排队、撤销的对象"的问题。每个操作封装成一个命令对象,编辑器只管持有命令、在合适时调用,需要撤销就调用命令的 undo() 方法,互不干扰。
概念
命令模式(Command Pattern)是一种行为型设计模式 ,核心思想是将请求封装为对象,从而使你可以用不同的请求对客户进行参数化,支持请求的排队、记录日志以及撤销操作。
命令模式的灵魂是"把请求变成对象"------可以存储、排队、撤销、组合。 Command 代表一个可以被存储、排队、撤销的操作。
命令模式包含四个角色:
- Command(抽象命令类) :声明执行操作的接口,通常包含
execute()方法 - ConcreteCommand(具体命令类) :将一个接收者对象绑定于一个动作,实现
execute()方法,调用接收者的相应操作 - Receiver(接收者) :真正执行请求的对象,知道如何实施与执行一个请求相关的操作
- Invoker(调用者) :持有命令对象并调用其
execute()方法发送请求
图中各类之间的关系:ConcreteCommand 实现 Command 接口并持有 Receiver 引用,Invoker 持有一个 Command 引用------Invoker 和 Receiver 之间没有直接依赖。
可以把命令模式想象成餐厅点菜:顾客(客户端)跟服务员(Invoker)说"来一份宫保鸡丁",服务员不自己下厨,而是把需求写成订单(Command)转交给后厨厨师(Receiver)。订单可以排队、可以取消、可以记录,服务员和厨师完全解耦。
Invoker 负责调度(什么时候做),Command 负责绑定(把"谁做"和"做什么"绑在一起)。Invoker 不该知道 Receiver,就像调度员不该替老板决定谁干活。
如何区分哪些操作是命令?如果一个操作需要满足以下任一条件,就应该考虑将其设计为命令:
- 需要撤销/重做:操作需要记录历史状态,支持回滚
- 需要排队执行:操作需要按顺序执行或延迟执行
- 需要事务支持:多个操作需要作为一个原子单元执行
- 需要日志记录:操作需要被记录以便审计或恢复
- 需要解耦发送者和接收者:发送者不应该知道接收者的具体类型
实现
标准实现
GoF 推荐 Receiver 由客户端创建并注入到具体命令中,原因如下:
- 职责分离:客户端知道业务上下文,能决定使用哪个接收者;调用者只负责调度,不应该知道业务细节
- 灵活性:同一个命令可以配合不同的接收者使用(如"保存"命令可以保存到文件或数据库)
- 可测试性:便于单元测试时注入 Mock 接收者
下面是标准实现的代码示例,由客户端创建 Receiver 并注入到具体命令中:
java
// 抽象命令类
public interface Command {
public void execute();
}
// 具体命令类
public class ConcreteCommand implements Command {
private Receiver receiver;
public ConcreteCommand(Receiver receiver) {
this.receiver = receiver;
}
public void execute() {
receiver.action();
}
}
// 接收者
public class Receiver {
public void action() {
System.out.println("Receiver action executed");
}
}
// 调用者
public class Invoker {
private Command command;
public void setCommand(Command command) {
this.command = command;
}
public void executeCommand() {
command.execute();
}
}
// 客户端代码
Receiver receiver = new Receiver();
Command command = new ConcreteCommand(receiver);
Invoker invoker = new Invoker();
invoker.setCommand(command);
invoker.executeCommand();
⚠️ 反模式:如果由调用者(Invoker)创建接收者,会导致调用者与具体业务耦合,违背了命令模式的初衷。正确做法是由客户端创建 Receiver 并注入到具体命令中。
命令队列(延迟执行)
命令队列用于延迟执行场景------命令先入队,之后统一执行。核心原则:队列管"还没做的" ,入队的命令尚未执行,从队列移除只是取消,不需要回滚。
java
// Command 接口只需 execute()
public interface Command {
public void execute();
}
// 命令队列:管理未执行的命令
class CommandQueue {
private Deque<Command> queue = new ArrayDeque<>();
public void addCommand(Command command) {
queue.offer(command);
}
public void executeAll() {
while (!queue.isEmpty()) {
queue.poll().execute();
}
}
public void cancelLast() {
// 从队尾移除最后一个未执行的命令(还没执行,所以是取消,不是撤销)
if (!queue.isEmpty()) {
queue.pollLast();
}
}
}
撤销栈(执行后可回滚)
撤销栈用于需要回滚的场景------命令先执行,再压入历史栈,需要时可以撤销。核心原则:撤销栈管"已经做的" ,每个命令必须实现 undo() 方法以支持回滚。
java
// Command 接口需同时声明 execute() 和 undo()
public interface UndoableCommand {
public void execute();
public void undo();
}
// 撤销栈:管理已执行的命令,支持回滚
class CommandHistory {
private Deque<UndoableCommand> history = new ArrayDeque<>();
public void execute(UndoableCommand command) {
command.execute();
history.push(command);
}
public void undo() {
if (!history.isEmpty()) {
history.pop().undo();
}
}
}
一句话区分:队列管"还没做的"(取消即可),撤销栈管"已经做的"(需要回滚) 。不要把两者混在一个类里。
常见反模式
以下反模式基于一个典型场景展开:点餐系统中,用户依次添加订单命令到队列(如"奶茶"→"咖啡"→"果汁"),Cancel 是取消队列中最后一个未执行的命令(从队列移除),Confirm 是确认并执行队列中所有命令(按顺序制作)。Cancel 和 Confirm 都是对队列本身的管理操作。
反模式一:不是所有操作都适合做成 Command
Cancel 和 Confirm 是 Invoker 对队列的管理操作,属于 Invoker 的职责,不应设计为 Command。只有需要被"存储、排队、撤销"的业务操作才是 Command。
java
// ❌ 错误:CancelCommand 放进队列,confirm 时会"执行"它------毫无意义
q.addLast(new OrderCommand("MilkTea"));
q.addLast(new CancelCommand()); // confirm 时执行 CancelCommand?语义矛盾
// ✅ 正确:Cancel 是 Invoker 提供的能力,直接操作队列
q.removeLast(); // 从队列移除,不涉及任何 Command 执行
反模式二:只有一种 Receiver 时,Receiver 是多余的一层
Receiver 的价值在于同一个 Command 接口,注入不同 Receiver 产生不同行为 (如 SaveCommand 可以注入 FileReceiver 存文件,也可以注入 DbReceiver 存数据库)。如果只有一种 Receiver,去掉它直接在 Command 里写逻辑没有区别,反而增加了一个无意义的间接层。
总结
命令模式的本质是把请求封装成对象,让请求可以被存储、排队、撤销、组合------发送者和接收者完全解耦,命令对象成为两者之间的桥梁。
什么时候用:
- 系统需要支持撤销/重做功能
- 需要将操作排队、延迟执行或批处理
- 需要记录操作日志以便审计或恢复
- 需要支持事务性操作(多个操作要么全部成功,要么全部回滚)
- 发送者和接收者需要解耦
什么时候不用:
- 简单的请求调用,不需要撤销、排队、日志等功能
- 只有一种 Receiver,Receiver 层没有存在的必要
- 操作不需要被存储或传递
简单记忆:
命令模式解决"把请求变成对象"的问题,让操作可以存储、排队、撤销、组合。
⚠️ 用命令模式要注意:每个具体命令都需要一个类,类数量会爆炸;如果命令需要支持撤销,需要维护命令执行前的状态,增加了实现复杂度。
相似模式区分
命令模式容易和策略、状态模式混淆,它们都涉及"封装行为",但实现方式和意图不同。
总览对比:
| 模式 | 核心意图 | 典型场景 |
|---|---|---|
| 命令 | 将请求封装为对象,支持排队、撤销、日志 | 撤销重做、事务处理、任务队列 |
| 策略 | 定义算法族,使它们可以相互替换 | 折扣算法、支付方式、排序 |
| 状态 | 允许对象在状态改变时改变其行为 | 订单状态流转、审批流程 |
口诀:命令管请求,策略管算法,状态管行为。
命令 vs 策略
两者都涉及"封装可变行为",但意图完全不同。命令模式关注的是请求的管理 ------把请求变成对象,支持存储、排队、撤销;策略模式关注的是算法的替换------把算法封装成独立对象,运行时可互换。
| 维度 | 命令模式 | 策略模式 |
|---|---|---|
| 核心意图 | 将请求封装为对象,支持排队、撤销、日志 | 定义算法族,使它们可以相互替换 |
| 结构差异 | 包含 Command、Receiver、Invoker | 包含 Strategy、Context |
| 关注点 | 请求的封装和管理 | 算法的替换和扩展 |
| 典型场景 | 撤销重做、事务处理、任务队列 | 排序算法、支付方式、折扣策略 |
逐步区分法:
- 如果需要支持撤销、重做、排队 → 选择命令模式
- 如果需要动态切换算法或策略 → 选择策略模式
- 如果需要将请求发送者与接收者解耦 → 选择命令模式
- 如果需要让算法独立于使用它的客户端变化 → 选择策略模式
简单记忆口诀:命令管请求,策略管算法。
命令 vs 状态
两者都涉及"封装行为",但实现方式和意图完全不同。命令模式中,命令对象是独立的,可以被存储、传递;状态模式中,状态对象依赖于 Context,状态之间可以触发转换。
| 维度 | 命令模式 | 状态模式 |
|---|---|---|
| 核心意图 | 将请求封装为对象 | 允许对象在状态改变时改变其行为 |
| 结构差异 | Command 对象是独立的 | State 对象依赖于 Context |
| 关注点 | 请求的封装和管理 | 对象状态的转换和行为 |
| 典型场景 | 撤销重做、事务处理 | 订单状态、工作流状态 |
逐步区分法:
- 如果需要将请求封装为对象以便管理 → 选择命令模式
- 如果对象行为随状态改变而改变 → 选择状态模式
- 如果需要支持撤销和重做 → 选择命令模式
- 如果需要管理对象的生命周期状态 → 选择状态模式
简单记忆口诀:命令封装请求,状态管理行为。
练习题目
自助点餐机
题目描述 :奶茶店的自助点餐机支持堂食和外卖两种订单。堂食订单由堂食接收者处理,输出 Dine-in: XXX is ready!;外卖订单由外卖接收者处理,输出 Takeout: XXX is ready!。点餐机支持点单、取消、确认三种操作。
输入描述:第一行是一个整数 n(1 ≤ n ≤ 100),表示操作的数量。接下来的 n 行,每行包含操作信息:
- 点单操作:1 饮品名称 订单类型(D 表示堂食,T 表示外卖)
- 取消操作:2
- 确认操作:3
保证:取消操作时队列不为空;确认操作时队列不为空。
输出描述:每次确认操作时,按顺序输出队列中所有订单的制作情况。
输入示例:
r
8
1 MilkTea D
1 Coffee T
1 Cola D
1 Juice T
2
3
1 Coffee D
3
输出示例:
vbnet
Dine-in: MilkTea is ready!
Takeout: Coffee is ready!
Dine-in: Cola is ready!
Dine-in: Coffee is ready!
设计推演:
看题目描述,提取关键信息:
- 输入:点单操作(饮品名 + 堂食/外卖)、取消、确认
- 输出:确认时按顺序输出所有订单的制作情况
- 关键词:按顺序输出 、队列 、取消
订单不是点一个做一个,而是先攒着,确认时才统一执行------这是命令队列的典型场景。
识别角色:
- Command :
MakeCommand,封装"制作请求"(做什么饮品 + 谁来做) - Receiver :
DineinReceiver和TakeoutReceiver,真正执行制作的人 - Invoker :
OrderSystem,只管命令队列(点单、取消、确认),不关心怎么做饮品
为什么 Invoker 不直接持有 Receiver?因为职责混乱、难以扩展。正确做法:客户端负责组装(决定谁做),Invoker 只负责调度(什么时候做),Command 负责绑定(把"谁做"和"做什么"绑在一起)。
解题思路:Command 是点单命令,Receiver 是做外卖的员工和做堂食的员工,Invoker 是点单系统。点单命令创建后加入队列,确认时按顺序执行(延迟执行,不是立即制作)。客户端负责创建 Receiver 实例并注入到具体命令中;Invoker 只管理命令队列,不关心具体业务逻辑。
java
import java.util.*;
public class Main {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
// Client 创建 Receiver 实例
Receiver dineIn = new DineinReceiver();
Receiver takeout = new TakeoutReceiver();
// Invoker 只管命令队列
OrderSystem os = new OrderSystem();
while (n-- > 0) {
int op = sc.nextInt();
if (op == 1) {
String name = sc.next();
String type = sc.next();
// Client 创建 Command 并指定 Receiver
Receiver r = "D".equals(type) ? dineIn : takeout;
Command cmd = new MakeCommand(name, r);
os.order(cmd);
} else if (op == 2) {
os.cancel();
} else {
os.confirm();
}
}
}
}
// Command:声明接口
interface Command {
public void execute();
}
// ConcreteCommand:绑定 Receiver 和动作
class MakeCommand implements Command {
private String drinkName;
private Receiver receiver;
public MakeCommand(String drinkName, Receiver receiver) {
this.drinkName = drinkName;
this.receiver = receiver;
}
public void execute() {
receiver.make(drinkName);
}
}
// Invoker:只管理命令队列,只和 Command 接口交互
class OrderSystem {
private Deque<Command> q = new ArrayDeque<>();
public void order(Command command) {
q.addLast(command);
}
public void cancel() {
q.removeLast();
}
public void confirm() {
while (!q.isEmpty()) {
q.pollFirst().execute();
}
}
}
// Receiver 接口
interface Receiver {
public void make(String name);
}
// ConcreteReceiver
class DineinReceiver implements Receiver {
public void make(String name) {
System.out.println("Dine-in: " + name + " is ready!");
}
}
class TakeoutReceiver implements Receiver {
public void make(String name) {
System.out.println("Takeout: " + name + " is ready!");
}
}
扩展:实际项目中的命令模式
撤销/重做(文本编辑器)
文本编辑器需要支持撤销操作,命令模式可以将每个操作封装为命令对象,通过记录命令历史实现撤销和重做。
java
// 抽象命令:支持撤销
public interface UndoableCommand {
public void execute();
public void undo();
}
// 具体命令:插入文本
public class InsertTextCommand implements UndoableCommand {
private TextEditor editor;
private String text;
private int position;
public InsertTextCommand(TextEditor editor, String text, int position) {
this.editor = editor;
this.text = text;
this.position = position;
}
public void execute() {
editor.insertText(text, position);
}
public void undo() {
editor.deleteText(position, position + text.length());
}
}
// 调用者:编辑器控制器(维护命令历史)
public class EditorController {
private List<UndoableCommand> history = new ArrayList<>();
private int currentCommandIndex = -1;
public void executeCommand(UndoableCommand command) {
command.execute();
history = new ArrayList<>(history.subList(0, currentCommandIndex + 1));
history.add(command);
currentCommandIndex++;
}
public void undo() {
if (currentCommandIndex >= 0) {
history.get(currentCommandIndex).undo();
currentCommandIndex--;
}
}
public void redo() {
if (currentCommandIndex < history.size() - 1) {
currentCommandIndex++;
history.get(currentCommandIndex).execute();
}
}
}
关键点:插入命令撤销时执行反操作(删除)即可;命令历史列表支持重做功能,需要维护当前命令索引;执行新命令时需要清除索引之后的所有命令。
类似的场景:数据库事务管理 (转账时扣款和加款要么都成功,要么都回滚)、图形编辑器的宏命令(多个操作组合成一个命令,撤销时逆序撤销所有子命令)。
命令队列(任务调度)
在电商系统中,订单处理需要执行多个步骤(库存扣减、支付处理、物流通知等),这些步骤可以异步执行。命令模式可以将每个步骤封装为命令对象,由任务队列统一调度。
java
// 抽象命令
public interface TaskCommand {
public void execute();
public String getTaskName();
}
// 具体命令:库存扣减
public class ReduceInventoryCommand implements TaskCommand {
private InventoryService inventoryService;
private String productId;
private int quantity;
public ReduceInventoryCommand(InventoryService inventoryService, String productId, int quantity) {
this.inventoryService = inventoryService;
this.productId = productId;
this.quantity = quantity;
}
public void execute() {
inventoryService.reduce(productId, quantity);
}
public String getTaskName() {
return "Reduce inventory for product: " + productId;
}
}
// 调用者:任务队列(异步执行)
public class TaskQueue {
private Queue<TaskCommand> queue = new LinkedList<>();
private ExecutorService executor = Executors.newFixedThreadPool(3);
public void addTask(TaskCommand command) {
queue.offer(command);
}
public void processTasks() {
while (!queue.isEmpty()) {
TaskCommand command = queue.poll();
executor.submit(() -> {
try {
command.execute();
System.out.println("Task completed: " + command.getTaskName());
} catch (Exception e) {
System.err.println("Task failed: " + command.getTaskName());
}
});
}
}
public void shutdown() {
executor.shutdown();
}
}
关键点:任务队列将命令存储在队列中,由线程池异步执行;每个命令都是独立的,可以并行执行;任务队列不关心具体业务逻辑,只负责调度执行。
类似的场景:GUI 按钮事件处理(按钮不直接调用文档方法,而是通过命令对象解耦,支持运行时动态更换按钮功能)。
技术交流 & 更多原创内容,关注公众号:咖啡八杯