使用 MySQL 和 Brighter 实现出站箱模式

简介  

本指南演示了如何使用 Brighter 库在 MySQL 和 .NET 8 中实现 Outbox 模式(出站箱模式), 以确保数据库更新和消息发布之间的事务一致性。

项目目标  

处理一个 CreateNewOrder 命令,并且只有在事务成功时才发布两个事件(OrderPlaced, OrderPaid)。如果发生错误(例如业务规则冲突), 则数据库更改和消息发布都将回滚。

要求  

.NET 8+ 
Podman(或 Docker) 运行本地容器:  

  • MySQL  
  • RabbitMQ

了解 Brighter 的 RabbitMQ 使用
NuGet 包:  

  • Paramore.Brighter.Extensions.DependencyInjection  
  • Paramore.Brighter.Extensions.Hosting  
  • Paramore.Brighter.MessagingGateway.RMQ  
  • Paramore.Brighter.Outbox.MySql  
  • Paramore.Brighter.ServiceActivator.Extensions.DependencyInjection  
  • Paramore.Brighter.ServiceActivator.Extensions.Hosting  

消息定义  

对于此项目,我们需要以下 3 条消息:CreateNewOrder, OrderPlaced 和 OrderPaid

public class CreateNewOrder() : Command(Guid.NewGuid())
{
    public decimal Value { get; set; }
}

public class OrderPlaced() : Event(Guid.NewGuid())
{
    public string OrderId { get; set; } = string.Empty;
    public decimal Value { get; set; }
}

public class OrderPaid() : Event(Guid.NewGuid())
{
    public string OrderId { get; set; } = string.Empty;
}

消息映射器  

由于只有 OrderPlaced 和 OrderPaid 事件会发布到 RabbitMQ,因此需要使用 JSON 序列化实现它们的映射器。

public class OrderPlacedMapper : IAmAMessageMapper
{
    public Message MapToMessage(OrderPlaced request)
    {
        var header = new MessageHeader();
        header.Id = request.Id;
        header.TimeStamp = DateTime.UtcNow;
        header.Topic = "order-placed";
        header.MessageType = MessageType.MT_EVENT;
        var body = new MessageBody(JsonSerializer.Serialize(request));
        return new Message(header, body);
    }

    public OrderPlaced MapToRequest(Message message)
    {
        return JsonSerializer.Deserialize(message.Body.Bytes)!;
    }
}

public class OrderPaidMapper : IAmAMessageMapper
{
    public Message MapToMessage(OrderPaid request)
    {
        var header = new MessageHeader();
        header.Id = request.Id;
        header.TimeStamp = DateTime.UtcNow;
        header.Topic = "order-paid";
        header.MessageType = MessageType.MT_EVENT;
        var body = new MessageBody(JsonSerializer.Serialize(request));
        return new Message(header, body);
    }

    public OrderPaid MapToRequest(Message message)
    {
        return JsonSerializer.Deserialize(message.Body.Bytes)!;
    }
}

请求处理器  

对于 OrderPlaced 和 OrderPaid,我们将记录收到的消息。

public class OrderPlaceHandler(ILogger logger) : RequestHandlerAsync
{
    public override Task HandleAsync(OrderPlaced command, CancellationToken cancellationToken = default)
    {
        logger.LogInformation("{OrderId} placed with value {OrderValue}", command.OrderId, command.Value);
        return base.HandleAsync(command, cancellationToken);
    }
}

public class OrderPaidHandler(ILogger logger) : RequestHandlerAsync
{
    public override Task HandleAsync(OrderPaid command, CancellationToken cancellationToken = default)
    {
        logger.LogInformation("{OrderId} paid", command.OrderId);
        return base.HandleAsync(command, cancellationToken);
    }
}

创建新订单  

CreateNewOrder 处理器会等待 10 毫秒以模拟处理流程,然后发布 OrderPlaced。如果值能被 3 整除,则抛出异常(模拟业务错误), 否则发布 OrderPaid。  

