.NET Core 微服务之最终一致性事务方案

前言

年年有风,风吹年年,慢慢即漫漫。

日日无事,事复日日,忙忙亦茫茫。

1.集成事件-事件日志服务

1.1创建Outbox表

首先我回忆下在3.4的时候在商品服务数据库上下文中(CatalogContext)配置了一张Outbox的表

image-20250922005926422

IntegrationEventLogEntry实际上就是一张普通的 数据库表(Entity):

  • 存储事件 ID、事件类型、事务 ID、状态、JSON 内容、创建时间等信息。
  • 本质就是一个 事件日志表,用于实现 Transactional Outbox 模式。

image-20250922010033675

1.2事件存储与状态管理

IntegrationEventLogService:这个类是 Outbox(事务型 Outbox)模式 中的事件日志管理组件:负责把事件写入数据库的事件表、查询待发布事件、更新事件状态(InProgress / Published / Failed),以及管理资源释放。

1.2.1事件存储

构造函数:找到当前应用(入口程序集)中所有类型名以 IntegrationEvent 结尾的类型,便于把数据库中存的 JSON 反序列化回具体的事件类。

image-20250923014142201

RetrieveEventLogsPendingToPublishAsync:查询属于某事务、状态为 NotPublished 的事件;按创建时间排序;把 JSON 反序列化成 IntegrationEvent 对象返回。

简单理解就是事件日志表IntegrationEventLog的查询

image-20250923013407395

SaveEventAsync把事件写入事件日志表,并把 DbContext 与外部事务绑定(outbox 的关键一步:与业务数据操作在同一个数据库事务里)。

image-20250923013456013

SaveEventAsync中为什么要使用外部事务?

image-20250923014750335

举例

假设你有一个业务操作流程:

  1. 修改业务数据库表(比如更新订单状态)。
  2. 生成一个事件(OrderCreatedEvent)发送到消息队列(比如 RabbitMQ)。

如果不使用事务

情况 发生顺序 后果
失败 A 业务表更新成功,但事件写入失败 消息队列里没有事件,其他服务收不到通知 → 数据不一致
失败 B 事件写入成功,但业务表更新失败 消息队列收到了事件,但数据库里业务没完成 → 数据不一致

使用外部事务

SaveEventAsync 中的关键代码:

_context.Database.UseTransaction(transaction.GetDbTransaction());

这里的 transaction 是 业务操作已经创建的数据库事务(比如你更新订单状态的那一段 IDbContextTransaction)。

_context.Database.UseTransaction(...) 表示 将事件日志表的写入绑定到这个已经存在的事务。

这样做的效果:

  • 业务数据 + 事件日志 写入 在同一个数据库事务里提交。
  • 只要事务提交,两者都会成功;如果事务回滚,两者都会回滚。

总结

  • 保证“业务操作”和“事件日志写入”在同一事务中提交。
  • 避免因为单独事务导致的消息与数据库状态不一致。
  • 实现可靠事件发布(Outbox 模式)。

SaveEventAsync中为什么要保存事务Id(TransactionId)?EventId的作用?

image-20250923020233477

EventId

EventId:唯一标识一条事件日志。

  • 每个事件(比如 OrderCreatedEvent(创建订单事件)、PaymentSucceededEvent(支付成功事件))都有一个 全局唯一的 ID。
  • 作用:
    • 避免重复发送:在 Outbox 模式里,后台发送服务可以通过 EventId 判断事件是否已经发送过。
    • 状态更新:在发送完成后,可以根据 EventId 更新状态(Published / PublishedFailed)。
    • 追踪和日志:方便调试或查询某个事件在系统中的处理情况。

TransactionId

TransactionId:把事件日志与业务事务绑定。关联业务事务,保证事件与业务数据原子提交(Outbox 模式核心),方便按事务查询事件。

  • 每次业务操作都会在数据库里开启一个事务(IDbContextTransaction)。

  • 把事务 ID 存入事件日志表的好处:

    • 保证事务一致性:确保事件日志和业务数据在同一个事务里提交。如果事务回滚 → 事件日志不会保存 → 消息不会发送 → 保证数据一致。

    • 按事务查询事件:后台发送服务可以按事务批量获取“待发送事件”(比如一个业务涉及多个事件发布)。

      var events = _context.Set<IntegrationEventLogEntry>().Where(e => e.TransactionId == transactionId && e.State == NotPublished)
      
  • 解决并发问题:即使同一业务表多条记录同时写入事件日志,也能通过 TransactionId 关联,保证同一次业务事务内产生的所有事件,只会被发送一次(一次业务操作可能产生多条事件日志,通过 TransactionId 关联,后台会按事务(TransactionId)批量发送这些事件,确保不会重复发送,也保持顺序)。

1.2.2状态管理

事件默认有四种状态,默认应该是未发布,这里提供了三个方法修改事件的状态。

事件日志状态的统一更新,配合 Outbox 模式,可以追踪事件从未发布 → 发布中 → 已发布 / 发布失败的整个生命周期。

image-20250923021952890

封装了更新方法

image-20250923023041288

/// <summary>
/// 私有方法:更新事件状态
/// </summary>
private Task UpdateEventStatus(Guid eventId, EventStateEnum status)
{
// 查询单条事件日志
var eventLogEntry = _context.Set<IntegrationEventLogEntry>().Single(ie => ie.EventId == eventId);
// 更新状态
eventLogEntry.State = status;
// 如果状态为 InProgress,则记录发送次数
if (status == EventStateEnum.InProgress)
  eventLogEntry.TimesSent++;
// 保存更改到数据库
return _context.SaveChangesAsync();
}

为什么要记录发送次数(eventLogEntry.TimesSent++)?

防止无限重试

  • 如果某个消息因为配置错误、消息体过大、目标队列不存在等原因 永远无法成功,那无限重试会拖垮系统。
  • 通过记录次数,可以在超过阈值(如 5 次、10 次)后停止,转入 死信队列 或人工处理。

提供可观测性

运维/开发人员可以通过 TimesSent 判断消息的健康情况:

  • 0 → 说明一次就成功

  • 3 → 说明经历了几次失败才成功

  • 10+ → 高度可疑,需要排查

触发补偿机制

  • 当超过重试次数后,可以触发额外动作:
    • 报警通知
    • 人工介入处理
    • 将消息存入备用数据库,后续再批量补偿

队列不是可靠的吗?为什么还要重试?

队列(RabbitMQ、Kafka 等)通常被称为“可靠消息系统”,但它的可靠性主要体现在 消息进入 Broker 之后:

  • 存储可靠:消息一旦写入队列,会持久化到磁盘,多副本保障不会丢失。
  • 投递可靠:消费者 ACK 机制保证“至少投递一次”。

但是,生产者发布消息到 Broker 的过程并不是 100% 可靠,可能失败:

网络问题

  • 生产者发送消息 → Broker 收到并持久化 → Broker 返回确认
  • 如果确认丢失,生产者以为失败 → 重发消息,导致重复

Broker 问题

  • Broker 宕机、磁盘写满、队列限流等,导致消息写入失败

事务一致性问题

  • 数据库事务提交成功,但消息未能及时写入 Broker
  • 如果没有重试,数据库和消息队列之间会出现数据不一致

Broker

  • 接收消息(从生产者来)

  • 存储消息(队列/分区,保证可靠性)

  • 分发消息(投递给消费者)

  • 管理机制(确认、重试、路由、持久化、集群等)

为什么方法没有async,并且只返回Task

image-20250923222959483

直接返回Task是合法且常见的优化:当方法内部只是把另一个 Task 直接返回时,不必加 async,可以避免编译器生成状态机(节省一点分配和开销)。

但在含有同步阻塞操作或需要处理异步结果/异常的情况下,应把整个方法改为异步(async/await)并“异步链路到底”,以保证非阻塞、异常语义清晰和可读性。

这里的Single方法可以使用SingleAsync优化成异步的写法,并且整个可以优化成async/await

private async Task UpdateEventStatusAsync(Guid eventId, EventStateEnum status)
{
    var eventLogEntry = await _context.Set<IntegrationEventLogEntry>()
        .SingleOrDefaultAsync(ie => ie.EventId == eventId);
    .....
    await _context.SaveChangesAsync();
}

这里再总结async/await、Task、状态机

Task:异步操作

async:标记异步方法,编译器生成状态机来管理挂起和恢复

await:等待一个Task完成,如果未完成 -> 挂起方法,释放线程,Task完成后继续执行

状态机:

  • 本质:编译器生成的隐藏类,用来记录方法当前执行状态和局部变量

  • 字段 _state:表示方法当前执行到哪一步

  • MoveNext():根据 _state 执行或恢复方法

  • Task 回调:当异步操作完成时,状态机触发 MoveNext,继续执行剩余代码

1.2.3资源释放

_disposedValue:标记对象是否已经释放过资源。

volatile:

  • 确保多线程环境下对 _disposedValue 的访问总是读取最新值
  • 防止 CPU 或编译器对该字段的读写进行优化或重排序

防止重复释放资源,避免 ObjectDisposedException 或其他异常

/// <summary>
/// 标记对象是否已释放
/// </summary>
private volatile bool _disposedValue;

检查 _disposedValue

  • 如果对象已经释放过,则直接返回
  • 防止重复释放导致异常或重复调用 dispose

disposing 参数

  • true → 显式调用 Dispose(),需要释放 托管资源 + 非托管资源
  • false → 由 GC 调用终结器 时,只释放非托管资源,托管资源可能已被回收
/// <summary>
/// 显式释放资源
/// </summary>
protected virtual void Dispose(bool disposing)
{
    if (!_disposedValue)
    {
        if (disposing)
        {
            // 释放托管资源。比如DbContext
            _context.Dispose();
        }
        // 释放非托管资源(如果有)
        _disposedValue = true;
    }
}

调用 Dispose(bool disposing)

  • disposing: true → 表示显式释放,释放托管资源 + 非托管资源

抑制终结器

  • 显示调用会释放托管资源和非托管资源

  • 托管和非托管资源已经释放,告诉 GC 不需要再调用对象的终结器

  • 避免对象进入 finalization 队列(终结器),提高性能

public void Dispose()
{
    // 可扩展,区分托管和非托管资源。
    Dispose(disposing: true);
    // 避免 GC 重复调用析构函数。
    // 提高性能,避免重复释放资源。
    GC.SuppressFinalize(this);
}

为什么要两个 Dispose 方法?

image-20250923233212676

Dispose(bool disposing)

  • 核心释放逻辑
  • 可被继承类重写扩展
  • 区分显式释放和 GC 回收

Dispose()

  • 外部调用接口
  • 调用核心逻辑,并通知 GC 不用再调用终结器

1.3总结

简单理解:就是每个服务数据库创建了一个IntegrationEventLog表,用于存储需要发布的集成事件,业务数据和集成事件要在一个事务中同时保存

image-20251204003621661

2.集成事件-商品模块

