使用DDD模式实现简单API(8/8)
盒子模式
介绍
有时,当执行一个业务操作时,需要与外部组件通信,例如:
- 外部服务
- 消息巴士
- 邮件服务
- 相同数据库,但是不同的数据库事务
- 其他数据库
类似的场景:
- 下订单之后发送一封邮件
- 客户注册后发布消息到消息引擎
- 执行其他集成根内的方法,触发新的数据库事务-例如下订单之后减少库存中产品的数量
下面通过代码来看存在的问题:
public class RegisterCustomerCommandHandler : IRequestHandler<RegisterCustomerCommand, CustomerDto>
{
private readonly ICustomerRepository _customerRepository;
private readonly ICustomerUniquenessChecker _customerUniquenessChecker;
private readonly IEventBus _eventBus;
public RegisterCustomerCommandHandler(
ICustomerRepository customerRepository,
ICustomerUniquenessChecker customerUniquenessChecker,
IEventBus eventBus)
{
this._customerRepository = customerRepository;
_customerUniquenessChecker = customerUniquenessChecker;
_eventBus = eventBus;
}
public async Task<CustomerDto> Handle(RegisterCustomerCommand request, CancellationToken cancellationToken)
{
var customer = new Customer(request.Email, request.Name, this._customerUniquenessChecker);
await this._customerRepository.AddAsync(customer);
await this._customerRepository.UnitOfWork.CommitAsync(cancellationToken);
// End of transaction--------------------------------------------------------
this._eventBus.Send(new CustomerRegisteredIntegrationEvent(customer.Id));
return new CustomerDto { Id = customer.Id };
}
}
如上,第24行执行之后,事务被提交。在第28行想要发送一个事件到事件巴士,但是此时可能发送其他情况:
- 系统可能在事务提交之后就崩溃了,导致事件发送失败
- 事件巴士不可用,导致事件发送失败

上面两种情况都导致了潜在的危险,可能形成脏数据。如果我们不能保持操作的一致性,我们如何尽可能增加系统的可靠性,我们可以实现“盒子模式”。
盒子模式
盒子模式基于保证传达模式,如下图:

当保存一个事务时,同时把一个消息保存到数据库,这个消息稍后会通知程序执行一些操作。存储这些待执行消息的就是盒子。
第二个部分是一个单独的进程,他会定期检查盒子,查看是否有需要执行的消息,在消息执行之后,将消息标记为已执行来防止重复执行。然后,在标记消息为已执行时,程序可能会发生异常,如下图:

在这种情况下,当进程再次执行时,就会重复发送相同的消息。所以说“盒子模式”是至少保证一次成功的模式。基于这一点,我们需要考虑消息的幂等性,定义如下:
In Messaging this concepts translates into a message that has the same effect whether it is received once or multiple times. This means that a message can safely be resent without causing any problems even if the receiver receives duplicates of the same message.
这样,即使是多次发送相同的消息,接收端只会执行一次。
实现
盒子消息
首先,定义盒子消息的结构:
CREATE SCHEMA app AUTHORIZATION dbo
GO
CREATE TABLE app.OutboxMessages
(
[Id] UNIQUEIDENTIFIER NOT NULL,
[OccurredOn] DATETIME2 NOT NULL,
[Type] VARCHAR(255) NOT NULL,
[Data] VARCHAR(MAX) NOT NULL,
[ProcessedDate] DATETIME2 NULL,
CONSTRAINT [PK_app_OutboxMessages_Id] PRIMARY KEY ([Id] ASC)
)
public class OutboxMessage
{
/// <summary>
/// Id of message.
/// </summary>
public Guid Id { get; private set; }
/// <summary>
/// Occurred on.
/// </summary>
public DateTime OccurredOn { get; private set; }
/// <summary>
/// Full name of message type.
/// </summary>
public string Type { get; private set; }
/// <summary>
/// Message data - serialzed to JSON.
/// </summary>
public string Data { get; private set; }
private OutboxMessage()
{
}
internal OutboxMessage(DateTime occurredOn, string type, string data)
{
this.Id = Guid.NewGuid();
this.OccurredOn = occurredOn;
this.Type = type;
this.Data = data;
}
}
internal class OutboxMessageEntityTypeConfiguration : IEntityTypeConfiguration<OutboxMessage>
{
public void Configure(EntityTypeBuilder<OutboxMessage> builder)
{
builder.ToTable("OutboxMessages", SchemaNames.Application);
builder.HasKey(b => b.Id);
builder.Property(b => b.Id).ValueGeneratedNever();
}
}
需要注意的是,盒子消息的类是基础架构的一部分,而不是领域模型的一部分。盒子消息的类里面并没有“执行时间”属性,因为这个类只在保存到数据库时使用,这时执行时间永远为null。
保存消息
需要避免在每个命令处理器中保存盒子消息到数据库中,这违反了不要重复原则。通过领域事件通知来实现这一点,如下面的解决方案:
注:一次请求中的领域事件应该在一个数据库事务之内,如果领域事件需要在事务之外执行,那么应该新定义一个领域事件通知来代替他,这个领域事件通知在相同的事务内被写入盒子,并在之后被其他进程在事务外执行。
public async Task<int> CommitAsync(CancellationToken cancellationToken = default(CancellationToken))
{
var notifications = await this._domainEventsDispatcher.DispatchEventsAsync(this);
foreach (var domainEventNotification in notifications)
{
string type = domainEventNotification.GetType().FullName;
var data = JsonConvert.SerializeObject(domainEventNotification);
OutboxMessage outboxMessage = new OutboxMessage(
domainEventNotification.DomainEvent.OccurredOn,
type,
data);
this.OutboxMessages.Add(outboxMessage);
}
var saveResult = await base.SaveChangesAsync(cancellationToken);
return saveResult;
}
领域事件:
public class CustomerRegisteredEvent : DomainEventBase
{
public Customer Customer { get; }
public CustomerRegisteredEvent(Customer customer)
{
this.Customer = customer;
}
}
领域事件通知:
public class CustomerRegisteredNotification : DomainNotificationBase<CustomerRegisteredEvent>
{
public Guid CustomerId { get; }
public CustomerRegisteredNotification(CustomerRegisteredEvent domainEvent) : base(domainEvent)
{
this.CustomerId = domainEvent.Customer.Id;
}
[JsonConstructor]
public CustomerRegisteredNotification(Guid customerId) : base(null)
{
this.CustomerId = customerId;
}
}
- 注意Json.NET库的使用;
- 注意CustomerRegisteredNotification类的两个构造器:第一个是为了创建基于领域事件的通知;第二个是为了从JSON字符串中反序列化通知。
执行消息
消息的执行由一个独立的进程或者线程实现。首先,我们需要一个定时器来定期检查盒子,这里使用了一个成熟的框架-Quartz.NET。配置如下:
// Startup class
public void StartQuartz(IServiceProvider serviceProvider)
{
this._schedulerFactory = new StdSchedulerFactory();
this._scheduler = _schedulerFactory.GetScheduler().GetAwaiter().GetResult();
var container = new ContainerBuilder();
container.RegisterModule(new OutboxModule());
container.RegisterModule(new MediatorModule());
container.RegisterModule(new InfrastructureModule(this._configuration[OrdersConnectionString]));
_scheduler.JobFactory = new JobFactory(container.Build());
_scheduler.Start().GetAwaiter().GetResult();
var processOutboxJob = JobBuilder.Create<ProcessOutboxJob>().Build();
var trigger =
TriggerBuilder
.Create()
.StartNow()
.WithCronSchedule("0/15 * * ? * *")
.Build();
_scheduler.ScheduleJob(processOutboxJob, trigger).GetAwaiter().GetResult();
}
- 使用工厂类创建定时器
- 创建一个新的IOC容器来解析依赖项
- 配置定时执行的任务,上图所示,每15秒检查一次,具体的间隔取决于系统
消息执行的代码如下:
[DisallowConcurrentExecution]
public class ProcessOutboxJob : IJob
{
private readonly ISqlConnectionFactory _sqlConnectionFactory;
private readonly IMediator _mediator;
public ProcessOutboxJob(
ISqlConnectionFactory sqlConnectionFactory,
IMediator mediator)
{
_sqlConnectionFactory = sqlConnectionFactory;
_mediator = mediator;
}
public async Task Execute(IJobExecutionContext context)
{
using (var connection = this._sqlConnectionFactory.GetOpenConnection())
{
string sql = "SELECT " +
"[OutboxMessage].[Id], " +
"[OutboxMessage].[Type], " +
"[OutboxMessage].[Data] " +
"FROM [app].[OutboxMessages] AS [OutboxMessage] " +
"WHERE [OutboxMessage].[ProcessedDate] IS NULL";
var messages = await connection.QueryAsync<OutboxMessageDto>(sql);
foreach (var message in messages)
{
Type type = Assembly.GetAssembly(typeof(IDomainEventNotification<>)).GetType(message.Type);
var notification = JsonConvert.DeserializeObject(message.Data, type);
await this._mediator.Publish((INotification) notification);
string sqlInsert = "UPDATE [app].[OutboxMessages] " +
"SET [ProcessedDate] = @Date " +
"WHERE [Id] = @Id";
await connection.ExecuteAsync(sqlInsert, new
{
Date = DateTime.UtcNow,
message.Id
});
}
}
}
}
最重要的部分有:
- 第1行,[DisallowConcurrentExecution]属性表明,当有其他的任务在执行时,不会触发新的任务实例,因为不希望盒子消息的执行并发。
- 第25行,获取所有的消息到程序
- 第30行,反序列化消息到通知对象
- 第32行,执行通知对象
- 第38行,标记消息为已执行
正如前文所提到的,如果在第32行或者第38行出现错误,那么下次任务执行时,相同的消息将会再次被执行。
领域事件通知的处理器模板如下:
public class CustomerRegisteredNotificationHandler : INotificationHandler<CustomerRegisteredNotification>
{
public Task Handle(CustomerRegisteredNotification notification, CancellationToken cancellationToken)
{
// Send event to bus or e-mail message...
return Task.CompletedTask;
}
}
最后的盒子如下图:

总结
这篇文章描述在业务系统保持操作原子性所遇到的问题,并描述了如何通过实现“盒子模式”来解决问题,增强系统的可靠性。
代码资源:https://github.com/kgrzybek/sample-dotnet-core-cqrs-api
完结撒花。
浙公网安备 33010602011771号