商品服务(Catalog.API)

前言

世界上没有白走的路,只有停下来的人

1.基础设施配置

builder.AddServiceDefaults()是一个 扩展方法,把一堆“常用的基础设施配置”统一封装起来。

这个方法主要实现了:

  • 健康检查 (Health Checks)
  • OpenTelemetry(可观测性,日志/指标/追踪)
  • 服务注册与发现(Service Discovery,比如 Consul、Dapr、YARP 等)
  • HttpClient 默认行为配置(比如重试策略、超时、Tracing Header 注入)

因为大多服务都使用了,我把这个模块单独拿出来,重复调用的时候我就不在过了。

2.Build模式跳过数据库

为什么 eShop 要在 Build/文档生成模式下只注册占位 DbContext?

在 Build/文档生成阶段(比如生成 OpenAPI/Swagger 文档):

  • 我们只是 扫描控制器、服务、接口签名
  • 根本不需要读写数据库

如果去连接真实数据库:

  • 构建机器可能没有数据库
  • 连接数据库会失败,导致文档生成报错

优点:

  • 只注册占位 DbContext → 启动快 → 构建/文档生成效率高。

  • 避免 CI/CD 构建因为数据库或事件总线等其他重量级代码不可用而失败。

  • 保证文档生成阶段的安全、可靠、快速

所以用 InMemory 占位 DbContext 就够了,保证程序启动成功即可。

使用场景:

场景 为什么使用占位 DbContext 好处
文档生成 生成 OpenAPI 文档,需要 DI 容器解析控制器 快速启动,无需数据库,保证文档生成
CI/CD 构建 自动化构建流程中启动应用 避免构建失败,提高构建效率
测试 单元/集成测试需要 DbContext 数据可控、无需真实数据库,测试隔离

2.1Build模式使用DbContext

什么是InMemory 数据库?

InMemory Database(内存数据库)是 EF Core 提供的一种 数据库提供者。

数据存储在 内存中,程序关闭后数据就消失。

典型用法是:

  • 单元测试
  • 开发调试
  • 或者在不需要真实数据库的场景下占位

这段代码首先判断当前环境是否为 Build/文档生成模式。

  • 如果是,则只注册 DbContext 类型,以便构建工具正常使用(此时通常会使用 InMemory 数据库 作为占位)。
  • 同时会跳过后续的真实数据库迁移、种子数据初始化、事件总线等重量级操作。
// 如果当前环境是 Build/文档生成模式
// Avoid loading full database config and migrations if startup
// is being invoked from build-time OpenAPI generation
if (builder.Environment.IsBuild())
{
    // 仅注册 DbContext,避免连接真实数据库或执行迁移
    // 使用 InMemory 数据库作为占位,满足 DI 和控制器反射需求
    // *********************************************************************************
    // “InMemory” 是 EF Core 提供的一种内存数据库,所有数据都只存在内存中,不会写到真实数据库。
    // 在 ASP.NET Core 中,控制器、服务等通常通过构造函数依赖注入(DI)获取 DbContext。
    // 如果没有注册 DbContext,DI 容器在解析控制器时会报错。
    // 使用 InMemory 数据库注册 DbContext 就相当于“占位”,DI 容器可以成功解析,程序继续反射和构建对象图。
    // 在构建时,我们不需要真实数据库,只是注册一个“假”的 DbContext,让依赖注入和文档生成工具能顺利工作。
    builder.Services.AddDbContext<CatalogContext>();
    return;
}

image-20250920010128987

2.2判断Build模式

这里段代码2中方式判断Build模式:

  • IHostEnvironment.IsEnvironment(string environmentName) 是 ASP.NET Core 内置方法,用于判断环境名称是否匹配。如果程序启动时环境变量 DOTNET_ENVIRONMENT 或 ASPNETCORE_ENVIRONMENT 被设置为 "Build",这里就会返回 true(环境名为 "Build")。
  • GetDocument.Insider 是 一个文档生成工具,用来在构建阶段扫描应用 API 并生成 OpenAPI/Swagger 文档,不运行真实业务逻辑。(入口程序集名为 "GetDocument.Insider")。

image-20250920012339313

3.数据库上下文

这段代码的作用是:

  • 在 真实运行环境下,将 CatalogContext 注册到 DI 容器。
  • 使用 PostgreSQL 数据库 并启用向量扩展功能。
  • 为后续执行数据库操作(如商品信息查询、向量搜索)提供支持。

正好学到这里,顺便滚固一下EFCore的相关知识,接着查看一下CatalogContext的相关配置

// 为 CatalogContext 注册 PostgreSQL 数据库上下文(DbContext)
builder.AddNpgsqlDbContext<CatalogContext>("catalogdb", configureDbContextOptions: dbContextOptionsBuilder =>
{
    // 使用 Npgsql 提供的 PostgreSQL 数据库
    dbContextOptionsBuilder.UseNpgsql(builder =>
    {
        // 启用 PostgreSQL 的向量扩展功能
        // Vector 通常用于存储向量数据(如向量搜索、推荐系统、AI Embedding)
        builder.UseVector();
    });
});

image-20250920013713882

3.1代码优先(Code-First)

EFCore一般有三种模式:

  • Code-First(代码优先):
    • 特点:
      • 先写 C# 类(实体类 + DbContext)
      • 根据代码生成数据库表和列
      • 支持迁移(Migrations)来管理数据库结构
    • 使用场景:
      • 新建项目,数据库尚未存在。
      • 项目更关注 C# 对象模型而非现有数据库结构
  • Database-First(数据库优先):
    • 特点:
      • 数据库已经存在
      • 使用 Scaffold-DbContext 或 dotnet ef dbcontext scaffold 生成实体类和 DbContext
      • 类和 DbContext 与数据库表结构同步
    • 使用场景:
      • 现有数据库项目
      • 主要是读取/维护数据库,不经常改动表结构
  • Model-First / Fluent-API-First:
    • 特点:
      • 使用 Fluent API 或 EDMX 模型先设计模型
      • 然后生成数据库
      • EF Core 不直接用 EDMX,但 Fluent API 配置方式也被称作“Model-First”
    • 使用场景:
      • 需要通过代码精细控制表结构、索引、约束
      • 更复杂的映射需求

这里使用了 Code-First 模式,并结合了Fluent API配置。

CatalogContextSeed配置了三张表:商品表、商品类型、商品品牌

image-20250920015257445

3.2链式配置API(Fluent API)

Fluent API 是 EF Core 提供的一种 基于方法链式调用的配置方式。它用来 精细控制实体类和数据库表之间的映射关系。

主要作用:

  • 配置表名、列名、数据类型
  • 配置主键、索引、外键、约束
  • 启用数据库特性(如 PostgreSQL 的 vector 扩展)
  • 注册额外表(如 Outbox 集成事件日志表)

Data Annotations(特性) 有局限性:

  • 只能配置简单属性,如 [Required]、[MaxLength(100)]
  • 无法配置复杂索引、表名映射、数据库扩展

Fluent API 提供更强大、更灵活的配置能力。

我们通过IEntityTypeConfiguration<T>接口封装 CatalogItem 实体的数据库映射规则

商品品牌(CatalogBrandEntityTypeConfiguration)和商品类型(CatalogTypeEntityTypeConfiguration)也是这么配置的。

/// <summary>
/// EF Core 实体类型配置类,用于配置 CatalogItem 实体在数据库中的映射
/// Fluent API 替代或补充 Data Annotations
/// </summary>
class CatalogItemEntityTypeConfiguration
    : IEntityTypeConfiguration<CatalogItem>
{
    /// <summary>
    /// 配置 CatalogItem 实体的属性、关系和索引
    /// </summary>
    /// <param name="builder">EntityTypeBuilder 对象,用于构建实体模型</param>
    public void Configure(EntityTypeBuilder<CatalogItem> builder)
    {
        // 指定数据库表名为 "Catalog"
        builder.ToTable("Catalog");

        // 配置 Name 属性最大长度为 50
        builder.Property(ci => ci.Name)
            .HasMaxLength(50);

        // 配置 Embedding 属性的数据类型为 vector(384)
        // 用于存储向量数据(例如 AI Embedding)
        builder.Property(ci => ci.Embedding)
            .HasColumnType("vector(384)");

        // 配置与 CatalogBrand 的一对多关系
        // CatalogItem 有一个 CatalogBrand,CatalogBrand 可对应多个 CatalogItem
        builder.HasOne(ci => ci.CatalogBrand)
            .WithMany();

        // 配置与 CatalogType 的一对多关系
        builder.HasOne(ci => ci.CatalogType)
            .WithMany();

        // 在 Name 属性上创建索引,提高查询性能
        builder.HasIndex(ci => ci.Name);
    }
}

image-20250920020502996

3.3vector扩展

什么是向量相似度查询?

根据向量特征计算对象之间的相似程度,并返回最相似的结果。

场景:商品推荐、搜索、相似图片查找、语义搜索

PostgreSQL vector 扩展:提供原生向量存储和查询能力,高效支持大规模向量检索。

什么是384 维?

向量是一个 数组/列表,每个元素是一个浮点数。

维度(Dimension) 就是数组的长度,也就是元素个数。

384 维 代表 向量里有 384 个浮点数,用于 表示商品或文本的特征,维度越高 代表 语义信息越丰富。

向量的生成

用户输入商品信息:

{"Name": "苹果手机","Brand": "Apple", "Type": "手机","Description": "最新款苹果手机,256GB存储"}

生成向量(通常是 由 AI 模型生成):

  • 调用 AI 模型(如 OpenAI、FAISS、BERT、CLIP 等)

  • 输入商品描述或特征

  • 得到一个浮点数组向量(如 384 维):

    float[] embedding = GenerateEmbedding("最新款苹果手机,256GB存储");
    

存储到数据库:

这里 Embedding 对应 PostgreSQL vector(384) 列

var item = new CatalogItem
{
    Name = "苹果手机",
    CatalogBrandId = 1,
    CatalogTypeId = 2,
    Embedding = embedding
};
dbContext.CatalogItems.Add(item);
dbContext.SaveChanges();

这里是好奇,了解一下,其实就是生成一个向量值(一般来说由 AI 模型生成),可以通过特定的查询语法检索相似的数据。留一下上面的向量查询语法。

PostgreSQL 是一个功能非常强大的关系型数据库。

PostgreSQL 支持 扩展(Extensions),可以增加数据库功能。

vector 扩展:PostgreSQL 的一个扩展,用于存储和查询向量(vector),常用于:

  • 向量搜索 / 相似度搜索(embedding、推荐系统、AI 检索)
  • 高维数据的距离计算(如欧氏距离、余弦相似度)

