使用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行想要发送一个事件到事件巴士,但是此时可能发送其他情况:

  1. 系统可能在事务提交之后就崩溃了,导致事件发送失败
  2. 事件巴士不可用,导致事件发送失败

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

盒子模式

盒子模式基于保证传达模式,如下图:

当保存一个事务时,同时把一个消息保存到数据库,这个消息稍后会通知程序执行一些操作。存储这些待执行消息的就是盒子。

第二个部分是一个单独的进程,他会定期检查盒子,查看是否有需要执行的消息,在消息执行之后,将消息标记为已执行来防止重复执行。然后,在标记消息为已执行时,程序可能会发生异常,如下图:

在这种情况下,当进程再次执行时,就会重复发送相同的消息。所以说“盒子模式”是至少保证一次成功的模式。基于这一点,我们需要考虑消息的幂等性,定义如下:

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
完结撒花。

posted @ 2020-03-12 16:30  东方未  阅读(156)  评论(0)    收藏  举报