DYNAMIC EXPLAINER / ARTICLE FLOW

把章节变成可单步观察的过程

01 / 13
STEP 01 / CODE / LOGIC

先说结论

Command 表达“请执行一个动作”,可以排队、记录结果并按明确规则撤销;Event 表达“某件事已经发生”,监听者不应决定原动作是否成立。撤销依赖执行时保存的事实,不能靠重新猜一个反向操作。

执行前金币:90
命令:AddGold(+10)
执行后金币:100
命令记录:实际增加 10
事件:GoldChanged(90 -> 100)
撤销:按记录减去 10,金币回到 90

分类:工程设计

先说结论

Command 表达“请执行一个动作”,可以排队、记录结果并按明确规则撤销;Event 表达“某件事已经发生”,监听者不应决定原动作是否成立。撤销依赖执行时保存的事实,不能靠重新猜一个反向操作。

加金币命令的执行与撤销

执行前金币:90
命令:AddGold(+10)
执行后金币:100
命令记录:实际增加 10
事件:GoldChanged(90 -> 100)
撤销:按记录减去 10,金币回到 90

若金币上限为 95,实际只增加 5,撤销时也只能减 5。直接调用 AddGold(-10) 会得到 85,说明“反向参数”不等于可靠撤销。

先用生活模型理解命令

想象一张办理单:

申请人写下“把物品从 A 柜移动到 B 柜”。
窗口接收办理单。
工作人员稍后执行。
办理结果可能成功,也可能明确失败。
若规则允许,还可以按记录把操作撤销。

办理单不是事情已经发生的通知,而是一个希望系统执行的请求

这就是命令的核心:

命令描述意图。
执行者决定能否执行。
执行结果必须明确。

把请求封装成对象后,它可以被排队、记录、重放、撤销或交给不同执行时机处理。

为什么需要命令模式

直接调用方法并没有错:

document.Insert("示例文字");

当调用方只需立即完成一次简单操作时,直接调用通常最清楚。

当系统需要下面一种或多种能力时,命令对象才开始有价值:

  • 请求产生时间与执行时间分离;
  • 请求需要进入队列并按规则处理;
  • 执行前需要统一校验;
  • 需要记录成功操作以支持撤销;
  • 需要重放同一类确定性操作;
  • 调用方不应直接依赖具体执行对象。

命令模式的代价是多出对象、接口和状态管理,因此不应只为了“套模式”而使用。

命令由哪些部分组成

一个最小命令模型通常包含:

调用方:提出请求。
命令:保存执行所需参数。
执行器:执行命令。
接收者:真正持有并修改状态的对象。
结果:明确表示成功或失败。

有时执行器和接收者会合并;有时命令对象自己持有 Execute。两种结构都可以,关键是职责边界清楚。

命令与事件不是一回事

命令和事件都可能被封装成消息,但语义完全不同:

对比项 命令 事件
表达内容 希望执行的动作 已经发生的事实
时间语义 将要做 已做完
处理者数量 通常有一个明确责任方 可以有零个、一个或多个监听者
能否拒绝 可以校验并失败 事实不能被监听者“拒绝为未发生”
返回结果 常需要明确结果 通常不依赖监听者返回值控制原操作
命名习惯 MoveItemInsertText ItemMovedTextInserted

错误的边界是:

发布“请求移动”事件,
然后期待某个不确定的监听者负责真正移动。

这样调用方不知道是否有人处理,也不知道处理是否成功。

更清楚的顺序是:

提交 MoveItemCommand
    ↓
明确执行成功
    ↓
发布 ItemMovedEvent

命令负责改变状态,事件负责传播已经发生的事实。

命令队列怎样运行

队列把“提交请求”和“执行请求”分开。

逐步状态

下面是一个教学流程:

创建命令
    ↓
Queued:进入待执行队列
    ↓
Executing:执行器取出并校验
    ├─ Succeeded:状态修改完成,可按规则进入撤销栈
    └─ Failed:保留失败信息,不进入成功历史

队列通常使用先进先出结构,但这不是命令模式强制规定。若系统采用优先级、截止时间或分组顺序,必须把排序规则显式写入队列契约。

为什么提交时不要假装成功

进入队列只表示:

请求已被接收,等待执行。