builder.HasPostgresExtension("vector") 是 Fluent API 配置的一部分:

  • 启用 vector 扩展

  • 在生成迁移时,会在数据库中执行:

    CREATE EXTENSION IF NOT EXISTS vector;
    
  • 确保你的列可以使用 vector 类型,例如:

    builder.Property(ci => ci.Embedding)
           .HasColumnType("vector(384)");
    

如果不启用扩展:

  • 数据库迁移会报错
  • vector 类型无法使用

生成的向量相似度查询:

SELECT * FROM "Catalog"
ORDER BY "Embedding" <-> '[0.1,0.2,...]'::vector
LIMIT 10;

image-20250920022027852

3.4Outbox表(事务最终一致性)

通过EFCore注册Outbox 表

builder.UseIntegrationEventLogs();的作用是 在数据库中注册一个专门的表(Outbox 表)来存储集成事件(Integration Event)。UseIntegrationEventLogs() 是一个 自定义的扩展方法,它不是 EF Core 原生提供的,而是项目里为了支持 Outbox 模式 自定义的。

image-20250920025750685

Outbox 模式(Integration Event Outbox Pattern)

在微服务系统中,我们可能希望在 数据库操作成功之后,再触发消息(事件)到 RabbitMQ、Kafka 等事件总线上。但直接在同一个事务里同时写数据库和发送消息,很容易出现:

  • 数据库写入成功,但消息没发出去 → 消息丢失。
  • 消息发出去,但数据库写入失败 → 数据不一致。

使用 Outbox 模式:

  • 在你的业务数据库中增加一张 Outbox 表,记录所有待发送的事件。
  • 业务操作成功写数据库的同时,把事件写入 Outbox 表。
  • 有一个后台服务或定时任务扫描 Outbox 表,把事件发送到消息总线(RabbitMQ/Kafka)。
  • 发送成功后标记事件已发送。

好处:保证数据库操作和事件消息最终一致(最终一致性)。

使用UseIntegrationEventLogs() 配置事件表

在 EF Core 中注册 Outbox 表的实体模型并添加相关配置

image-20250920025840381

事件总线

当业务代码和事件成功写入了数据库,可以通过后台任务/服务扫描Outbox 表,把事件发送到消息总线,这里就可以使用RabbitMQ/Kafka

消息队列(如 RabbitMQ、Kafka)本身支持持久化和可靠传输,为什么在微服务中还要把事件存储到数据库的 Outbox 表中?不是直接发送消息就可以了吗?

其他疑问:

  • 消息队列持久化了,为什么还会发送失败或收不到消息?
  • 如果在处理消息前查询了商品状态,为什么仍然需要 Outbox?
  • 消息队列本身不是可靠的吗,重发不能解决问题吗?

消息队列可靠性与业务原子性是两个概念

  • 消息队列(RabbitMQ、Kafka)提供持久化、ACK 确认和重试机制,保证消息不会丢失。
  • 但是消息队列无法保证消息发送和数据库操作的原子性。
  • 数据库和消息队列是两个独立系统,没有内置分布式事务。

问题场景

  • 查询库存
  • 创建订单(写入数据库)
  • 订单创建后发布消息(这里创建订单可能成功或失败)
if(stock > 0)
{
    db.Orders.Add(order);
    db.SaveChanges();
    rabbitMQ.Publish(orderCreatedEvent);
}

可能出现的失败场景:

场景 结果 问题
数据库写入成功,但消息发送失败 消息未发出 消费者收不到事件,业务不同步
消息发送成功,但数据库写入失败 消费者已收到消息 数据库没有订单,造成不一致

即使队列可靠,也无法保证“数据库操作和消息发送同时成功或失败”。

Outbox 模式的作用

核心思路:把“业务操作”和“事件记录”放在同一个数据库事务里,再异步发送消息。

  • 开启事务

  • 创建订单(写入订单表)

  • 创建事件(写入Outbox 表)

  • 提交事务

using var tx = db.BeginTransaction();
db.Orders.Add(order);
db.Outbox.Add(orderCreatedEvent); // 写入 Outbox 表
tx.Commit();

事务提交时,就能保证订单和事件要么都成功,要么都失败

后台服务或定时任务扫描 Outbox 表,把事件发送到消息队列。

如果发送失败,可以重试,不会丢消息。(消息队列的持久化、ACK 确认和重试机制,保证消息不会丢失)

保证数据库操作和消息发送的最终一致性。

总结

消息队列能保证消息可靠传输,但不能保证与数据库状态一致。Outbox 模式正是为了解决这个跨系统一致性问题。

3.5数据库链接配置

配置数据链接

image-20250920032651085

4.数据库迁移和种子逻辑

4.1ActivitySource

4.1.1基本概念

ActivitySource:是用于产生 tracing 数据(span / activity) 的工厂/入口。库或应用通过它来“创建”Activity(相当于 OpenTelemetry 的 span)。

Activity:表示一个具体的追踪单元(span),有开始/结束时间、标签(attributes/tags)、事件(events)、TraceId/SpanId 等。

ActivitySource 负责“发起”活动;采样器/监听器/导出器(例如 OpenTelemetry SDK) 决定是否记录、如何导出这些 Activity。

典型用途:把关键操作(DB migration、外部 HTTP 请求、队列消费等)包成 Activity,便于分布式追踪、日志关联和性能分析。

4.1.2源码分析

创建ActivitySource

创建追踪活动(Activity),用于标记应用程序中某段操作的开始和结束。

ActivitySourceName 是追踪源名称,用于 OpenTelemetry 或其他追踪系统区分不同的追踪来源。

创建了一个名为 "DbMigrations" 的 ActivitySource,以后通过它就可以生成追踪活动,用于监控数据库迁移或其他相关操作。

image-20250920214915239

把ActivitySource 注册到 OpenTelemetry 追踪系统

在服务中注册 OpenTelemetry 并启用追踪功能。

将 "DbMigrations" 这个追踪源加入 OpenTelemetry 的监听列表。

以后生成的所有活动(Activity)都会被(OpenTelemetry )收集和导出。

image-20250920215312291

创建一个追踪活动(Activity )

StartActivity 会创建一个 Activity 实例,标记一次操作的开始。

Activity 可以记录:

  • Name:操作名,如 "Migrating CatalogContext"
  • Duration:从 StartActivity 到 Dispose 的时间
  • Tags / Metadata:用 SetTag 或自定义方法记录额外信息
  • Exception:调用 SetExceptionTags 记录异常信息

image-20250920220100801

ActivitySource 是 .NET 内置在 System.Diagnostics 命名空间下的分布式追踪源,而 OpenTelemetry 是第三方监控/追踪框架。OpenTelemetry 是如何配置来监听和收集由 System.Diagnostics.ActivitySource 产生的 Activity 的?

image-20250920220543834

ActivitySource:.NET 提供的标准 API,用于创建 Activity(类似于 span)。它本身不负责收集或导出数据。

Activity:表示一次追踪操作,包括开始/结束时间、标签、事件、TraceId/SpanId。

OpenTelemetry SDK:通过订阅 ActivitySource(内部使用 ActivityListener),收集 Activity 数据,然后导出到控制台、Jaeger、Zipkin 或 OTLP 等后端。

4.2后台服务(BackgroundService)

**简单了解一下AddHostedService **

AddHostedService:注册一个 托管后台服务(IHostedService)(或 BackgroundService)到容器。

IHostedService 是 .NET Core 定义的接口,用于实现 后台服务:

public interface IHostedService
{
 Task StartAsync(CancellationToken cancellationToken);
 Task StopAsync(CancellationToken cancellationToken);
}

生命周期由 Host(WebHost / GenericHost) 管理:

  • StartAsync:应用启动时调用。
  • StopAsync:应用关闭时调用。

image-20250921002845761

BackgroundService 是 IHostedService 的抽象基类:

提供 ExecuteAsync(CancellationToken) 方法,让你只关注后台任务逻辑,不必自己实现 StartAsync/StopAsync。

  • ExecuteAsync:后台服务核心逻辑,Host 启动时执行。
  • Host 启动时调用,可做初始化,通常不重写。
  • Host 停止时调用,用于优雅退出和释放资源。

image-20250921002929082

4.3根容器

Root Provider(根容器)

  • 就是 IServiceProvider 的 顶层实例,在整个应用里通常只有一个。
  • 作用:
    • 管理 Singleton 的生命周期(全局唯一,随应用存活)。
    • 负责创建 Scope Provider(作用域容器)。
    • 解析服务(包括 Singleton、Scoped、Transient)。
生命周期 谁管理 在 Root Provider 中的表现 释放时机
Singleton Root 在 Root Provider 创建一次,全局唯一 Root Provider Dispose 时释放
Scoped Scope 必须在某个 scope 中解析;如果直接在 Root 解析,会被“提升”为根级 scope(活到 Root Dispose) Scope Dispose 时释放(如果被提升,就等同于 Singleton)
Transient 无管理者 每次解析都会新建,容器不缓存 立刻交给调用方使用,调用方自己负责释放(如果实现了 IDisposable)

总结

  • Root Provider 只真正管理 Singleton。
  • Scoped 必须依附 Scope Provider,不能直接在 Root 用,否则生命周期会错乱。
  • Transient 完全不缓存,每次 new 新的,释放要么靠宿主,要么调用方自己管。

4.4EFCore执行策略(IExecutionStrategy)

IExecutionStrategy 表示一种“执行策略”,它可以在执行数据库操作时根据策略自动重试(或不重试)。它只是一个策略/执行器:真正的重试是通过调用它的 Execute / ExecuteAsync 方法来触发。

IExecutionStrategy:

  • bool RetriesOnFailure { get; }:
    • 作用:表示该策略可能会重试(true)还是绝对不重试(false)。调用端可以据此选择是否需要使用策略的 Execute 包裹或直接执行。注意:true 并不意味着一定会重试——只是“可能会”。
  • TResult Execute<TState, TResult>(TState state, Func<DbContext, TState, TResult> operation, Func<DbContext, TState, ExecutionResult<TResult>>? verifySucceeded);
    • 作用:同步执行 operation,并在遇到可重试异常时由策略控制重试流程。
    • 参数:
      • state:用户自定义状态,传给 operation(可用来传递幂等键、标识信息等)。
      • operation:实际执行的委托,签名 (DbContext, TState) => TResult。注意 DbContext 会被传入,方便在委托中直接使用上下文。
      • verifySucceeded:可选。当 operation 抛出异常但你怀疑操作实际上可能已生效(比如超时/网络中断但写入已到数据库),策略会在捕获异常后调用 verifySucceeded 来检查操作是否已成功。该方法返回 ExecutionResult<TResult>(含是否成功标记和可能的返回值)。
  • Task<TResult> ExecuteAsync<TState, TResult>( TState state, Func<DbContext, TState, CancellationToken, Task<TResult>> operation, Func<DbContext, TState, CancellationToken, Task<ExecutionResult<TResult>>>? verifySucceeded, CancellationToken cancellationToken = default);
    • 作用:异步版本,语义与同步一样,多了 CancellationToken 支持。