public class CreateNewOrderHandler(IAmACommandProcessor commandProcessor,
    IUnitOfWork unitOfWork,
    ILogger logger) : RequestHandlerAsync
{
    public override async Task HandleAsync(CreateNewOrder command, CancellationToken cancellationToken = default)
    {
        await unitOfWork.BeginTransactionAsync(cancellationToken);
        try
        {
            string id = Guid.NewGuid().ToString();
            logger.LogInformation("Creating a new order: {OrderId}", id);
            await Task.Delay(10, cancellationToken); // 模拟处理流程
            _ = await commandProcessor.DepositPostAsync(new OrderPlaced { OrderId = id, Value = command.Value }, cancellationToken: cancellationToken);
            if (command.Value % 3 == 0)
            {
                throw new InvalidOperationException("invalid value");
            }
            _ = await commandProcessor.DepositPostAsync(new OrderPaid { OrderId = id }, cancellationToken: cancellationToken);
            await unitOfWork.CommitAsync(cancellationToken);
            return await base.HandleAsync(command, cancellationToken);
        }
        catch
        {
            logger.LogError("Invalid data");
            await unitOfWork.RollbackAsync(cancellationToken);
            throw;
        }
    }
}

关键洞察  

  • DepositPostAsync 会将消息存储在出站箱中,并与业务数据处于同一事务中。  
  • 如果发生异常(例如 InvalidOperationException), 事务会回滚,确保不会产生孤立消息。  

配置 MySQL  

要将 Outbox 模式与 MySQL 集成,首先需确保 outbox_messages 表存在。  

1. SQL 表结构  

CREATE TABLE IF NOT EXISTS `outbox_messages`(
  `MessageId` CHAR(36) NOT NULL,
  `Topic` VARCHAR(255) NOT NULL,
  `MessageType` VARCHAR(32) NOT NULL,
  `Timestamp` TIMESTAMP(3) NOT NULL,
  `CorrelationId` CHAR(36) NULL,
  `ReplyTo` VARCHAR(255) NULL,
  `ContentType` VARCHAR(128) NULL,
  `Dispatched` TIMESTAMP(3) NULL,
  `HeaderBag` TEXT NOT NULL,
  `Body` TEXT NOT NULL,
  `Created` TIMESTAMP(3) NOT NULL DEFAULT NOW(3),
  `CreatedID` INT(11) NOT NULL AUTO_INCREMENT,
   UNIQUE(`CreatedID`),
   PRIMARY KEY (`MessageId`)
);

2. 依赖注入配置  

注册出站箱和事务:

services
    .AddServiceActivator(opt => { /* 订阅配置(参考前文) */ })
    .UseMySqlOutbox(new MySqlConfiguration(ConnectionString, "outbox_messages"), typeof(MySqlConnectionProvider), ServiceLifetime.Scoped)
    .UseMySqTransactionConnectionProvider(typeof(MySqlConnectionProvider))
    .UseOutboxSweeper(opt => opt.BatchSize = 10);

3. 事务管理  

为确保业务逻辑和消息发布在 Brighter 中的原子性,需实现 IMySqlTransactionConnectionProvider 和 IUnitOfWork 以共享事务上下文。这保证了仅当数据库事务提交成功时,消息才会存储到出站箱。  

a. MySqlConnectionProvider  

public class MySqlConnectionProvider(MySqlUnitOfWork sqlConnection) : IMySqlTransactionConnectionProvider
{
    private readonly MySqlUnitOfWork _sqlConnection = sqlConnection;

    public MySqlConnection GetConnection()
    {
        return _sqlConnection.Connection;
    }

    public Task GetConnectionAsync(CancellationToken cancellationToken = default)
    {
        return Task.FromResult(_sqlConnection.Connection);
    }

    public MySqlTransaction? GetTransaction()
    {
        return _sqlConnection.Transaction;
    }

    public bool HasOpenTransaction => _sqlConnection.Transaction != null;
    public bool IsSharedConnection => true;
}

b. 工作单元接口  

public interface IUnitOfWork
{
    Task BeginTransactionAsync(CancellationToken cancellationToken, IsolationLevel isolationLevel = IsolationLevel.Serializable);
    Task CommitAsync(CancellationToken cancellationToken);
    Task RollbackAsync(CancellationToken cancellationToken);
}

c. MySqlUnitOfWork 实现  

public class MySqlUnitOfWork : IUnitOfWork
{
    public MySqlUnitOfWork(MySqlConfiguration configuration)
    {
        Connection = new(configuration.ConnectionString);
        Connection.Open();
    }
    public MySqlConnection Connection { get; }
    public MySqlTransaction? Transaction { get; private set; }

    public async Task BeginTransactionAsync(CancellationToken cancellationToken, IsolationLevel isolationLevel = IsolationLevel.Serializable)
    {
        if (Transaction == null)
        {
            Transaction = await Connection.BeginTransactionAsync(isolationLevel);
        }
    }