它不等于:

状态已经改变。

如果接口把“已排队”返回成“执行成功”,后续代码可能读取到尚未更新的状态,也可能掩盖之后发生的校验失败。

因此应区分:

EnqueueResult:是否成功进入队列。
ExecutionResult:实际执行是否成功。

最小 C# 示例

下面的示例展示队列、执行结果和撤销记录。字符串和容量均为教学示例数据。

public readonly record struct CommandResult(bool Succeeded, string Message)
{
    public static CommandResult Success(string message) => new(true, message);
    public static CommandResult Failure(string message) => new(false, message);
}

public interface ICommand
{
    CommandResult Execute();
    void Undo();
}

public sealed class TextBuffer
{
    private readonly List<string> _parts = new();

    public int Count => _parts.Count;

    public void Add(string text)
    {
        _parts.Add(text);
    }

    public string RemoveLast()
    {
        if (_parts.Count == 0)
        {
            throw new InvalidOperationException("没有可移除的文本。");
        }

        int lastIndex = _parts.Count - 1;
        string removed = _parts[lastIndex];
        _parts.RemoveAt(lastIndex);
        return removed;
    }

    public string PeekLast()
    {
        if (_parts.Count == 0)
        {
            throw new InvalidOperationException("没有可检查的文本。");
        }

        return _parts[^1];
    }

    public override string ToString()
    {
        return string.Concat(_parts);
    }
}

public sealed class AppendTextCommand : ICommand
{
    private readonly TextBuffer _buffer;
    private readonly string _text;
    private bool _executed;

    public AppendTextCommand(TextBuffer buffer, string text)
    {
        _buffer = buffer;
        _text = text;
    }

    public CommandResult Execute()
    {
        if (_executed)
        {
            return CommandResult.Failure("同一个命令实例不能重复执行。");
        }

        if (string.IsNullOrEmpty(_text))
        {
            return CommandResult.Failure("待追加文本不能为空。");
        }

        _buffer.Add(_text);
        _executed = true;
        return CommandResult.Success("追加完成。");
    }

    public void Undo()
    {
        if (!_executed)
        {
            throw new InvalidOperationException("未成功执行的命令不能撤销。");
        }

        // 先做只读校验,失败时不再改变缓冲区。
        if (_buffer.PeekLast() != _text)
        {
            throw new InvalidOperationException("缓冲区已变化,无法安全撤销。");
        }

        _buffer.RemoveLast();
        _executed = false;
    }
}

public sealed class CommandQueue
{
    private readonly Queue<ICommand> _pending = new();
    private readonly Stack<ICommand> _history = new();

    public void Enqueue(ICommand command)
    {
        ArgumentNullException.ThrowIfNull(command);
        _pending.Enqueue(command);
    }

    public CommandResult ExecuteNext()
    {
        if (_pending.Count == 0)
        {
            return CommandResult.Failure("队列中没有待执行命令。");
        }

        ICommand command = _pending.Dequeue();
        CommandResult result = command.Execute();

        // 只有明确成功的命令才进入撤销历史。
        if (result.Succeeded)
        {
            _history.Push(command);
        }

        return result;
    }

    public void UndoLast()
    {
        if (_history.Count == 0)
        {
            throw new InvalidOperationException("没有可撤销的成功命令。");
        }

        ICommand command = _history.Pop();
        command.Undo();
    }
}

var buffer = new TextBuffer();
var queue = new CommandQueue();

queue.Enqueue(new AppendTextCommand(buffer, "甲"));
queue.Enqueue(new AppendTextCommand(buffer, "乙"));
// “甲”“乙”仅是教学示例文本。

Console.WriteLine(queue.ExecuteNext());
Console.WriteLine(queue.ExecuteNext());
Console.WriteLine(buffer); // 教学示例输出:甲乙

queue.UndoLast();
Console.WriteLine(buffer); // 教学示例输出:甲

这个示例故意让失败保持可见:

  • 执行校验失败会返回明确的 CommandResult.Failure
  • 不可撤销状态会抛出异常。
  • 只有成功执行的命令进入历史。

它没有把失败替换成“什么也没发生但仍算成功”。

撤销不是“再调用一次反向方法”

撤销要求恢复命令执行前的可识别状态。