image-20250921221531191

使用IExecutionStrategy注意事项

ExecutionStrategy 的重试机制能帮你应对短暂错误,但也会导致操作被重复执行。

所以注意:

  • 操作必须能安全重试(幂等性)。

  • 事务要放到重试内部。

    // ✅ 正确写法
    strategy.Execute(() =>
    {
        using var tx = context.Database.BeginTransaction();
        ... // 数据操作
        tx.Commit();
    });
    
  • 涉及外部资源要重新创建。比如新的 DbContext,新的 HTTP 请求。

  • 不是所有异常都会重试。比如主键重复、外键约束失败、业务逻辑抛的异常不会重试。

  • 重试会增加延迟,要监控。每次重试都会等待时间。

  • verifySucceeded 只是兜底,谨慎使用。查询会多一次数据库开销,所以不要滥用。

4.5数据库迁移

MigrateDbContextExtensions提供了三种数据库迁移的办法:

// 在 ASP.NET Core 的依赖注入容器中注册数据库迁移和数据种子服务
// 当程序启动时,会自动执行数据库迁移(Migrations)并初始化种子数据(Seed)
// 注意:这种做法适合开发环境,不建议在生产环境使用
// REVIEW: This is done for development ease but shouldn't be here in production
builder.Services.AddMigration<CatalogContext, CatalogContextSeed>();

第一种:数据库迁移,不执行种子逻辑,直接调用下面的方法,传入空操作func委托即可

image-20250921000509644

第二种:数据库迁移,通过委托参数Func<TContext, IServiceProvider, Task> seeder传入自定义种子逻辑

注册 OpenTelemetry trace source,让迁移操作可以生成 trace。

注册一个 MigrationHostedService 后台服务,在应用启动时执行迁移。

Func<TContext, IServiceProvider, Task> seeder:用户自定义逻辑,可插入初始数据。

image-20250921001008142

第三种使用 IDbSeeder 类

泛型约束 TDbSeeder 必须实现 IDbSeeder<TContext>。

将类实现的 SeedAsync 包装成委托,传给 AddMigration。

image-20250921001334705

核心方法MigrateDbContextAsync

我们发现三种方法最后都是开启了一个后台服务MigrationHostedService

image-20250921001848638

MigrationHostedService是一个后台服务,这里不需要循环执行,所以ExecuteAsync直接返回Task.CompletedTask。数据库迁移的核心代码主要是MigrateDbContextAsync,因为只需要在程序启动时执行一次种子逻辑即可,所以数据库迁移的核心代码写在StartAsync。

image-20250921002100374

查看MigrateDbContextAsync的实现

这里并没有执行迁移的逻辑代码,但是有2个地方需要注意一下

为什么在 MigrateDbContextAsync 里必须单独创建作用域?

MigrateDbContextAsync 用于在 应用启动阶段执行数据库迁移和种子逻辑。

它会用到 DbContext,而 DbContext 在 ASP.NET Core 中默认注册为 Scoped 生命周期:

builder.AddNpgsqlDbContext<CatalogContext>()

Scoped 服务设计为 每个作用域一个实例,生命周期结束会自动释放资源(比如数据库连接)。

根容器无法提供正常的作用域,Scoped 服务会退化成“根容器下的全局实例”,生命周期与应用一样长。

可能导致:

  • 数据库连接不释放 → 内存/连接泄漏
  • 并发问题 → 多个任务共享同一个 DbContext

为什么使用context.Database.CreateExecutionStrategy()执行数据迁移和种子逻辑?

执行策略(Execution Strategy)主要功能:

功能 说明
自动重试(Retry) 遇到可重试异常时自动重试操作,一般会有延迟和最大重试次数控制
事务安全 如果操作在事务中,失败重试时会自动回滚并重新执行事务
可自定义 可以通过 DbContextOptionsBuilder 配置策略,例如:重试次数、延迟、异常过滤等

使用 CreateExecutionStrategy() 是为了在数据库迁移和种子逻辑中应对临时故障,保证操作可靠性和事务安全,避免启动阶段因偶发错误失败。

image-20250921005050549

接着查看InvokeSeeder方法:

  • MigrateAsync():执行数据库迁移
  • seeder(context, services):执行种子逻辑

image-20250921012358234

4.6种子逻辑接口

AddMigration方法俩个参数:

  • where TContext : DbContext:数据库上下文
  • where TDbSeeder : class, IDbSeeder<TContext> :必须继承IDbSeeder接口实现种子逻辑

image-20250921012920859

我们可以从以下代码直接定位到CatalogContextSeed种子逻辑的实现

builder.Services.AddMigration<CatalogContext, CatalogContextSeed>();

CatalogContextSeed确实继承了IDbSeeder接口,并实现了SeedAsync方法,在SeedAsync方法写入初始化数据的逻辑

image-20250921012841673

4.7种子逻辑实现

第一步:构造函数注入需要的服务

第二步:读取配置文件和获取路径

第三步:PostgreSQL 特殊处理

image-20250921230157366

在 EF Core + PostgreSQL (Npgsql) 项目里,为什么需要写下面这两行代码(ReloadTypes)?

context.Database.OpenConnection();
((NpgsqlConnection)context.Database.GetDbConnection()).ReloadTypes();

打开数据库连接:

  • EF Core 默认是延迟打开连接。
  • 如果要在执行数据库操作前手动做初始化,就需要显式调用 OpenConnection()。

重新加载 PostgreSQL 类型映射:

  • Npgsql 在启动时会缓存数据库里已有的类型(比如 enum、composite、range、自定义扩展类型)。
  • 如果应用运行过程中 新增/修改了类型(如 Migration 里新建了 enum、启用了 pgvector 扩展),Npgsql 不会自动感知,需要手动调用 ReloadTypes()。

为什么这里需要ReloadTypes()?

因为我们的CatalogContext开启了PostgreSQL 的vector 扩展

image-20250921231959557

我们先理解一下迁移和种子逻辑的执行顺序:

  • 1.打开 DbContext 连接 → Npgsql 缓存类型
  • 2.执行 Migration SQL(创建表、创建扩展类型 vector)
  • 3.执行 Seed 数据插入

所以:

  • 当第一步打开连接时,vector 类型还不存在,所以缓存里没有。
  • 当第二步 Migration 创建了 vector 类型后,Npgsql 的缓存并不会自动更新。
  • 第三步 Seed 使用了 vector 类型字段,如果不 ReloadTypes(),Npgsql 还认为这个类型不存在 → 会报错。

所以核心问题就是:

  • 不是数据库没有类型,而是 Npgsql 客户端缓存的类型还没更新。
  • Migration 新建了类型(vector/enum/composite 等),但 Seed 在同一次连接中执行。
  • 在 Seed 阶段调用 ReloadTypes(),同步客户端缓存。

EF Core + Npgsql 的类型缓存机制

Npgsql 客户端在第一次打开数据库连接时,会查询数据库中所有已知类型(如 int, text, 自定义 enum、composite、vector 等),并 缓存到客户端。

之后的数据库操作(查询、插入、更新)都会使用这个缓存,而不会再次查询数据库类型。

好处:提高性能,避免每次都查数据库类型。

问题:如果数据库在连接打开后新增类型(例如 Migration 创建了 vector 类型),客户端缓存不会自动更新。

SQL Server / MySQL / SQLite的类型系统相对固定,几乎不存在运行时新增类型的情况。EF Core 在这些数据库里通常不缓存类型映射,或者类型是固定的,不会出现 Seed 阶段找不到类型的问题。因此,在 SQL Server/MySQL/SQLite 中一般不需要类似 ReloadTypes() 的操作。

第四步:读取json配置文件里的商品信息(配置文件就在右边Setup/catalog.json)

第五步:清空品牌表并添加品牌数据

第六步:清空商品类型表并添加商品类型数据

image-20250921232835648

第七步:保存品牌和类型到数据库,然后获取品牌和类型的Id

为什么要先保存品牌和类型

EF Core 中,新增实体在调用 Add 或 AddRange 时:对象会被标记为Added,主键(如果是数据库自增)不会立刻生成。

只有在 SaveChanges() 或 SaveChangesAsync() 后EF Core 才会把新增记录写入数据库,数据库生成的自增 Id 才会回填到实体对象上。

所以:先保存品牌和类型是为了保证它们的主键 Id 已经生成,这样在创建 CatalogItem 时可以正确映射外键。

第八步:保存商品信息

image-20250921233657824

第九步:调用 AI 服务生成向量 Embeddings(这个下面单独详细看一下)。

第十步:保存商品数据

image-20250921234736849

4.8启动项目

我们已经学习完了数据库上下文的相关配置、数据库迁移、种子逻辑,接着我们可以尝试启动项目,因为后面调试可能需要运行项目

请首先确定3.5数据库链接配置没有问题,然后启动项目

为什么在应用启动时,EF Core 迁移日志里先提示连接 CatalogDB 失败,但后面又能成功创建数据库并完成迁移?

image-20250923011748445

EF Core 启动时会尝试直接连接目标数据库 CatalogDB。

如果数据库不存在,就会报一次连接错误(日志显示失败)。

EF Core 捕获异常后,会在默认数据库(postgres)里执行 CREATE DATABASE "CatalogDB";。

创建成功后,它再连接到新建的 CatalogDB,继续执行迁移(建表、加扩展等)。

所以,前面的报错并不代表迁移失败,而是 提示目标数据库当时不存在。最终 EF Core 已经自动创建并迁移完成了。

然后使用客户端连接工具查看,发现已经成功迁移了四张表,并且表中也有种子数据

image-20250923012259005

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

5.1创建Outbox表

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

image-20250922005926422

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

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

image-20250922010033675

5.2事件存储与状态管理

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

5.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)批量发送这些事件,确保不会重复发送,也保持顺序)。

5.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,继续执行剩余代码

5.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 不用再调用终结器

6.集成事件-商品模块

这个类负责在 Catalog 模块 中:

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

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

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

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

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

image-20250927235315076

6.1发布事件

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

image-20250927235351836

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

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

总结:

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

6.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

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

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

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

image-20250928005924181

6.4资源释放

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

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

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

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

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

image-20250928011628598

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

7.1基本概念

事件总线(Event Bus)

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

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

    • 用户下单成功 → OrderCreatedEvent

    • 商品库存更新 → ProductStockChangedEvent

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

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

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

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

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

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

7.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

7.3集成 OpenTelemetry 追踪

7.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 做事件审计/细节还原。

7.3.2ActivitySource(活动源)

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

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

在 RabbitMQ 场景中:

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

RabbitMQ 场景:

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

image-20250928185142902

7.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

7.3.4RabbitMQTelemetry

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

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

总结:

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

image-20250928190115867

