把章节变成可单步观察的过程
分类:工程设计
先说结论
Command 表达“请执行一个动作”,可以排队、记录结果并按明确规则撤销;Event 表达“某件事已经发生”,监听者不应决定原动作是否成立。撤销依赖执行时保存的事实,不能靠重新猜一个反向操作。
加金币命令的执行与撤销
执行前金币:90
命令:AddGold(+10)
执行后金币:100
命令记录:实际增加 10
事件:GoldChanged(90 -> 100)
撤销:按记录减去 10,金币回到 90
若金币上限为 95,实际只增加 5,撤销时也只能减 5。直接调用 AddGold(-10) 会得到 85,说明“反向参数”不等于可靠撤销。
先用生活模型理解命令
想象一张办理单:
申请人写下“把物品从 A 柜移动到 B 柜”。
窗口接收办理单。
工作人员稍后执行。
办理结果可能成功,也可能明确失败。
若规则允许,还可以按记录把操作撤销。
办理单不是事情已经发生的通知,而是一个希望系统执行的请求。
这就是命令的核心:
命令描述意图。
执行者决定能否执行。
执行结果必须明确。
把请求封装成对象后,它可以被排队、记录、重放、撤销或交给不同执行时机处理。
为什么需要命令模式
直接调用方法并没有错:
document.Insert("示例文字");
当调用方只需立即完成一次简单操作时,直接调用通常最清楚。
当系统需要下面一种或多种能力时,命令对象才开始有价值:
- 请求产生时间与执行时间分离;
- 请求需要进入队列并按规则处理;
- 执行前需要统一校验;
- 需要记录成功操作以支持撤销;
- 需要重放同一类确定性操作;
- 调用方不应直接依赖具体执行对象。
命令模式的代价是多出对象、接口和状态管理,因此不应只为了“套模式”而使用。
命令由哪些部分组成
一个最小命令模型通常包含:
调用方:提出请求。
命令:保存执行所需参数。
执行器:执行命令。
接收者:真正持有并修改状态的对象。
结果:明确表示成功或失败。
有时执行器和接收者会合并;有时命令对象自己持有 Execute。两种结构都可以,关键是职责边界清楚。
命令与事件不是一回事
命令和事件都可能被封装成消息,但语义完全不同:
| 对比项 | 命令 | 事件 |
|---|---|---|
| 表达内容 | 希望执行的动作 | 已经发生的事实 |
| 时间语义 | 将要做 | 已做完 |
| 处理者数量 | 通常有一个明确责任方 | 可以有零个、一个或多个监听者 |
| 能否拒绝 | 可以校验并失败 | 事实不能被监听者“拒绝为未发生” |
| 返回结果 | 常需要明确结果 | 通常不依赖监听者返回值控制原操作 |
| 命名习惯 | MoveItem、InsertText |
ItemMoved、TextInserted |
错误的边界是:
发布“请求移动”事件,
然后期待某个不确定的监听者负责真正移动。
这样调用方不知道是否有人处理,也不知道处理是否成功。
更清楚的顺序是:
提交 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 为历史命令数 |
主要工程代价通常不是队列本身,而是:
- 命令对象和快照占用内存;
- 历史长度需要明确管理;
- 异步命令使完成顺序更复杂;
- 撤销要求状态变化可逆且可验证;
- 重放要求输入和外部依赖具有可解释的一致性。
何时不适合使用命令模式
- 操作只是局部、立即且没有排队或撤销需求;
- 封装后没有减少调用方与执行细节的耦合;
- 外部副作用无法可靠逆转,却被强行包装为可撤销;
- 请求结果必须同步返回,但系统又把它无意义地绕进队列;
- 操作语义本来是“事实通知”,却被写成命令。
模式应服务于状态和时间边界,而不是增加文件数量。
小结
命令是请求,事件是事实。
入队不等于执行成功。
只有明确成功的命令才应进入撤销历史。
撤销必须保存并验证执行前状态。
失败策略、队列顺序和重做分支都必须显式定义。
设计命令系统时,先回答四个问题:
- 谁是唯一负责执行请求的人?
- 请求何时算成功,失败怎样保持可见?
- 哪些状态必须保存,才能可靠撤销?
- 成功后发布什么事实事件,监听者是否影响原操作结果?
边界清楚后,队列、栈和接口只是承载这些规则的数据结构。