一、为什么需要命令模式
你正在写一个文本编辑器。用户点击「加粗」,你直接调用 document.boldSelection();点击「插入图片」,你直接调用 document.insertImage(path);点击「删除行」,你直接调用 document.deleteLine()。每个操作都是 UI 控件直接调用业务方法,看起来没什么问题。
直到产品经理说:「我要撤销。」
你开始给每个操作写对应的反向操作:加粗的撤销是取消加粗,插入图片的撤销是删除图片,删除行的撤销是……等等,删除的行存哪?你发现每个操作都需要记住「之前是什么」,才能撤销。于是每个按钮的点击处理里塞满了状态保存和恢复逻辑,代码越来越乱。
更糟糕的是,产品经理又来了:「我要宏录制——用户点一系列操作,然后一键回放。」这下你彻底懵了。UI 控件直接调用业务方法,操作没有被记录下来,怎么回放?
问题的根源是:请求没有被封装。UI 控件(调用者)和业务对象(接收者)之间是直接耦合的。调用者知道接收者的方法签名,知道参数是什么,但不知道怎么撤销,也没法把操作存下来延迟执行或排队。
命令模式把请求封装成对象。每个操作是一个命令对象,包含执行所需的一切——接收者引用、参数、甚至撤销逻辑。调用者只需要调用 command.Execute(),不需要知道接收者是谁、方法是什么。命令对象可以存储、排队、记录日志、序列化——因为它是对象,不是方法调用。
二、现实类比
餐厅点餐:顾客(调用者)在点餐单上写下「一份宫保鸡丁,微辣」,服务员把点餐单送到厨房。厨师(接收者)看到点餐单,按单做菜。顾客不需要知道厨师叫什么、灶台在哪,只需要在单子上写菜名。点餐单就是命令对象——它封装了「做什么菜」和「口味要求」,可以在服务员手里排队,也可以被撤回(「那个菜不要了」)。
遥控器是另一个经典类比:每个按钮绑定一个命令对象,按下按钮就是 command.Execute()。同一个按钮可以绑定不同设备的命令——今天控制电视,明天控制空调。按钮不关心命令发给谁,它只负责触发。
三、核心思想
命令模式的核心是三层解耦:调用者(Invoker)只依赖命令接口,命令(Command)绑定接收者(Receiver)和动作,接收者执行实际工作。
3.1 命令接口与具体命令
命令接口定义两个方法:Execute() 执行操作,Undo() 撤销操作。具体命令持有接收者引用和执行所需的状态(参数、撤销快照等)。
// Command 是命令接口type Command interface { Execute() Undo()}具体命令的关键在于:它把「调用哪个对象的哪个方法」和「传什么参数」全部固化在自身内部。调用者拿到命令对象后,只需要调 Execute(),完全不需要知道背后发生了什么。
3.2 调用者与命令栈
调用者(Invoker)存储命令对象并触发执行。更关键的是,调用者维护一个命令栈用于撤销——每次执行命令后压栈,撤销时弹栈并调用 Undo()。
// Invoker 管理命令的执行和撤销type Invoker struct { history []Command // 已执行命令的栈}
func (inv *Invoker) Execute(cmd Command) { cmd.Execute() inv.history = append(inv.history, cmd)}
func (inv *Invoker) Undo() { if len(inv.history) == 0 { return } cmd := inv.history[len(inv.history)-1] inv.history = inv.history[:len(inv.history)-1] cmd.Undo()}撤销栈是后进先出(LIFO)的——最后执行的操作最先撤销,这和用户的直觉一致。重做(Redo)则需要在撤销时把命令压入另一个栈,重做时从重做栈弹出执行。
3.3 宏命令
宏命令是命令的组合:一个宏命令包含多个子命令,Execute() 依次执行所有子命令,Undo() 逆序撤销所有子命令。这就是宏录制的实现基础——用户操作时记录每个命令,回放时把命令序列包装成宏命令一次性执行。
// MacroCommand 宏命令——组合多个子命令type MacroCommand struct { commands []Command}
func (mc *MacroCommand) Execute() { for _, cmd := range mc.commands { cmd.Execute() }}
func (mc *MacroCommand) Undo() { // 逆序撤销,保证状态正确恢复 for i := len(mc.commands) - 1; i >= 0; i-- { mc.commands[i].Undo() }}3.4 复杂度分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| Execute | O(1) | 直接调用接收者方法 |
| Undo | O(1) | 从栈顶弹出并调用撤销 |
| 宏命令 Execute | O(n) | n 为子命令数量 |
| 宏命令 Undo | O(n) | 逆序撤销 n 个子命令 |
空间开销主要来自命令栈——每个已执行的命令对象都需要保留,直到被丢弃或栈被清空。对于长时间运行的编辑器,通常需要设置栈深度上限,超出时丢弃最早的命令。
四、变体与对比
| 模式 | 与命令的关系 | 核心区别 |
|---|---|---|
| 策略 | 两者都封装行为 | 命令关注「何时做什么」,策略关注「怎么做」 |
| 事件 | 两者都表示发生的事情 | 事件是单向通知,命令隐含动作且通常期望响应 |
| 状态机 | 命令可以触发状态转移 | 状态机按状态转移,命令按请求封装 |
| 迭代器 | 两者都封装访问模式 | 迭代器封装遍历,命令封装操作 |
4.1 命令 vs 策略:请求对象 vs 算法对象
命令和策略都把行为封装成对象,但意图不同。策略模式是「用不同的算法解决同一个问题」——排序策略、压缩策略、渲染策略。调用者知道自己在做什么,只是想换一种实现。命令模式是「把请求本身变成对象」——调用者可能根本不知道请求会被谁处理、什么时候处理。命令可以被存储、排队、撤销,策略通常不会。
// 策略:同一个操作的不同实现,调用者知道自己在排序interface SortStrategy { sort(data: number[]): number[];}
// 命令:请求被封装成对象,调用者不知道也不关心背后是什么interface Command { execute(): void; undo(): void;}一个简单的判断标准:如果对象需要 Undo(),它大概率是命令而不是策略。策略没有「撤销」的概念——你不会「撤销一次快速排序」。
4.2 命令 vs 事件:动作 vs 通知
命令和事件都表示「发生了某件事」,但语义不同。命令是「请做这件事」——发送者期望接收者执行动作,命令有明确的意图和方向。事件是「这件事发生了」——发送者不关心谁在听,甚至可能没人听。
| 维度 | 命令 | 事件 |
|---|---|---|
| 方向 | 点对点,发送者知道接收者 | 发布-订阅,发送者不知道接收者 |
| 期望 | 发送者期望动作被执行 | 发送者只是通知,不期望特定响应 |
| 失败 | 发送者需要处理失败 | 事件丢失通常不影响发送者 |
| 撤销 | 命令天然支持撤销 | 事件没有撤销的概念 |
4.3 命令队列与命令总线
命令对象可以放入队列,由工作线程依次取出执行——这就是命令队列。它解耦了命令的发起和执行时机:UI 线程提交命令,后台线程执行命令。数据库的写操作队列、消息中间件的任务队列,都是命令队列的应用。
命令总线(Command Bus)是命令队列的进阶形式,常见于 CQRS 架构。命令总线负责把命令路由到对应的处理器(Handler),命令对象只描述「做什么」,处理器决定「怎么做」。这种分离让命令的发送方和执行方完全解耦。
// CommandBus 命令总线——路由命令到处理器type CommandBus struct { handlers map[reflect.Type]CommandHandler}
type CommandHandler interface { Handle(cmd Command) error}
func (bus *CommandBus) Dispatch(cmd Command) error { handler, ok := bus.handlers[reflect.TypeOf(cmd)] if !ok { return fmt.Errorf("no handler for %T", cmd) } return handler.Handle(cmd)}4.4 撤销栈 vs 检查点回滚
撤销有两种实现策略:命令栈记录每个操作的增量变化,撤销时逆序回放;检查点(Checkpoint)在关键节点保存完整状态快照,撤销时恢复到最近的检查点。
| 维度 | 命令栈 | 检查点 |
|---|---|---|
| 内存占用 | 低——只存增量 | 高——存完整快照 |
| 撤销速度 | O(1)——弹出栈顶 | O(1)——恢复快照 |
| 实现复杂度 | 高——每个命令需实现 Undo | 低——直接序列化状态 |
| 适用场景 | 操作粒度细、对象大 | 操作粒度粗、对象小 |
Photoshop 用检查点(历史记录快照),VS Code 用命令栈(TextBuffer 的 undo 栈)。选择哪种取决于被操作对象的大小和操作的粒度。
五、多语言实现
5.1 Go 实现
package main
import "fmt"
// Command 命令接口type Command interface { Execute() Undo()}
// TextBuffer 文本缓冲区——接收者type TextBuffer struct { content string}
func (tb *TextBuffer) Insert(text string, pos int) { tb.content = tb.content[:pos] + text + tb.content[pos:]}
func (tb *TextBuffer) Delete(pos, length int) string { deleted := tb.content[pos : pos+length] tb.content = tb.content[:pos] + tb.content[pos+length:] return deleted}
func (tb *TextBuffer) Content() string { return tb.content}
// InsertCommand 插入命令type InsertCommand struct { buffer *TextBuffer text string pos int}
func NewInsertCommand(buffer *TextBuffer, text string, pos int) *InsertCommand { return &InsertCommand{buffer: buffer, text: text, pos: pos}}
func (ic *InsertCommand) Execute() { ic.buffer.Insert(ic.text, ic.pos)}
func (ic *InsertCommand) Undo() { ic.buffer.Delete(ic.pos, len(ic.text))}
// DeleteCommand 删除命令type DeleteCommand struct { buffer *TextBuffer pos int length int deleted string // 撤销时需要恢复的文本}
func NewDeleteCommand(buffer *TextBuffer, pos, length int) *DeleteCommand { return &DeleteCommand{buffer: buffer, pos: pos, length: length}}
func (dc *DeleteCommand) Execute() { dc.deleted = dc.buffer.Delete(dc.pos, dc.length)}
func (dc *DeleteCommand) Undo() { dc.buffer.Insert(dc.deleted, dc.pos)}
// Editor 编辑器——调用者type Editor struct { buffer *TextBuffer undoStack []Command redoStack []Command}
func NewEditor(buffer *TextBuffer) *Editor { return &Editor{buffer: buffer}}
func (e *Editor) Execute(cmd Command) { cmd.Execute() e.undoStack = append(e.undoStack, cmd) e.redoStack = nil // 新操作清空重做栈}
func (e *Editor) Undo() { if len(e.undoStack) == 0 { return } cmd := e.undoStack[len(e.undoStack)-1] e.undoStack = e.undoStack[:len(e.undoStack)-1] cmd.Undo() e.redoStack = append(e.redoStack, cmd)}
func (e *Editor) Redo() { if len(e.redoStack) == 0 { return } cmd := e.redoStack[len(e.redoStack)-1] e.redoStack = e.redoStack[:len(e.redoStack)-1] cmd.Execute() e.undoStack = append(e.undoStack, cmd)}
func main() { buffer := &TextBuffer{} editor := NewEditor(buffer)
// 插入 "Hello" editor.Execute(NewInsertCommand(buffer, "Hello", 0)) fmt.Println(buffer.Content()) // Hello
// 插入 " World" editor.Execute(NewInsertCommand(buffer, " World", 5)) fmt.Println(buffer.Content()) // Hello World
// 撤销 editor.Undo() fmt.Println(buffer.Content()) // Hello
// 重做 editor.Redo() fmt.Println(buffer.Content()) // Hello World
// 删除 " World" editor.Execute(NewDeleteCommand(buffer, 5, 6)) fmt.Println(buffer.Content()) // Hello
// 撤销删除 editor.Undo() fmt.Println(buffer.Content()) // Hello World}Go 的实现展示了命令模式的完整闭环:InsertCommand 和 DeleteCommand 各自维护撤销所需的状态——插入命令记住插入位置和文本,撤销时删除对应内容;删除命令记住被删除的文本,撤销时重新插入。Editor 维护双栈(撤销栈 + 重做栈),新操作清空重做栈,撤销时命令从撤销栈移到重做栈,重做时反向移动。这是几乎所有编辑器 undo/redo 的标准实现。
5.2 TypeScript 实现
// Command 命令接口interface Command { execute(): void; undo(): void;}
// UI 面板状态——接收者interface PanelState { visible: boolean; position: { x: number; y: number }; width: number; height: number;}
class Panel { private state: PanelState;
constructor(initial: PanelState) { this.state = { ...initial }; }
show() { this.state.visible = true; } hide() { this.state.visible = false; } move(x: number, y: number) { this.state.position = { x, y }; } resize(width: number, height: number) { this.state.width = width; this.state.height = height; }
getState(): PanelState { // 返回深拷贝,防止外部修改 return JSON.parse(JSON.stringify(this.state)); }
restore(state: PanelState) { this.state = JSON.parse(JSON.stringify(state)); }}
// MovePanelCommand 移动面板命令class MovePanelCommand implements Command { private panel: Panel; private oldPos: { x: number; y: number }; private newPos: { x: number; y: number };
constructor(panel: Panel, x: number, y: number) { this.panel = panel; this.newPos = { x, y }; this.oldPos = panel.getState().position; }
execute() { this.panel.move(this.newPos.x, this.newPos.y); }
undo() { this.panel.move(this.oldPos.x, this.oldPos.y); }}
// TogglePanelCommand 显示/隐藏面板命令class TogglePanelCommand implements Command { private panel: Panel; private wasVisible: boolean;
constructor(panel: Panel) { this.panel = panel; this.wasVisible = panel.getState().visible; }
execute() { this.wasVisible ? this.panel.hide() : this.panel.show(); }
undo() { this.wasVisible ? this.panel.show() : this.panel.hide(); }}
// CommandManager 命令管理器class CommandManager { private undoStack: Command[] = []; private redoStack: Command[] = [];
execute(cmd: Command) { cmd.execute(); this.undoStack.push(cmd); this.redoStack = []; // 新操作清空重做栈 }
undo() { const cmd = this.undoStack.pop(); if (!cmd) return; cmd.undo(); this.redoStack.push(cmd); }
redo() { const cmd = this.redoStack.pop(); if (!cmd) return; cmd.execute(); this.undoStack.push(cmd); }
canUndo(): boolean { return this.undoStack.length > 0; } canRedo(): boolean { return this.redoStack.length > 0; }}
// 使用示例const panel = new Panel({ visible: true, position: { x: 100, y: 100 }, width: 300, height: 200,});
const manager = new CommandManager();
// 移动面板manager.execute(new MovePanelCommand(panel, 200, 150));console.log(panel.getState().position); // { x: 200, y: 150 }
// 撤销移动manager.undo();console.log(panel.getState().position); // { x: 100, y: 100 }
// 重做manager.redo();console.log(panel.getState().position); // { x: 200, y: 150 }TypeScript 的实现面向 UI 场景。MovePanelCommand 在构造时保存旧位置,撤销时恢复。TogglePanelCommand 记住操作前的可见状态,撤销时恢复到之前的状态。CommandManager 提供了 canUndo() / canRedo() 方法,方便 UI 层控制按钮的启用/禁用状态——这是实际前端项目中的常见需求。
六、生产验证
-
JUnit 5 —— Command.java JUnit 的测试执行框架将每个测试用例封装为命令对象。
TestDescriptor描述「要执行什么测试」,TestExecutionListener充当调用者,EngineExecutionContext是接收者。测试的发现(Discovery)、执行(Execution)、报告(Reporting)是三种不同的命令阶段,每个阶段可以独立扩展。这种设计让 JUnit 5 支持参数化测试、重复测试、动态测试——这些本质上都是不同类型的命令,由框架统一调度。 -
Emacs —— keyboard.c#Lcmd_error Emacs 的命令循环(Command Loop)是命令模式的经典实现。用户按键被映射为命令符号(如
kill-line、yank),每个命令符号绑定到一个 Elisp 函数。命令循环不断读取按键、查找绑定、执行命令,执行结果修改缓冲区状态。Emacs 的撤销机制把缓冲区变更记录为撤销记录(undo record),每个记录就是一次命令的增量效果。宏录制则直接记录按键序列,回放时重新送入命令循环。 -
MediatR —— MediatR/src/MediatR/SendPipeline.cs MediatR 是 .NET 生态最流行的命令总线库。
IRequest<TResponse>接口定义命令(或查询),IRequestHandler<TRequest, TResponse>定义处理器。IMediator.Send(command)把命令路由到注册的处理器。MediatR 还支持管道行为(Pipeline Behavior)——在命令到达处理器之前执行横切逻辑(验证、日志、缓存),这和 HTTP 中间件的概念一致。CQRS 架构中,MediatR 的命令总线是写操作的标准入口,查询走另一条路径,读写分离。
七、小结
何时使用:
- 编辑器和绘图工具——需要撤销/重做的交互式应用,每个操作封装为命令对象,双栈管理撤销和重做。VS Code、Figma、Photoshop 都在用
- 任务队列和调度——命令对象可以序列化后存入队列,由工作进程取出执行。数据库写队列、CI/CD 流水线、消息中间件的任务分发
- 宏录制和回放——用户操作记录为命令序列,一键回放。Excel 宏、Photoshop 动作、Emacs 键盘宏
- CQRS 架构——命令总线将写操作封装为命令对象,路由到处理器,实现读写分离和横切关注点
何时不用:
- 操作不需要撤销——如果应用没有 undo 需求,命令模式引入的间接层就是过度设计。一个只读的数据展示页面,按钮直接调方法就好
- 请求是同步且唯一的——调用者只有一个,接收者只有一个,不需要排队、不需要记录,直接调用更简单
- 命令数量爆炸——如果每个操作都是不同的命令类,类数量会急剧膨胀。此时可以考虑用数据驱动的方式:一个通用命令类,操作类型作为字段,而不是为每种操作建一个类
八、参考资料
- Design Patterns: Elements of Reusable Object-Oriented Software - GoF 原著,命令模式的经典定义
- CQRS - Martin Fowler 对 CQRS 模式的阐述,命令总线的理论依据
- JUnit 5 Source - 测试框架中命令模式的实际应用
- MediatR - .NET 生态的命令总线实现,CQRS 实践参考
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