7.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 是开源标准,支持多种导出后端,便于切换/扩展观测后端而不改业务代码。

7.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 支持

7.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 和内部事件处理器会有一些开销。

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

7.6发布事件(PublishAsync)

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

image-20250930005058410

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

7.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。

7.6.1.2序列化配置

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

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

image-20250930022738895

7.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,保证它不会依赖反射,所以不用警告我。”

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

7.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 时能直接看到延迟计算逻辑,逻辑清晰
  • 内部工具函数不必暴露给类外部,避免污染类命名空间

7.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 捕获重试

7.7初始化RabbitMQ消费者(StartAsync)

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

image-20251001182109186

7.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 会起一些辅助线程。

7.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

5.创建异步事件消费者

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

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

image-20251001194134892

6.注册收到消息回调

当消息到达时触发 OnMessageReceived。

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

image-20251001194908184

7.启动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,宿主启动不会被阻塞。

7.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

5.处理消息

  • 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

7.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

7.10事件订阅和执行

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

7.10.1协变和逆变

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

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

image-20251006231342411

7.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;   // 正常继承关系

7.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>

7.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

7.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> 不变

7.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,因为接口里已经有默认逻辑
}

7.10.3Keyed Service 模式

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

image-20251007174544582

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

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

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

优势

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

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

7.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

5.事件类型&事件处理器

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

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

image-20251007230119439

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

image-20251007230345515

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

image-20251007230546228

6.事件的执行

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

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

image-20251007231201140

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

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

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

image-20251007231839160

7.11注册事件总线服务

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

image-20251007235539247

7.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

7.13事件总线资源释放

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

image-20251008000500313

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

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

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

image-20251008002039714

7.14事件总线构建器

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

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

public interface IEventBusBuilder
{
    IServiceCollection Services { get; }
}

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

image-20251008005352785

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

image-20251008005624954

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

image-20251008010006910

总结

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

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

image-20251008011134071

8.事件订阅

商品服务(Catalog.API)在注册事件总线的时候订阅了2个事件,看完了事件总线的实现流程,这里看一下事件的具体实现

image-20251008011722939

8.1订单状态-等待验证

当订单进入“等待验证(AwaitingValidation)”状态时,本处理器检查商品库存并基于检查结果发布一个“库存确认”或“库存拒绝”类型的集成事件(OrderStockConfirmedIntegrationEvent 或 OrderStockRejectedIntegrationEvent),并确保事件与数据库变更以一致方式持久化和发布。

简单理解:

  • 检查库存:商品服务(Catalog)在接收到订单服务发布的
    OrderStatusChangedToAwaitingValidationIntegrationEvent 事件后,会遍历订单中的所有商品,逐一检查库存是否充足。
  • 根据库存结果发布事件:
    • 库存确认:如果有任何商品库存不足,则发布OrderStockRejectedIntegrationEvent
    • 库存拒绝:如果全部库存充足,则发布 OrderStockConfirmedIntegrationEvent
  • SaveEventAndCatalogContextChangesAsync使用了EFCore执行策略,保证了事件日志和商品数据上下文能够同时保存(事务一致性。即:只有当商品库存数据和事件日志都成功保存时,操作才会提交。如果保存失败,会自动回滚或重试,避免“事件已记录但数据未更新”的问题。)。
  • 最后发布事件(PublishThroughEventBusAsync),将已持久化的事件通过事件总线(EventBus,如 RabbitMQ)正式发布到消息中间件。

image-20251008163341539

为什么这段代码在 foreach 中多次使用 Find(),而不是一次性异步查完所有商品?

其实正常来说我的代码风格也趋向于一次异步方法查询所有,但是工作中遇到过不允许在循环中查询数据库的要求,觉得过多占用数据库连接(当时的MySQL的并发有限),其实当时我也觉得循环单个索引查询比大批量查询要好,但是迫于领导最后还是改过来了。我觉得微软的项目可参考性还是挺高的(同时考虑了业务和技术),这里也记录一下。

订单项数量一般较少

  • 在典型的电商场景中,一个订单往往只有少量商品(例如 3~10 件)。
  • EF Core Find() 先查内存,再查数据库,因此性能损耗可忽略。
  • 若每次只查几条数据,优化为批量查询收益不明显。

Find() 支持 EF 缓存机制

  • 如果某个 CatalogItem 在当前事务上下文中已经被查询或修改过,Find() 不会再发 SQL;

  • 而 ToListAsync() 总会从数据库重新查询,无法利用 EF 的内存跟踪机制。

保持事务一致性和简洁性

  • catalogIntegrationEventService.SaveEventAndCatalogContextChangesAsync() 会在同一上下文内完成数据一致性;
  • 在 foreach 中使用 Find(),可确保每次获取的实体都受当前上下文跟踪,后续变更(如果有)可以一并保存。

俩中方式对比

方式 优点 缺点 适用场景
在 foreach 中使用 Find() 简洁、逻辑清晰、能利用 EF 缓存 多次 DB 调用,性能略低 小订单(少量商品)
一次性批量查询 (Where + ToListAsync) 一次 DB 查询,性能高 逻辑略复杂,不利用缓存 大订单或高并发库存校验场景

微软示例用 Find() 而不是 FindAsync(),主要是为了逻辑简单、示例清晰、性能足够。
对于大订单或高并发场景,可以考虑异步批量查询。

image-20251008170441505

8.2订单状态-已支付

OrderStatusChangedToPaidIntegrationEventHandler 是商品服务中“响应订单支付成功,执行库存扣减”的事件处理器

这个事件处理器不需要发布事件,所以不需要调用catalogIntegrationEventService保存集成事件和商品服务数据库上下文

image-20251008165124886

这里在foreach中使用find和OrderStatusChangedToAwaitingValidationIntegrationEventHandler事件处理器理由差不多

为什么 RemoveStock 方法在实体里,而不是直接在事件处理器?

这里涉及到 DDD(领域驱动设计) 的只是,可以提前了解一下,后面好多地方都有类似的场景,订单服务(Ordering.API)好像涉及了DDD,后面再详细看看。

在 DDD(领域驱动设计) 中:

  • 实体(Entity) 是具有唯一标识的业务对象(如商品 CatalogItem)。
  • 行为(Behavior) 应该放在实体里,而不是散落在服务或事件处理器里。
  • 数据 + 行为 = 完整的领域模型。

CatalogItem 代表一个商品,它 本身知道如何管理库存,所以库存扣减的方法放在它内部最合理。

简单理解:

  • 实体 = 数据 + 业务规则
  • 事件处理器 = 协调事件流、调用实体行为
  • 数据库 = 持久化结果

在实体封装业务规则,方便外部调用,不用到处写扣减库存逻辑。

image-20251008172032123

9.CatalogOptions配置绑定

将appsettings.json配置中的属性自动映射到CatalogOptions 对象的属性上

image-20251008174030345

appsettings.json

image-20251008174130876

10.Embedding配置(Ollama或OpenAI)

简单来说,Embedding(向量表示)就是把某种信息(通常是文本、图片、音频等)转化为一个高维向量,以便计算机可以进行数学运算和相似度比较。

Embedding = 向量(Vector)

  • 一个向量是由一组数字组成的数组,例如:[0.12, -0.34, 0.78, ...]
  • 维度通常很高,比如 512、768 或 1024 维

这段代码根据配置选择 Ollama 或 OpenAI 作为 embedding 提供者并注册相关服务

image-20251008230038502

其实所有服务OllamaEnabled和textEmbeddingModel选项都没有配置,意思就是不启用这个功能呢,我们这里简单了解一下这2个AI工具,后面确实需要再补充

在eShop 扩展中:

  • Ollama:可以在本地处理 Embedding,用于:
    • 商品语义搜索
    • FAQ 问答
  • OpenAI:可以直接调用云端 Embedding API,用于同样的场景,但无需本地硬件支持

Ollama 更适合本地私有化部署和企业内部数据安全场景;

OpenAI 更适合快速接入云端 AI 服务和低成本实验。

11.Catalog商品AI向量生成器

这个和上面一样也需要部署AI工具,Ollama支持docker部署,OpenAI需要远程调用

CatalogAI用于生成商品向量,本地电脑可能配置不够搭建AI工具,所以这里只做了解

image-20251008234039625

IsEnabled没有注入_embeddingGenerator就不开启AI工具,向量结果直接返回null,不影响我们别的功能学习

image-20251008234542226

商品向量是根据商品名称和描述生成的文本嵌入,高维向量表示其语义特征。向量越接近,表示商品语义越相似,可用于搜索和推荐等场景。

image-20251008234750246

为什么eshop的所有服务没有启用AI的功能?

  • 默认不开启 AI 是为了保证项目可直接运行,不依赖复杂环境

  • AI 功能属于可选扩展,需要开发者自行配置和部署环境

  • 这2中AI工具都有一定的要求:

    • Ollama:
      • 需要 Docker 容器启动 Ollama 模型
      • 需要挂载卷 + 足够内存
      • 如果本地没有启动容器或环境不符合,功能无法使用
    • OpenAI / Azure OpenAI:
      • 需要网络和有效 API Key
      • 国内访问 OpenAI 需要FQ

所以这里我们了解这个概念就行了,重心还是放在微服务上

12.配置Problem Details中间件

AddProblemDetails() 是 ASP.NET Core 中用于注册 Problem Details 中间件的扩展方法。

作用:统一处理 Web API 的异常和错误响应,将错误信息格式化为 RFC 7807 标准的 JSON 对象。

它提供了一种 一致、可扩展的错误返回格式,方便前端和客户端统一解析。

启用后,当 API 抛出异常或返回错误时,返回 JSON 格式如下:

{
  "type": "https://example.com/probs/out-of-credit",           //错误类型 URI(可选)
  "title": "You do not have enough credit.",                   //简短错误描述
  "status": 403,                                               //HTTP 状态码
  "detail": "Your current balance is 30, but that costs 50.",  //详细错误信息
  "instance": "/account/12345/transactions/abc"                //引发错误的请求 URI
}

场景

全局异常处理:

  • 不需要在每个 Controller 写 try-catch
  • 异常由 Problem Details 中间件统一处理

API 错误标准化

  • 对外 API 响应一致
  • 客户端更容易解析和展示错误信息

可扩展

  • 可以添加自定义映射,例如:

    builder.Services.AddProblemDetails(options =>
    {
        options.Map<NotFoundException>(ex => new StatusCodeProblemDetails(404));
        options.Map<ValidationException>(ex => new ValidationProblemDetails(ex.Errors));
    });
    

image-20251009000513226

ProducesResponseType<ProblemDetails> 用于声明接口在请求错误时(400 Bad Request)返回的类型为 ProblemDetails,Content-Type 为 application/problem+json;它本身不生成响应,而是与 ASP.NET Core 的 ProblemDetails 中间件配合使用,确保接口文档与实际错误返回保持一致,方便客户端处理和文档生成。