    public async Task CommitAsync(CancellationToken cancellationToken)
    {
        if (Transaction != null)
        {
            await Transaction.CommitAsync(cancellationToken);
        }
    }

    public async Task RollbackAsync(CancellationToken cancellationToken)
    {
        if (Transaction != null)
        {
            await Transaction.RollbackAsync(cancellationToken);
        }
    }

    public Task CreateSqlCommandAsync(string sql, MySqlParameter[] parameters, CancellationToken cancellationToken)
    {
        var command = Connection.CreateCommand();
        if (Transaction != null)
        {
            command.Transaction = Transaction;
        }
        command.CommandText = sql;
        if (parameters.Length > 0)
        {
            command.Parameters.AddRange(parameters);
        }
        return Task.FromResult(command);
    }
}

d. 在依赖注入中注册服务  

services
    .AddScoped()
    .TryAddScoped(provider => provider.GetRequiredService());

结论  

通过在 Brighter 和 MySQL 中实现 Outbox 模式,我们展示了如何确保数据库更新和消息发布之间的事务一致性。此方法保证了:  

  • 仅当事务提交成功时, 消息才会发布
    • 使用 DepositPostAsync,消息(如 OrderPlaced 和 OrderPaid) 会与业务数据在同一事务中存储到 outbox_messages 表。  
    • 如果处理器失败 (例如因模拟错误), 事务回滚, 不会发送孤立消息。  
  • Brighter 的 IMySqlTransactionConnectionProvider 
    • 保证数据库更新和消息存储共享同一事务。  
  • 通过 Outbox Sweeper 实现容错  
    • UseOutboxSweeper 会轮询未传递的消息并重试,直到 RabbitMQ 确认。这解耦了消息发布与处理器执行,确保可靠性。  
  • 解耦架构  
    • 应用专注于本地事务,而 Brighter 异步处理消息传递,避免与消息基础设施的紧密耦合,简化扩展性。  

此实现展示了 Brighter 如何抽象复杂性,使开发者专注于业务逻辑,同时确保分布式系统的可靠性。生产环境建议:  

  • 配合监控工具 (如 Prometheus) 
  • 使用死信队列 (DLQ) 处理中毒消息  
  • outbox 表的 Dispatched 和 Timestamp 列上添加索引  

参考  

GitHub 完整代码

using System.Data;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using MySqlConnector;
using Paramore.Brighter;
using Paramore.Brighter.Extensions.DependencyInjection;
using Paramore.Brighter.Extensions.Hosting;
using Paramore.Brighter.MessagingGateway.RMQ;
using Paramore.Brighter.MySql;
using Paramore.Brighter.Outbox.MySql;
using Paramore.Brighter.ServiceActivator.Extensions.DependencyInjection;
using Paramore.Brighter.ServiceActivator.Extensions.Hosting;
using Serilog;

const string ConnectionString = "server=127.0.0.1;uid=root;pwd=Password123!;database=brighter_test";

using (MySqlConnection connection = new(ConnectionString))
{
    await connection.OpenAsync();

    using var command = connection.CreateCommand();
    command.CommandText =
      """
      CREATE TABLE IF NOT EXISTS `outbox_messages`(
        `MessageId` CHAR(36) NOT NULL,
        `Topic` VARCHAR(255) NOT NULL,
        `MessageType` VARCHAR(32) NOT NULL,
        `Timestamp` TIMESTAMP(3) NOT NULL,
        `CorrelationId` CHAR(36) NULL,
        `ReplyTo` VARCHAR(255) NULL,
        `ContentType` VARCHAR(128) NULL,
        `Dispatched` TIMESTAMP(3) NULL,
        `HeaderBag` TEXT NOT NULL,
        `Body` TEXT NOT NULL,
        `Created` TIMESTAMP(3) NOT NULL DEFAULT NOW(3),
        `CreatedID` INT(11) NOT NULL AUTO_INCREMENT,
        UNIQUE(`CreatedID`),
        PRIMARY KEY (`MessageId`)
      );
      """;
    _ = await command.ExecuteNonQueryAsync();

}

Log.Logger = new LoggerConfiguration()
    .MinimumLevel.Information()
    .MinimumLevel.Override("Paramore.Brighter", Serilog.Events.LogEventLevel.Warning)
    .Enrich.FromLogContext()
    .WriteTo.Console()
    .CreateLogger();