这个类负责在 Catalog 模块 中:

  • 把业务产生的集成事件按顺序写入事件日志(IntegrationEventLog)并与 Catalog 的本地数据库变更保持原子性(事务一致性);

  • 将已经写好的事件从事件日志发布到事件总线(EventBus),并在日志中记录事件的发布状态(NotPublished / InProgress / Published / PublishedFailed);

  • 实现 IDisposable 以释放相关资源。

首先查看集成事件服务注入了那些服务:

  • 日志服务(ILogger)是NetCore默认的服务,
  • 集成事件日志服务(IIntegrationEventLogService)、商品上下文服务(CatalogContext)我们刚看过了
  • 这里的IEventBus主要用于事件总线的发布,我们后面再详细看一下

image-20250927235315076

2.1发布事件

  • 发布前标记事件为发布中(InProgress),这里会记录发布次数
  • 事件总线发布事件
  • 发布后标记事件为已发布(Published)
  • 如果捕获到异常标记事件为发布失败(PublishedFailed)

image-20250927235351836

为什么事件总线发布事件没有使用事务?

  • 更新状态是幂等的:发布事件更新事件状态,就算发布失败(比如网络中断),状态会被标记为 PublishedFailed,后台重试时还会再次尝试。
  • 外部发布操作没法与数据库事务绑定:消息总线(RabbitMQ、Kafka 等)不是同一个数据库资源,没法和数据库事务组成分布式事务(除非引入 2PC / XA,这在微服务里几乎不用)。
  • Outbox 模式就是靠“状态机 + 重试”来保证最终一致性:不依赖跨系统事务,而是通过状态跟踪 + 后台重试机制保证事件最终一定会发出去。

总结:

在 发布事件到事件总线 时 不需要开启事务。事务只在 保存业务数据 + 事件日志 时才需要,以保证本地一致性。跨系统一致性通过 Outbox 模式的“状态+重试”来实现。

2.2EFCore执行策略(ExecutionStrategy)

上面执行种子数据的时候也使用了EFCore执行策略(ExecutionStrategy),这里把EFCore执行策略(ExecutionStrategy)进行了封装,再来了解一下执行策略:

  • ExecutionStrategy 是 EF Core 提供的连接/命令重试框架,用于自动重试短暂性(transient)错误(网络抖动、云服务短暂不可用等)。
  • 框架层定义了 IExecutionStrategy / 抽象类 ExecutionStrategy,各个 provider(SQL Server、Cosmos 等)可以实现自己的策略逻辑。
  • 默认行为:多数 provider(例如本地 SQL Server、Postgres、MySQL、SQLite)默认不重试(NonRetryingExecutionStrategy)。Azure SQL / Cosmos 等环境通常会默认启用重试或提供相应实现。
  • 引入重试能提高可用性,但会带来幂等性、事务不确定性、内存缓冲等副作用,需要谨慎设计。