image-20251010225454032

13.配置OpenAPI/Scalar

13.1API Versioning

在微服务或 Web API 中,接口会随着业务发展不断迭代:

  • v1:旧版本接口,客户端仍在使用
  • v2:新版本接口,引入新功能或改进

如果没有版本控制,升级接口会破坏已有客户端。

API Versioning 就是用来支持多版本接口并保持兼容性的机制。

常见做法:

  • URL 版本:/api/v1/products
  • Query 参数:?api-version=1.0(eshop就是使用的这种方式)
  • Header 版本:api-version: 1.0

withApiVersioning 就是“多版本 API 的配置对象”,它让你的应用和文档生成知道有哪些 API 版本,并支持不同版本的接口共存。

13.2配置OpenAPI 与Scalar

在应用运行时注册OpenAPI路由和开发环境下的Scalar 文档路由。

  • 检查appsettings.json的OpenAPI配置
  • 映射OpenAPI 路由到应用
  • 仅在开发环境打开Scalar 文档

image-20251009220535816

13.3注册OpenAPI和多版本配置

根据配置注册 OpenAPI,并支持 API 版本化的多个文档。

  • 获取appsettings.json中的OpenAPI和Identity的配置节点
  • 提取Identity的Scopes,用于OpenAPI 安全检查

image-20251009222653280

  • 判断apiVersioning
  • 假设有v1、v2俩个版本, 为这2个版本注册OpenAPI 文档

image-20251009223501996

13.4注册多版本OpenAPI

在Programe调用AddDefaultOpenApi注册多版本OpenAPI

image-20251009225057384

13.5Minimal API多版本路由配置

