命令模式实战:把“下单后的动作“打包成可执行的任务

一句话定位:将一个请求封装为一个对象,从而可用不同的请求对客户进行参数化;对请求排队或记录请求日志,以及支持可撤销的操作。

一、实战场景

用户下单成功后,系统要做一连串"后置动作":

  1. 发送订单确认短信/邮件;
  2. 发放积分;
  3. 更新会员等级;
  4. 通知仓储系统备货;
  5. 记录运营埋点。

如果直接在主流程里逐个调用:

csharp 复制代码
public async Task OnOrderCreatedAsync(Order order)
{
    await _smsService.SendAsync(order.CustomerPhone, "下单成功......");
    await _pointsService.GrantAsync(order.CustomerId, 100);
    await _memberService.UpgradeAsync(order.CustomerId);
    await _warehouseService.NotifyAsync(order);
    await _trackingService.TrackAsync("order.created", order);
    // 每加一个动作,这里就多一行;失败处理也各不相同......
}

问题:

  1. 主流程被业务动作塞满,订单核心逻辑和后置动作耦合在一起;
  2. 这些动作往往需要异步执行、失败重试、排队削峰,直接内联调用做不到;
  3. 想"批量重放"(比如系统恢复后重跑某天的后置动作)无从下手;
  4. 每个动作的失败处理策略不同(短信失败可忽略、积分发放失败要重试),散落在主流程里。

命令模式的解法:把每个动作封装成一个"命令对象"(实现统一接口),主流程只负责"创建命令并提交"。命令可以被排队、被记录、被重放、被批量执行------动作本身变成了数据。

二、设计思路

复制代码
        调用方(下单服务)
              │ 创建并提交
              ▼
┌─────────────────────────────┐
│      CommandQueue(命令队列) │
│  排队 → 逐个执行 → 失败重试    │
└──────────────┬──────────────┘
               ▼ 执行(Invoke)
┌─────────────────────────────┐
│    IOrderCommand(命令接口)   │
│   ├─ SendSmsCommand          │
│   ├─ GrantPointsCommand      │
│   ├─ UpgradeMemberCommand    │
│   ├─ NotifyWarehouseCommand  │
│   └─ TrackEventCommand       │
└─────────────────────────────┘

命令模式的精髓:"做什么"被封装成对象,可以存储、传输、排队、重放。

三、代码实现(.NET 10 / C# 14)

3.1 命令接口与执行上下文

csharp 复制代码
namespace Orders.AfterOrder;

public interface IOrderCommand
{
    string Name { get; }
    Task ExecuteAsync(OrderContext context, CancellationToken ct = default);
    Task UndoAsync(OrderContext context, CancellationToken ct = default);  // 可选的补偿
}

public sealed record OrderContext(OrderSnapshot Order);

public sealed record OrderSnapshot(string OrderId, string CustomerId, string CustomerPhone, decimal Amount);

3.2 具体命令们(每个动作一个命令)

csharp 复制代码
namespace Orders.AfterOrder.Commands;

// 命令1:发短信
public sealed class SendSmsCommand(ISmsSender sms) : IOrderCommand
{
    public string Name => "SendSms";

    public Task ExecuteAsync(OrderContext ctx, CancellationToken ct = default)
        => sms.SendAsync(ctx.Order.CustomerPhone, $"您已成功下单 {ctx.Order.OrderId},金额 {ctx.Order.Amount:C}", ct);

    public Task UndoAsync(OrderContext ctx, CancellationToken ct = default) => Task.CompletedTask; // 短信无法撤回
}

// 命令2:发积分(失败可重试)
public sealed class GrantPointsCommand(IPointsService points) : IOrderCommand
{
    public string Name => "GrantPoints";

    public Task ExecuteAsync(OrderContext ctx, CancellationToken ct = default)
        => points.GrantAsync(ctx.Order.CustomerId, (int)(ctx.Order.Amount / 10), ct);

    public Task UndoAsync(OrderContext ctx, CancellationToken ct = default)
        => points.RecoverAsync(ctx.Order.CustomerId, (int)(ctx.Order.Amount / 10), ct);
}

// 命令3:通知仓储
public sealed class NotifyWarehouseCommand(IWarehouseService warehouse) : IOrderCommand
{
    public string Name => "NotifyWarehouse";

    public Task ExecuteAsync(OrderContext ctx, CancellationToken ct = default)
        => warehouse.NotifyAsync(ctx.Order.OrderId, ct);

    public Task UndoAsync(OrderContext ctx, CancellationToken ct = default) => Task.CompletedTask;
}

// 命令4:运营埋点(可忽略失败)
public sealed class TrackEventCommand(ITrackingService tracking) : IOrderCommand
{
    public string Name => "TrackEvent";

    public Task ExecuteAsync(OrderContext ctx, CancellationToken ct = default)
        => tracking.TrackAsync("order.created", ctx.Order, ct);

    public Task UndoAsync(OrderContext ctx, CancellationToken ct = default) => Task.CompletedTask;
}

3.3 执行器:命令队列(排队、重试、记录)

csharp 复制代码
namespace Orders.AfterOrder;

public sealed class CommandQueue(ILogger logger)
{
    private readonly Queue<IOrderCommand> _commands = new();

    public void Enqueue(IOrderCommand command) => _commands.Enqueue(command);

    public async Task<IReadOnlyList<CommandExecutionResult>> FlushAsync(CancellationToken ct = default)
    {
        var results = new List<CommandExecutionResult>();

        while (_commands.Count > 0)
        {
            var command = _commands.Dequeue();
            var sw = Stopwatch.StartNew();

            try
            {
                await ExecuteWithRetryAsync(command, ct);
                results.Add(new CommandExecutionResult(command.Name, true, sw.ElapsedMilliseconds, null));
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "命令 {Name} 执行失败", command.Name);
                results.Add(new CommandExecutionResult(command.Name, false, sw.ElapsedMilliseconds, ex.Message));
            }
        }
        return results;
    }

    private async Task ExecuteWithRetryAsync(IOrderCommand command, CancellationToken ct, int maxRetries = 3)
    {
        for (var attempt = 1; ; attempt++)
        {
            try
            {
                await command.ExecuteAsync(CommandContext.Current, ct);
                return;
            }
            catch when (attempt < maxRetries)
            {
                await Task.Delay(TimeSpan.FromSeconds(attempt), ct);   // 指数退避的简化版
            }
        }
    }
}