ResilientTransaction

  • 这里通过私有构造函数接收外部传来的DbContext(必须传入非空 DbContext)

  • public static ResilientTransaction New(DbContext context) => new(context);
    
    • New(DbContext context):方法名是 New,接受一个参数 context,类型是 DbContext。

    • => new(context):表达式主体语法(C# 6+),等价于:

      public static ResilientTransaction New(DbContext context)
      {
          return new ResilientTransaction(context);
      }
      
    • new(context):调用的是 类的私有构造函数

    • 这个方法就是“帮你生成一个新的 ResilientTransaction 实例”。

提供一个简洁、统一的入口来创建 ResilientTransaction 实例,而不让外部直接访问私有构造函数。

image-20250928003613948

通过 CreateExecutionStrategy().ExecuteAsync(...),整个事务逻辑会被 EF Core 的执行策略包裹。也就是每次重试都会使用事务。

image-20250928004407509

2.3事务保存Catalog数据变更和集成事件日志

保证“Catalog 数据变更”和“把 IntegrationEvent 写入 IntegrationEventLog”在同一个事务(原子)中提交------经典的本地事务 + outbox 日志写入方案。

  • ResilientTransaction就是封装的EFCore执行策略(ExecutionStrategy)
  • catalogContext.SaveChangesAsync()保存catalogContext
  • integrationEventLogService.SaveEventAsync保存集成事件日志,但是需要传入当前catalogContext的事务,确保catalogContext和集成事件日志的保存在一个事务里(outbox日志写入方案)。

image-20250928005924181

2.4资源释放

实现标准的 disposable 模式,释放托管/非托管资源,并防止重复释放。

  • volatile 修饰符用于 保证多线程访问时的可见性。

    • 写操作立即对其他线程可见。
    • 读操作总是从内存中获取最新值,而不是寄存器缓存的旧值。
  • disposedValue:防止重复释放。

  • disposing: true:释放托管资源。integrationEventLogService就是实现了自己的dispose,integrationEventLogService只有托管资源。

  • Dispose(bool disposing):如果有非托管资源,也可以在这里释放。

image-20250928011628598

2.5总结

CatalogIntegrationEventService:

  • PublishThroughEventBusAsync:发布集成事件
  • SaveEventAndCatalogContextChangesAsync:EFCore执行策略和事务保存业务数据和集成事件

3.集成事件-事件总线(RabbitMQEventBus)

3.1基本概念

事件总线(Event Bus)

事件总线(Event Bus) 是一种 发布-订阅(Pub/Sub)模式 的消息传递机制,用于在应用程序的不同模块或微服务之间传递事件。

  • 事件(Event):代表系统中发生的某个事实或动作,例如:

    • 用户下单成功 → OrderCreatedEvent

    • 商品库存更新 → ProductStockChangedEvent

  • 发布者(Publisher):触发事件的一方(通常是业务服务或模块)。

  • 订阅者(Subscriber):监听事件并执行相关操作的一方(可以是同一进程,也可以是其他微服务)。

  • 总线(Bus):负责传递事件,解耦发布者和订阅者。发布者无需知道谁在消费事件,订阅者无需知道谁发布事件。

事件总线(Event Bus)有什么用处?

  • 解耦系统组件
    • 发布者和订阅者不直接依赖对方。
    • 例如,订单模块发布 OrderCreatedEvent,库存模块、通知模块都可以订阅处理,不需要订单模块知道它们的存在。
  • 支持异步处理
    • 事件可以异步传递,提高系统响应速度。
    • 发布事件后,业务流程无需等待所有订阅者完成操作。
  • 实现跨微服务通信
    • 在微服务架构中,各服务可能部署在不同主机或容器中。
    • 事件总线可用消息中间件(如 RabbitMQ、Kafka、Azure Service Bus)实现跨服务通信。
  • 支持事务一致性
    • 配合 事件日志(IntegrationEventLog),可以保证业务操作和事件发布的一致性,避免事件丢失。

剩下的不多做赘述,知道事件总线是发布/订阅的消息发布机制即可。

3.2注册客户端

我们直接从源码中查看事件总线的相关实现

image-20250928013940732

注册 RabbitMQ 客户端

  • 从配置文件中读取RabbitMQ 的链接配置
  • 允许消费者回调支持 async/await 异步方法。

image-20250928015712650

为什么这里的链接配置只传入了eventbus?实际的配置文件是ConnectionStrings:EventBus(这是个小问题,只是比较好奇)

image-20250928020139517

跟踪代码发现Aspire.RabbitMQ.Client会对connectionName进行处理,会自动从ConnectionStrings:connectionName中找到RabbitMQ 的链接配置,其实上面数据库的链接也是这样的

public static string? GetConnectionString(this IConfiguration configuration, string name)
{
return configuration?.GetSection("ConnectionStrings")[name];
}

image-20250928020534869

3.3集成 OpenTelemetry 追踪

3.3.1OpenTelemetry

再次回顾一下OpenTelemetry :

OpenTelemetry 是一个 由 CNCF 托管的开源可观测性(Observability)框架/规范与工具集,目的是为应用提供统一、厂商无关的采集、传输与语义(attribute)约定,覆盖 traces(分布式追踪)/metrics(指标)/logs(日志)/profiles(轮廓)等信号。它是 OpenTracing 与 OpenCensus 的合并后继项目。

  • Metrics(指标):轻量、低成本,适合做告警与长期趋势(CPU、请求速率、错误率等)。

  • Traces(追踪):把单次请求在多服务间的调用链还原,用于性能分析、定位服务间延迟与瓶颈。采样通常要平衡成本(不可能/不必要采集 100% trace)。

  • Logs(日志):事件级别的记录,适合审计和细粒度问题排查;当日志中关联 trace_id 时,能实现 trace ↔ log 的互相跳转。

三者互补:在实践中通常以 metrics 监控全量健康、trace 做深入单流量排查、logs 做事件审计/细节还原。

3.3.2ActivitySource(活动源)

ActivitySource 是 OpenTelemetry 的核心入口,用来创建 Activity(span):

一个 ActivitySource 表示一个逻辑的“追踪来源”,可以是 HTTP 服务、数据库访问或者 RabbitMQ 消息总线。

在 RabbitMQ 场景中:

  • Producer:发送消息前创建 Activity 表示“发布消息的操作”。
  • Consumer:接收消息后创建 Activity 表示“处理消息的操作”。

RabbitMQ 场景:

  • 每条消息发布或消费都可以用 Activity 来表示。
  • Activity 可以设置标签(Tag)记录队列名、消息 ID 等元数据。
  • 通过 Activity 的开始/结束时间,可以度量消息在队列中的延迟和处理耗时。

image-20250928185142902

3.3.3TextMapPropagator(跨进程上下文传播器)

TextMapPropagator(跨进程上下文传播器)用来 在进程或服务之间传播 trace context。

Trace context 包括:

  • traceparent:当前 trace 的唯一标识
  • tracestate:Trace 状态信息
  • Baggage:业务自定义的小量信息(如 userId、tenantId)

RabbitMQ 场景:

  • 发布消息:把当前 Activity 的 trace context 写入消息 headers。
  • 消费消息:从消息 headers 中提取 trace context,恢复父 Activity,实现端到端追踪。

image-20250928185239603

3.3.4RabbitMQTelemetry

RabbitMQTelemetry类就是配置了事件总线的ActivitySource和TextMapPropagator

组件 作用 场景
ActivitySource 生成 Activity(span) Producer/Consumer 创建 span
TextMapPropagator 在消息 header 中传递 trace 上下文 保证跨服务/跨进程 trace 连贯性

总结:

  • ActivitySource:负责“生成 span”,标识 RabbitMQ 相关操作。
  • TextMapPropagator:负责“跨进程传递 trace”,保证分布式链路完整。

image-20250928190115867

3.3.5注册OpenTelemetry

这段代码的作用是把 RabbitMQ 消息流的 Activity 纳入 OpenTelemetry 追踪系统,让你能看到完整的发布-消费链路。手动采集 RabbitMQ 消息流的 trace,因为 RabbitMQ.Client 本身没有内置 OpenTelemetry 支持。配合 Activity 和 Propagator 可以实现分布式追踪,保证跨服务消息链路可视化。

  • 注册 OpenTelemetry Tracing:AddOpenTelemetry() 把 OpenTelemetry 的核心追踪服务(TracerProvider)注册到 DI 容器中。
  • 配置 TracerProvider
    • .WithTracing(...) 用于配置 TracerProvider 的采集规则。
    • 可以指定要采集哪些 ActivitySource、自动 instrumentation、采样策略、资源信息和导出器。
  • 指定 ActivitySource
    • tracing.AddSource(RabbitMQTelemetry.ActivitySourceName) 告诉 OpenTelemetry “只收集这个名字的 Activity”。
    • 对应 RabbitMQTelemetry 中的 ActivitySource,用于消息的发布(Producer)和消费(Consumer)链路追踪。

image-20250928021202823

为什么在分布式系统里要把 RabbitMQ(异步消息通道)集成到 OpenTelemetry(OTel)追踪里?

首先 RabbitMQ.Client 本身没有内置 OpenTelemetry 支持,所以需要手动配置RabbitMQ的 Activity 和 Propagator ,这个体现在RabbitMQTelemetry这个类中。

因为异步消息是分布式系统里容易“断链”的部分,将 RabbitMQ 纳入 OpenTelemetry 能把消息的“生产 → 排队/传输 → 消费”整合到同一条 Trace 中,从而实现完整链路可视化、快速定位问题和性能优化。

  • 链路可视化(End-to-end trace)
    • 把生产者(Producer)和消费者(Consumer)产生的 Activity 用同一 traceId 串联起来,构成完整调用链。
    • 没有追踪时,日志通常只显示“有人发了消息”和“有人消费了消息”,难以证明两者因果关系。
  • 快速定位异步问题
    • 能看出消息在队列中等待的时长(queue delay)、传输延迟、哪个消费者慢或失败,便于定位谁是瓶颈或故障点。
  • 性能优化和容量规划
    • 量化消费者处理耗时、队列积压、热点队列,帮助优化伸缩策略和资源分配。
  • 统一观测(同步 + 异步)
    • 与 HTTP/gRPC 的追踪统一在同一后端(Jaeger/Tempo/Elastic),便于在单一 UI 中查看整条请求流(同步 + 异步混合场景)。
  • 标准化与可移植性
    • OpenTelemetry 是开源标准,支持多种导出后端,便于切换/扩展观测后端而不改业务代码。

3.4事件总线配置项

EventBusOptions主要2个配置项:

  • SubscriptionClientName:客户端名称。比如这是商品服务(Catalog.API),这里就配置了Catalog。
  • RetryCount: 在连接/发布失败时的重试次数,默认 10。商品服务(Catalog.API)使用的就是默认项。

image-20250929003442309

这行把配置系统中的 SectionName(通常是 "EventBus")这一节绑定(bind)为一个 EventBusOptions 类型,并把它注册到依赖注入容器中,供应用通过 IOptions<T> / IOptionsMonitor<T> / IOptionsSnapshot<T> 等方式获得配置值。

Configure<T>(IConfiguration):把配置节绑定为强类型 T 并注册到 DI 中(可注入 IOptions<T> / IOptionsMonitor<T> / IOptionsSnapshot<T>)。

builder.Configuration.GetSection(SectionName) 返回配置文件中 SectionName 节的 IConfigurationSection(例如 appsettings.json 中的 "EventBus": { ... })。

image-20250929004150190

这里在复习一下IConfiguration和IOptions

IConfiguration(配置系统)

作用

  • 抽象多个配置源(Providers):appsettings.json、环境变量、命令行、Azure Key Vault、User Secrets 等。
  • 提供按 key 获取配置值(层级用冒号 : 表示),并合并多个 provider(后注册的 provider 覆盖前面的同 key 值)。
  • 支持 reloadOnChange(当底层 provider 支持变更通知时,可动态更新)。

优点/缺点

  • 优点:灵活、可以直接获取任意键;支持动态变化(若 provider 支持)。
  • 缺点:在工程大量散布 configuration["..."] 会导致字符串 key 泄露、难测试、不够类型安全。

IOptions<T>(选项)

作用

  • 把配置映射为强类型对象(POCO),提高类型安全与可测试性。

  • 可以在注册时做校验、默认值、命名实例、变更通知等。

  • 便于注入到服务构造函数:public MyService(IOptions<MyOptions> opts)

三个接口(IOptions<T>主要实现,挺重要的)

接口 生命周期 / 作用 适合场景
IOptions<T> 单例配置,Value 在应用启动时绑定一次(不会更新) 启动时固定配置,或不关心运行时变更的服务(简单场景)
IOptionsSnapshot<T> 仅在 Scoped(请求)生效,每次请求返回最新绑定值(适合 Web 请求) 每个请求需要读取最新配置(绑定随配置源变更而更新)
IOptionsMonitor<T> Singleton,可实时监听变更;CurrentValue 始终最新,可通过 OnChange 注册回调 单例服务或后台任务需要最新配置或响应配置变化

为什么在 ASP.NET Core 中,有了 IConfiguration,微软还要封装 IOptions?

IConfiguration 更偏向底层 API,适合灵活读写和组合配置源。IConfiguration 就像一份原始的 JSON 文件 → 你要自己记住路径(key)、自己解析。

IOptions<T> 是微软封装的 最佳实践层,鼓励开发者用类型化、可验证的方式访问配置,更安全、更契合 DI、更容易维护。IOptions<T> 就像是给 JSON 设计了一个 C# 模型 → 系统自动帮你映射、验证、热更新,你直接用模型对象就行。

特性 IConfiguration IOptions
类型安全 ❌ 手动转换 ✅ 强类型 POCO
错误发现 运行时报错 编译时/启动时就能发现
DI 集成 支持,但原始 天然支持(IOptions/IOptionsMonitor/IOptionsSnapshot)
配置分组 依赖字符串 key,容易散乱 POCO 类清晰分组
配置更新 需要手动处理 IOptionsMonitor 内置支持
验证 ❌ 没有 ✅ 内置验证管道
多实例 ❌ 不支持 ✅ Named Options 支持

3.5单例注入RabbitMQTelemetry

RabbitMQTelemetry封装了 ActivitySource(追踪源)和 TextMapPropagator(上下文传播器),专门用于在 RabbitMQ 场景下生成 trace、记录 span,并在跨服务/跨进程的消息传递中传播 trace context 和 baggage,实现分布式追踪。

RabbitMQTelemetry上面已经讲过,上面只是调用了RabbitMQTelemetry的静态字段ActivitySourceName,这里把 RabbitMQTelemetry 注册到 ASP.NET Core 的 依赖注入容器。

image-20250929011405209

为什么把 RabbitMQTelemetry注册为单例(Singleton)?

ActivitySource 官方推荐一个模块单实例

  • ActivitySource 是 .NET System.Diagnostics 的核心对象。
  • 官方推荐:
    • 一个 逻辑组件/模块只需要一个 ActivitySource。
    • 创建多个 ActivitySource 会消耗额外资源,并且会导致同一模块的 trace 数据分散。
  • RabbitMQTelemetry 封装了这个 ActivitySource,所以整个应用只需要一个实例。

Propagator本身无状态, 是线程安全,可复用

  • Propagator 负责在消息头中注入/提取 trace 上下文。
  • 它是 线程安全、可复用的,不需要每次创建新的实例。
  • 单例可以复用 Propagator,避免重复创建。

全局统一追踪源,方便聚合数据

  • 分布式追踪要求全局唯一的追踪源名称(EventBusRabbitMQ)。就是RabbitMQTelemetry 配置的ActivitySourceName字段。
  • 如果每次使用都 new 一个 RabbitMQTelemetry:
    • 会创建多个 ActivitySource。
    • Trace 数据可能在不同实例中分散,观察者难以聚合。
  • 单例确保:
    • 统一 trace 名称。
    • 所有组件共用同一个 ActivitySource,生成的 Activity 能统一采集、发送到 OpenTelemetry 或 Jaeger/Zipkin。

节省资源和性能开销

  • 创建 ActivitySource 和内部事件处理器会有一些开销。

  • 单例只创建一次,降低内存占用和性能消耗。

3.6发布事件(PublishAsync)

RabbitMQEventBus类负责 发布(PublishAsync) 与 消费(StartAsync → OnMessageReceived → ProcessEvent) RabbitMQ 消息,集成了 OpenTelemetry 的 trace 注入/提取,并用一个简单的重试管道(ResiliencePipeline)包装发布操作。它实现了 IEventBus(业务接口)、IHostedService(随宿主启动消费者)和 IDisposable(释放资源)。

image-20250930005058410

3.6.1事件名和序列化配置(EventBusSubscriptionInfo)

3.6.1.1事件名

EventTypes:维护事件总线(EventBus)里“的事件名

image-20250930015019157

为什么这里EventTypes使用 Dictionary<TKey, TValue>类型,而没有使用ConcurrentDictionary<string, Type>类型?

Dictionary<TKey, TValue>

首先普通的 Dictionary<TKey, TValue> 在 多线程并发写入时 不是线程安全的。即使 Dictionary 在只读的情况下可以并发安全使用,但一旦有修改操作,就有风险。

如果 EventTypes 在 初始化阶段一次性加载好,运行时只读,用普通 Dictionary 就够了,没必要引入并发容器(更轻量)。这里就是先配置要订阅的事件类型,然后在app.run之后启动的StartAsync监听队列消息才使用,简单理解就事件类型提前已经加载好了。

image-20250930021908946

ConcurrentDictionary<string, Type>

顺便了解一下ConcurrentDictionary<string, Type>

ConcurrentDictionary<string, Type>是线程安全,适合多线程高并发读写场景。内部实现了 分段锁(striped lock) 或 无锁算法,保证在并发访问时:

  • GetOrAdd、TryAdd、TryRemove、TryUpdate 这些操作是原子性的。
  • 多线程下不会抛异常,也不会破坏字典内部结构。

如果你在 事件总线运行中,会动态订阅或取消订阅事件(即运行时不断修改 EventTypes),就要用 ConcurrentDictionary。

3.6.1.2序列化配置

根据运行时是否支持反射来决定序列化方式:

  • 正常模式(支持反射) → 用 DefaultJsonTypeInfoResolver,可以自动发现类型。
  • AOT/Trim 模式(不支持反射) → 必须依赖 JsonTypeInfoResolver(比如源码生成的类型信息)。

image-20250930022738895

3.6.2序列化/反序列化(AOT/Trim)

在发布事件方法PublishAsync中使用了封装的SerializeMessage方法进行序列化,我们看下这方法上俩个特性

image-20250930011244224

SerializeMessage方法中的俩个特性什么意思(反序列化DeserializeMessage也使用了这俩个特性)?

[UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode",
Justification = "The 'JsonSerializer.IsReflectionEnabledByDefault' feature switch, which is set to false by default for trimmed .NET apps, ensures the JsonSerializer doesn't use Reflection.")]