IHost host = new HostBuilder()
    .UseSerilog()
    .ConfigureServices(
        (ctx, services) =>
        {
            RmqMessagingGatewayConnection connection = new()
            {
                AmpqUri = new AmqpUriSpecification(new Uri("amqp://guest:guest@localhost:5672")),

                Exchange = new Exchange("paramore.brighter.exchange"),
            };

            services
              .AddScoped()
              .TryAddScoped(provider => provider.GetRequiredService());

            _ = services
                .AddHostedService()
                .AddServiceActivator(opt =>
                {
                    opt.Subscriptions =
                    [
                        new RmqSubscription(
                            new SubscriptionName("subscription"),
                            new ChannelName("queue-order-placed"),
                            new RoutingKey("order-placed"),
                            makeChannels: OnMissingChannel.Create,
                            runAsync: true
                        ),

                        new RmqSubscription(
                            new SubscriptionName("subscription"),
                            new ChannelName("queue-order-paid"),
                            new RoutingKey("order-paid"),
                            makeChannels: OnMissingChannel.Create,
                            runAsync: true
                        ),
                    ];

                    opt.ChannelFactory = new ChannelFactory(
                        new RmqMessageConsumerFactory(connection)
                    );
                })
                .AutoFromAssemblies()
                .UseMySqlOutbox(new MySqlConfiguration(ConnectionString, "outbox_messages"), typeof(MySqlConnectionProvider), ServiceLifetime.Scoped)
                .UseMySqTransactionConnectionProvider(typeof(MySqlConnectionProvider))
                .UseOutboxSweeper(opt =>
                {
                    opt.BatchSize = 10;
                })
                .UseExternalBus(
                    new RmqProducerRegistryFactory(
                        connection,
                        [
                            new RmqPublication
                            {
                                MakeChannels = OnMissingChannel.Create,
                                Topic = new RoutingKey("order-paid"),
                            },
                            new RmqPublication
                            {
                                MakeChannels = OnMissingChannel.Create,
                                Topic = new RoutingKey("order-placed"),
                            },
                        ]
                    ).Create()
                );

        }
    )
    .Build();


await host.StartAsync();

CancellationTokenSource cancellationTokenSource = new();

Console.CancelKeyPress += (_, _) => cancellationTokenSource.Cancel();

while (!cancellationTokenSource.IsCancellationRequested)
{
    await Task.Delay(TimeSpan.FromSeconds(10));
    Console.Write("Type an order value (or q to quit): ");
    string? tmp = Console.ReadLine();

    if (string.IsNullOrEmpty(tmp))
    {
        continue;
    }

    if (tmp == "q")
    {
        break;
    }

    if (!decimal.TryParse(tmp, out decimal value))
    {
        continue;
    }

    try
    {
        using IServiceScope scope = host.Services.CreateScope();
        IAmACommandProcessor process = scope.ServiceProvider.GetRequiredService();
        await process.SendAsync(new CreateNewOrder { Value = value });
    }
    catch
    {
        // ignore any error
    }
}

await host.StopAsync();

public class CreateNewOrder() : Command(Guid.NewGuid())
{
    public decimal Value { get; set; }
}

public class OrderPlaced() : Event(Guid.NewGuid())
{
    public string OrderId { get; set; } = string.Empty;
    public decimal Value { get; set; }
}


public class OrderPaid() : Event(Guid.NewGuid())
{
    public string OrderId { get; set; } = string.Empty;
}

public class CreateNewOrderHandler(IAmACommandProcessor commandProcessor,
    IUnitOfWork unitOfWork,
    ILogger logger) : RequestHandlerAsync
{
    public override async Task HandleAsync(CreateNewOrder command, CancellationToken cancellationToken = default)
    {
        await unitOfWork.BeginTransactionAsync(cancellationToken);
        try
        {
            string id = Guid.NewGuid().ToString();
            logger.LogInformation("Creating a new order: {OrderId}", id);

            await Task.Delay(10, cancellationToken); // emulating an process

            _ = await commandProcessor.DepositPostAsync(new OrderPlaced { OrderId = id, Value = command.Value }, cancellationToken: cancellationToken);
            if (command.Value % 3 == 0)
            {
                throw new InvalidOperationException("invalid value");
            }

            _ = await commandProcessor.DepositPostAsync(new OrderPaid { OrderId = id }, cancellationToken: cancellationToken);

            await unitOfWork.CommitAsync(cancellationToken);
            return await base.HandleAsync(command, cancellationToken);
        }
        catch
        {
            logger.LogError("Invalid data");
            await unitOfWork.RollbackAsync(cancellationToken);
            throw;
        }
    }
}