public sealed record CommandExecutionResult(string Name, bool Success, long ElapsedMs, string? Error);

// 简单模拟全局执行上下文(真实项目可用 AsyncLocal / DI Scope)
public static class CommandContext
{
    public static OrderContext Current { get; set; } = default!;
}

3.4 主流程:下单后把动作"打包提交"

csharp 复制代码
namespace Orders;

public sealed class OrderService(
    CommandQueue queue,
    SendSmsCommand smsCommand,
    GrantPointsCommand pointsCommand,
    NotifyWarehouseCommand warehouseCommand,
    TrackEventCommand trackCommand)
{
    public async Task<OrderSnapshot> CreateOrderAsync(CreateOrderRequest req, CancellationToken ct = default)
    {
        // 1. 核心下单逻辑(校验、扣库存、建单......省略)
        var order = new OrderSnapshot(
            OrderId: $"ORD-{Guid.NewGuid():N}"[..18],
            CustomerId: req.CustomerId,
            CustomerPhone: req.Phone,
            Amount: req.Amount);

        // 2. 后置动作全部"打包"成命令,进队列
        CommandContext.Current = new OrderContext(order);
        queue.Enqueue(smsCommand);
        queue.Enqueue(pointsCommand);
        queue.Enqueue(warehouseCommand);
        queue.Enqueue(trackCommand);

        // 3. 统一执行(真实项目:队列可换成 Hangfire / RabbitMQ,实现异步削峰)
        var results = await queue.FlushAsync(ct);

        return order with { /* 可附加 results 状态 */ };
    }
}

3.5 组装与运行

csharp 复制代码
// Program.cs(.NET 10 顶层语句)
using Orders;
using Orders.AfterOrder;
using Orders.AfterOrder.Commands;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddLogging();

// 注册各种服务与命令
builder.Services.AddSingleton<ISmsSender, FakeSmsSender>();
builder.Services.AddSingleton<IPointsService, FakePointsService>();
builder.Services.AddSingleton<IWarehouseService, FakeWarehouseService>();
builder.Services.AddSingleton<ITrackingService, FakeTrackingService>();

builder.Services.AddSingleton<SendSmsCommand>();
builder.Services.AddSingleton<GrantPointsCommand>();
builder.Services.AddSingleton<NotifyWarehouseCommand>();
builder.Services.AddSingleton<TrackEventCommand>();
builder.Services.AddSingleton<CommandQueue>();
builder.Services.AddScoped<OrderService>();

var app = builder.Build();