image-20250930014552175

这两个特性是在编译/发布阶段抑制特定警告(不是运行时的行为改变)。它们告诉编译器/IL Linker(或 AOT 编译器)“我知道这段代码在 Trim/AOT 下可能有风险,但我已经考虑过/处理好了,不需要警告”。之所以要这样做,是因为 System.Text.Json 默认在运行时用反射(dynamic)来生成序列化逻辑,而 AOT(提前编译)/PublishTrimmed(裁剪) 会让反射或动态代码失效或导致类型被删除,从而引发问题。

AOT(Ahead-Of-Time)

AOT(Ahead-Of-Time):在发布时把 IL 编译成本机代码(例如 NativeAOT)。优点:启动快、适合无 JIT 平台;缺点:不支持运行时生成/某些反射场景。

在 AOT 模式下,运行时不能 JIT,也不能动态生成代码(Emit、反射构造表达式树等)。 一些 API 被标记了 [RequiresDynamicCode],如果你调用它,在 AOT 编译时会报 IL3050 警告。

这个特性显式告诉编译器:“我清楚这个 API 在 AOT 下可能触发 IL3050,但我们这里用的 JsonSerializerOptions.TypeInfoResolver 已经替换掉了反射式 resolver,不会生成动态代码,所以请别报 warning。”

Trim(裁剪)

Trim(裁剪):在发布时移除未被检测到使用的类型/成员以减小体积。风险是:如果你通过反射使用某类型,裁剪器可能误删该类型。

在 Trim 模式下,IL Linker 会裁剪未使用的类型/方法。如果一个 API 被标记了 [RequiresUnreferencedCode](表示用到反射或动态代码,可能被裁掉),编译器会在调用它时发 IL2026 警告。

这个特性显式告诉编译器:“我知道这个 API 会触发 IL2026,但我们项目的配置里 JsonSerializer.IsReflectionEnabledByDefault = false,保证它不会依赖反射,所以不用警告我。”

3.6.3局部方法

什么是局部静态方法?(有好几个地方使用了局部静态方法,很简单的知识点,这里回顾一下)

局部方法(Local Method) 是 C# 7.0 引入的特性:

  • 允许在一个方法内部定义另一个方法。
  • 这个内部方法只能在定义它的外部方法里调用。

局部静态方法(local static method)是 C# 8.0 引入的扩展:

  • 在局部方法前加 static 关键字。
  • 不会捕获外部变量,也就是 不会生成闭包,运行效率更高。
  • 只能访问自己的参数和方法内部的局部变量。
void OuterMethod()
{
    // 普通局部方法
    int LocalAdd(int x, int y) => x + y;
    // 静态局部方法
    static int StaticLocalAdd(int x, int y) => x + y;
    int a = LocalAdd(1,2);
    int b = StaticLocalAdd(3,4);
}

3.6.4重试策略(ResiliencePipeline)

RabbitMQEventBus在发布事件PublishAsync的时候使用了重试策略ResiliencePipeline

image-20251001014702641

我们查看CreateResiliencePipeline的实现:

CreateResiliencePipeline 方法用于创建一个 带重试策略(Retry, 带指数退避的延迟) 的 ResiliencePipeline(用 Polly 构建一个 “指定异常才重试 + 最大 N 次 + 指数退避延迟” 的 ResiliencePipeline。)。这个 ResiliencePipeline 可以在执行操作(比如 RabbitMQ 发布消息)时,遇到 网络异常(BrokerUnreachableException 或 SocketException)时,自动进行一定次数的重试,每次重试之间的延迟时间是 指数退避(2的1次方,2的2次方,2的3次方...)。

这个管道配置了指定的异常,也就是指定的异常触发时会指数级重试:

  • BrokerUnreachableException:RabbitMQ 客户端无法连接 Broker、拒绝连接
  • SocketException:TCP 连接失败、网络中断

image-20251001021429012

为什么 GenerateDelay 写在 CreateResiliencePipeline 内部?

image-20251001020501945

  • 只在该方法内部使用,局部方法封装逻辑
  • 加 static避免闭包、提高效率
  • 阅读 CreateResiliencePipeline 时能直接看到延迟计算逻辑,逻辑清晰
  • 内部工具函数不必暴露给类外部,避免污染类命名空间

3.6.5发布事件(PublishAsync)

1.路由键

使用事件类型名作为 routing key(这是常见约定)。

2.创建 channel

为每次发布创建短生命周期 channel(RabbitMQ 建议 channel 非线程安全且轻量,可以短期创建)。

3.声明 exchange

幂等声明(在StartAsync方法初始化中也声明了exchange),确保交换机存在。

image-20250930005434887

4.序列化消息

使用 JsonSerializer.SerializeToUtf8Bytes。使用封装的SerializeMessage方法,这个方法在正常模式(支持反射,返回DefaultJsonTypeInfoResolver)和AOT/Trim模式(不支持反射,返回JsonTypeInfoResolver.Combine())通过EventBusSubscriptionInfo返回不同的JsonSerializerOptions(JSON 序列化配置)。详情参考上面的EventBusSubscriptionInfo。

image-20250930011035327

5.重试策略

发布事件的核心代码都写在弹性策略管道(重试管道)里,触发指定异常时会重试。

image-20251001020724701

6.OpenTelemetry activity

Activity 表示一次操作(比如发送消息、HTTP请求等),这里就是RabbitMQ的消息。

尝试创建一个新的 Activity 实例。如果 采样器(Sampler)拒绝 或者 没有注册 Listener,这个方法可能返回 null:

  • 如果创建成功,则用它的 Context 进行消息传播。
  • 如果创建失败(被采样掉),则退回到当前已有 Activity 的 Context,保证追踪链可用,不丢失 trace。

image-20250930025056860

7.消息属性

properties.DeliveryMode:消息持久化:1 = 非持久化,2 = 持久化(写入磁盘)

  • Broker crash (RabbitMQ 服务器在发生崩溃或意外断电) 前,如果消息还在内存中且未写入磁盘,可能丢失。

  • 建议结合 Publisher Confirms(让消息发送方确认消息是否被 broker 成功接收和持久化) 来确保消息已被 broker(RabbitMQ 服务器)接收。

image-20251001022632548

8.trace header

把 OpenTelemetry 的 trace context(如 traceparent、tracestate)写入 RabbitMQ 消息的 headers 中。

  • InjectTraceContextIntoBasicProperties:IBasicProperties.Headers是键值对类型(IDictionary<string, object>)的参数,这个方法就是 将 trace header 注入到 IBasicProperties.Headers中
  • _propagator.Inject:遍历 PropagationContext 中所有 trace 字段(traceparent、tracestate、baggage(附加上下文信息)等),然后调用委托写入到RabbitMQ 消息的 headers 中。

image-20251001023936450

9.Activity 标签设置

  • 给 Activity 添加标签(tag)
  • 遵循 OpenTelemetry messaging 规范
  • 方便在追踪系统(如 Jaeger、Zipkin)查看消息元数据

image-20251001030317330

10.RabbitMQ发布消息

  • mandatory = true:

    • 如果没有匹配的队列,服务器会返回 BasicReturn

    • 需要额外处理,否则消息可能丢失

    • 可以使用BasicReturn处理找不到匹配的消息

      // 1. 注册 BasicReturn 回调
      channel.BasicReturn += (sender, args) =>
      {
          // args.Body = 消息体
          // args.RoutingKey = 路由键
          // args.Exchange = 交换机
          // args.ReplyText = 失败原因
          Console.WriteLine($"消息未投递到队列: {args.ReplyText}, RoutingKey={args.RoutingKey}");
      
          // 2. 根据需求处理:
          // - 日志记录
          // - 保存到数据库 / 文件,后续补发
          // - 触发告警
          // - 死信队列
      };
      

image-20251001030620615

11.异常记录(Activity 标签)

  • 按照 OpenTelemetry 规范,把异常信息写入标签(message、stacktrace、type)
  • Activity 状态标记为 Error
  • 分布式追踪系统可以展示:
    • 哪个消息发送失败
    • 异常类型和堆栈
    • 出错时间和调用链

image-20251001031255191

12.IEventBus

RabbitMQEventBus 实现了 IEventBus 接口,该接口定义了一个用于事件发布的方法;通过依赖注入(IOC),后续业务代码只需面向 IEventBus 编程即可完成事件发布。

image-20251001232206681

13.总结

PublishAsync(@event)
       │
序列化消息
       │
创建 Channel → 声明 Exchange
       │
创建 Activity (OpenTelemetry)
       │
注入 Trace Context 到消息 Header
       │
_pipeline.Execute
       │
尝试 channel.BasicPublish()
       │
 ┌─────异常─────┐
 │              │
指定异常?     其他异常
 │              │
指数退避重试    抛出异常
 │
成功 → Task.CompletedTask

14.外观异步、内部同步和全异步(真正async/await)

为什么PublishAsync方法没有全部使用异步(async/await)的方式?

  • RabbitMQ .NET 客户端 API 是同步的:
    • IModel.BasicPublish 是 同步方法,发送消息时会阻塞调用线程
    • RabbitMQ 官方的 .NET 客户端目前并没有提供真正的异步 BasicPublishAsync
    • 所以即使你用 async/await,也无法真正异步发送消息
  • _pipeline.Execute 也是同步
    • _pipeline.Execute 接受一个委托返回 Task
  • 使用 Task.CompletedTask 代替 async/await
    • 表示方法已经完成,不需要额外的异步等待
    • 避免 多余的状态机开销
    • 适合这种“外部看起来是异步,但内部本质同步”的场景

关键点:外观异步、内部同步,在高吞吐场景下是常见设计。

image-20251001032842913

“外观异步、内部同步”和 全异步 在高吞吐场景下的利弊?为什么在 PublishAsync 场景下选择外观异步、内部同步是常见设计。

外观异步、内部同步

特点

  • 方法返回 Task 或 ValueTask → 对调用方是异步友好
  • 内部操作 同步执行 → RabbitMQ 的 BasicPublish 是阻塞调用
  • 异常会通过 Task 传播,可结合弹性重试。

优点

  • 性能开销低

    • 不会生成 async/await 状态机(Task 状态机有额外分配和上下文切换开销)

    • 高吞吐下调用非常轻量

  • 实现简单

    • RabbitMQ .NET 客户端大部分 API 是同步的

    • 不需要复杂的异步封装

  • 可靠性高

    • 同步调用失败时,异常立即抛出

    • 结合 _pipeline 或重试策略可以精确控制

缺点

  • 调用线程在 BasicPublish 阻塞期间被占用
  • 在高延迟网络或慢 broker 场景下,可能出现线程等待

所以适 局部同步、短耗时操作,外观异步用于接口统一(对外暴露 Task → 调用方无需关心内部同步或异步)

全异步(真正 async/await)

特点

  • 方法体内使用 async/await
  • 使用异步 IO 操作,比如 Task.Run 包裹同步方法或 RabbitMQ Publisher Confirms 异步 API
  • 发布消息不阻塞调用线程