简单命令可能只需保存少量数据:

插入文本 -> 记录插入位置和内容
改变数值 -> 记录修改前的数值
移动元素 -> 记录原位置

复杂命令可能影响多个对象。此时需要先明确原子边界:

全部修改成功,命令才算成功;
任何一步失败,都不能留下无法解释的半完成状态。

具体怎样保证原子性,取决于状态存储方式。可能使用预校验、不可变快照、事务或补偿操作,但不能在没有契约的情况下假设某种方式天然安全。

撤销栈的逐步状态

教学示例:

执行 A 成功 -> 历史 [A]
执行 B 成功 -> 历史 [A, B]
执行 C 失败 -> 历史仍为 [A, B]
撤销一次     -> 弹出 B,历史 [A]

失败命令不应进入成功历史,否则“撤销失败操作”没有明确含义。

重做

若支持重做,通常还需要一个重做栈:

撤销成功 -> 命令进入重做栈
执行新的普通命令 -> 清空旧重做分支

“执行新命令后是否保留重做分支”属于产品语义,不是命令模式自动决定的规则。

进阶:队列中的失败路径

命令队列必须明确失败如何影响后续命令。

可能的策略包括:

停止:当前命令失败后暂停队列。
继续:记录失败,然后处理下一条。
整批原子执行:任一失败则整批不生效。

这些策略会改变外部可见行为,不能由队列随意默认。

本文的最小示例一次只执行一个命令,并把结果交还调用方决定下一步,因此没有隐藏失败,也没有擅自继续整批操作。

常见失败来源

  • 命令参数在执行时已经失效;
  • 接收者状态与创建命令时不同;
  • 同一命令实例被重复执行;
  • 撤销所依赖的历史状态已被其他操作改变;
  • 队列被多线程同时访问但没有同步约束;
  • 命令在执行一半时抛出异常。

如果命令可能部分修改后失败,必须从源头设计一致性边界。仅仅捕获异常并返回成功会掩盖损坏状态。

命令与事件的完整边界

教学流程可以写成:

1. 调用方创建 ChangeNameCommand。
2. 执行器校验并执行命令。
3. 接收者状态完成修改。
4. 命令返回成功结果。
5. 发布 NameChangedEvent,描述旧值与新值。
6. 监听者更新各自派生状态。

若第 3 步失败,就没有“名称已改变”这个事实,不应发布成功事件。

事件监听者后续失败,并不会让第 3 步在时间上变成“从未发生”。是否需要事务式联动,必须在更高层明确设计,不能靠把事件改名成命令解决。

复杂度与代价

使用普通队列和栈时:

操作 常见时间复杂度 主要代价
命令入队 O(1) 保存命令对象
取出下一命令 O(1) 实际执行成本另计
成功命令入历史栈 O(1) 保存撤销所需状态
撤销最近命令 O(1) 加撤销逻辑成本 恢复状态
查看全部历史 O(n) n 为历史命令数

主要工程代价通常不是队列本身,而是:

  • 命令对象和快照占用内存;
  • 历史长度需要明确管理;
  • 异步命令使完成顺序更复杂;
  • 撤销要求状态变化可逆且可验证;
  • 重放要求输入和外部依赖具有可解释的一致性。

何时不适合使用命令模式

  • 操作只是局部、立即且没有排队或撤销需求;
  • 封装后没有减少调用方与执行细节的耦合;
  • 外部副作用无法可靠逆转,却被强行包装为可撤销;
  • 请求结果必须同步返回,但系统又把它无意义地绕进队列;
  • 操作语义本来是“事实通知”,却被写成命令。

模式应服务于状态和时间边界,而不是增加文件数量。

小结

命令是请求,事件是事实。
入队不等于执行成功。
只有明确成功的命令才应进入撤销历史。
撤销必须保存并验证执行前状态。
失败策略、队列顺序和重做分支都必须显式定义。

设计命令系统时,先回答四个问题:

  1. 谁是唯一负责执行请求的人?
  2. 请求何时算成功,失败怎样保持可见?
  3. 哪些状态必须保存,才能可靠撤销?
  4. 成功后发布什么事实事件,监听者是否影响原操作结果?

边界清楚后,队列、栈和接口只是承载这些规则的数据结构。