app.MapPost("/orders", async (CreateOrderRequest req, OrderService orders) =>
{
    var order = await orders.CreateOrderAsync(req);
    return Results.Ok(new { order.OrderId, Status = "created" });
});

app.Run();

// 各 Fake 服务的实现(演示用,真实项目替换为实际客户端)
public interface ISmsSender { Task SendAsync(string phone, string text, CancellationToken ct); }
public sealed class FakeSmsSender : ISmsSender
{
    public Task SendAsync(string phone, string text, CancellationToken ct)
    { Console.WriteLine($"[短信] {phone}: {text}"); return Task.CompletedTask; }
}

public interface IPointsService { Task GrantAsync(string customer, int points, CancellationToken ct); Task RecoverAsync(string customer, int points, CancellationToken ct); }
public sealed class FakePointsService : IPointsService
{
    public Task GrantAsync(string customer, int points, CancellationToken ct)
    { Console.WriteLine($"[积分] 用户 {customer} +{points}"); return Task.CompletedTask; }
    public Task RecoverAsync(string customer, int points, CancellationToken ct) => Task.CompletedTask;
}

public interface IWarehouseService { Task NotifyAsync(string orderId, CancellationToken ct); }
public sealed class FakeWarehouseService : IWarehouseService
{
    public Task NotifyAsync(string orderId, CancellationToken ct)
    { Console.WriteLine($"[仓储] 备货通知 {orderId}"); return Task.CompletedTask; }
}

public interface ITrackingService { Task TrackAsync(string evt, object payload, CancellationToken ct); }
public sealed class FakeTrackingService : ITrackingService
{
    public Task TrackAsync(string evt, object payload, CancellationToken ct)
    { Console.WriteLine($"[埋点] {evt}"); return Task.CompletedTask; }
}

public sealed record CreateOrderRequest(string CustomerId, string Phone, decimal Amount);

四、应用要点与注意事项

  1. 命令模式的三大价值 :解耦 (主流程不再知道后置动作的实现)、可排队 (命令是对象,能进队列、异步执行、削峰)、可重放(命令可记录、可批量重跑、可做补偿)。
  2. 与"消息队列"的关系:命令模式是"代码层面的封装",消息队列是"执行层面的载体"。两者常组合使用------把命令序列化成消息投递到 RabbitMQ/Kafka,消费者反序列化后执行。理解命令模式,就理解了任务系统的核心。
  3. Undo 是可选能力 :不是所有命令都能撤销(短信发出去撤不回)。UndoAsync 返回空实现即可,但设计命令接口时保留这个钩子,未来做"补偿/回滚"时不用改接口。
  4. 区分命令 vs 事件:命令是"明确的意图,期望被执行"(发积分);事件是"已经发生的事实,可以触发副作用"(订单已创建)。下单后置动作既可以用命令模式,也可以用观察者模式(事件驱动)实现------命令模式更强调"可排队、可重放",事件驱动更强调"解耦发布订阅"。两者可以并存。
  5. 每个命令保持单一职责 :一个命令只做一件事,失败重试策略可以在执行器层面统一(如示例的 ExecuteWithRetryAsync),不要让命令内部各自实现重试。

五、小结

命令模式把"动作"从"代码"升级成了"数据":可排队、可记录、可重放、可补偿。下单后置动作、定时任务、批量操作、撤销重做,都是它的经典舞台。配合消息队列、Hangfire 这类执行载体,它几乎构成了现代后端"异步任务体系"的全部雏形。

相关推荐
焦玉全1 小时前
Sharding-JDBC 分库分表实战:从 800 万订单表的查询优化说起
后端
王中阳Go1 小时前
面试官问"你怎么证明它有效",200个转AI的后端没几个答得上来
人工智能·后端·面试
一只旭宝1 小时前
【C++复习】四种类型转换 + 常见设计模式 + Redis/MySQL(后端面试复习完整版)
c++·redis·笔记·mysql·设计模式
淡海水1 小时前
16-01-C#数据结构源码-附录A-术语表
unity·c#·游戏引擎·il2cpp
深入云栈1 小时前
Netty 4.2.x 源码深度解析 (十四):KQueue 传输 —— macOS 高性能 IO 的 Netty 实现
java·后端
一只公羊1 小时前
在 Ubuntu 26.04 LTS (Root) 下使用 uv 丝滑安装 Microsoft MarkItDown
后端
小羊没烦恼!1 小时前
【译】利用Asp.net MVC处理文件的上传下载
开发语言·缓存·c#·word·powerpoint·.net
dd聊技术1 小时前
一张本地消息表装两个业务:差异一点没进表里
后端