优点

  • 不阻塞线程

    • 高延迟或慢 网络下调用线程可释放
    • 更适合大量并发请求
  • 与现代异步框架一致

    • ASP.NET Core、gRPC、微服务调用链自然异步

    • 与异步 DB、HTTP 调用等整合更优

缺点

  • 额外开销

    • Task.Run 包装同步方法会占用线程池线程 → 实际增加线程切换开销

    • async/await 状态机有分配和上下文切换成本

  • 复杂度增加

    • RabbitMQ 本身大部分 API 是同步的

    • 全异步可能只是“表面异步”,内部依旧阻塞 → 并没有真正节省线程

  • 吞吐量可能下降

    • 对高吞吐场景(每秒几千或几万条消息),每条消息都生成 Task.Run 反而消耗更多线程

典型使用场景对比

场景 外观异步、内部同步 全异步
短耗时发布(毫秒级) ✅ 高性能,线程占用少 ❌ Task 状态机开销不划算
高延迟 broker / 网络 ⚠️ 阻塞调用线程 ✅ 非阻塞,可释放线程
大量并发请求 ✅ 性能稳定 ⚠️ Task.Run 线程池压力大
整合异步链路(HTTP/DB) ⚠️ 与异步链路混用可能阻塞 ✅ 自然异步链路

为什么在 PublishAsync 场景下选择外观异步、内部同步是常见设计。

PublishAsync 选择 外观异步、内部同步 是合理的:

  • RabbitMQ 的同步 Publish 很快
  • 高吞吐场景下状态机开销小
  • 异常可被 _pipeline 捕获重试

3.7初始化RabbitMQ消费者(StartAsync)

该方法在宿主(Host)启动时在后台线程上建立 RabbitMQ 连接并创建消费者通道,声明 exchange/queue,绑定 routing key,并启动 BasicConsume 开始异步接收消息。StartAsync 本身马上返回 Task.CompletedTask,不等待后台消费者初始化完成,从而避免阻塞宿主启动流程。

image-20251001182109186

3.7.1Net中的线程分类

StartAsync专门开启了一个专用线程用于初始化RabbitMQ消费者,这里顺便了解一下Net中的线程分类

image-20251001190050188

线程池线程(ThreadPool Threads)

来源:Task.Run、Task.Factory.StartNew(默认)、Parallel.For、ThreadPool.QueueUserWorkItem 等。也可以叫后台线程。

特点:

  • 来去快,适合 短小任务。
  • 数量受限(CLR 会动态调整)。
  • 异常不会直接崩进程,而是走 TaskScheduler.UnobservedTaskException。

适用场景:并行计算、异步 continuation、轻量后台任务。

专用线程(Dedicated Threads)

来源:

  • new Thread(...)(手动创建)。
  • Task.Factory.StartNew(..., TaskCreationOptions.LongRunning)(调度器通常会分配专用线程)。
  • 也可以叫前台线程。

特点:

  • 不在池子里,独立存在,生命周期自己控制。
  • 适合 长期运行 / 阻塞 的逻辑(如 RabbitMQ 消费者、后台监控)。
  • 未捕获异常会导致该线程终止。

适用场景:消息循环、阻塞型后台任务。

UI 线程(STA Thread)

  • 来源:WinForms/WPF/MAUI 应用的主线程。
  • 特点:
    • 单线程消息泵(Message Loop)。
    • 必须是 单线程单元 (STA),用于 COM 互操作和 UI 控件。
    • UI 更新只能在 UI 线程上进行。
  • 适用场景:桌面应用界面渲染。

线程池线程(ThreadPool Threads)、专用线程(Dedicated Threads)、UI 线程(STA Thread)这三种线程是应用代码可见的线程

CLR/系统内部线程

GC 线程

  • 垃圾回收器专用。
  • 后台低优先级线程,不可控制。

Finalizer 线程

  • 调用对象的析构函数(~ClassName())。
  • 确保清理非托管资源。

Timer 线程

  • 用于调度 System.Threading.Timer 回调。

IOCP 线程(I/O Completion Port Threads)

  • 用于处理 异步 I/O 完成事件。
  • 比如 Socket、File、HttpClient 等的异步 I/O 完成后,会在 IOCP 线程上回调。

调试 / Profiler 线程

  • 当有调试器或性能分析器附加时,CLR 会起一些辅助线程。

3.7.2初始化RabbitMQ消费者(StartAsync)

1.专用线程(前台线程)

开启专用线程初始化RabbitMQ消费者

注意点:这里用 Task.Factory.StartNew(..., TaskCreationOptions.LongRunning),意在告诉调度器创建独立线程以避免占用线程池。_ = 表示不关心返回的 Task(不等待它完成)。

TaskCreationOptions.LongRunning 是对调度器的提示:任务预计会长期执行,默认的 ThreadPoolTaskScheduler 会倾向于为它分配一个 非线程池的专用线程,以避免长期占用线程池线程影响其他短任务。

为什么这里要使用TaskCreationOptions.LongRunning?

  • RabbitMQ 消费者是长期运行的阻塞任务;
  • 避免它占用线程池线程,影响其他短任务的调度;
  • LongRunning 会提示调度器为它分配一个专用线程,保证系统整体性能与稳定性。

为什么线程(Task.Factory.StartNew)中使用try...catch

在 .NET 里,如果一个线程里抛出了未捕获的异常:

  • 线程池线程:异常会被吞掉,最后触发 TaskScheduler.UnobservedTaskException。
    • 默认 .NET 4.5+:CLR 不会崩溃进程,只是记录日志。
    • .NET 4.0 及以前:如果不处理,CLR 会把它当作未处理异常 → 进程崩溃(ProcessCrash)。
  • 专用线程(new Thread / LongRunning):未捕获的异常会直接终止该线程,线程退出。

对 RabbitMQ 这种长期运行的消费者线程,如果不捕获异常,一旦启动时抛错(比如队列不存在、网络断开),线程就直接挂掉,不再工作。

image-20251001182314082

2.创建RabbitMQ连接

从 DI 容器获取RabbitMQ连接,如果连接未打开则直接返回(建议生产环境应实现重连逻辑或退避重试)。

image-20251001192454885

感兴趣可以去跟一下创建连接的源码

 Microsoft.Extensions.Hosting.AspireRabbitMQExtensions.CreateConnection

image-20251001192819691

3.创建消费者通道

  • CreateModel() 返回 Channel,用于发送/接收消息。
  • 注册异常回调

注意:

  • Channel 不是线程安全的,同一 Channel 在多线程中操作需要小心。
  • 当 Channel 内部发生异常时触发回调。这里仅记录日志,没有实现重建 channel 的逻辑。

image-20251001193150705

4.声明交换机(exchange)和队列(queue)

  • 声明一个 direct 类型交换机。如果 exchange 已存在且类型不匹配会抛异常。生产环境通常还会设置 durable、autoDelete 等参数。
  • 声明队列:
    • durable: true : 队列重启后仍存在(队列持久化)。
    • exclusive: false : 不仅限当前连接(队列独占)。
    • autoDelete: false : 队列不会在无消费者时删除(自动删除)。
    • arguments: null:扩展参数,可以配置 TTL、死信队列等高级特性。

image-20251001193514581

创建异步事件消费者

  • 使用 AsyncEventingBasicConsumer 支持 异步消息处理(async handler)。注册事件同步,触发事件执行异步(消息处理异步)。

  • 比传统 EventingBasicConsumer 更适合异步业务逻辑。

image-20251001194134892

6.注册收到消息回调

当消息到达时触发 OnMessageReceived。

这个是重点,后面单独再看。

image-20251001194908184

3.启动BasicConsume监听队列

  • 开始监听队列。

  • autoAck: false :消息需要 业务处理完成后手动 Ack(手动确认消息),保证消息不丢失。

  • 注意:消息处理失败必须调用 BasicNack 或 BasicReject。

image-20251001195134451

8.队列(queue)绑定交换机(exchange)

  • 将队列绑定到交换机的各个 routingKey,实现按事件路由消息。
  • 每个 routingKey 对应一个事件名。
  • 这样队列就可以接收不同类型的事件消息。

image-20251001195321364

9.异常处理

  • 这里异常只是简单记录了日志
  • 避免初始化失败导致进程崩溃
  • 可扩展重连或报警机制

image-20251001195745996

10.IHostedService

RabbitMQEventBus继承了IHostedService接口,这个接口需要实现2个方法:

StartAsync

  • 宿主(host)启动时调用一次
  • 用于初始化服务(如 RabbitMQ 消费者、定时任务、后台工作线程等)

StopAsync

  • 宿主(host)关闭时调用一次
  • 用于释放资源、关闭连接、停止线程等

image-20251001232008134

StartAsync实现了初始化RabbitMQ消费者

StopAsync没有任何实现

image-20251001231758140

11.总结

StartAsync()
       │
后台线程初始化 RabbitMQ 消费者
       │
获取连接 IConnection(DI 容器)
       │
连接未打开?
   ┌───────┴───────┐
   │               │
  是             否
   │               │
直接返回        创建消费者通道
                   │
           注册 CallbackException 回调
                   │
           声明 Exchange & Queue
                   │
           绑定事件名 routingKey
                   │
           创建 AsyncEventingBasicConsumer
                   │
           注册 Received 回调
                   │
           BasicConsume 开始监听(手动 Ack)
                   │
        成功 → Task.CompletedTask
                   │
         异常 → 记录 Error 日志

为什么StartAsync不用async/await?

  • 不用 async/await 是因为初始化是阻塞操作,不需要等待完成。CreateModel()、QueueDeclare()、BasicConsume()全部都是同步阻塞API。
  • 使用 LongRunning 是为了让阻塞初始化不占用线程池线程。
  • StartAsync 立即返回 Task.CompletedTask,宿主启动不会被阻塞。

3.8消息接收回调(OnMessageReceived)

1.局部静态方法(从IBasicProperties.Headers中提取trace header)

从 IBasicProperties.Headers 中按 key 提取值,返回字符串集合(propagator.Extract要求的委托(delegate))

image-20251001233649695

2.从消息头提取上游的PropagationContext

  • 使用 TextMapPropagator.Extract(OpenTelemetry)从 headers 中取 trace context + baggage。

  • 把提取到的 Baggage 设置为当前上下文,方便后续 Activity 使用。

  • 注意:如果没有 header,parentContext 会是默认的空上下文。

image-20251001234037385

为什么接收消息要传 parentContext.ActivityContext?发布消息的时候并没有这个 parentContext.ActivityContext

发布端(Producer)

  • 当发送消息时,会创建一个 Activity(通常 ActivityKind.Producer 或 ActivityKind.Client)表示“消息发送”操作。
  • Activity 会生成一个 新的 ActivityContext(包含 trace id、span id)。
  • 通过 propagator,将 trace context 注入到消息的 headers(如 traceparent、tracestate、baggage),以便下游使用。

