发件箱模式(The Outbox Pattern)

原文链接

系列文章目录

一、简单的CQRS实现与原始SQL和DDD
二、使用EF的领域模型的封装和持久化透明(PI)
三、REST API数据验证
四、领域模型验证
五、如何发布和处理领域事件
六、处理领域事件:缺失的部分
七、发件箱模式(The Outbox Pattern)
八、.NET Core中的旁路缓存模式(Cache-Aside Pattern)

简介

有时,在处理业务操作时,需要在即发即弃(Fire-and-forget)模式下与外部组件通信。例如,该组件可以是:

  • 外部服务
  • 消息总线
  • 邮件服务器
  • 相同的数据库,但不同的数据库事务
  • 另一个数据库

与外部组件集成的这种类型的例子:

  • 下单后发送电子邮件
  • 向消息传递系统发送关于新客户端注册的事件
  • 在不同的数据库事务中处理另一个DDD聚合-例如在下订单以减少库存产品数量之后

由此产生的问题是从技术角度来看,我们是否能够保证业务操作的原子性?不幸的是,并不总是能够保证,或者即使我们可以(使用2PC协议),从延迟、吞吐量、可伸缩性和可用性的角度来看,这些因素也会限制我们的系统.有关这些限制的详细信息,我邀请您阅读文章是时候从两阶段提交中走出来了( It’s Time to Move on from Two Phase Commit).

我所写的问题如下:

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行中,我们想发送一个事件到事件总线,但不幸的是,可能会发生两件糟糕的事情:

  • 我们的系统可能会在事务提交后和发送事件之前崩溃
  • 此时事件总线不可用,因此无法发送事件

image

如果我们不能提供原子性,或者由于上面提到的原因我们不想这样做,那么我们能做些什么来提高系统的可靠性呢?这时,我们应该实现发件箱模式。

发件箱模式

发件箱模式基于保证交付模式,如下所示:

image

当您将数据保存为某个事务的一部分时,您还将保存稍后要作为同一事务的一部分处理的消息。与邮件客户端一样,要处理的消息列表称为发件箱。

这个谜题的第二个元素是一个单独的进程,它定期检查发件箱的内容并处理消息。处理完每条消息后,应该将该消息标记为已处理,以避免重新发送。但是,由于与发件箱的通信错误,我们可能无法将邮件标记为已处理:

image

在这种情况下,当与发件箱的连接恢复时,将再次发送相同的消息。这一切对我们意味着什么?发件箱模式提供至少一次交付。我们可以确定消息只发送一次,但也可以发送多次!这就是为什么这种方法的另一个名称是“一次或多次交付”。我们应该记住这一点,并尝试将我们的信息的接收者设计为Idempotents,意思是:

在消息传递中,这个概念转化为一个消息,无论它被接收一次还是多次都具有相同的效果。这意味着即使接收方收到相同消息的副本,也可以安全地重新发送消息而不会引起任何问题。

好了,理论讲够了,让我们看看如何在 .NET世界中实现这个模式。

实现

发件箱

首先,我们需要定义OutboxMessage的结构:

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();
    }
}

重要的是,OutboxMessage类是基础设施的一部分,而不是领域模型的一部分!尝试与业务部门讨论发件箱,他们会考虑outlook应用程序,而不是消息模式。🙂我没有包括ProcessedDate属性,因为这个类只需要将消息保存为事务的一部分,所以这个属性在这个上下文中总是为NULL。

保存消息

当然,我不希望在每个命令处理程序中每次都编写向发件箱写入消息的程序,这违反了DRY (Don’t Repeat Yourself)原则。因此,可以使用关于发布领域事件的帖子中描述的通知对象。下面的解决方案是基于链接文章,很少修改-而不是立即处理通知,它序列化它们并将它们写入数据库。

作为提醒,由一个操作引起的所有领域事件都作为同一事务的一部分进行处理。如果应该在正在进行的事务之外处理域事件,则应该为其定义一个通知对象。这是应该写入发件箱的对象。代码如下:

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字符串中反序列化消息,这将在下面的处理部分中介绍。

处理消息

发件箱消息的处理应该在一个单独的进程中进行。但是,我们也可以使用相同的进程,但根据需要使用另一个线程,而不是单独的进程。下面介绍的解决方案可以用于这两种情况。

在开始时,我们需要使用调度程序定期运行发件箱处理。我不想自己创建调度器(这是已知的和解决的问题),所以我将使用一个成熟的解决方案在 .NET - Quartz.NET。Quartz调度器的配置非常简单:

// 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秒执行一次,但它的配置实际上取决于您的系统中有多少消息。

ProcessOutboxJob是这样的:

[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行)之间出现错误,那么下一次迭代中的job将希望再次处理它。

通知处理程序模板如下所示:

public class CustomerRegisteredNotificationHandler : INotificationHandler<CustomerRegisteredNotification>
{
    public Task Handle(CustomerRegisteredNotification notification, CancellationToken cancellationToken)
    {
        // Send event to bus or e-mail message...

        return Task.CompletedTask;
    }
}

最后,这是我们发件箱的视图:

image

总结

在这篇文章中,我描述了在业务操作处理期间确保事务原子性的问题。我提出了2PC协议的话题以及不使用它的动机。我介绍了发件箱模式是什么以及如何实现它。因此,我们的系统可以更加可靠。

源代码

如果你想看到完整的工作示例——查看我的GitHub存储库

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值