设计模式实战(九):命令与状态模式——CQRS 与状态机的落地

写在前面

收官篇。讲两个行为型模式——命令状态。选它俩收尾,是因为它们正好接上 DDD 系列的两个核心:命令模式 = CQRS 里的 Command 对象(DDD 第五篇);状态模式 = 订单聚合里的状态流转(DDD 第二篇)。

讲完这篇,设计模式系列和 DDD 系列就完美合龙了。


一、命令模式:把"请求"变成对象

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
场景:一个编辑器,要做"加粗""复制""粘贴"等操作,还要支持【撤销/重做】。

  朴素写法(直接调方法):
    editor.Bold();  editor.Copy();  editor.Paste();
  问题:
  ✗ 没法撤销(不知道"刚才做了什么")
  ✗ 没法排队、记录日志、宏录制
  ✗ 调用者(按钮/快捷键)和具体操作绑死

  反转:把"做一件事"封装成一个对象,里面记录"做什么 + 怎么撤"。
       调用者只管"执行这个对象",不知道里面是什么操作。
  → 这就是命令模式。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
意图:把请求封装成对象,从而可以参数化、排队、记录、撤销。

  结构:
    ICommand            ← 命令接口(Execute / Undo)
    ConcreteCommand      ← 每个操作一个命令对象(持有接收者)
    Invoker              ← 调用者(按钮、队列),触发命令
    Receiver             ← 真正干活的(Editor、Service)

  价值:
    ✓ 撤销/重做(每个命令记录"怎么撤")
    ✓ 队列/调度/延迟执行(命令是对象,可存可排)
    ✓ 宏/日志/重放(命令序列化、记录、批量)
    ✓ 调用者与接收者解耦

二、命令模式手写

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 命令接口:执行 + 撤销
public interface ICommand
{
    void Execute();
    void Undo();
}

// 具体命令:加粗(持有接收者 Editor)
public class BoldCommand(Editor _editor) : ICommand
{
    private string? _previousText;   // 记录撤销所需的状态
    public void Execute()
    {
        _previousText = _editor.Text;
        _editor.BoldSelection();
    }
    public void Undo() => _editor.Text = _previousText!;
}

// 调用者:命令历史栈,支持撤销
public class CommandHistory
{
    private readonly Stack<ICommand> _done = new();
    public void Execute(ICommand cmd) { cmd.Execute(); _done.Push(cmd); }
    public void Undo() { if (_done.Count > 0) _done.Pop().Undo(); }
}
1
2
3
4
关键:每个命令对象封装了"做什么 + 怎么撤",
     调用者(历史栈)只管 Push/Pop/Execute,不关心具体操作。
  → 加新操作 = 加一个命令类,调用者不动(开闭)。
  → 撤销/重做、命令队列、宏录制,全靠"命令是对象"这一点。

三、.NET 里的命令模式:CQRS 的 Command

我在 DDD 第五篇讲 CQRS 时说过:写侧的入口是"命令"(CancelOrderCommand)。那正是命令模式的工程化应用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// CQRS 命令对象(MediatR 的 IRequest)
public sealed record CancelOrderCommand(Guid OrderId, string Reason) : IRequest;

// 命令处理器(接收者)
public class CancelOrderHandler(IOrderRepository _repo) : IRequestHandler<CancelOrderCommand>
{
    public async Task Handle(CancelOrderCommand cmd, CancellationToken ct)
    {
        var order = await _repo.Find(cmd.OrderId, ct) ?? throw new NotFoundException();
        order.Cancel(cmd.Reason);
        await _repo.Save(order, ct);
    }
}

// 调用者:发命令,不关心谁处理
await _mediator.Send(new CancelOrderCommand(orderId, "误操作"));
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
对应关系:
  ICommand(Execute)      → CancelOrderCommand(数据载体)+ Handler(逻辑)
  Invoker                  → MediatR(_mediator.Send 派发)
  Receiver                 → Order 聚合 / 仓储

  CQRS 把"命令"提升为一等公民:
    ✓ 可序列化、可入队(消息队列)、可重放
    ✓ 写侧与读侧、调用方与处理方解耦
    ✓ 配合事件溯源,命令/事件可审计
  → 这就是命令模式在现代架构里的"高阶用法"。

四、状态模式:行为随状态变

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
场景:订单有 草稿/已下单/已支付/已发货/已取消 多种状态,
     每种状态下,"能不能改地址""能不能取消""能不能再付款"的规则都不一样。

  朴素写法(每个方法里一堆 switch 状态):
    void Cancel() {
      switch (Status) {
        case Draft: ...; break;
        case Placed: ...; break;
        case Shipped: throw "已发货不能取消"; break;
        ...
      }
    }
    void ChangeAddress() { switch (Status) {...} }
    void Pay() { switch (Status) {...} }
  病:
  ✗ 每个方法都重复判断状态,switch 满天飞
  ✗ 加新状态 = 改所有方法的 switch
  ✗ 状态流转规则散落,看不出"某状态下能做什么"

  反转:把"每种状态下的行为"封装成对象,让当前状态对象决定行为。
  → 这就是状态模式。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