消费端(Consumer)

  • 从消息 headers 提取上游 trace context → parentContext.ActivityContext。

  • 创建消费端 Activity 时,将它作为父上下文:

    using var activity = _activitySource.StartActivity("event receive", ActivityKind.Consumer, parentContext.ActivityContext);
    
  • 这样做的目的是 延续上游 trace,让消费端的 span 和发布端 span 属于同一条 trace。

如果不传 parentContext.ActivityContext

  • 消费端会生成独立的 trace(新的 trace id),无法和发布端关联。
  • 链路图中会断开,无法完整追踪消息从发送到消费的流程。

消费端使用 parentContext.ActivityContext 是为了延续发布端 trace,实现完整的消息链路追踪;发布端没有 parentContext 是因为它是链路起点。

image-20251002001754674

3.创建并启用Activity(追踪)

  • StartActivity 用 parentContext.ActivityContext 作为父上下文,建立链路追踪。

  • ActivityKind.Client:表示“客户端”Span。建议:接收消息常用 ActivityKind.Consumer(OpenTelemetry messaging 语义),但这是可调整的实现细节。

  • SetActivityContext(...) 是你自己的 helper(或扩展方法),通常会设置 messaging.* 的标签(messaging.system="rabbitmq"、messaging.destination=...、messaging.operation="receive" 等)。

image-20251001234604148

4.取出事件名与消息体

  • eventName 用 routingKey 表示事件类型(与之前队列绑定的 routingKey 对应)。
  • eventArgs.Body 通常是 ReadOnlyMemory<byte>,用 Encoding.UTF8.GetString(eventArgs.Body.Span) 可以得到字符串(注意大消息时的内存开销)。

image-20251001234752481

处理消息

  • SetTag("message", message):在 trace 中打上原始消息,注意敏感数据不要直接打到 trace/log 中。
  • throw-fake-exception:测试用的假异常触发器。
  • ProcessEvent:处理事件,这个后面单独看。
  • activity.SetExceptionTags(ex):try...catch捕获到异常后写入activity(ActivityStatusCode.Error)。

image-20251001235651052

6.手动Ack

这里无论是否异常都会Ack,这会把消息从队列移除,即使处理失败也会丢失该消息。

建议:

  • 异常时不要直接 Ack。可以 BasicNack(或 BasicReject)并 requeue=true(重入队列,可能导致死循环)或 requeue=false(进入死信队列 DLX)。

  • 更优做法:实现重试策略(重试队列 / 带 TTL 的延迟重试 / header 带重试计数),超过重试上限后发送到死信队列。

  • BasicNack 用法:显式告诉 RabbitMQ “消息未确认 (Not Acknowledged)”。

    // deliveryTag:消息的唯一标识
    // multiple:是否一次拒绝多个(比如批量拒绝到某个 tag)
    // requeue:是否把消息重新放回队列(true → 重新投递,false → 丢弃或进入 DLX)
    _consumerChannel.BasicNack(eventArgs.DeliveryTag, false, requeue: false);
    
  • BasicReject用法:拒绝单个消息。

    // deliveryTag:消息的唯一标识
    // requeue:是否重新入队(true → 再次投递,false → 丢弃或进入 DLX)
    _consumerChannel.BasicReject(eventArgs.DeliveryTag, requeue: false);
    

image-20251002000154012

3.9消息处理(ProcessEvent)

1.创建异步scope

  • 使用 IServiceProvider.CreateAsyncScope() 创建一个新的 DI Scope。

  • 优点:

    • 每条消息有独立的生命周期,使用独立的 Scoped 服务(如 DbContext、事务等)。

    • 避免不同消息的处理互相干扰。

  • await using 表示 异步释放 scope,如果 scope 内有异步释放资源(如 EF Core DbContext),会正确释放。

image-20251002004717498

2.根据事件名获取事件类型

  • _subscriptionInfo.EventTypes 是一个 事件名 → 事件类型 Type 的映射字典。
  • TryGetValue 根据 eventName 获取对应事件类型:
    • 成功:eventType 得到对应类型
    • 失败:记录警告日志,直接返回,不处理此消息
  • 确保 只处理已注册的事件类型,防止未知事件报错。

image-20251002005022136

关于事件名的EventBusSubscriptionInfo得类我们上面已经看过了,主要负责:

  • 保存事件名和事件类型的键值对
  • 用于获取兼容AOT/Trim的序列化/反序列化的对象

这里简单再过一遍

image-20251002005151752

3.接收的消息反序列化成事件对象

将接收到的消息字符串反序列化成 事件对象(继承自 IntegrationEvent)。

DeserializeMessage反序列化对象支持正常模式和AOT/Trim模式。(上面讲过EventBusSubscriptionInfo和支持AOT/Trim序列化/反序列化的方法,这里简单再过一下)

根据运行时是否支持反射来决定序列化方式:

  • 正常模式(支持反射) → 用 DefaultJsonTypeInfoResolver,可以自动发现类型。
  • AOT/Trim 模式(不支持反射) → 必须依赖 JsonTypeInfoResolver(比如源码生成的类型信息)。

image-20251002005643081

4.获取所有注册的处理器(IIntegrationEventHandler)并执行Handle方法

  • 根据事件类型获取 所有注册的处理器(IIntegrationEventHandler 实例集合):

    • GetKeyedServices<IIntegrationEventHandler>(eventType) 是一个扩展方法,用于从 DI 容器中按类型获取对应服务。
  • foreach 遍历每个 handler:

    • 默认 顺序执行

    • 英文注释中提醒可以用 Task.WhenAll 并行执行,但需考虑并发问题和事务一致性。

关于这里的事件订阅和执行,下面详细讲一下

image-20251002010435137

3.10事件订阅和执行

前面讲了收到消息后,需要找到对应订阅的事件进行执行,我觉得这个比较重要的(而且挺复杂的),我们单独看一下

3.10.1协变和逆变

泛型接口中的泛型参数用了关键字in,这意味着泛型参数 TIntegrationEvent 只在方法参数中用作输入(in)。而不会被返回(不会作为输出类型 out)。所以我们可以让它逆变。

协变和逆变好几年前(大概6年了)我都看过,一直没怎么用上,这里又出现了(我就记得和继承有关),我就再学习一边,这里接口的设计挺精妙的,还得是微软官方项目。

image-20251006231342411

3.10.1.1基本概念

协变(out):用于“只输出/只产生”的泛型,允许 子类型 → 父类型 的赋值转换(IProducer<Sub> 可当作 IProducer<Base>)。

逆变(in):用于“只输入/只消费”的泛型,允许 父类型 → 子类型 的赋值转换(IConsumer<Base> 可当作 IConsumer<Sub>)。

不变(无修饰):既作为输入又作为输出(或可变集合),必须类型完全匹配,例如 List<T>。

在 C# 泛型中,“型变(Variance)”描述的是泛型参数在继承关系中的兼容性。

名称 英文 关键字 含义
协变 Covariance out “子类可以替代父类”的方向
逆变 Contravariance in “父类可以替代子类”的方向
不变 Invariance (无修饰) 不允许替代,类型必须完全匹配

简单看一下继承关系

class Animal { }
class Dog : Animal { }

Dog d = new Dog();
Animal a = d;   // 正常继承关系

3.10.1.2协变(out)

关键字:out

语义:协变允许把 I<Derived> 赋值给 I<Base>(子 → 父)。

典型接口:IEnumerable<out T>、IQueryable<out T>。

IEnumerable<Dog> 只“取出” Dog,当把它当成 IEnumerable<Animal> 使用时,使用者只期望得到 Animal(Dog 是 Animal 的子类),安全。

class Animal { }
class Dog : Animal { }

IEnumerable<Dog> dogs = new List<Dog>();
IEnumerable<Animal> animals = dogs; // 协变:IEnumerable<out T>

3.10.1.3逆变(in)

关键字:in

语义:逆变允许把 I<Base> 赋值给 I<Derived>(父 → 子)。

典型接口:IComparer<in T>、Action<in T>(Action<T> 实际是 in)。

Action<object> 能接受任何 object,当然也能接受 string(string 是 object 的子类),把一个“能处理更宽泛类型”的处理器当成处理更窄类型的处理器是安全的。

class Animal { }
class Dog : Animal { }

// IComparer
IComparer<Animal> animalComparer =  Comparer<Animal>.Default;
IComparer<Dog> dogComparer = animalComparer; // 逆变(in):IComparer<in T>

// Action
Action<object> actObj = o => Console.WriteLine(o);
Action<string> actStr = actObj; // 逆变:Action<in T>
actStr("hello"); // 安全:actObj 接受 object,也能接受 string

3.10.1.4不变

大多数通用可变集合是不变的(类型必须完全匹配):例如 List<T>、Dictionary<TKey,TValue>。

不变是因为类型既被当作输入(Add 参数)又被当作输出(取出元素),若允许自动转换会导致类型安全问题。

若允许下述赋值,随后对 animalList.Add(new Dog())(假设 Dog : Animal)会把 Dog 插入到实际上存放 Cat 的集合中,类型安全被破坏。

List<Cat> catList = new List<Cat>();
// 下面会编译错误(不允许把 List<Cat> 当作 List<Animal>)
List<Animal> animalList = catList; //  编译失败 —— List<T> 不变

3.10.2默认接口方法实现

在事件总线接口中,我发现接口中自带实现,比较好奇,潜意识中记得接口只有方法,没有实现。

其实这里就是默认接口方法实现。

image-20251006235434716

在 C# 8.0(.NET Core 3.0 起支持)中,微软引入了一个新的语言特性:接口允许包含带默认实现的方法。

也就是说,接口不再只能定义“签名”,它还可以提供一个“默认实现”,而实现类可以选择是否覆盖。

public interface ILogger
{
    void Log(string message);

    // 默认接口实现(C# 8+)
    void LogInfo(string message)
    {
        Log("[INFO] " + message);
    }
}

public class ConsoleLogger : ILogger
{
    public void Log(string message) => Console.WriteLine(message);
    // 可以不实现 LogInfo,因为接口里已经有默认逻辑
}

3.10.3Keyed Service 模式

在事件订阅的时候使 AddKeyedTransient 把事件类型和处理器映射注册到 IOC 容器,这里简单了解一下这种方式

image-20251007174544582

Keyed Service 模式是一种 依赖注入注册方式:

  • Key(键):用来标识某一类服务,通常是事件类型、字符串或者枚举。
  • Service(接口):统一的服务类型接口,用于运行时调用。
  • Implementation(实现类):具体服务逻辑的实现。

通过 Key + 接口,可以注册多个实现类,并在运行时根据 Key 获取对应服务实例。

优势

优势 说明
动态分发 运行时根据 Key 找到对应处理器集合
一事件多处理 一个事件可以有多个处理器同时处理
类型安全 泛型接口保证编译期类型约束
性能优化 启动时注册好 Key → 实例映射,运行时只读访问