public class OrderPlaceHandler(ILogger logger) : RequestHandlerAsync
{
    public override Task HandleAsync(OrderPlaced command, CancellationToken cancellationToken = default)
    {
        logger.LogInformation("{OrderId} placed with value {OrderValue}", command.OrderId, command.Value);
        return base.HandleAsync(command, cancellationToken);
    }
}

public class OrderPaidHandler(ILogger logger) : RequestHandlerAsync
{
    public override Task HandleAsync(OrderPaid command, CancellationToken cancellationToken = default)
    {
        logger.LogInformation("{OrderId} paid", command.OrderId);
        return base.HandleAsync(command, cancellationToken);
    }
}

public class OrderPlacedMapper : IAmAMessageMapper
{
    public Message MapToMessage(OrderPlaced request)
    {
        var header = new MessageHeader();
        header.Id = request.Id;
        header.TimeStamp = DateTime.UtcNow;
        header.Topic = "order-placed";
        header.MessageType = MessageType.MT_EVENT;

        var body = new MessageBody(JsonSerializer.Serialize(request));
        return new Message(header, body);
    }

    public OrderPlaced MapToRequest(Message message)
    {
        return JsonSerializer.Deserialize(message.Body.Bytes)!;
    }
}

public class OrderPaidMapper : IAmAMessageMapper
{
    public Message MapToMessage(OrderPaid request)
    {
        var header = new MessageHeader();
        header.Id = request.Id;
        header.TimeStamp = DateTime.UtcNow;
        header.Topic = "order-paid";
        header.MessageType = MessageType.MT_EVENT;

        var body = new MessageBody(JsonSerializer.Serialize(request));
        return new Message(header, body);
    }

    public OrderPaid MapToRequest(Message message)
    {
        return JsonSerializer.Deserialize(message.Body.Bytes)!;
    }
}

public class MySqlConnectionProvider(MySqlUnitOfWork sqlConnection) : IMySqlTransactionConnectionProvider
{
    private readonly MySqlUnitOfWork _sqlConnection = sqlConnection;

    public MySqlConnection GetConnection()
    {
        return _sqlConnection.Connection;
    }

    public Task GetConnectionAsync(CancellationToken cancellationToken = default)
    {
        return Task.FromResult(_sqlConnection.Connection);
    }

    public MySqlTransaction? GetTransaction()
    {
        return _sqlConnection.Transaction;
    }

    public bool HasOpenTransaction => _sqlConnection.Transaction != null;
    public bool IsSharedConnection => true;
}

public interface IUnitOfWork
{
    Task BeginTransactionAsync(CancellationToken cancellationToken, IsolationLevel isolationLevel = IsolationLevel.Serializable);
    Task CommitAsync(CancellationToken cancellationToken);
    Task RollbackAsync(CancellationToken cancellationToken);
}

public class MySqlUnitOfWork : IUnitOfWork
{
    public MySqlUnitOfWork(MySqlConfiguration configuration)
    {
        Connection = new(configuration.ConnectionString);
        Connection.Open();
    }

    public MySqlConnection Connection { get; }
    public MySqlTransaction? Transaction { get; private set; }

    public async Task BeginTransactionAsync(CancellationToken cancellationToken, IsolationLevel isolationLevel = IsolationLevel.Serializable)
    {
        if (Transaction == null)
        {
            Transaction = await Connection.BeginTransactionAsync(isolationLevel);
        }
    }

    public async Task CommitAsync(CancellationToken cancellationToken)
    {
        if (Transaction != null)
        {
            await Transaction.CommitAsync(cancellationToken);
        }
    }

    public async Task RollbackAsync(CancellationToken cancellationToken)
    {
        if (Transaction != null)
        {
            await Transaction.RollbackAsync(cancellationToken);
        }
    }

    public Task CreateSqlCommandAsync(string sql, MySqlParameter[] parameters, CancellationToken cancellationToken)
    {
        var command = Connection.CreateCommand();

        if (Transaction != null)
        {
            command.Transaction = Transaction;
        }

        command.CommandText = sql;
        if (parameters.Length > 0)
        {
            command.Parameters.AddRange(parameters);
        }

        return Task.FromResult(command);
    }
}

你可能感兴趣的:(.net,c#,brighter,rabbitmq,mysql,发件箱模式)