意图:对象行为随状态变化,把每种状态封装成类,自动切换。

  结构:
    IState             ← 状态接口(Cancel/Pay/ChangeAddress)
    ConcreteState       ← 每种状态一个实现(DraftState/PaidState...)
    Context             ← 持有当前状态,把行为委托给它

  vs 一堆 switch:
    ✓ 每种状态的行为内聚在一个类
    ✓ 加新状态 = 加一个状态类,老的不动(开闭)
    ✓ 状态流转规则集中、清晰

五、状态模式 vs DDD 的状态机

在 DDD 第二篇里,订单聚合的状态流转是这样写的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public class Order : Entity<OrderId>, IAggregateRoot
{
    public OrderStatus Status { get; private set; }

    public void Cancel(string reason)
    {
        if (Status >= OrderStatus.Shipped)
            throw new DomainException("已发货不能取消");
        Status = OrderStatus.Cancelled;
    }
    public void MarkPaid() { /* 仅 Placed → Paid */ }
    public void Ship()     { /* 仅 Paid → Shipped */ }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
这是状态模式的"轻量版":
  - 状态用枚举表示(OrderStatus)
  - 行为内聚在聚合根方法里(Cancel/MarkPaid/Ship 各自校验 + 流转)
  - 没有为每种状态单独建类(订单状态不多,枚举够用)

  对比完整状态模式(每种状态一个类):
    当状态非常多、每个状态行为复杂、流转规则庞大时,
    才值得拆成 IState + 各 State 类。
    订单这种 5 个状态、规则简单的场景,枚举 + 聚合方法就够(YAGNI)。

  → DDD 的 OrderStatus 是状态模式的务实落地,不是完整形态。

六、状态 vs 策略(极易混)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
两者结构几乎一样(都是"持有当前对象,委托行为"),但意图不同:

  策略模式:客户端【主动选】一个算法
    → 算法之间平等,可随时替换,互不转换
    → 例:选折扣算法、选排序方式
    → "用哪个"由外部决定

  状态模式:对象【根据当前状态】自动表现不同行为,状态间会【转换】
    → 状态之间有流转关系(Draft → Placed → Paid)
    → "现在是哪个"由对象自己管理
    → 例:订单状态机、TCP 连接状态

  鉴别:
    选项会互相转换、有生命周期 → 状态
    选项平行、随时换、互不转换 → 策略

七、何时用 / 何时别用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
命令模式:
  ✓ 需要撤销/重做、命令队列、宏录制、任务调度
  ✓ 调用者与执行者解耦(CQRS、事件驱动)
  ✗ 简单一次性调用、不需要撤销/排队 → 直接调方法

状态模式:
  ✓ 对象状态多、每状态行为不同、状态间有流转
  ✓ switch(state) 满天飞、加状态要改一堆方法
  ✗ 状态少、规则简单 → 枚举 + 几个 if 就够(DDD 订单就是)
  ✗ 是"选算法"而非"状态流转" → 那是策略

八、小结

  • 命令模式:把请求封装成对象 → 可撤销/排队/记录/解耦
    • 手写:ICommand(Execute/Undo)+ 接收者 + 历史栈
    • .NET 高阶:CQRS 的 Command + MediatR(呼应 DDD 第五篇)
  • 状态模式:把每种状态的行为封装成类,行为随状态变
    • 解决 switch(state) 满天飞,加状态加类(开闭)
    • DDD 的 OrderStatus 是它的轻量落地(枚举够用就别拆类)
  • 状态 vs 策略:状态有流转/生命周期,策略是平行可替换
  • 务实原则:简单场景用枚举/直接调用,复杂了再上完整模式

九、设计模式系列总结

九篇下来,一条主线:

  • (一)SOLID 是底座:把变化关进笼子,所有模式的共同骨架
  • (二)策略:算法族可替换,干掉 switch
  • (三)工厂:封装创建,DI 容器是终极形态
  • (四)建造者:分步构造,对抗伸缩构造函数
  • (五)装饰器:透明叠加功能,组合优于继承
  • (六)责任链:处理者串链,ASP.NET Core 中间件的真相
  • (七)观察者:一对多通知,C# event 天生支持
  • (八)单例:全局唯一,优先 DI Singleton 别手写
  • (九)命令与状态:请求即对象(CQRS)、行为随状态变(状态机)

回到第一篇的初心:模式是"有名字的设计经验",不是教条。.NET 里很多模式已经融化进语言和框架(event/委托/DI/ LINQ),你不必死记 UML,要记的是意图——什么问题、用什么解、付出什么代价。

和 DDD 系列、微服务系列合在一起看,你会发现它们都在讲同一件事:把复杂度控制在边界内、把变化封装起来、让代码对扩展开放对修改关闭。SOLID 是原则,模式是套路,DDD 是建模,架构是组合——殊途同归。真正理解了这一层,写出来的代码,天然就是好代码。