命令模式听起来抽象,但落地极其具体:把"做一件事"封装成一个对象。一旦做到这点,你可以排队、撤销、重做、记录、回放、跨进程传输 —— 这些能力都是因为"操作变成数据"而获得的。Redux 的 action、消息队列的 message、数据库的事务日志、文本编辑器的 undo/redo、Git 的 commit —— 背后都是命令模式。这篇文章把命令模式从动机讲到 CQRS、event sourcing,讲清楚它和策略、责任链、备忘录的边界。
问题:行为没法当数据传递
看一个文本编辑器的需求:点按钮、按快捷键、宏录制 都能触发"删除当前选中文本"。朴素实现:
class Editor {
public void deleteSelection() { ... }
public void insertText(String t) { ... }
public void undo() { ??? } // 怎么撤销?
}
button.addClickListener(() -> editor.deleteSelection());
keymap.bind("Ctrl+D", () -> editor.deleteSelection());
问题:
- "做删除"和"撤销删除"是两个不同的方法,关联性没有显式表达。
- 宏录制时,要把"用户做了什么"记下来 —— 一个用户操作要记下"哪个方法 + 哪些参数"才能回放。
- 多个按钮触发的"动作"不能统一处理(比如统一加日志、统一加权限校验)。
命令模式说:把"做删除"包装成一个对象,这个对象有 execute、undo 等方法,所有触发器都只接受这种对象。
命令模式的标准结构
// 1. 命令接口
interface Command {
void execute();
void undo(); // 可选:支持撤销
}
// 2. 具体命令:封装"动作 + 参数 + 撤销逻辑"
class DeleteSelectionCommand implements Command {
private final Editor editor;
private String backup; // 记录被删的内容,用于撤销
public DeleteSelectionCommand(Editor editor) { this.editor = editor; }
public void execute() {
backup = editor.getSelection(); // 先存档
editor.deleteSelection();
}
public void undo() {
editor.insertText(backup);
}
}
class InsertTextCommand implements Command {
private final Editor editor;
private final String text;
private int positionBeforeInsert;
public InsertTextCommand(Editor editor, String text) {
this.editor = editor; this.text = text;
}
public void execute() {
positionBeforeInsert = editor.getCursorPosition();
editor.insertText(text);
}
public void undo() {
editor.deleteRange(positionBeforeInsert, positionBeforeInsert + text.length());
}
}
// 3. 触发器:只面对 Command,不知道具体动作
class Button {
private Command command;
public Button(Command c) { this.command = c; }
public void onClick() { command.execute(); }
}
// 4. Invoker:维护命令历史,支持 undo/redo
class History {
private final Deque<Command> undoStack = new ArrayDeque<>();
private final Deque<Command> redoStack = new ArrayDeque<>();
public void execute(Command c) {
c.execute();
undoStack.push(c);
redoStack.clear(); // 新动作清空 redo
}
public void undo() {
if (undoStack.isEmpty()) return;
Command c = undoStack.pop();
c.undo();
redoStack.push(c);
}
public void redo() {
if (redoStack.isEmpty()) return;
Command c = redoStack.pop();
c.execute();
undoStack.push(c);
}
}
// 使用
Editor editor = new Editor("Hello world");
History history = new History();
history.execute(new InsertTextCommand(editor, " everyone"));
history.execute(new DeleteSelectionCommand(editor));
history.undo(); // 恢复
history.redo(); // 再删
命令模式带来的能力一次性显现:
- 触发器解耦:Button 不知道点击具体做什么 —— 创建 Button 时绑定哪个命令就做哪个。
- 历史记录:每个命令都进栈,undo 弹出。
- 统一处理:可以在 History 里加日志、权限校验、批量提交 —— 所有命令统一对待。
实战 1:Redux Action
Redux 的 action 是命令模式的现代代言。一个 action 描述"发生了什么":
// 命令对象(Action)—— 纯数据
const addTodo = (text) => ({ type: 'ADD_TODO', payload: { text } });
const removeTodo = (id) => ({ type: 'REMOVE_TODO', payload: { id } });
// 执行(reducer)
function reducer(state, action) {
switch (action.type) {
case 'ADD_TODO':
return [...state, { id: Date.now(), text: action.payload.text }];
case 'REMOVE_TODO':
return state.filter(t => t.id !== action.payload.id);
}
}
// Invoker (Store)
const store = createStore(reducer);
store.dispatch(addTodo('learn redux'));
store.dispatch(removeTodo(123));
Redux 之所以能做"时间旅行调试"(看到每次 action 后 state 的变化、可以回到任一时刻),正是因为所有变化都是命令。中间件机制就是命令链。
实战 2:消息队列里的消息
把命令对象序列化后扔进队列,跨进程执行:
// 生产者:把"创建订单"打包成消息
public class CreateOrderCommand implements Serializable {
public String userId;
public List<Item> items;
public BigDecimal totalAmount;
}
CreateOrderCommand cmd = new CreateOrderCommand();
cmd.userId = "u1"; cmd.items = items; cmd.totalAmount = total;
queue.send("order.create", JSON.stringify(cmd));
// 消费者:反序列化后执行
queue.subscribe("order.create", (msg) -> {
CreateOrderCommand cmd = JSON.parse(msg, CreateOrderCommand.class);
orderService.create(cmd);
});
命令模式让"调一个方法"变成"发一条消息" —— 调用方和执行方解耦,可以异步、可以重试、可以削峰。RabbitMQ / Kafka / SQS 上跑的所有"任务"本质都是命令对象。
实战 3:数据库事务日志
MySQL 的 binlog、Redis 的 AOF、PostgreSQL 的 WAL —— 都是命令的持久化记录。每条 binlog 是一个命令,故障恢复时按顺序重放,数据库状态就重建出来了:
# MySQL binlog 解码后的样子
INSERT INTO orders (id, user_id, total) VALUES (1, 'u1', 100);
UPDATE users SET balance = 900 WHERE id = 'u1';
INSERT INTO order_items (order_id, sku) VALUES (1, 'A1');
这种"把所有变更操作日志化"的思路就是 Event Sourcing(事件溯源)的基础 —— 不存当前状态,只存命令序列;查询时从头重放命令。这种架构在金融、审计、版本控制场景里非常强大,因为"历史是完整可追溯的"。
实战 4:Git
Git 每个 commit 本质就是一个命令:"在这个父提交基础上,加这些改动"。git reset 是 undo(回退命令),git revert 是 inverse command(用一个相反命令撤销原命令)。git rebase 是命令重排。git cherry-pick 是命令复用。整个 Git 模型就是命令模式的极致演绎。
命令的组合:宏命令
多个命令可以组合成一个"宏命令",一次执行多步,一次撤销多步:
class MacroCommand implements Command {
private final List<Command> commands;
public MacroCommand(List<Command> cmds) { this.commands = cmds; }
public void execute() {
for (Command c : commands) c.execute();
}
public void undo() {
for (int i = commands.size() - 1; i >= 0; i--) commands.get(i).undo(); // 反序撤销
}
}
// 录制宏:用户按下"开始录制" -> 操作一段 -> "停止录制",得到一个 MacroCommand
List<Command> recorded = new ArrayList<>();
// ... 用户操作期间记录
MacroCommand macro = new MacroCommand(recorded);
// 后续:一键回放
history.execute(macro);
反序撤销很关键 —— 如果你按顺序做了 A、B、C,撤销时必须按 C、B、A 撤,否则状态错乱。Photoshop、AutoCAD 的"宏录制"就是这个机制。
异步命令
interface AsyncCommand {
CompletableFuture<Void> execute();
CompletableFuture<Void> undo();
}
class HttpRequestCommand implements AsyncCommand {
private final String url;
private final HttpClient client;
public CompletableFuture<Void> execute() {
return client.sendAsync(buildRequest(url), BodyHandlers.discarding())
.thenAccept(resp -> log.info("done {}", resp.statusCode()));
}
public CompletableFuture<Void> undo() { /* 撤销 HTTP 调用?业务定义 */ }
}
异步命令在 UI 应用里特别常见 —— 点击一个按钮触发一个长任务,任务自己进度跟进,完成或失败时通知。
命令模式 + CQRS
CQRS(Command Query Responsibility Segregation,命令查询职责分离)是命令模式在系统架构层面的发扬:
// Command 侧:写
public class CreateOrderCommand {
public String userId;
public List<OrderItem> items;
}
@CommandHandler
public class CreateOrderHandler {
public OrderId handle(CreateOrderCommand cmd) {
// 校验 + 持久化 + 发事件
}
}
// Query 侧:读(完全独立,可能用不同的存储)
@QueryHandler
public class OrderListQueryHandler {
public List<OrderDTO> handle(OrderListQuery q) {
return readModelStore.findByUser(q.userId);
}
}
把"写操作"和"读操作"在代码层面甚至基础设施层面分开,各自优化。写侧追求一致性和领域纯净,读侧追求查询性能(可以用 ES、Redis、宽表反范式)。这种架构在大型 DDD 系统里被广泛采用。
命令 vs 策略
结构上都"把行为封装成对象",区别:
- 策略:同一件事的不同算法(快排 vs 归并)。客户端在不同 Strategy 间切换。
- 命令:把"操作 + 参数 + 上下文"做成一个请求对象。重点在"做一件事",而不是"怎么做"。
策略关注 how,命令关注 what。
命令 vs 责任链
命令是"一个操作",责任链是"多个处理器"。命令可以被放进责任链里(每个 Handler 处理一种命令),但二者解决的问题层级不同。
各语言里的命令
Python:函数 + 闭包就够
# 不需要专门的 Command 类
commands = []
def make_insert(editor, text):
pos = editor.cursor
def execute(): editor.insert(text)
def undo(): editor.delete_range(pos, pos + len(text))
return (execute, undo)
cmd = make_insert(editor, "hello")
cmd[0]() # execute
commands.append(cmd)
# 撤销:
commands[-1][1]() # undo
JavaScript
// 函数即命令
const commands = [];
function createInsert(editor, text) {
const pos = editor.cursor;
return {
execute() { editor.insert(text); },
undo() { editor.deleteRange(pos, pos + text.length); }
};
}
commands.push(createInsert(editor, 'hello'));
commands[commands.length - 1].execute();
commands.pop().undo();
命令的常见坑
坑 1:undo 不可逆。 不是所有操作都能撤销 —— "发了一条 HTTP 请求""扣了银行账户的钱""发了短信"。这类操作要么不支持 undo,要么用"补偿事务"(发起一个相反操作)。设计时清楚标记"哪些命令支持 undo"。
坑 2:命令对象状态变了。 命令 execute 期间 mutating 它自己的字段,undo 时拿到的不是 execute 时的状态。规范:命令对象应该是不可变的输入参数 + execute 时计算并保存撤销所需信息。
坑 3:撤销链路太深。 几百次操作的 undo 历史占大量内存(每个命令带 backup 数据)。优化:用快照 + 增量(只存"和上一次状态的 diff"),或者限制 undo 栈深度。
坑 4:命令对象太重。 命令里持有大对象、文件句柄、数据库连接,序列化/缓存都有问题。规范:命令对象只存"标识 + 参数",真正的资源访问由 Handler 通过依赖注入获取。
识别命令场景
关键信号:
- 需要撤销/重做。
- 需要排队 / 异步执行(消息队列、任务队列)。
- 需要持久化 / 重放(事件溯源、binlog)。
- 需要把"操作"作为参数传递(回调、宏、批量操作)。
- 需要统一拦截(权限、日志、Tracing)所有操作。
命中任一项,命令模式是首选。
写在最后
命令模式的本质是"把动词变成名词" —— 一旦"做某件事"变成"某个对象",所有对对象能做的事(传递、存储、序列化、组合、统一处理)都对它生效。Redux、消息队列、Git、Event Sourcing、CQRS、Undo/Redo —— 这些看似毫无关系的技术,本质都是命令模式的不同尺度演绎。
给一个判断信号:当你的代码里有一组"做某件事"的函数,且它们需要被同等对待(排队、撤销、回放、记录)时 —— 命令模式让所有可能性都成立。它把动作"对象化"的这一步,是函数式 vs 面向对象、命令式 vs 声明式之间最深刻的桥梁。理解了命令,你就理解了为什么"action 驱动"的架构在现代系统里越来越主流。
—— 别看了 · 2026