``app.NewVersionedApi("Catalog")`:创建一个版本化 API 的路由组

vApi.MapGroup("api/catalog") :创建一个路由组,所有 /api/catalog 下的接口都可以挂在它下面

HasApiVersion(1,0) :表示这个路由组只属于 v1

HasApiVersion(2,0) :表示这个路由组只属于 v2

可以给同一条路由同时指定多个版本,比如 api 路由同时支持 v1 和 v2

image-20251009225627707

启动项目,可以成功访问2个版本的api

image-20251009230347651

13.6访问不同版本的接口

13.6.1源码解读

从AddApiVersioning跟踪到AddApiVersioningServices

image-20251012010219321

然后查看AddApiVersioningServices的实现

我们发现单利注册2次ApiVersionReader,这是依赖注入的常见做法:一个对象实现多个接口,需要分别注册。

  • IApiVersionReader :用于解析 API 版本
  • IApiVersionParameterSource:用于提供版本来源信息

image-20251012010559821

接着查看ApiVersionReader的实现,这里的Versioning.ApiVersionReader.Default就是获取接口版本的默认实现,上面提到有三种方式区分接口版本:查询字符串(/api/products?api-version=2.0)、HTTP头(Header: api-version: 2.0)、URL路径(/v2/products)

image-20251012011001658

接着查看ApiVersionReader Default的实现:默认会从查询字符串和地址获取版本

Combine 方法(多来源 API 版本读取器组合器):

  • 把多个 IApiVersionReader 组合成一个,允许从多个来源读取 API 版本(query string、URL、header 等)。
  • 返回一个 CombinedApiVersionReader,按顺序尝试每个读取器。

剩下的不多跟了,本来这块不想看这么细的,突然好奇如何区分版本的,就捯饬了一下

image-20251012012215281

13.6.1访问不通版本接口

我们打开俩个版本的scalar访问/api/catalog/items/{id}接口,发现版本号在查询参数里面

http://localhost:5222/scalar/v1#tag/items/GET/api/catalog/items/{id}
http://localhost:5222/scalar/v2#tag/items/GET/api/catalog/items/{id}

image-20251012014415096

既然发现了API Versioning 的 实现方式,我们就可以通过不同的api-version访问接口

这里为了方便测试,选了2个版本都有的接口GetItemById,自己可以在GetItemById打上断点,发现不管请求api-version=1.0还是api-version=2.0,请求都可以进断点,知道规则后以后就知道如何单独访问2个版本的接口了

http://localhost:5222/api/catalog/items/1?api-version=1.0
http://localhost:5222/api/catalog/items/1?api-version=2.0

image-20251012014630475

14.注册默认端点

Prometheus 端点(可选,暂时未启用)

  • 需要安装 OpenTelemetry.Exporter.Prometheus.AspNetCore包,MapPrometheusScrapingEndpoint() 暴露 /metrics
  • Prometheus 会周期性拉取,用于应用监控和指标收集

image-20251009231438451

健康检查 /health(Readiness)

  • 检查应用的所有健康项
  • 用于判断应用是否可以接收流量
  • 在开发环境启用,生产环境建议限制访问

活性检查 /alive(Liveness)

  • 只检查带 "live" 标签的健康项
  • 判断应用进程是否存活
  • 容器编排系统可以根据结果重启应用

image-20251009231630035

健康检查(/health)和活性检查(/alive)有什么区别?

健康检查(/health):第三方服务服务可用性检查

活性检查(/alive):应用自身存活检查

类型 英文 作用 判断依据 Kubernetes 应用
健康检查 Readiness Probe 判断应用是否准备好接收流量 检查应用依赖是否正常(数据库、缓存、第三方服务等) 如果失败 → Kubernetes 不会把流量发给 Pod
活性检查 Liveness Probe 判断应用进程是否存活 只检查应用进程本身是否存活(比如内存溢出、死锁) 如果失败 → Kubernetes 重启 Pod

在Programe调用MapDefaultEndpoints(映射默认端点)

image-20251009232111976

15.注册状态码中间件

这是 ASP.NET Core 的一个中间件,用于处理 HTTP 状态码响应,特别是客户端或服务器错误(例如 404、500)时的默认处理方式。(比较简单的东西了解一下即可)

中间件功能:拦截未处理的 HTTP 错误码,返回默认状态码页面

默认效果:简单文本,显示状态码和信息

可扩展性:支持自定义文本、HTML、重定向、Lambda 响应

开发调试:方便查看接口错误

生产环境:建议配合自定义错误页或 JSON 返回

image-20251009232419032

16.Minimal API(Net9)

Minimal API 是 ASP.NET Core 提供的一种“极简”风格的写法:用非常少的样板代码(通常在 Program.cs)直接注册路由和处理器,适合微服务、后端小服务、快速原型或需要极高性能/低开销的 HTTP 接口。它并不是 MVC 的替代品——大型系统仍然可能更适合 Controller + 属性滤器 的组织方式。

Minimal API 的强大之处在于:路由、参数绑定、依赖注入、OpenAPI 元数据、返回结果都可以用简短的委托(lambda 或方法)写清楚。

Minimal API 是 .NET 6/7/8/9+ 推出的一个轻量化 API 构建方式,特点是:

  • 无需控制器和 MVC 框架,直接在 Program.cs 里定义路由和处理逻辑;
  • 语法简洁,只需 app.MapGet(...)、app.MapPost(...) 等方法;
  • 支持依赖注入、验证、OpenAPI 自动生成;
  • 类型安全:方法签名里可以定义参数类型、返回类型。

16.1API Version分组

v1, v2, api代表不同的 API 版本或不同的路由分组(例如 app.MapGroup("/api/v1") / app.MapGroup("/api/v2") / app.MapGroup("/api"))。

之前有提过,这里不详说了

image-20251010212641277

16.2MapGet(查询资源(Read))

基本语法

  • /route → 请求路径

  • HandlerFunction → 请求到达时执行的处理函数,可以是同步或异步

app.MapGet("/route", HandlerFunction);

功能:

  • 处理 HTTP GET 请求
  • 查询/获取数据,不修改服务器状态

请求与参数:

  • 路径参数:/items/{id} → 自动绑定
  • Query 参数:/items?page=1&pageSize=10
  • 依赖注入:可以直接注入服务对象
  • 不支持请求体(GET 本身不应该有 body)

image-20251010220121648

16.3MapPost(创建资源(Create))

功能:

  • 处理 HTTP POST 请求
  • 创建资源或提交数据
  • 可以带请求体

请求与参数:

  • 请求体绑定:DTO、对象
  • 依赖注入:服务对象可直接注入
  • 路径参数:可有可无(一般 POST 创建新资源时不需要 ID)

image-20251010220537021

16.4MapPut(更新资源(Update))

功能

  • 处理 HTTP PUT 请求
  • 更新整个资源(覆盖原资源)

请求与参数

  • 路径参数:标识资源 ID
  • 请求体:完整对象 DTO
  • 依赖注入:服务对象可直接注入

image-20251010220610826

16.5MapDelete(删除资源(Delete))

功能

  • 处理 HTTP DELETE 请求
  • 删除资源

请求与参数

  • 路径参数:标识资源 ID
  • 一般无请求体
  • 依赖注入:服务对象可直接注入

image-20251010220720473

16.6路由参数约束类型

约束类型 示例 说明
int {id:int} 只能是整数
long {id:long} 只能是长整型
bool {flag:bool} 只能是 true/false
datetime {date:datetime} 必须是有效日期时间
decimal {price:decimal} 必须是小数
double {value:double} 必须是双精度浮点
minlength(value) {name:minlength(3)} 字符串最小长度
maxlength(value) {name:maxlength(10)} 字符串最大长度
range(min,max) {age:range(1,120)} 数值范围限制
alpha {name:alpha} 只能包含字母
regex(pattern) {code:regex(^\d{4}$)} 正则表达式匹配

image-20251010221703514

16.7参数绑定(MapGet/MapPost/MapPut/MapDelete)

接口方法 参数类型 来源 支持情况 使用场景 注意事项/限制
MapGet 路由参数 URL 路径 {id} ✅ 标识资源 ID 名称必须匹配路由模板;可使用约束(int、minlength 等);类型不匹配返回 404
查询参数 URL Query ✅ 分页、筛选、搜索 可选参数可设置默认值或 ?;类型不匹配返回 400
请求体参数 HTTP Body ❌ 不推荐 GET 请求使用 Minimal API 默认不绑定 GET Body
DI 服务 DI 容器 ✅ 注入服务对象、数据库等 类型必须已注册到容器
MapPost 路由参数 URL 路径 {id} ✅ 标识资源或更新目标 可用约束限制类型
查询参数 URL Query ✅ 补充额外参数 可选参数可设置默认值
请求体参数 HTTP Body ✅ 创建/更新资源 常用 DTO 或 POCO 类自动 JSON 绑定
DI 服务 DI 容器 ✅ 注入服务对象、数据库等 支持同步或异步注入
MapPut 路由参数 URL 路径 {id} ✅ 更新资源 常与 Body 参数一起使用
查询参数 URL Query ✅ 补充更新条件 可选参数可设置默认值
请求体参数 HTTP Body ✅ 更新资源 DTO/POCO 类绑定 JSON
DI 服务 DI 容器 ✅ 注入服务对象、数据库等 支持异步或同步
MapDelete 路由参数 URL 路径 {id} ✅ 标识删除资源 必须匹配路由模板
查询参数 URL Query ✅ 补充删除条件 可选参数可设置默认值
请求体参数 HTTP Body ❌ 不推荐 DELETE 使用 DELETE 一般不绑定 Body
DI 服务 DI 容器 ✅ 注入服务对象、数据库等 支持同步或异步

16.8Scalar接口文档配置

这四个接口比较简单,知道用处就行了

16.8.1WithName

WithName("接口名称") 用于给接口设置唯一标识名称,方便在系统内部引用或查找

16.8.2WithSummary

WithSummary("简短说明") 用于提供接口的一句话功能概述,让开发者快速了解接口作用;

16.8.3WithDescription

WithDescription("详细描述") 用于给接口写详细说明,包括逻辑、参数和返回结果信息,便于团队理解和维护;

16.8.4WithTags

WithTags("标签") 用于对接口进行分组管理,把相关接口归类到同一个标签下,方便在 Scalar 管理界面筛选和查找。

16.8.5总结

image-20251010224825173

16.9[ProducesResponseType<ProblemDetails>]

[ProducesResponseType]:告诉框架和 Scalar该 API 方法可能返回的 HTTP 状态码和响应类型。

<ProblemDetails>:ProblemDetails 是 ASP.NET Core 内置的 错误响应模型,符合 RFC 7807 标准。

  • 常用字段
    • type:错误类型 URI
    • title:错误标题
    • status:HTTP 状态码
    • detail:错误描述
    • instance:请求实例 URI
  • 这个接口在 400 Bad Request 时,会返回一个 ProblemDetails 对象给客户端。

StatusCodes.Status400BadRequest:指明返回的 HTTP 状态码是 400(请求参数错误)。

"application/problem+json":指定返回的 Content-Type。当返回 ProblemDetails 时,HTTP 响应会带上 Content-Type: application/problem+json。这符合标准 RFC 7807,方便客户端统一解析错误信息。

这行特性说明:

  • 这个 API 方法可能返回 400 Bad Request
  • 返回体类型是 ProblemDetails
  • Content-Type 是 application/problem+json

image-20251010230312457

告诉框架和 Scalar该 API 方法可能返回的 HTTP 状态码和响应类型。

image-20251011015832259

16.10参数绑定系统(Parameter Binding)

16.10.1概念

Minimal API 会根据参数的类型与绑定特性(attribute)来决定把请求的哪一部分绑定到这个参数上。常见绑定源包括:

  • 路由(route path){id}
  • 查询字符串(query)?q=...
  • 请求体(body)——通常是 JSON
  • 表单(form / multipart)
  • 请求头(header)
  • DI 容器(services)
  • HttpContext / CancellationToken / 特定 framework 类型

原则:简单标量类型(int、string、bool、DateTime、Guid、enum 等)优先从路由/查询读;复杂引用类型(class/record)通常从 body 绑定(除非你显式要求其它来源)。

16.10.2绑定规则

Route(路由)

  • 如果路由模板里有同名占位符({id}),则会从路由数据绑定到对应参数。
  • 路由约束({id:int})会做类型校验,不匹配返回 404。

Query(查询字符串)

  • 对于简单类型(或标注了 [FromQuery] 的复杂类型属性),会在 URL Query 中查找同名键并绑定。
  • 可用于分页、过滤、可选参数等。

Body(请求体,通常 JSON)

  • 复杂类型(class / record)默认会从请求体绑定(用于 POST/PUT 等)。
  • 一个 endpoint 只能有一个 body 参数(请求体只能反序列化一次)——若需要多个部分,需合并到一个 DTO 或使用 AsParameters/FromForm 等策略。

Services(DI)

  • 对象类型会尝试从 DI 容器解析(当类型在容器中有注册,或显式用 [FromServices])。
  • 典型:ILogger<T>, DbContext 等。

Special framework types

  • HttpContext, HttpRequest, HttpResponse, CancellationToken, ClaimsPrincipal 等会被框架直接提供。

Header / Form / File

  • [FromHeader]、[FromForm]、IFormFile、IFormCollection 等有专门来源。文件需要 multipart/form-data。

16.10.3绑定属性

[FromRoute]:从路由绑定(强制)。

[FromQuery]:从查询字符串绑定。

[FromBody]:从请求体绑定(显式)。

[FromHeader]:从请求头绑定。

[FromForm]:从表单数据绑定(multipart/form-data)。

[FromServices]:从 DI 容器解析。

[AsParameters](Minimal API helper):将一个复杂类型的属性当作多个简单参数从路由/查询/Form 绑定(常用于把 DTO 的属性按字段绑定到 query/route,而不是整个对象作为 body)。

绑定属性([BindProperties] )是 MVC 的概念,Minimal API 常用的是 AsParameters 风格(记录/类包裹参数属性)。

16.10.4总结

简单类型(int/long/string/bool/Guid/enum):route -> query -> header

复杂类型(class/record):body(默认)(或 [FromQuery]/[AsParameters])

注入服务:从 DI(或 [FromServices])

文件:IFormFile 来自 multipart/form-data

HttpContext / CancellationToken / ClaimsPrincipal:框架直接注入

一个 endpoint 仅允许一个 body 参数

16.11[AsParameters]

[AsParameters] 是 Minimal API 的新特性,用于 把一个复杂对象的多个属性自动绑定到请求参数。

换句话说,如果你有一个 DTO(数据传输对象),使用 [AsParameters] 可以让它:

  • 自动从 查询字符串 (query) 获取数据
  • 自动从 路由参数 (route) 获取数据
  • 自动从 服务容器 (services) 注入依赖

当客户端访问 /items?pageIndex=1&pageSize=10 时:

  • request.PageIndex = 1
  • request.PageSize = 10

避免了每个参数单独写 [FromQuery] int pageIndex, [FromQuery] int pageSize

image-20251011003533727

PaginationRequest:自动从查询字符串读取分页参数 PageIndex 和 PageSize

CatalogServices:自动从 DI 容器注入服务实例(数据库上下文、AI 服务等)

  • 普通类型参数(如 int pageIndex):尝试从 路由 或 查询字符串绑定
  • 复杂类型参数(如 CatalogServices):如果参数是复杂对象,且构造函数的依赖都可解析(如 [FromServices]、IOptions<T>、ILogger<T>),就会按需创建对象,即使你没在全局 DI 注册整个服务类。

image-20251011003720907

为什么AsParameters可以自动注入复杂类型参数?

AsParameters执行流程:

  • 参数类型检测:Minimal API 会检查 CatalogServices 是一个复杂类型,而不是简单类型(int、string 等)。
  • 构造对象实例:
    • 它会尝试调用 CatalogServices 的构造函数。
    • 构造函数里的每个参数:
      • 如果参数类型已注册到 DI 容器 → 直接从容器获取
      • 如果参数是 IOptions<T>、ILogger<T> → .NET 内置自动解析
      • 如果参数加了 [FromServices] → 强制从 DI 获取
      • 如果参数类型无法解析 → 报错

Minimal API 会“按需”构造 CatalogServices,不需要你显式在全局注册整个类。

使用前提:构造函数的依赖类型必须可解析,即 CatalogServices 构造函数中需要的对象必须已经注册到 DI 容器,或是 .NET 内置可解析类型(如 ILogger<T>、IOptions<T>),否则 Minimal API 无法自动创建实例。

以下的这些对象在Programe中已经提前注入了

image-20251011004822836

[AsParameters] 主要用于“批量绑定查询/路由 + 注入服务”。
如果你是 POST/PUT 的请求体对象,Minimal API 会自动把 JSON 映射到你的复杂对象,所以不需要 [AsParameters]。

image-20251011010608251

16.12[FromBody]

本质与作用

  • [FromBody] 显式告诉框架把参数从 HTTP 请求体(Body)反序列化。

  • 对于复杂引用类型(class / record),Minimal API 默认会把它们当作 body 绑定,即使不写 [FromBody] 也会从 Body 解析。

  • [FromBody] 最主要的作用是在需要明确或覆盖默认绑定行为时使用(例如:对简单类型强制从 body 读取,或与 [FromQuery] 等共同使用来消除歧义)。

只能有一个 Body 参数

  • HTTP 请求体只能被反序列化一次;因此 一个 endpoint 只能有一个参数从 Body 绑定。

  • 如果你声明多个将从 body 绑定的参数(显式或隐式),会引发运行时错误或不可预期行为。解决方案:

    • 把多个字段合并为一个 DTO(推荐)。

    • 将一些字段放到 query/route/header/form 中。

    • 使用 multipart/form-data 并在 server 侧手动解析表单字段与文件。

Minimal API 默认会把CatalogItem当作 body 绑定,即使不写 [FromBody] 也会从 Body 解析。

image-20251012161140595

16.13HttpContext

HttpContext 表示一次 HTTP 请求的上下文(context)——包含请求、响应、用户信息、路由数据、服务容器、连接信息等。它是一次请求的“全局对象”,在中间件和 endpoint 处理器中都可访问。每个请求有且只有一个 HttpContext 实例(按请求范围生命周期)。

在 Minimal API(或 endpoint handler)方法里直接声明 HttpContext 参数是完全合法且常见的做法。框架会在每次请求执行该 handler 时,把当前请求对应的 HttpContext 自动传入。
不需要也不应该把 HttpContext 直接注入并长期保存在像 CatalogServices 这样的服务实例里。

在 Minimal API 中使用HttpContext

app.MapGet("/ping", (HttpContext ctx) => {
    return Results.Text($"Path: {ctx.Request.Path}");
});

image-20251012153747122

在任何服务/中间件中通过构造函数注入 IHttpContextAccessor(当无法直接注入 HttpContext 时):

// 注:要使用 IHttpContextAccessor 需要在 Program.cs 注册:
builder.Services.AddHttpContextAccessor();


public class MyService {
    private readonly IHttpContextAccessor _acc;
    public MyService(IHttpContextAccessor acc) => _acc = acc;
    public void Do() {
        var ctx = _acc.HttpContext;
    }
}

为什么Minimal API可以直接使用HttpContext — 原理(Parameter Binding)

ASP.NET Core 的 Minimal API 有一个参数绑定系统(parameter binding)。

当框架调用 handler(例如 UpdateItem)时,会根据参数类型和特性决定如何绑定每个参数:如果参数类型是 HttpContext,框架把当前请求的 HttpContext 实例传入。

16.14[Description]

类型:System.ComponentModel.DescriptionAttribute

作用

  • 为方法参数提供 文本说明
  • 在 自动生成 API 文档 时显示
  • 增强代码可读性,方便前端或客户端开发者理解参数用途

接口属性

属性 功能
[Description("...")] 提供参数用途说明
[Required] 必填校验,缺省会返回 400
[MinLength(1)] 字符串最小长度校验
[FromQuery] / [FromBody] 指定参数绑定来源(Query / Body)

看到 [Description(...), Required, MinLength(1)],可能觉得这些都属于 Description 的一部分,其实不是。这里发生的是 C# 特性的叠加(Attribute stacking),每个特性都是独立的。

image-20251011005909238

16.15IQueryable动态查询

IQueryable动态查询应该是EFCore的知识点,eshop项目为了方便大家学习,所以分层比较简单,数据查询也写在MiniAPI代码里,这里一起总结一下

概念

IQueryable<T> 是 延迟执行的查询接口,允许在 数据库层面动态构建 LINQ 查询。

与 IEnumerable<T> 不同:

  • IQueryable<T>:查询在数据库执行,支持动态条件、分页、排序。
  • IEnumerable<T>:查询在内存执行,先拉取数据再处理,效率低。

延迟执行与 SQL 生成

IQueryable 不会立即访问数据库。SQL 查询直到调用以下方法时才生成并执行:

  • ToListAsync() :获取数据列表
  • LongCountAsync() :获取总记录数

优点:

  • 动态过滤:根据传入参数组合不同条件
  • 高效分页:Skip + Take 在数据库层执行
  • 减少内存占用:只查询必要的数据

强制转换 (IQueryable<CatalogItem>)

不是必须,只是 明确意图:

  • 强调这是延迟查询
  • 避免歧义,保证类型兼容

即使省略强转,EF Core 也会自动识别为 IQueryable<T>。

使用 IQueryable<CatalogItem> 可以在数据库层动态构建查询、延迟执行、支持分页和排序。

把查询逻辑写在这里其实是不太合理的。Minimal API 中尽量保持 Endpoint 简洁,把查询逻辑抽到 Service 层或 Query 层,是更合理的架构方式。

image-20251011012644229

16.16响应结果

16.16.1TypedResults

TypedResults 是 .NET 9+ Minimal API 提供的静态辅助类,它的作用是:

  • 1.快速生成 HTTP 响应对象(实现了 IResult 接口);
  • 2.类型安全:可以指定返回类型(如 Ok<CatalogItem>、BadRequest<ProblemDetails>);
  • 3.简化代码:不用手动操作 HttpContext.Response;
  • 4.自动支持 OpenAPI / Scalar 文档生成。

换句话说,TypedResults 就是 Minimal API 的“响应工厂”,帮你快速生成类型安全的 HTTP 响应,并配合 Results<T1,T2,...> 支持多分支返回,自动处理状态码和响应体,同时可生成 OpenAPI 文档。

常用方法及作用

方法 返回类型 HTTP 状态码 用途
Ok<T>(T value) Ok<T> 200 返回成功数据
Created(uri) / Created<T>(uri, T value) Created / Created<T> 201 创建资源
NoContent() NoContent 204 成功但不返回内容
BadRequest<T>(T value) BadRequest<T> 400 请求无效或参数错误
NotFound() / NotFound<T>(T value) NotFound / NotFound<T> 404 资源不存在
PhysicalFile(...) PhysicalFileHttpResult 200 返回磁盘文件或流

16.16.2IResult

IResult 是 Minimal API 的统一返回接口,核心作用是:

  • 抽象所有 HTTP 响应,包括状态码、响应体、头信息等;

  • 框架执行 endpoint 时,会调用

    Task ExecuteAsync(HttpContext httpContext);
    

把返回的 IResult 写入 HTTP 响应。

换句话说,任何 Minimal API 返回值最终都会被转成 IResult。

IResult 的核心作用

  • 封装 HTTP 响应逻辑:处理状态码、响应体、Content-Type、Headers 等;例如:Ok<T> → 200,NotFound → 404,NoContent → 204。
  • 类型安全:与 TypedResults 配合,可以返回指定类型的数据,如 Ok<CatalogItem>、BadRequest<ProblemDetails>。
  • 支持多种返回类型:配合 Results<T1,T2,...> 使用,表示一个 endpoint 可以返回多种不同的结果。

TypedResults 是 Minimal API 的“响应工厂”,生成各种 IResult 实例;IResult 是 Minimal API 的统一返回接口,所有 endpoint 返回的结果最终都会通过它生成 HTTP 响应。

16.16.3Ok<T>

Ok<T> 是 Minimal API 框架中用于返回 HTTP 200 成功响应 的一种 强类型结果(Typed Result)。

它表示:“请求已成功执行,并且响应体中包含类型为 T 的数据。”

Ok<T> 只在你主动调用时返回 200; 方法抛异常不会自动变成 200,而是会被框架捕获返回 500(或自定义错误)。

和传统 Controller 的区别

对比项 Minimal API (Ok<T>) Controller (return Ok(obj))
所在命名空间 Microsoft.AspNetCore.Http.HttpResults Microsoft.AspNetCore.Mvc
返回接口 IResult IActionResult
编程范式 函数式 API(轻量) MVC 模式(重量)
特点 类型安全(Ok<T> 明确类型) 通用型返回,类型不明确

Ok<T> 适合演示和简单场景, 生产代码建议使用 Results<...> 明确所有可能的响应。

写法 优点 缺点 适用场景
Task<Ok<T>> 简洁、直观 不能表达失败分支 Demo、内部简单接口
Task<IResult> 灵活、可动态返回 Scalar 无法自动推断类型 原型、实验性接口
Task<Results<Ok<T>, NotFound, BadRequest<ProblemDetails>>> 类型安全、文档完善、表达丰富 稍显冗长 正式生产环境(推荐)

image-20251011014901567

16.16.4Created

Created 是 Minimal API / Microsoft.AspNetCore.Http.HttpResults 提供的一个结果类型(实现了 IResult),表示 HTTP 201 Created 响应。

Created 实际会做什么(HTTP 行为):

  • 状态码:201 Created。

  • Location header:通常会在响应头中设置 Location,指向新创建资源的 URI(比如 TypedResults.Created($"/api/catalog/items/{item.Id}") 就是在设置这个 header)。

  • 响应体:默认 Created 代表 没有返回 body(仅有 Location 和 201)。如果希望同时返回资源实体(body),可以使用 Created<T>(或 TypedResults.Created(uri, value)),那会返回 201 + body(并设置 Content-Type: application/json)。

推荐用 Results<...> 或 IResult

写法 优点 缺点 推荐场景
Task<Created> 简洁、直观 不能表达错误情况 Demo、小型项目、保证成功的操作
Task<IResult> 灵活 文档不自动推断返回类型 原型开发或动态返回
Task<Results<Created, BadRequest<ProblemDetails>>> 类型安全、文档清晰、生产推荐 稍微啰嗦 生产项目、公共API(推荐)

TypedResults.Created(...) 返回一个 Created 结果(201 + Location),Minimal API 在框架层会把它写到 HTTP 响应。

image-20251011020958342

16.16.5NoContent

在 Minimal API 中,NoContent 是一个 强类型 HTTP 响应结果,它表示:请求成功,但没有返回内容。它对应的 HTTP 状态码是 204 No Content。在 Minimal API 中常用于 DELETE 等无需返回数据的请求。

根据 HTTP 标准:204 表示请求成功处理,但服务器没有返回任何内容,响应体为空。

常用于:

  • DELETE 操作成功后,无需返回数据;
  • PUT 或 PATCH 操作更新成功但无需返回资源;
  • 某些异步操作启动成功时。

image-20251011222632605

16.16.6BadRequest

BadRequest<T> 是 TypedResults 提供的一种返回类型,它实现了 IResult 接口,用于生成 HTTP 400 Bad Request 响应。

核心特点:

  • 1.HTTP 状态码固定为 400(请求无效);
  • 2.返回体类型可自定义,通常是 ProblemDetails,也可以是任何 DTO;
  • 3.配合 Results<T1,T2,...> 使用,可以在一个 endpoint 返回多种状态码类型;
  • 4.自动生成 OpenAPI 文档。

image-20251011232132209

16.16.7NotFound

NotFound 是 TypedResults 提供的一种返回类型,实现了 IResult 接口,用于生成 HTTP 404 Not Found 响应。

核心特点:

  • 1.HTTP 状态码固定为 404;
  • 2.响应体可选:可以不带 body,也可以带一个对象,如 NotFound<ProblemDetails>(...);
  • 3.与 Results<T1,T2,...> 配合使用,表示 endpoint 可能返回 404;
  • 4.支持 OpenAPI 自动生成文档。

image-20251011232419345

16.16.8Results<T1, T2, ...>

Results<T1,...> 是 Minimal API 提供的“一类可能返回结果的联合类型(discriminated-union 风格)”,用来在方法签名里明确列出该 endpoint 可能返回的几种 IResult,例如 Created、BadRequest<ProblemDetails>、NotFound<ProblemDetails> 等。

Results<TResult1, TResult2, ...> 本质上是一个实现了 IResult(并且实现了 IEndpointMetadataProvider)的“联合类型”。它的设计目的是:在编译期把所有可能的返回结果列出来,让编译器和框架都能知道这个 endpoint 会返回哪些具体结果,从而:

  • 在编译期限制你只能返回声明的那些结果(返回未声明的结果会报编译错)。
  • 框架可以自动把这些信息加入 endpoint 元数据(对 OpenAPI/Swagger 很有帮助),因此常常可以省掉手动 [ProducesResponseType]。

如下表示只会返回三种结果之一——201 Created、400 Bad Request(ProblemDetails) 或 404 Not Found(ProblemDetails)。编译器会在编译期约束返回类型,框架会把这些信息作为 endpoint 元数据(例如用于生成 OpenAPI)。

这个接口因为返回值使用了Results<Created, BadRequest<ProblemDetails>, NotFound<ProblemDetails>>,就省略了接口上的ProducesResponseType<ProblemDetails>特性。

image-20251011223909644

运行项目,通过Scalar文档可以看到该接口可能返回的类型

image-20251011224252682

16.16.9PhysicalFileHttpResult

PhysicalFileHttpResult 是 Minimal API/TypedResults 提供的一个 “返回物理磁盘文件的结果”,用于把磁盘上的文件以流式方式发回给客户端,同时为响应自动设置合适的 HTTP 头(Content-Type、Content-Length、Last-Modified 等),并可支持条件请求与分片(Range)请求。它是框架中 IResult 的一种实现,功能与 MVC 里的 PhysicalFileResult 类似。

核心功能

功能 说明
读取并返回磁盘文件 自动将指定路径的文件内容写入 HTTP 响应流。
设置 MIME 类型 根据扩展名或自定义设置 Content-Type(如 image/png)。
设置文件元信息 可包含 Last-Modified、ETag 等 HTTP 缓存头。
支持条件请求 若客户端发送 If-Modified-Since,服务器可返回 304 Not Modified。
支持分段传输 启用 enableRangeProcessing 时可断点续传(返回 206 Partial Content)。
高性能 内部使用零拷贝(sendfile)方式,避免文件读入内存。

常用写法

return TypedResults.PhysicalFile(
    path,              // 文件物理路径
    contentType,       // MIME 类型,例如 "image/png"
    lastModified: lastModifiedTime, // 可选:设置缓存头
    enableRangeProcessing: true     // 可选:支持分段下载
);

常用响应头

Header 说明
Content-Type 文件类型,例如 image/jpeg
Content-Length 文件大小
Last-Modified 文件最后修改时间
ETag 唯一版本标识(可选)
Accept-Ranges: bytes 支持范围请求
响应状态码 200(完整)、206(分段)、304(缓存)

Results<PhysicalFileHttpResult,NotFound>的含义是:

  • 成功时返回 文件 → PhysicalFileHttpResult(状态码 200)
  • 失败时返回 404 Not Found

[ProducesResponseType<byte[]>(StatusCodes.Status200OK, "application/octet-stream", [...])]
表示:这个 API 在成功时返回 200 状态码,响应体是 二进制字节流(图片),可能的图片格式包括 PNG、JPEG、GIF、BMP、WEBP 等。

image-20251011225853795

16.16.10总结

总结返回策略

  • 单一成功返回:用 Ok<T> / Created / NoContent

  • 多种结果返回:用 Results<T1, T2, ...>

  • 返回文件:用 PhysicalFileHttpResult

  • 错误信息标准化:用 ProblemDetails

  • 文档友好:用 [ProducesResponseType] 标注

Minimal API 返回类型灵活,可直接在方法上定义返回类型。

对于 CRUD 操作:

  • GET:Ok<T> 或 Results<Ok<T>, NotFound, BadRequest>
  • POST:Created
  • PUT:Results<Created, BadRequest, NotFound>
  • DELETE:Results<NoContent, NotFound>

分页查询 PaginatedItems<T> 封装分页信息(总数、页码、每页数量、数据列表)。

17.Net9知识点

17.1record

概念

record 是 C# 9.0 引入的新类型,专门用于表示 不可变数据对象(Immutable Data)。

它是一种 引用类型(reference type),但可以通过 record struct 变为值类型。

record 强调 值比较(Value Equality),而普通类默认是 引用比较(Reference Equality)。

特点

特性 描述
不可变默认 构造后字段默认不可改,用 init 可以初始化
值比较 自动生成 Equals 和 GetHashCode,比较对象内容而非引用
with 表达式 可以方便地基于已有对象创建新对象
自动生成方法 ToString() 输出属性信息,方便调试
可继承 record 可以继承其他 record,支持多态
模式匹配支持 record 可以直接用在 switch/is 模式匹配中

基本语法

Positional Record(位置型)

public record Point(int X, int Y);
var p1 = new Point(1, 2);
Console.WriteLine(p1);  // 输出: Point { X = 1, Y = 2 }

Record with Properties(属性型)

public record Person
{
    public string Name { get; init; }
    public int Age { get; init; }
}
var p = new Person { Name = "Alice", Age = 20 };

不可变和 with 表达式

var p2 = p with { Age = 21 };  // 基于 p 创建新对象,修改 Age

record vs class vs struct

类型 比较方式 可变性 继承 特点
class 引用比较 可变 支持 普通对象
struct 值比较 可变/不可变 不支持继承 值类型
record 值比较 默认不可变,可用 init 支持继承 数据对象,模式匹配友好

使用场景

  • DTO / VO(数据传输对象 / 值对象)

  • 配置对象

  • 不可变对象、功能型编程场景

  • 模式匹配

  • 对象内容比较要求高的场景

事件总线中继承事件基础类用的就是record

image-20251012002705534

18.注册商品服务接口

Minimal API我们了解之后,接着就看一下商品服务复杂点的接口(熟悉熟悉业务逻辑)。

18.1商品服务聚合类(CatalogServices)

CatalogServices的核心思想:

  • 聚合服务:CatalogServices 不是一个单独的业务逻辑服务,而是 多个相关服务的聚合。

  • 面向接口编程:依赖的都是接口(ICatalogAI、ICatalogIntegrationEventService)或抽象类型(IOptions<T>、ILogger<T>)通过接口和 IOptions 注入,提高可测试性和可替换性。

  • 只读、不可变:使用只读属性,保证服务实例不会在中途被替换,增加安全性和可预测性。(保证服务引用不可被随意更改)

  • Minimal API 配合良好:在 Minimal API 中使用时,可以只注入一个 CatalogServices,而不用分别注入五个服务,简化参数传递。

CatalogServices提供了5个功能:

  • CatalogContext:操作商品表、分类表等 Catalog 数据库
  • ICatalogAI:AI 服务接口,比如生成商品嵌入向量、推荐算法或智能搜索
  • IOptions<T>:配置对象,存储 Catalog 相关配置,如文件路径等
  • ILogger<T>:日志服务,用于记录操作日志
  • ICatalogIntegrationEventService:集成事件服务

image-20251012001609198

18.2分页查询商品(GetAllItems)

大部分分页条件查询都是调用的这个方法

分页参数PaginationRequest和商品服务CatalogServices都是通过AsParameters特性自动映射和注入

然后接口进行条件拼接,筛选商品名称、商品类型、商品品牌

image-20251011233652727

然后进行分页查询

image-20251012000716462

18.3语义搜索(GetItemsBySemanticRelevance)

这个接口挺契合实际开发场景,但是使用了AI模型环境,我们没有这个环境,这里主要是想学习人家的思路

代码也挺严谨,如果没有AI模型环境,就走普通的条件查询

image-20251012015431313

如果有AI模型环境,语义搜索就比较简单了,我的理解就是比较了一个数组(向量的维度384)

image-20251012015648038

18.4更新商品(UpdateItem)(价格变更事件)

这部分代码含义:

  • 数据库查询该商品,没有返回NotFound
  • 更新商品属性:
    • Entry(catalogItem) 获取 EF Core 的 EntityEntry,用于访问实体的状态和属性。
    • CurrentValues.SetValues(productToUpdate) 会 把 DTO 的所有属性值覆盖到实体上。
      • 优点:一次性更新多个字段。EF 会自动标记发生变化的属性为 IsModified = true
      • 缺点:如果 productToUpdate 某些字段是默认值(0、null),会覆盖原有值。对复杂类型(导航属性等)不会自动递归更新

我也第一次遇见使用catalogEntry.CurrentValues.SetValues更新,我觉得这种方式学习即可,不适合实际开发。SetValues 虽然简洁,但在企业级项目中 安全性和可控性不足,手动赋值或 AutoMapper 映射才是更适合的开发方式。

image-20251012162651067

这部分代码含义:

  • Property(i => i.Price) 获取价格属性的 EF PropertyEntry

  • IsModified 为 true 表示价格发生了变化

  • 使用 priceEntry.OriginalValue可以获取修改前的价格

  • 判断价格是否变更:

    • 价格变更:

      • 通过事务同时保存数据库更新和事件日志(典型 Outbox 模式)
      • 发布价格变更事件(ProductPriceChangedIntegrationEvent 包含商品 ID、新价和旧价)
    • 价格未变更:仅保存

  • 最后返回Created。这里返回 Created 主要是为了方便前端拿到商品详情页 URL,但严格来说,更新操作应该用 200 OK 或 204 NoContent,更符合 REST 标准,同时也能让前端拿到数据跳转。

image-20251012163333764

18.5创建商品(CreateItem)

这部分代码比较简单:

  • DTO转换实体
  • SaveChangesAsync保存数据库

image-20251012164706483

为什么这里没有使用异步方法AddAsync ?

  • 1.Add 只是在内存里标记实体,操作非常快,无需异步。
  • 2.真正的异步数据库操作在 SaveChangesAsync。
  • 3.EF Core 文档也建议:只有在特殊主键生成策略(例如 SequenceHiLo)需要异步访问数据库时才用 AddAsync。

image-20251012164836399

EFCore文档:https://learn.microsoft.com/zh-cn/dotnet/api/microsoft.entityframeworkcore.dbcontext.addasync?view=efcore-9.0

image-20251012165557653

18.6删除商品(DeleteItemById)

根据Id删除商品

image-20251012165741632

19.启动项目

商品服务的代码都看了,最后总结一下项目的启动

19.1配置数据库连接

搭建数据库环境后,数据库连接字符串正确的话商品服务会自动帮你生成测试数据

image-20251012170006340

可以使用数据库客户端查看

image-20251012170205219

19.1配置事件总线

首先保证自己的RabbitMQ环境没有问题,使用浏览器可以正常访问

image-20251012170332511

每个服务使用事件总线,即是生产者,也是消费者(每个服务都会发布事件,订阅事件)

在appsettings.json配置RabbitMQ的地址和客户端订阅名称

SubscriptionClientName 是消费者(Consumer)在 RabbitMQ 上的唯一标识(这里是商品服务)

当一个事件被发布时,EventBus 会根据订阅关系将事件路由到对应 SubscriptionClientName 的队列

image-20251012170446239

RabbitMQEventBus在项目启动的时候就创建了交换机(eshop_event_bus)和队列(Catalog)

image-20251012171257472

然后把交换机(eshop_event_bus)、队列(Catalog)已经当前服务订阅的事件(routingKey)绑定在一起

image-20251012171507220

然后我们启动项目,可以看到交换机和队列已经创建成功

image-20251012171713318

在Exchange: eshop_event_bus交换机详情页可以看到已经绑定的队列和他们的Routing key

image-20251012171805532

在队列详情页Queue Catalog也能看到绑定的交换机和routingKey

image-20251012171940411

绑定的routingKey就是自己订阅的事件:

  • 订单状态-待验证(OrderStatusChangedToAwaitingValidationIntegrationEvent)
  • 订单状态-已支付(OrderStatusChangedToPaidIntegrationEvent)

image-20251012172144790

19.1配置Scalar

我看商品服务这里好像只用了Document:Title和Document:Description,其他好像都是默认的

image-20251012172640531

启动项目,默认打开v1版本的接口文档

http://localhost:5222/scalar/v1#tag/items

image-20251012172734792

v2版本的接口文档

http://localhost:5222/scalar/v2#tag/items

image-20251012172831063

20.完结

商品服务(Catalog.API)总算学完了,数据库、事件总线、MiniAPI等一些知识点要单独抽离出来,因为后面的购物车、订单等服务可能也复用了,在遇到就不看这么细了,大致在过一下就可以了。

📌 创作不易,感谢支持!

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

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

微信打赏

posted @ 2025-12-06 00:26  peng_boke  阅读(94)  评论(0)    收藏  举报