乘风破浪,遇见最佳跨平台跨终端框架.Net Core/.Net生态 - 浅析ASP.NET Core集成事件发布订阅,通过CAP和RabbitMQ实现跨服务一致性
什么是集成事件
集成事件(Integration Event)用于使领域状态在多个微服务或外部系统中保持同步。这种功能是通过在微服务之外发布集成事件来实现的。
当一个事件被发布到多个接收方微服务(被订阅到集成事件的微服务之多)时,每个接收方微服务中的适当事件处理程序会处理该事件。
与领域事件的区别
领域事件(DomainEvent)是推送到领域事件(DomainEvent)调度程序的消息,可基于IoC容器或任何其他方法作为内存中转存进程实现(如Mediator
)。
集成事件(Integration Event)是将已提交事务和更新传播到其他子系统,无论它们是其他微服务、绑定上下文,还是外部应用程序。(集成事件是跨服务的,领域事件则不是)
工作原理
基于事件的通信时,当值得注意的事件发生时,微服务会发布事件,例如更新业务实体时。其他微服务订阅这些事件。微服务收到事件时,可以更新其自己的业务实体,这可能会导致发布更多事件。这是最终一致性概念的本质。
通常通过使用事件总线(EventBus)实现来执行此发布/订阅系统。最终一致事务由一系列分布式操作组成。在每个操作中,微服务会更新业务实体,并发布可触发下一个操作的事件。
集成事件是单个应用程序级别的,不建议跨应用使用同一个集成事件,这将导致事件来源混乱(微服务必须独立)
事件总线
事件总线可实现发布/订阅式通信,无需组件之间相互显式识别。
微服务A发布到事件总线,这会分发到订阅微服务B和C,发布服务器无需知道订阅服务器。
事件总线与观察者模式和发布-订阅模式相关。
动手实践
集成事件的实现方式
- 发布-订阅,通过EventBus
- 观察者模式,由观察者将事件发送给关注事件的人
定义集成事件
在应用层下属的IntegrationEvents
中我们定义了两个示例集成事件。
订单创建集成事件
OrderCreatedIntegrationEvent
/// <summary>
/// 订单创建集成事件
/// </summary>
public class OrderCreatedIntegrationEvent
{
/// <summary>
/// 构造函数
/// </summary>
/// <param name="orderId"></param>
public OrderCreatedIntegrationEvent(long orderId) => OrderId = orderId;
/// <summary>
/// 订单Id
/// </summary>
public long OrderId { get; }
}
订单支付成功集成事件
OrderPaymentSucceededIntegrationEvent
/// <summary>
/// 订单支付成功集成事件
/// </summary>
public class OrderPaymentSucceededIntegrationEvent
{
/// <summary>
/// 构造函数
/// </summary>
/// <param name="orderId"></param>
public OrderPaymentSucceededIntegrationEvent(long orderId) => OrderId = orderId;
/// <summary>
/// 订单Id
/// </summary>
public long OrderId { get; }
}
总结
- 集成事件是跨服务的领域事件
- 集成事件大部分场景由领域事件驱动触发,也有个别场景比如说定时任务触发的。
- 集成事件是跨微服务来传递信息的,无法通过事务来处理集成事件(可借助Cap这样的框架来实现最终一致性)
- 仅在必要的情况下定义和使用集成事件,一旦引入了集成事件,比如EventBus,在应用程序发布新版本的时候,新旧版本的事件发布和订阅都会受到影响。
使用RabbitMQ来实现EventBus
什么是RabbitMQ
RabbitMQ是一套开源(MPL)的消息队列服务软件,是由LShift提供的一个Advanced Message Queuing Protocol(AMQP)的开源实现,由以高性能、健壮以及可伸缩性出名的Erlang写成。
通过Docker准备MYSQL实例
包括很多Tag,其中带有management
的Tag代表是包含Web控制台程序的。
docker run -d --name rabbitmq --restart unless-stopped -p 5672:5672 -p 15672:15672 rabbitmq:3.11.1-management
设置默认账号密码
docker run -d --name rabbitmq --restart unless-stopped -p 5672:5672 -p 15672:15672 -e RABBITMQ_DEFAULT_USER=root -e RABBITMQ_DEFAULT_PASS=password rabbitmq:3.11.1-management
访问RabbitMQ控制台:http://localhost:15672
输入前面设置的账号密码(默认账号和密码都是guest
),进入控制台
使用CAP来实现集成事件发送和订阅
什么是CAP框架
CAP是一个基于.NET Standard的C#库,它是一种处理分布式事务的解决方案,同样具有Event Bus的功能,它具有轻量级、易使用、高性能等特点。
在我们构建SOA或者微服务系统的过程中,我们通常需要使用事件来对各个服务进行集成,在这过程中简单的使用消息队列并不能保证数据的最终一致性,CAP采用的是和当前数据库集成的本地消息表的方案来解决在分布式系统互相调用的各个环节可能出现的异常,它能够保证任何情况下事件消息都是不会丢失的。
你同样可以把CAP当做Event Bus来使用,CAP提供了一种更加简单的方式来实现事件消息的发布和订阅,在订阅以及发布的过程中,你不需要继承或实现任何接口。
这是CAP集在ASP.NET Core微服务架构中的一个示意图:
CAP框架实际上实现了一个叫发件箱(Outbox)的设计模式,在我们每个微服务,比如微服务A的数据库A,在这个数据库内部它建立了两张表,一张叫Publish
事件表和一张叫Receive
事件表。这两张表用来记录微服务A发出的和接收的事件。
当我们发出事件时,我们会把事件的存储的逻辑与我们业务逻辑的事务合并,在同一个事务里提交,这意味着当我们业务逻辑提交成功时,我们的事件表里面的事件是一定存在的,它是与我们的业务逻辑的事务是强绑定的。如果说我们的业务逻辑失败了,事务回滚了,这条事件是不会出现在我们的事件表里的,这样子就可以做到我们要发送的事件一定是与业务逻辑是一致的。
接下来就是由组件来负责将事件表里的事件全部都发送到EventBus,比如说RabbitMQ消息队列里面去,由接收方订阅。
对于订阅的事件的话,设计的模式也是同理,当我们的应用程序在消息队列获取到信息的时候,它就会将这些消息持久化到我们的数据库的Reveive
事件表里,这样我们就可以在本地进行事务的处理、失败重试等操作。
CAP支持主流的消息队列作为传输器,你可以按需选择下面的包进行安装:
dotnet add package DotNetCore.CAP.Kafka
dotnet add package DotNetCore.CAP.RabbitMQ
dotnet add package DotNetCore.CAP.AzureServiceBus
dotnet add package DotNetCore.CAP.AmazonSQS
dotnet add package DotNetCore.CAP.NATS
dotnet add package DotNetCore.CAP.RedisStreams
dotnet add package DotNetCore.CAP.Pulsar
CAP提供了主流数据库作为存储,你可以按需选择下面的包进行安装:
// 按需选择安装你正在使用的数据库
dotnet add package DotNetCore.CAP.SqlServer
dotnet add package DotNetCore.CAP.MySql
dotnet add package DotNetCore.CAP.PostgreSql
dotnet add package DotNetCore.CAP.MongoDB
首先配置CAP到Startup.cs
文件中
public void ConfigureServices(IServiceCollection services)
{
......
services.AddDbContext<AppDbContext>();
services.AddCap(x =>
{
//如果你使用的EF进行数据操作,你需要添加如下配置:
x.UseEntityFramework<AppDbContext>(); //可选项,你不需要再次配置 x.UseSqlServer 了
//如果你使用的ADO.NET,根据数据库选择进行配置:
x.UseSqlServer("数据库连接字符串");
x.UseMySql("数据库连接字符串");
x.UsePostgreSql("数据库连接字符串");
//如果你使用的 MongoDB,你可以添加如下配置:
x.UseMongoDB("ConnectionStrings"); //注意,仅支持MongoDB 4.0+集群
//CAP支持 RabbitMQ、Kafka、AzureServiceBus、AmazonSQS 等作为MQ,根据使用选择配置:
x.UseRabbitMQ("ConnectionStrings");
x.UseKafka("ConnectionStrings");
x.UseAzureServiceBus("ConnectionStrings");
x.UseAmazonSQS();
});
}
在
Controller
中注入ICapPublisher
然后使用ICapPublisher
进行消息发送
public class PublishController : Controller
{
private readonly ICapPublisher _capBus;
public PublishController(ICapPublisher capPublisher)
{
_capBus = capPublisher;
}
//不使用事务
[Route("~/without/transaction")]
public IActionResult WithoutTransaction()
{
_capBus.Publish("xxx.services.show.time", DateTime.Now);
return Ok();
}
//Ado.Net 中使用事务,自动提交
[Route("~/adonet/transaction")]
public IActionResult AdonetWithTransaction()
{
using (var connection = new MySqlConnection(ConnectionString))
{
using (var transaction = connection.BeginTransaction(_capBus, autoCommit: true))
{
//业务代码
_capBus.Publish("xxx.services.show.time", DateTime.Now);
}
}
return Ok();
}
//EntityFramework 中使用事务,自动提交
[Route("~/ef/transaction")]
public IActionResult EntityFrameworkWithTransaction([FromServices]AppDbContext dbContext)
{
using (var trans = dbContext.Database.BeginTransaction(_capBus, autoCommit: true))
{
//业务代码
_capBus.Publish("xxx.services.show.time", DateTime.Now);
}
return Ok();
}
}
在
Action
上添加CapSubscribeAttribute
来订阅相关消息
public class PublishController : Controller
{
[CapSubscribe("xxx.services.show.time")]
public void CheckReceivedMessage(DateTime datetime)
{
Console.WriteLine(datetime);
}
}
如果你的订阅方法没有位于
Controller
中,则你订阅的类需要继承ICapSubscribe
:
namespace xxx.Service
{
public interface ISubscriberService
{
void CheckReceivedMessage(DateTime datetime);
}
public class SubscriberService: ISubscriberService, ICapSubscribe
{
[CapSubscribe("xxx.services.show.time")]
public void CheckReceivedMessage(DateTime datetime)
{
}
}
}
然后在
Startup.cs
中的ConfigureServices()
中注入你的ISubscriberService
类
public void ConfigureServices(IServiceCollection services)
{
services.AddTransient<ISubscriberService,SubscriberService>();
services.AddCap(x=>{});
}
借助CAP发送集成事件
依赖包
dotnet add package DotNetCore.CAP
接下来我们在订单创建领域事件OrderCreatedDomainEventHandler
的处理事件中,通过Cap组件来发送我们的订单创建集成事件OrderCreatedIntegrationEvent
/// <summary>
/// 订单创建领域事件处理方法
/// </summary>
public class OrderCreatedDomainEventHandler : IDomainEventHandler<OrderCreatedDomainEvent>
{
/// <summary>
/// Cap发布者
/// </summary>
readonly ICapPublisher _capPublisher;
/// <summary>
/// 构造函数
/// </summary>
/// <param name="capPublisher"></param>
public OrderCreatedDomainEventHandler(ICapPublisher capPublisher)
{
this._capPublisher = capPublisher;
}
/// <summary>
/// 处理方法
/// </summary>
/// <param name="notification"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task Handle(OrderCreatedDomainEvent notification, CancellationToken cancellationToken)
{
await _capPublisher.PublishAsync("OrderCreated", new OrderCreatedIntegrationEvent(notification.Order.Id));
}
}
订阅其它微服务发送的集成事件消息
定义订阅服务类SubscriberService
,它继承自DotNetCore.CAP
中的ICapSubscribe
接口,这样就可以标记它为订阅服务的对象。
/// <summary>
/// 订阅服务
/// </summary>
public class SubscriberService : ISubscriberService, ICapSubscribe
{
IMediator _mediator;
/// <summary>
/// 构造函数
/// </summary>
/// <param name="mediator"></param>
public SubscriberService(IMediator mediator)
{
_mediator = mediator;
}
/// <summary>
/// 订阅订单创建成功集成事件
/// </summary>
/// <param name="event"></param>
[CapSubscribe("OrderCreated")]
public void OrderCreated(OrderCreatedIntegrationEvent @event)
{
//Do SomeThing
}
/// <summary>
/// 订阅订单支付成功集成事件
/// </summary>
/// <param name="event"></param>
[CapSubscribe("OrderPaymentSucceeded")]
public void OrderPaymentSucceeded(OrderPaymentSucceededIntegrationEvent @event)
{
//Do SomeThing
}
}
在订阅服务内部的方法通过标记CapSubscribe
来定义对指定名称的集成事件的订阅接收。
将CAP添加到事务一起
依赖包
dotnet add package DotNetCore.CAP.MySql
在这个包中,有一个静态扩展方法,可以在事务提交的时候将Cap带上,修改EFContext
的构造函数,引入ICapPublisher
/// <summary>
/// EFContext
/// </summary>
public class EFContext : DbContext, IUnitOfWork, ITransaction
{
protected IMediator _mediator;
protected ICapPublisher _capPublisher;
public EFContext(DbContextOptions options, IMediator mediator, ICapPublisher capPublisher) : base(options)
{
_mediator = mediator;
_capPublisher = capPublisher;
}
同时在其下属的开启事务BeginTransactionAsync
方法,将原来的EF自带的BeginTransaction
方法替换成DotNetCore.CAP.MySql
新增的,同时引入Cap对象。
/// <summary>
/// 开启事务
/// </summary>
/// <returns></returns>
public Task<IDbContextTransaction> BeginTransactionAsync()
{
if (_currentTransaction != null) return null;
_currentTransaction = Database.BeginTransaction(_capPublisher, autoCommit: true);
return Task.FromResult(_currentTransaction);
}
注意这里的Database.BeginTransaction
已经是DotNetCore.CAP.MySql
新增的的静态扩展方法了。
public static class CapTransactionExtensions
{
/// <summary>
/// Start the CAP transaction
/// </summary>
/// <param name="database">The <see cref="DatabaseFacade" />.</param>
/// <param name="publisher">The <see cref="ICapPublisher" />.</param>
/// <param name="autoCommit">Whether the transaction is automatically committed when the message is published</param>
/// <returns>The <see cref="IDbContextTransaction" /> of EF dbcontext transaction object.</returns>
public static IDbContextTransaction BeginTransaction(this DatabaseFacade database,
ICapPublisher publisher, bool autoCommit = false)
{
var trans = database.BeginTransaction();
publisher.Transaction.Value = ActivatorUtilities.CreateInstance<MySqlCapTransaction>(publisher.ServiceProvider);
var capTrans = publisher.Transaction.Value.Begin(trans, autoCommit);
return new CapEFDbTransaction(capTrans);
}
同时我们还需要配置CAP框架,才能使其生效,正常应该在Startup.cs
的ConfigureServices
中添加它,但是我们定义在之前的服务容器扩展类ServiceCollectionExtensions
中。
/// <summary>
/// 添加集成事件总线
/// </summary>
/// <param name="services"></param>
/// <param name="configuration"></param>
/// <returns></returns>
public static IServiceCollection AddEventBus(this IServiceCollection services, IConfiguration configuration)
{
// 先将订阅服务注入进来
services.AddTransient<ISubscriberService, SubscriberService>();
// 添加CAP相关的服务和配置
services.AddCap(options =>
{
// 告诉框架我们是要针对DomainContext来实现我们的EventBus,EventBus和我们数据库共享数据库连接
options.UseEntityFramework<DomainContext>();
// 使用RabbitMQ来作为EventBus的消息队列的存储
options.UseRabbitMQ(options =>
{
configuration.GetSection("RabbitMQ").Bind(options);
});
//options.UseDashboard();
});
return services;
}
这里需要留意UseEntityFramework<DomainContext>()
这个指向,表示我们是要针对DomainContext来实现我们的EventBus,EventBus和我们数据库共享数据库连接。
同时这里还需要配置UseRabbitMQ
来作为EventBus的消息队列的存储,这里需要引入一个新包。
依赖包
dotnet add package DotNetCore.CAP.RabbitMQ
这里如果要启用CAP的面板,还需要另外一个包
依赖包
dotnet add package DotNetCore.CAP.Dashboard
这个可以根据需要启用:options.UseDashboard()
在UseRabbitMQ
内部,我们看到这里我们还引入了一个RabbitMQ
的配置,我们去appsettings.json
中添加它。
{
"RabbitMQ": {
"HostName": "localhost",
"UserName": "root",
"Password": "0gsieyVXF#hxH4RN",
// 将RabbitMQ的空间区分为不同的空间,可认为是一个租户,相同的值会被认为属于同一个RabbitMQ集群
"VirtualHost": "/",
// 队列需要订阅的Exchange的名称
"ExchangeName": "tesla_order_queue"
},
}
这里HostName
就是RabbitMQ的地址了,因为是本地,这里可以用localhost
,账号密码就是之前创建RabbitMQ实例用到的账号密码,如果没有设置,那就是guest
,接下来VirtualHost
代表了RabbitMQ的空间名称,同一个空间名称的RabbitMQ会被认为属于同一个集群。ExchangeName
是消息交换需要用到的Exchange名称。
最后我们在Startup.cs
的ConfigureServices
中添加前面的静态扩展方法AddEventBus
public void ConfigureServices(IServiceCollection services)
{
services.AddEventBus(Configuration);
演示CAP发送事件
为了演示,我们在订单创建领域事件处理OrderCreatedDomainEventHandler
方法中通过Cap发布一个订单创建OrderCreated
的事件。
/// <summary>
/// 订单创建领域事件处理方法
/// </summary>
public class OrderCreatedDomainEventHandler : IDomainEventHandler<OrderCreatedDomainEvent>
{
/// <summary>
/// Cap发布者
/// </summary>
readonly ICapPublisher _capPublisher;
/// <summary>
/// 构造函数
/// </summary>
/// <param name="capPublisher"></param>
public OrderCreatedDomainEventHandler(ICapPublisher capPublisher)
{
this._capPublisher = capPublisher;
}
/// <summary>
/// 处理方法
/// </summary>
/// <param name="notification"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task Handle(OrderCreatedDomainEvent notification, CancellationToken cancellationToken)
{
await _capPublisher.PublishAsync("OrderCreated", new OrderCreatedIntegrationEvent(notification.Order.Id));
}
}
然后在订阅的地方SubscriberService.OrderCreated
添加断点。
在这个流程中,我们会创建一个订单创建的领域事件,在订单创建的领域事件里面,又发送一个订单创建的集成事件,最后在订阅服务里面订阅了订单创建的集成事件。
我们来看下运行效果。
首先启动之后,我们从RabbitMQ面板看到了一个连接。
这个连接就是来自我们的服务。
我们有一个队列,这个队列就是我们刚才订阅的队列
我们这个队列定义了两个RouteKey,一个是OrderCreated
,一个是OrderPaymentSucceeded
可以看到它自动创建了我们前面在配置中设置的Exchange tesla_order_queue
,并且它的类型是topic
我们在Swagger中触发一下创建订单的请求。
最终我们成功接收到了集成事件消息。
这时候我们可以同步观察一下数据库的情况,集成事件发送表cap.published
其中Content的内容格式是
{
"Headers": {
"cap-callback-name": null,
"cap-msg-id": "1582391784400445440",
"cap-msg-name": "OrderCreated",
"cap-msg-type": "TeslaOrder.API.Application.IntegrationEvents.OrderCreatedIntegrationEvent",
"cap-senttime": "2022/10/18 23:22:55 +08:00",
"cap-corr-id": "1582391784400445440",
"cap-corr-seq": "0"
},
"Value": {
"OrderId": 3
}
}
再看看集成事件接收表cap.received
其Content内容格式是
{
"Headers": {
"cap-callback-name": null,
"cap-msg-id": "1582389173176340480",
"cap-msg-name": "OrderCreated",
"cap-msg-type": "TeslaOrder.API.Application.IntegrationEvents.OrderCreatedIntegrationEvent",
"cap-senttime": "2022/10/18 23:12:32 +08:00",
"cap-corr-id": "1582389173176340480",
"cap-corr-seq": "0",
"cap-msg-group": "cap.queue.teslaorder.api.v1"
},
"Value": {
"OrderId": 1
}
}
总结CAP实现原理
- 事件表
- 事务控制
将事件的存储嵌入到业务逻辑的事务中去,保证业务与事件是要么都能存储成功,要么都失败。
参考
- 在微服务(集成事件)之间实现基于事件的通信
- 领域事件、集成事件、事件总线区别与关系
- Domain Events vs. Integration Events in Domain-Driven Design and microservices architectures
- dotnet-architecture/eShopOnContainers
- .Net Core 微服务实战 - 集成事件
- Downloading and Installing RabbitMQ
- docker 安装rabbitMQ(最详细)
- CAP
- ICapTransaction.MySql.cs
- Asp.Net Core&CAP实现分布式事务
- .NET Core 事件总线,分布式事务解决方案:CAP