本指南演示了如何使用 Brighter 库在 MySQL 和 .NET 8 中实现 Outbox 模式(出站箱模式), 以确保数据库更新和消息发布之间的事务一致性。
处理一个 CreateNewOrder 命令,并且只有在事务成功时才发布两个事件(OrderPlaced, OrderPaid)。如果发生错误(例如业务规则冲突), 则数据库更改和消息发布都将回滚。
.NET 8+
Podman(或 Docker) 运行本地容器:
了解 Brighter 的 RabbitMQ 使用
NuGet 包:
对于此项目,我们需要以下 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;
}
}
}
要将 Outbox 模式与 MySQL 集成,首先需确保 outbox_messages 表存在。
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`)
);
注册出站箱和事务:
services
.AddServiceActivator(opt => { /* 订阅配置(参考前文) */ })
.UseMySqlOutbox(new MySqlConfiguration(ConnectionString, "outbox_messages"), typeof(MySqlConnectionProvider), ServiceLifetime.Scoped)
.UseMySqTransactionConnectionProvider(typeof(MySqlConnectionProvider))
.UseOutboxSweeper(opt => opt.BatchSize = 10);
为确保业务逻辑和消息发布在 Brighter 中的原子性,需实现 IMySqlTransactionConnectionProvider 和 IUnitOfWork 以共享事务上下文。这保证了仅当数据库事务提交成功时,消息才会存储到出站箱。
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);
}
}
services
.AddScoped()
.TryAddScoped(provider => provider.GetRequiredService());
通过在 Brighter 和 MySQL 中实现 Outbox 模式,我们展示了如何确保数据库更新和消息发布之间的事务一致性。此方法保证了:
此实现展示了 Brighter 如何抽象复杂性,使开发者专注于业务逻辑,同时确保分布式系统的可靠性。生产环境建议:
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);
}
}