Keyed Service 模式就是“给每个服务加一个标签(Key)”,EventBus 可以通过标签快速找到并调用所有订阅的处理器,实现高效、动态、一事件多处理。

3.10.4事件订阅和执行

1.事件总线统一入口接口(IIntegrationEventHandler)

它是事件处理器在运行时统一的“入口点”。事件总线在收到消息、反序列化出 IntegrationEvent(基类)后,不知道具体泛型类型时,就可以把这个 IntegrationEvent 交给实现了该接口的处理器来处理。

image-20251002213724237

在事件总线「运行时分发」阶段,只有一个 IntegrationEvent(基类)实例和一些被注册的处理器类型(类型信息是在运行时才知道),编译期无法知道泛型参数。

事件总线收到消息时,只知道一个 IntegrationEvent(基类实例),不知道是具体继承 IntegrationEvent的那种事件类型。

IIntegrationEventHandler接口提供了统一的调用方法 Handle(IntegrationEvent),保证总线在运行时可以用相同的方式调用所有处理器。IIntegrationEventHandler接口是事件总线的“万能插口”,所有订阅事件都会走这里。

image-20251002204650652

2.事件总线强类型业务入口接口(IIntegrationEventHandler<T>)

其实这里用到了很多设计思路,我就从代码最明显的地方简单学习了几个知识点,保证在遇到这种源码时可以理解

  • 该接口继承IIntegrationEventHandler(非泛型接口)。
  • where TIntegrationEvent : IntegrationEvent表示TIntegrationEvent 必须是IntegrationEvent 的子类。
  • in 表示逆变:这个泛型类型参数只能用在输入(方法参数)位置,而不能作为返回值类型。这样设计的好处是让泛型处理器有更好的兼容性。
  • Task Handle(TIntegrationEvent @event):这是给业务开发者写逻辑用的强类型方法。例如:Handle(OrderCreatedEvent e),你可以直接访问 e.OrderId 等属性。好处:编译期类型安全、无需手动强转。
  • Task IIntegrationEventHandler.Handle(IntegrationEvent @event) => Handle((TIntegrationEvent)@event):显示接口实现IIntegrationEventHandler(非泛型接口)。

image-20251002213756394

显示接口实现:泛型接口显式实现了非泛型接口的 Task IIntegrationEventHandler.Handle(IntegrationEvent @event)。当事件总线在运行时只拿到 IIntegrationEventHandler 时,调用 Handle(IntegrationEvent),最终会被路由到强类型的 Handle(TIntegrationEvent)。这里用 (TIntegrationEvent)@event 做强制转换,确保传进来的 IntegrationEvent 真的是对应的子类。

泛型接口继承了非泛型接口

非泛型接口的方法参数是事件基类(IntegrationEvent)的类型。

泛型接口的方法参数是继承事件基类(IntegrationEvent)的类型。

泛型接口实现非泛型接口的 Handle(IntegrationEvent)时,将IntegrationEvent强制转换为子类TIntegrationEvent,然后泛型接口自己的实现。

这里确实比较绕,看起来有点吃力

image-20251002214801909

为什么这里的接口和泛型接口可以命名相同?

public interface IIntegrationEventHandler { ... }

public interface IIntegrationEventHandler<in TIntegrationEvent> : IIntegrationEventHandler
{ ... }

image-20251006231229254

首先理解一下这里的需求

需求 实现
框架层只需要一个统一入口 IIntegrationEventHandler.Handle(IntegrationEvent)
业务层希望使用强类型 IIntegrationEventHandler<TEvent>.Handle(TEvent)
避免反射或手动类型转换 泛型接口通过显式实现自动完成 (TIntegrationEvent) 转换
方便依赖注入注册 容器可以统一注册 IIntegrationEventHandler<>

命名相同是因为泛型参数数量不同,属于两种不同类型。
这样设计是为了让事件总线拥有统一入口(弱类型),同时保留业务代码的强类型安全性。

总结

  • 命名一致但语义分层,逻辑清晰
  • 框架统一入口,业务层写强类型代码
  • 避免手动类型转换
  • 方便依赖注入和事件扫描

3.事件订阅

消费端接收到消息时,会根据消息的类型(event type)从 IOC 容器 中获取注册的事件处理器(Handler)实例,然后调用这些 Handler 的 Handle 方法进行事件处理。

我们这里需要找到事件处理器的注入

image-20251007001222312

这里已商品服务(Catalog.API)为主,我们在Programe中找到了事件的订阅,在注册RabbitMQ客户端后订阅了2个事件

  • OrderStatusChangedToAwaitingValidationIntegrationEventHandler:订单状态变更为“等待验证”时触发的事件。
  • OrderStatusChangedToPaidIntegrationEventHandler:订单状态变更为“已支付”时触发的事件。

我们这里就接着看看AddSubscription的具体实现

image-20251007002008007

AddSubscription的具体实现:

  • 在 DI 容器注册事件处理器(按事件类型做 Key 的注册):把处理器实现类 TH 以 IIntegrationEventHandler 为服务接口注册到容器,并使用 typeof(T) 作为 Key(AddKeyedTransient<IIntegrationEventHandler, TH>(typeof(T))),以便运行时能根据事件类型动态解析出该事件的所有处理器(支持一事件多处理器)。(简洁版:用于将事件处理器注册到 DI 容器(按事件类型作为 Key))
  • 把事件类型记录到全局配置:将事件类型写入 EventBusSubscriptionInfo.EventTypes(o.EventTypes[typeof(T).Name] = typeof(T)),用于运行时根据 eventName 找到对应的 CLR Type 以便反序列化和分发。(简洁版:将事件类型写入 EventBusSubscriptionInfo.EventTypes,方便全局调用)image-20251007172105499

在事件总线(EventBus)中,为什么注册 DI 时使用 “接口 + Key → 处理器实现”(Keyed Service 模式)的方式?

  • 一个事件可能有多个处理器(可能是同服务的多个模块,也可能是跨服务的多个服务)。
  • Key(事件类型/事件名) 就像标签,EventBus 根据这个标签快速找到所有订阅该事件的处理器实例。
  • 接口 + Key → 实现类的注册方式,就是把这些处理器统一管理起来,方便运行时动态调用。

image-20251007174055266

简单理解就是一个事件可能多个服务订阅,可以通过事件名快速获取这些提前注入的服务

在事件消费阶段,事件总线通过事件名称 (eventName) 查找所有对应的事件处理器,并逐一调用它们的 Handle 方法进行处理。

image-20251007175431721

为什么 EventTypes 使用Dictionary而不使用 ConcurrentDictionary(线程安全集合) 来存储事件类型?

  • EventTypes 的事件类型注册通常只在 Host 启动阶段执行。在这个阶段,应用还没有开始并发消费事件,因此写操作不会产生并发冲突。写操作集中且可控,不存在并发冲突。
  • 在事件消费阶段,EventTypes 主要进行 读取操作,根据事件名称查找类型进行反序列化。读取操作在 Host 已启动后执行,是线程安全的,不依赖 ConcurrentDictionary 的线程安全特性。
  • 使用普通 Dictionary 可以避免 ConcurrentDictionary 的锁开销,提高性能。

EventTypes 之所以使用普通 Dictionary,是因为写入操作集中且可控,读取操作多且线程安全。这样的设计既保证正确性,又提升性能,无需额外线程安全开销

image-20251007175755695

4.跨服务事件序列化配置

EventBusBuilderExtensions除了事件订阅方法AddSubscription,还有一个Json配置的方法,购物车服务(Basket API)和订单服务(Ordering API)调用了这个方法

image-20251007180603437

为什么Basket API(购物车服务)和 Ordering API(订单服务)都需要单独配置 JSON?

跨服务事件通信需要一致的序列化规则

  • 订单服务发布的事件(如 OrderStartedIntegrationEvent)会被购物车服务等消费。
  • 如果不同服务的 JSON 配置不一致(比如类型信息、日期格式、枚举序列化方式不同),事件反序列化就可能失败。

支持复杂类型和自定义类型

  • IntegrationEventContext.Default 会提供自定义类型解析器,让事件总线在反序列化时知道如何解析事件对象中的复杂类型(如 Guid、枚举、ValueObject 等)。

保证跨服务事件订阅的一致性

  • 每个服务都可能订阅或发布事件。
  • 配置统一 JSON 选项,确保无论是生产者(Publisher)还是消费者(Subscriber),事件对象的序列化和反序列化行为一致,避免出现类型丢失或异常。

购物车和订单服务都配置 JSON,是为了保证跨服务事件在序列化和反序列化时保持一致性,确保事件总线能正确传输和处理事件对象。(这里跨服务事件订阅的JSON配置后面具体结合服务在学习一下)

image-20251007224900788

事件类型&事件处理器

前面讲了事件总线的接口、事件总线的订阅,接下来就是如何定义事件处理器了。

其实AddSubscription接收2个参数,一个事件实体,另一个就是事件处理器

image-20251007230119439

事件类型需要继承事件基类IntegrationEvent

image-20251007230345515

事件处理器需继承泛型接口 IIntegrationEventHandler<T>,以便绑定到特定的事件类型,实现强类型化的事件处理

image-20251007230546228

6.事件的执行

当消息到达时,事件总线根据事件名找到对应事件类型,将消息反序列化为事件对象,再从依赖注入容器中解析所有注册的事件处理器,逐一调用 Handle 方法完成业务逻辑执行。

这里和上面的 消息处理(ProcessEvent)  章节是相同的代码,具体细节实现可以看上面

image-20251007231201140

比如这里商品服务Catalog.API就订阅了订单状态更新的2个事件:

  • OrderStatusChangedToAwaitingValidationIntegrationEventHandler:订单状态变更为“等待验证”时触发的事件。
  • OrderStatusChangedToPaidIntegrationEventHandler:订单状态变更为“已支付”时触发的事件。

当 RabbitMQ 接收到事件消息后,事件总线会解析事件类型并直接调用对应的事件处理器执行处理逻辑。

image-20251007231839160

3.11注册事件总线服务

单例模式注册事件总线实现

image-20251007235539247

3.12注册事件总线后台服务

上面 **初始化RabbitMQ消费者(StartAsync) ** 章节就是具体的实现,其实事件总线的消费者初始化正是通过后台服务(IHostedService)的 StartAsync 方法完成的,当然是通过后台服务接口(IHostedService)的形式进行注册。

image-20251007232735935

为什么在注册 IHostedService 时,要通过 IServiceProvider 获取实例?

builder.Services.AddSingleton<IHostedService>(sp => (RabbitMQEventBus)sp.GetRequiredService<IEventBus>());

通过 IServiceProvider 获取实例,是为了重用依赖注入中已注册的单例对象,保证 RabbitMQEventBus 在 IEventBus 和 IHostedService 两个角色中是同一个实例,从而确保依赖注入完整、事件处理一致、启动逻辑统一。

事件总线相关的服务(RabbitMQEventBus)都是单例的image-20251007235325444

