命令模式完全指南:从 Undo/Redo 到 Event Sourcing 与 CQRS

命令模式听起来抽象,但落地极其具体:把"做一件事"封装成一个对象。一旦做到这点,你可以排队、撤销、重做、记录、回放、跨进程传输 —— 这些能力都是因为"操作变成数据"而获得的。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 通过依赖注入获取。

识别命令场景

关键信号:

  1. 需要撤销/重做
  2. 需要排队 / 异步执行(消息队列、任务队列)。
  3. 需要持久化 / 重放(事件溯源、binlog)。
  4. 需要把"操作"作为参数传递(回调、宏、批量操作)。
  5. 需要统一拦截(权限、日志、Tracing)所有操作。

命中任一项,命令模式是首选。

写在最后

命令模式的本质是"把动词变成名词" —— 一旦"做某件事"变成"某个对象",所有对对象能做的事(传递、存储、序列化、组合、统一处理)都对它生效。Redux、消息队列、Git、Event Sourcing、CQRS、Undo/Redo —— 这些看似毫无关系的技术,本质都是命令模式的不同尺度演绎。

给一个判断信号:当你的代码里有一组"做某件事"的函数,且它们需要被同等对待(排队、撤销、回放、记录)时 —— 命令模式让所有可能性都成立。它把动作"对象化"的这一步,是函数式 vs 面向对象、命令式 vs 声明式之间最深刻的桥梁。理解了命令,你就理解了为什么"action 驱动"的架构在现代系统里越来越主流。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

责任链模式完全指南:从 if/else 地狱到中间件管道的工程演化

2026-5-15 15:29:19

技术教程

迭代器模式完全指南:从 for-each 到 Stream / Generator 的现代演化

2026-5-15 15:29:20

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索