为什么 RabbitMQEventBus 要同时实现 IEventBus 和 IHostedService?

  • 自动启动消费者:IHostedService 是 .NET 提供的后台服务接口,Host 启动时会自动调用 StartAsync。通过实现 IHostedService,事件总线可以在应用启动时直接初始化 RabbitMQ 消费者,无需手动调用。
  • 避免资源重复创建:如果将 IEventBus 与 IHostedService 分开实现,可能会创建两个不同实例。每个实例都会建立自己的 RabbitMQ 连接、通道和消费者,造成资源浪费和状态不一致。合并实现后,事件总线的发布/订阅逻辑和消费者初始化共享同一实例,保证事件类型映射、订阅信息等状态一致。
  • 简化依赖注入和生命周期管理:单实例实现可以复用 DI 容器中已注册的依赖(ILogger、IOptions等),避免重复注入。生命周期统一为单例,IEventBus 和 IHostedService 引用同一个对象,管理更清晰。

同时实现 IEventBus 和 IHostedService,既能提供事件总线接口,又能在应用启动时自动初始化消费者,保证单实例、共享状态,并避免 RabbitMQ 连接和消费者被重复创建。

image-20251007235817972

3.13事件总线资源释放

事件总线RabbitMQEventBus继承IDisposable接口实现资源释放

image-20251008000500313

为什么Dispose只释放了消费者通道(IModel)?

RabbitMQEventBus大部分资源都是通过 DI 注入的,由 DI 容器统一管理生命周期,RabbitMQEventBus不需要自己调用 Dispose(),容器会在应用结束时释放。

_consumerChannel(IModel)是自己创建的资源(不是DI直接创建的资源),必须在 Dispose() 中释放,否则会导致通道泄漏,底层 TCP 套接字不会被回收。

image-20251008002039714

3.14事件总线构建器

IEventBusBuilder 是一个构建器接口,它的核心作用是封装事件总线相关服务注册和配置,让事件总线的初始化和订阅注册变得统一、可扩展和链式化。

本质上,它是对 IServiceCollection 的包装,提供了事件总线专用的扩展方法:

public interface IEventBusBuilder
{
    IServiceCollection Services { get; }
}

Services 属性持有 DI 容器的引用,通过它可以注册事件总线、事件处理器、OpenTelemetry 等依赖。

image-20251008005352785

IEventBusBuilder 是事件总线的“构建器”,它将服务注册、订阅绑定、序列化配置等操作封装起来,提供统一、链式、可扩展的接口,让业务代码无需关心底层实现细节。

image-20251008005624954

事件总线在初始化时会创建一个 EventBusBuilder,随后可直接通过链式调用进行配置。

image-20251008010006910

总结

  • IEventBusBuilder 是事件总线的配置和注册“桥梁”。提供统一入口管理事件总线相关服务。
  • 它提供链式调用、配置封装和可扩展性,让事件总线的初始化更加简洁和清晰。
  • 不需要将 IEventBusBuilder 注入到 IOC 容器,它仅在事件总线初始化阶段使用,配置完成后,底层服务就已经注册到容器中了。

3.15总结

事件总线的代码总算看完了。这个模块算是整个项目比较重要的代码,后面单独拿出来

4.总结

学了很多遍了,还是再看看,这次也是现总结的,每次学一遍理解都不一样,但是大差不差

这个比较简洁,没有第一学的那么凌乱

4.1注册事件总线

AddRabbitMqEventBus:

  • 1.注册RabbitMQ客户端
  • 2.集成OpenTelemetry追踪
  • 3.注册事件总线配置项(EventBusOptions)
  • 4.注册事件总线(RabbitMQ)分布式链路配置类(RabbitMQTelemetry)
  • 5.注册事件总线实现类(发布/订阅)

image-20251125000534848

4.2RabbitMQ分布式链路配置类(RabbitMQTelemetry)

RabbitMQTelemetry:

  • ActivitySourceName:ActivitySource 的全局唯一名称,用于标识事件总线的追踪源,在 OpenTelemetry 中注册以便收集 Trace 数据。
  • ActivitySource:用于创建 Activity(追踪片段 / Span),一个链路由多个 Activity 组成,记录消息发布、消费或处理的操作和耗时。传入 ActivitySourceName,用于标识 Activity 的来源,让 OpenTelemetry 能识别并收集来自 RabbitMQEventBus 的 Trace 数据。
  • TextMapPropagator:用于跨进程或服务传播链路追踪上下文(TraceContext),保证分布式链路中 TraceId、SpanId 等信息能够传递并保持完整性。
    • Inject:发布消息的时候注入 trace context 到消息 header
    • Extract:消费消息的时候从消息 header 提取 trace context

image-20251125000601072

RabbitMQTelemetry 注册为单例,是为了保证全局唯一的 ActivitySource 和 Propagator,实现 Trace 数据的一致收集和跨消息链路传播,同时避免重复创建资源。

image-20251125001224310

4.2.1ActivitySource名称(ActivitySourceName)

使用ActivitySourceName生成ActivitySource,用于标识Activity 的来源,让 OpenTelemetry 能识别并收集来自 RabbitMQEventBus 的 Trace 数据。”

image-20251125000803214

在 OpenTelemetry 注册 RabbitMQTelemetry 的 ActivitySource,让 OpenTelemetry 能收集和追踪 RabbitMQ 事件总线生成的 Activity(Trace/Span)数据。

image-20251125000956516

4.2.2活动源/追踪源”(ActivitySource)

在发布消息(PublishAsync)的时候启动和创建追踪片段(Span)

image-20251125002137082

在接收消息(OnMessageReceived)的时候启动和创建追踪片段(Span)

image-20251125001549632

4.2.3跨进程或服务传播链路追踪上下文(TextMapPropagator)

在发布消息(PublishAsync)的时候将链路追踪上下文(trace header )注入到消息头( IBasicProperties.Headers)中

image-20251125002212221

在接收消息(OnMessageReceived)的时候从消息头( IBasicProperties.Headers)中提取上游传递过来的链路追踪上下文(trace header )

image-20251125002346039

4.3事件总线配置项(EventBusOptions)

EventBusOptions:

  • SubscriptionClientName:当前服务绑定的队里名称
  • RetryCount:在发布消息的时候使用Polly构建了弹性重试管道(指数退避),RetryCount就是设置重试次数,默认就是10

image-20251125002728986

4.4初始化消费者(IHostedService/RabbitMQEventBus)

StartAsync:

  • 1.开启独立线程(TaskCreationOptions.LongRunning)初始化消费者
  • 2.创建RabbitMQ连接(IConnection)
  • 3.创建RabbitMQ消费者通道(CreateModel)
  • 4.创建交换机(ExchangeDeclare)
  • 5.创建队列(QueueDeclare)
  • 6.创建异步事件消费者(AsyncEventingBasicConsumer)
  • 7.注册收到消息回调(OnMessageReceived)
  • 8.监听队列(BasicConsume)
  • 9.获取服务订阅的所有事件,将订阅事件的事件名作为routingKey绑定交换机和队列

image-20251125004659238

4.5消息接收(OnMessageReceived/RabbitMQEventBus)

OnMessageReceived:

  • 1.从消息头(IBasicProperties.Headers)中提取(Extract)上游传递过来的链路追踪上下文(trace context)
  • 2.通过链路追踪上下文(parentContext.ActivityContext)开启和创建追踪片段(Span)
  • 3.将消息由字节转换为字符串
  • 4.封装处理事件方法(ProcessEvent)处理消息
  • 5.try catch捕获异常时也写入追踪过片段(activity)
  • 6.手动确认消息(BasicAck),告诉 RabbitMQ 当前消息已经成功处理,可以从队列中移除

image-20251125004807700

4.6消息处理(ProcessEvent/RabbitMQEventBus)

ProcessEvent:

  • 1.日志记录

  • 2.创建一个异步 scope,确保每个消息使用独立 scope

  • 3.获取事件类型(subscriptionInfo.EventTypes)

  • 4.反序列化消息为事件基类(IntegrationEvent)

  • 5.遍历事件的处理器(一个事件可能有多个处理器,也可能有多个服务订阅),然后调用事件处理器(IIntegrationEventHandler)

image-20251125005734871

4.7序列化/反序列化(SerializeMessage/DeserializeMessage)

DeserializeMessage:“将 JSON 字符串反序列化为指定事件类型并转换为 IntegrationEvent,根据不同运行环境使用不同的 JsonSerializerOptions,在 AOT/裁剪环境中通过 UnconditionalSuppressMessage 特性保留反射所需信息,避免裁剪导致运行时错误。”

SerializeMessage: “将 IntegrationEvent 对象序列化为 UTF-8 字节数组,保留实际运行时类型,根据不同运行环境使用不同的 JsonSerializerOptions,并通过 UnconditionalSuppressMessage 特性在 AOT/裁剪环境中确保动态序列化不会因反射被裁剪而失败。”

序列化比较简单,主要是支持 AOT/trim环境, AOT/trim可能不支持反射

image-20251125010443993

4.8发布事件(IEventBus/RabbitMQEventBus)

PublishAsync:

  • 1.创建通道(CreateModel)
  • 2.创建交换机(ExchangeDeclare), 确保 Exchange 存在(幂等)
  • 3.序列化消息为字节
  • 4.使用重试管道执行发布逻辑
  • 5.创建Activity,记录消息发布的追踪片段(Span)
  • 6.把链路追踪上下文( trace context)注入(Inject)消息头(headers)
  • 7.发布消息(BasicPublish)

image-20251125003625442

4.9事件总线入口

IIntegrationEventHandle:事件总线调用事件处理器时的统一弱类型入口接口,只接收 IntegrationEvent 基类,用来让事件总线在不知道具体事件类型的情况下也能调度所有事件处理器。

IIntegrationEventHandler<TIntegrationEvent>:一种具体集成事件的强类型业务处理逻辑,并通过显式接口实现(继承并显式实现IIntegrationEventHandle)把弱类型入口自动转换为强类型调用,既保持事件总线统一调度,又让实现类获得强类型开发体验。

image-20251125172502177

4.10订阅事件配置类(EventBusSubscriptionInfo)

EventTypes :保存“事件名称和事件类型”的映射,当前服务订阅所有的事件类型

JsonSerializerOptions:每个事件订阅独立的 JSON 配置,用于序列化/反序列化事件数据,并根据运行环境选择反射型或源生成解析器,确保在 AOT/PublishTrimmed 场景下安全运行。

image-20251125193320405

5.完结

后面的总结是我看了很多遍后总结的思路,包括事件总线和RabbitMQ分布式链路追踪

📌 创作不易,感谢支持!

每一篇内容都凝聚了心血与热情,如果我的内容对您有帮助,欢迎请我喝杯咖啡☕,您的支持是我持续分享的最大动力!

💬 加入交流群(QQ群):576434538

微信打赏

posted @ 2025-12-05 23:34  peng_boke  阅读(177)  评论(0)    收藏  举报