Sparkdo.Messaging 3.0.0-preview.1

This is a prerelease version of Sparkdo.Messaging.
dotnet add package Sparkdo.Messaging --version 3.0.0-preview.1
                    
NuGet\Install-Package Sparkdo.Messaging -Version 3.0.0-preview.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Sparkdo.Messaging" Version="3.0.0-preview.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Sparkdo.Messaging" Version="3.0.0-preview.1" />
                    
Directory.Packages.props
<PackageReference Include="Sparkdo.Messaging" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Sparkdo.Messaging --version 3.0.0-preview.1
                    
#r "nuget: Sparkdo.Messaging, 3.0.0-preview.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Sparkdo.Messaging@3.0.0-preview.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Sparkdo.Messaging&version=3.0.0-preview.1&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Sparkdo.Messaging&version=3.0.0-preview.1&prerelease
                    
Install as a Cake Tool

Sparkdo.Messaging

内核:注册面(IMessagingBuilder)、编译一次就不再变的冻结图、派发与接收管道、 六条扩展接缝、Saga、请求回投、端点运行时。Roslyn 生成器作为分析器资产随本包发出—— PackageReference 装上就生效,不需要任何挂接。

项 值
包 Sparkdo.Messaging
交付框架 net11.0
外部 NuGet 依赖 Microsoft.Extensions.ObjectPool
项目依赖 Sparkdo.Messaging.Abstractions
内嵌分析器 Sparkdo.Messaging.Generation(analyzers/dotnet/cs,不单独发包)
AOT / 裁剪 兼容(Sparkdo.Messaging.AotSmoke 是发布门禁,真发布真运行两条腿)
buildTransitive 资产 不发(本生成器一个 MSBuild 属性都不读)

1. 五分钟跑通

三件事:定义消息与处理器、接线、起消费。

// ① 消息:POCO 就够,不需要基类或属性
public sealed record OrderPlaced(string OrderId);

// ② 处理器:实现一个接口
public sealed class OrderPlacedHandler(ILogger<OrderPlacedHandler> log) : IEventHandler<OrderPlaced>
{
    public ValueTask HandleAsync(OrderPlaced message, CancellationToken cancellationToken)
    {
        log.LogInformation("收到 {OrderId}", message.OrderId);
        return ValueTask.CompletedTask;
    }
}
// ③ 接线(Web 宿主;控制台把 Host 那段换成同样的 provider 操作)
var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddSingleton<OrderPlacedHandler>()                        // 处理器必须在容器里
    .AddSparkdoMessaging(
        new MessageSerializerRegistry().Add(new JsonMessageSerializer(json)),
        bus => bus.AddTransport(new InMemoryTransport(), TransportDefault.Default))
    .AddSparkdoMessagingHosting();                             // 起消费、停机收尾(见 Hosting 包)

var app = builder.Build();
await app.StartAsync();

var bus = app.Services.GetRequiredService<IMessageBus>();
await bus.PublishAsync(new OrderPlaced("A-1"), PublishOptions.Default, ct);

没有 AddHandler。 生成器在编译期发现 OrderPlacedHandler,把 AddHandler<OrderPlacedHandler, OrderPlaced>("程序集名") 写进 [ModuleInitializer], AddSparkdoMessaging 建构建器时回放这批登记。要显式登记也完全可以(下一节),两条路能混用。

不想引宿主包时,自己起循环(这段就是 Sparkdo.Messaging.Hosting 里那段):

var graph = await provider.BuildMessagingAsync(ct);        // 建图:打开端口、编译管道

using var consumers = new CancellationTokenSource();
var running = graph.ReceiveEndpoints
    .Select(endpoint => Task.Run(() => endpoint.RunAsync(consumers.Token).AsTask()))
    .ToArray();

var bus = graph.CreateBus(provider);
await bus.PublishAsync(new OrderPlaced("A-1"), PublishOptions.Default, ct);

await consumers.CancelAsync();
await Task.WhenAll(running);

2. 接线面:登记什么、什么时候能取

AddSparkdoMessaging(serializers, configure?) 只登记一个"怎么造构建器"的工厂—— 不在登记时造构建器,更不建图:造它要读容器里的命名策略,建图要开端口且是异步的。

容器里出现的服务 生命周期 说明
IMessagingBuilder Singleton(工厂) 第一次取时才回放模块 + 应用你的 configure
IMessageSerializerRegistry Singleton 就是你传进来的那个实例
ReceiveContextAccessor Scoped(默认) 处理器取"这条投递"的入口;单例处理器要读它,宿主须把它注册成单例(见 §3)
MessageGraphAccessor Singleton 图上三样东西的持有者(Bus / Graph / Publisher)
IMessageBus Singleton(工厂 → 持有者) 注入就行,不必自己 CreateBus
IRawPublisher Singleton(工厂 → 持有者) 原样发布(发件箱中继、到点发布用它)
IMessageScheduler Singleton(工厂 → 图上的调度面) TryAdd:宿主自己注册的调度实现优先

图由 provider.BuildMessagingAsync(ct) 建,只能挂一次:

var graph = await provider.BuildMessagingAsync(ct);   // 内部:builder.BuildAsync(provider, ct) + Attach
时机 取 accessor.Bus / .Graph / .Publisher
建图之前 InvalidOperationException:冻结图还没建。修法:宿主先调用 BuildMessagingAsync()
建图之后 每次取都是一个新的薄门面,共享同一张图(容器里是单例 ≠ 对象只有一个)
挂第二次 抛——"图晚于容器诞生"这件事只能有一个答案

模块回放的范围(必须知道)

MessagingModuleRegistry.Modules 是进程级的:凡是编译时带了这个分析器的程序集,它的处理器 都会进这批登记。所以:

后果 做法
宿主拿到的是"整个进程里所有已编译的处理器" 每个处理器的类型都要能在容器里解析到,否则建图期 处理器 '…' 没有在容器里登记
只想跑其中一部分 别让别的程序集被引用进来(模块面今天不能按名筛),或自己 SparkdoMessaging.CreateBuilder(...) 绕开回放
并行测试里互相看见对方的处理器 用例别走 AddSparkdoMessaging,直接 new MessagingBuilder(names, registry)——本域测试基座就是这么做的

3. IMessagingBuilder 逐方法

配置期对象,单次可变:BuildAsync 之后不得再改,且只能编译一次(失败的那次也算一次)。

方法 用什么场景 要点
AddHandler<THandler, TEvent>() 处理器无状态、依赖都是单例 实例在构建期解析一次并缓存进终端;热路径 0 额外分配
AddScopedHandler<THandler, TEvent>() 处理器要 scoped 依赖(DbContext、每投递的访问器) 终端从本次投递的作用域解析;只有这种形态建作用域(一条投递实测 360 B,守 450 B;单例形态不建作用域、0 B)
AddRequestHandler<THandler, TRequest, TResponse>() 请求进、响应出 返回的响应自动回投(信封有回投地址时);请求类型随登记自动进图,不用再 AddMessage
AddScopedRequestHandler<...>() 同上,但要 scoped 依赖 同上
AddMessage<TMessage>(identity?) 只有发布方、没有处理器的消息;或要钉死身份 identity 覆盖约定,优先级最高(AddMessage > [Message(Identity)] > 命名策略)
AddSerializer(contentType, serializer) 运行期按内容类型补读写器 后写的赢,且不查重;建图期它优先于注册表里那一份
AddTransport(transport, transportDefault?) 登记传输 不传第二参=候选人(整张图只有它一条时照样顶上);多于一条默认 → 当场抛
Use(DispatchMiddlewareConfiguration, before?, after?) 出站管道插一条 见 §5
Use(ReceiveMiddlewareConfiguration, before?, after?) 入站管道插一条 见 §5
AddSaga<TState>(definition) 长流程状态机 一条转移 = 一个接收端点,见 §7
RequireScheduler() 本图必须有调度面 建图期找不到实现就抛,而不是等第一条延迟消息才炸
UseScheduler<TScheduler>() 断言"图上用的调度实现就是它" 不注册任何东西(构建器诞生在容器之前);容器里注册的是别的类型 → 当场抛
BuildAsync(services, ct) 编译冻结图 一次;失败要重试就重新 CreateBuilder 并重新登记传输

处理器两种生命周期,选错的代价

你选 处理器实例 能注入什么 每条投递的开销
AddHandler 构建期解析一次,全进程复用 只能是单例依赖 不建作用域,0 B
AddScopedHandler 每条投递从该投递的作用域解析 scoped / transient 依赖、ReceiveContextAccessor、WorkUnitAccessor 每投递一个作用域(实测 360 B,守 450 B)

两种形态的差别只有这一处,所以必须在登记时选好——把它做成"运行时看着办", 就等于允许"0 分配"与"每消息新实例"互相撒谎。 IHandlerActivator 换的是从哪里取,不改什么时候取:那个决定写在登记方法里。

还有四件事与形态判定有关:

事项 说法
单例处理器读 ReceiveContextAccessor 接线之前注册成单例(services.AddSingleton<ReceiveContextAccessor>());默认的 TryAddScoped 不覆盖已有登记。建图期只认「根与作用域解析到同一实例」
单例形态的 IReceiveContext.Services 是根提供者:这条投递没有作用域
接了接收中间件或 saga 的图 即使处理器是单例,也按每投递建作用域(判据在 EndpointReceivePlan.Scope)
IConsumerLimiter.Concurrency > 1 单例访问器被所有投递共享,并发投递会互相覆盖 Current;要并发读上下文,用 scoped 形态
// 处理器要拿"这条投递"的信封/工作单元 → 必须走 scoped 形态
public sealed class SettleOrderHandler(ReceiveContextAccessor contexts, IMessageOutbox outbox)
    : IEventHandler<Settled>
{
    public async ValueTask HandleAsync(Settled message, CancellationToken ct)
    {
        var context = contexts.Current ?? throw new InvalidOperationException("这条投递不在接收路径上。");
        var unit = WorkUnitAccessor.Of(context) ?? throw new InvalidOperationException("没有当前工作单元。");

        await outbox.EnqueueAsync(unit, envelope, payload, ct);   // 与业务写同一笔提交
    }
}

4. 冻结图上能读什么

public sealed class MessageBusGraph
{
    IMessageBus CreateBus(IServiceProvider services);
    string Describe();
    IReadOnlyList<ITransportPort> Transports { get; }
    IReadOnlyList<EndpointPlan> Endpoints { get; }
    IReadOnlyList<EndpointReceivePlan> ReceivePlans { get; }
    IReadOnlyList<ReceiveEndpoint> ReceiveEndpoints { get; }
    IMessageScheduler? Scheduler { get; }
    IReadOnlyList<TopologyEntity> DescribeTopology();
    RoutePlan GetRoute(Type messageType);
    RoutePlan GetRoute(MessageIdentity identity);
    EndpointPlan GetEndpoint(EndpointAddress address);
    EndpointReceivePlan GetReceivePlan(Type consumerType, MessageIdentity subscription);
}
你要 用什么
发消息 CreateBus(provider),或直接从容器注入 IMessageBus
起消费 graph.ReceiveEndpoints → 每条一个 RunAsync(ct)(或交给宿主包)
排障/自检 Describe()——行序稳定是契约,同一份配置两次读出的字节一样,能进日志、能被 diff
运维核对 broker DescribeTopology()——各传输自报的 topic/queue/binding 合起来
断言配置结果(测试) GetRoute(typeof(OrderPlaced))、ReceivePlans.Count

Describe() 的形状(每段的排序口径:传输按方案名序数、拓扑保留各传输自报顺序、 routes 按消息身份、receive 按订阅地址):

sparkdo.messaging
  transport rabbitmq  capabilities Durable
  topology
    topic sparkdo.messaging
    queue urn:message:sparkdo.orders:order-placed.sparkdo.orders.order-placed-handler
    binding urn:message:sparkdo.orders:order-placed.sparkdo.orders.order-placed-handler  urn:message:sparkdo.orders:order-placed
  routes
    OrderPlaced urn:message:sparkdo.orders:order-placed application/json rabbitmq://topic/urn:message:sparkdo.orders:order-placed identity-chain
  receive
    OrderPlacedHandler urn:message:sparkdo.orders:order-placed rabbitmq://in/urn:message:sparkdo.orders:order-placed/sparkdo.orders.order-placed-handler  middlewares inbox, work-unit

不说积压:那是传输运行期的状态,不是图的知识;要问传输——传输实现可选的 ITransportBacklogProvider 自报积压(InMemory / RabbitMQ / Postgres 三个传输都已实现),Hosting 的健康检查按它逐个探。

5. 中间件:怎么写一条

两侧各一条管道,各一个配置类型,形状都是 (context, next) => 返回新的委托:

// 出站:能看见 Envelope / Payload(还没交出去),改头、改名、审计都在这
var audit = new DispatchMiddlewareConfiguration(
    "audit",
    static (_, next) => async context =>
    {
        Console.WriteLine($"dispatch {context.Envelope?.MessageType}");
        await next(context);
    });

builder.Use(audit);                       // 追加
builder.Use(audit, after: "tenant");      // 定位:before / after 只能给一个

// 入站:能看见这条投递,决定要不要往下走、要不要自己结算
var recordId = new ReceiveMiddlewareConfiguration(
    "record-id",
    (_, next) => context =>
    {
        delivered.TrySetResult(context.Envelope.MessageId);
        return next(context);
    });

builder.Use(recordId);
口径 说法
顺序 先登记的在外层。Use(workUnit) 再 Use(inbox) ⇒ 工作单元包住幂等判定
定位 before / after 指向已注册的键;锚点不存在 → 中间件锚点 '…' 不存在;两个都给 → ArgumentException
键 自己起,全局唯一即可。内核那两个常量:"inbox"、"work-unit"
端口怎么取 按消息取,不捕获实例。WorkUnitMiddleware.Create(services => ...) 收的是 Func<IServiceProvider, …>:EF 的实现是 scoped 的,捕获构建期那一个实例等于让所有投递共用同一个 DbContext——"同事务"会退化成"同一个上下文"
结算权 中间件可以 context.SettleAsync(...) 提前终结这条投递(幂等命中就是这么短路的)

特性集合:中间件与处理器之间的临时总线

// 上游中间件挂
context.Features.Set(new TenantFeature(header["tenant"]));

// 下游中间件或处理器取(同一个集合,不是快照)
var tenant = context.Features.Get<TenantFeature>();

IFeatureCollection 默认实现是懒建的 FeatureCollection;集合在上下文归还池之前清空, 所以上一条投递挂的东西不会留到下一条。要换集合形状就实现这个接口并替换(接缝表里那条"特性")。

约定(IConvention):把重复的登记写成一次

internal sealed class TenantConvention : IConvention
{
    public string Name => "tenant";
    public void Apply(IMessagingBuilder builder) => builder.Use(TenantMiddleware.Create());
}

services.AddSingleton<IConventionRegistry>(new ConventionRegistry(new TenantConvention()));

约定在 BuildAsync 里、编译图之前应用,顺序=注册顺序。数组里有 null 当场 ArgumentException("空约定在建图期会直接炸")。不注册 IConventionRegistry 就不跑任何约定。

6. 派发与接收的运行语义

出站

事 语义
路由 按身份链查 RoutePlan:CLR 类型 → 登记的线身份 → 端点。给未登记的身份发 → 线身份 '…' 没有登记
SendOptions.Endpoint 唯一的路由旋钮:改写 Envelope.Destination,出站端口仍是路由那条。目标地址是不是某个订阅队列,由传输按文法决定
扇出 不是总线做的。传输按身份展开:InMemory 每个订阅者一份载荷复制,Postgres 按当时订阅名单插行,RabbitMQ 靠绑定
无人订阅 发布被受理。broker 档要先订阅、后发布——publish-without-subscriber-accepted 说的是不被拒,不等于晚起的订阅收得到
未受理 传输返回 SendReceipt(false, …),内核折成 InvalidOperationException(带方案名、目的地与故障种类)。调用方不看 receipt
分配 typed 路径复用池化的 IDispatchContext;恒等链热路径 0 分配由"判据·"用例钉住(只在 -c Release 下跑,Debug 数字不可比)

入站

ReceiveEndpoint.RunAsync(ct) 是唯一入口,构造是内部的——端点由运行时按图装配,宿主不该自己 new (那会绕过图上的校验与端点缓存)。

事 语义
形状 IConsumerLimiter.Concurrency <= 1 时串行(一条投递一个任务都不建),大于 1 才并发:先拿名额再起任务,结算之后还名额
监督 入站端口一次异常不终结这条端点:记一次 FaultCount、写一条警告、按 1 / 2 / 5 / 15 / 30 s 阶梯退避后换一个枚举器重新取投。没有这层,一次 broker 抖动就换来"进程活着、队列在涨、日志只有最开始一条"
取消 ct 触发的 OperationCanceledException 是正常收尾,不算故障、不计数
结算 处理器返回 ⇒ Ack;抛异常 ⇒ 交给 IRetryPolicy 判(见下);结算恰好一次
失败判决 Retry → 放回队列(可带 Delay,就地等);DeadLetter / Discard → 都结算成 DeliveryOutcome.DeadLetter,差别只在是否再叫 IDeadLetterSink
计数来源 RetryState.Attempt = (Envelope.DeliveryCount ?? 0) + 1——跨进程的那一份由传输递增,不是进程内计数器
策略自己炸了 兜底按 Retry 处置,不让一条坏策略把端点打死
无策略 用默认的 ExponentialRetryPolicy(MaxAttempts=5、BaseDelay=1s、MaxDelay=1min、JitterRatio=0.25);换它是注册 IRetryPolicy
// 并发上限:每端点取一次。注册成 Transient 才各端点一份,单例=全端点共用同一个预算
services.AddSingleton<IConsumerLimiter>(new ConcurrencyConsumerLimiter(2));

死信:去处,不是删除

结算成 DeadLetter 的投递先落到那条线的死信地址,IDeadLetterSink 是额外处置:

public interface IDeadLetterSink
{
    ValueTask DeadLetterAsync(IDeliveryLease lease, Exception? failure, CancellationToken ct);
}
点 说法
不注册会怎样 消息照样留在死信那条线,只记一条警告("没有注册死信出口…")。不会丢
注册了会怎样 在终局结算之后被叫一次;它自己抛异常只留痕,不改这条投递的结论
怎么读回来 按文法开一条入站端口:{scheme}://dead/{消息身份}/{订阅者}。订阅者段照收但不参与路由——死信按线归档,谁来读都是竞争消费
三个传输各自怎么留 InMemory 停进一条进程内队列(有上限、满了丢最旧的并计数);RabbitMQ 经死信交换机路由进 {身份}.dead;Postgres 给那行打 dead_at 留在表里

7. Saga

三件,各管一件事:SagaDefinition<TState>(初始状态与转移)、SagaExecution(编排)、 ISagaStore(持久化 SPI,必须带状态版本戳与租约,否则接口不成立)。内核只认字节: 状态以 byte[] 进出,序列化是读写器的事,与发件箱同一条口径。

builder.AddSaga(new SagaDefinition<OrderState>("order-saga", OrderState.Empty)
    .On<OrderOpened>(openedIdentity, static e => e.OrderId, static (s, e) => s with { Id = e.OrderId })
    .On<OrderPaid>(paidIdentity,     static e => e.OrderId, static (s, e) => s with { Paid = e.Amount })
    .WhenCompleted(static s => s.Paid >= s.Total));   // 可选:不声明就永不自动清

services.AddSingleton<ISagaStore>(new InMemorySagaStore());   // 或 AddMessagingEntityFrameworkCore<AppDbContext>()
点 说法
一个 Saga 一个消费者? 不是——AddSaga 把每条转移登记成一个接收端点,订阅地址按状态类型的订阅者名算,所以两条转移是两个端点、两个地址
事件身份要一致 On<TEvent>(eventIdentity, …) 给的身份必须与图上登记的相同,否则 Saga 声明的 '…' 线身份是 '…',图上登记的却是 '…'
关联 correlate 是 Func<TEvent, string>:算出 saga 实例标识。运行期不走反射
三个结论 Applied(写成功)、Passed(不归它管,连库都不碰)、Contended(租约或版本被抢 → 终端翻成 SagaContentionException,按可重试结算)
租约 默认 SagaExecution<TState>.DefaultLeaseDuration = 30 秒;每次调用一个持有者令牌
完成判据 WhenCompleted(static state => …) 可选声明:状态满足即算走完,标记交给 ISagaStore.SaveAsync(lease, state, completed, ct);不声明就永不自动清
完成后又来了事件 判据随新状态重算:状态回到未完成,标记自动消失(复活);清理删掉之后再来的事件按新实例从头开始
自定义存储要记标记 四参重载的默认实现退回旧三参——不覆盖它就不记完成标记,因此永远不会被自动清(安全退化)
没有存储 处理事件时容器里没有 ISagaStore → 抛,文案直接给三条修法(内存档 / EF 档 / 自己实现)
不用的东西 没有生成器、没有描述符、没有基类;状态类型就是 CLR 类型

8. 请求与回投

public sealed record GetOrderStatus(string OrderId) : IEventRequest<OrderStatus>;

public sealed class GetOrderStatusHandler(...) : IEventRequestHandler<GetOrderStatus, OrderStatus>
{
    public async ValueTask<OrderStatus> HandleAsync(GetOrderStatus request, CancellationToken ct) => ...;
}

builder.AddRequestHandler<GetOrderStatusHandler, GetOrderStatus, OrderStatus>();

var status = await bus.RequestAsync<GetOrderStatus, OrderStatus>(
    new GetOrderStatus("A-1"), new SendOptions { RequestTimeout = TimeSpan.FromSeconds(5) }, ct);
事 语义
默认超时 30 秒(SendOptions.RequestTimeout 覆盖)。超时不重投
回投地址 RequestAsync 自己开、自己盖:{scheme}://topic/urn:message:sparkdo.messaging:reply + 一条 temp 订阅
订阅数量 一个传输一条进程内共享订阅,第一条请求时懒开,随传输停止一起散。20 条请求不会留 20 条队列
认回等待方 靠 Envelope.CorrelationId,不靠队列名
没人认领的响应 当场 ack 并计数,不重投、不进死信
处理器侧 返回的响应自动回投(信封有 Response 时);要控制回投时机(先回后写、多段回)就注入 ReceiveContextAccessor 拿 context 自己 ReplyAsync(...)
非请求也 ReplyAsync 抛:这条投递的信封里没有回投地址,它不是请求,回不了

失败回投:别让你干等到超时

处理器终局失败(策略判死信或丢弃)且结算真的落地之后,往 Envelope.Response 回投一条故障: 载荷为空,原因写在保留头 sparkdo.fault / sparkdo.fault.message 里,请求方当场拿到 MessagingFaultException(FaultedMessageId、FailureType;原因句子在 Message 里)。

点 说法
与超时的分工 超时说的是"没人回"(InvalidOperationException),这一条说的是"回了,但回的是失败"。两件事的重投判断正相反,所以是两种异常
只回一次 通知挂在终局结算之后,不挂在每次失败上;结算没落地(这条还会重投)就留给下一次
只对请求 信封没有 Response(事件与命令)就什么都不做,死信是它们的终点
失手不外溢 回投自己失败只留痕:那条投递已终结,没有结论会因它改变,更不能顺走整条端点
为什么不做成消息类型 那要每个消费方的 JSON 闭包都认得框架自己的类型,还要为它建路由

9. 扩展接缝

每条缝都是「契约 + 默认实现 + 替换路径」三者齐备,否则不保留。默认在核心,替换方式是从容器取: 宿主注册什么就用什么,取不到就用默认。

缝 契约 默认 换法
路由 IRoutePolicy TopicRoutePolicy(按地址文法造话题地址) 注册 IRoutePolicy
激活 IHandlerActivator ServiceProviderHandlerActivator(按类型从容器解析;取不到就报"没在容器里登记") 注册 IHandlerActivator
并发 IConsumerLimiter SequentialConsumerLimiter(一次一条) 注册 IConsumerLimiter(每端点取一次,注册成 Transient 才各一份)
特性 IFeatureCollection FeatureCollection(懒建) 直接 context.Features.Set(...);换集合是实现接口
约定 IConventionRegistry 无(不注册就不跑) 注册 IConventionRegistry
拓扑 ITopologyProvider 无(传输可选实现) 传输实现它;核心用 graph.DescribeTopology() 合起来
重试 IRetryPolicy ExponentialRetryPolicy 注册 IRetryPolicy(AddSparkdoMessagingReliability 就是这个)
死信 IDeadLetterSink 无(不注册只记警告,消息仍留在死信那条线) 注册 IDeadLetterSink。它是额外处置
命名 INamePolicy DefaultNamePolicy 注册 INamePolicy,或自己 SparkdoMessaging.CreateBuilder(names, serializers)
调度 IMessageScheduler / IScheduleBackend 无 注册实现(适配包 AddMessagingScheduling()),或 RequireScheduler() 逼建图期表态

AddRequestHandler 之类都返回 IMessagingBuilder,可链式;SparkdoMessaging.CreateBuilder(names, serializers) 是绕开 DI 的入口(测试与非 MS DI 宿主用)。

10. 观测

只用 System.Diagnostics:一个 ActivitySource + 一个 Meter,都在 MessagingObservability。 核心只发事件,采集是消费方的事——不引 OpenTelemetry 包,也不假定任何导出器。

面 名字 标签
span(派发) messaging.dispatch messaging.identity、messaging.message_id
span(接收) messaging.receive messaging.identity、messaging.message_id、messaging.handler、messaging.outcome
计数 messaging.dispatch.count 身份
计数 messaging.receive.count 身份、处理器
计数 messaging.receive.settled 身份、处理器、结论
直方图 messaging.receive.duration(毫秒) 身份、处理器、结论

名字都是常量(MessagingObservability.*、MessagingTags.*),别在采集侧写裸字符串。

// 没有监听者时这些名字一次都不产生:创建 Activity 前先问 HasListeners(),记指标前先问仪器 Enabled
builder.Services.AddOpenTelemetry().Tracing(t => t.AddSource("Sparkdo.Messaging"))
                                          .Metrics(m => m.AddMeter("Sparkdo.Messaging"));

链路跨进程接得上:派发时把当前 span 的 W3C 标识(traceparent,带 tracestate 就一并)写进 信封那格保留头;接收侧从字节里读出来当 span 之父——投递跑在接收循环自己的线程上, 本地 Activity.Current 里没有派发那条 span。两处都在 HasListeners() 之后:没人监听就一个字节都不多写。

情形 处置
头里没有链路上下文 就地开一条新 trace(ActivityKind.Consumer)
有,但读不出(别家写的、形状不对) 当没有:开新 trace,照常投递——观测不能决定收不收得到
一次请求里再发布(第二跳) 接收 span 就是新的父,整条链一条 trace

11. 失败与排查(逐字文案)

配置期的错全部在建图时一次报全,文案都带"修法"。下表是本域的全部失败面,出现频率从高到低:

你会看到 为什么 / 怎么办
处理器 'X' 没有在容器里登记。修法:在宿主里登记它(AddSingleton<X>() 或 AddScoped<X>()),或换一个自定义 IHandlerActivator 从别处取实例。 登记面(生成器或 AddHandler)只管"谁处理什么",实例仍归容器。生成器最容易撞这一条
没有登记任何传输。修法:调用 AddTransport(...) 至少登记一个。 configure 没跑到,或 AddTransport 被写进了另一个构建器实例
配置了多个默认传输:a, b。修法:只保留一个默认传输,其余显式指定地址。 两条 TransportDefault.Default。只留一条,其余靠 SendOptions.Endpoint
消息类型 'X' 没有登记。修法:让处理器签名引用它,或显式调用 AddMessage<T>()。 纯发布方要 AddMessage<T>()
消息身份冲突:'A' 与 'B' 都想用 'urn:...'。修法:给其中一个加 [Message(Identity = "...")] 指定不同的身份。 嵌套类型或重名类型的默认身份撞了(见 Abstractions §1)
消费方 'A' 与 'B' 算出了同一个订阅地址 '…'。修法:核对 INamePolicy.SubscriberName——… 两个消费方同名(不同命名空间也一样)。改名或换策略,自定义策略必须算出不同订阅者名
消费方 'X' 在订阅地址 '…' 上登记了两次。重复登记等于对同一条队列声明两次… 同一处理器登记了两遍。一个处理器一次;要分给多处就登记多个处理器
内容类型 '…' 没有可用的读写器。修法:调用 AddSerializer(contentType, serializer) 注册,或把消息的默认内容类型改成已注册的那一种。 声明了没人实现的 MIME 串
线身份 '…' 没有登记。修法:确认该消息类型已登记…,或核对身份是否被 [Message(Identity = "...")] 改过。 原样发布用了没登记的身份
端点 '…' 没有登记。修法:确认该地址的协议方案有对应传输,且该消息类型已登记。 GetEndpoint 查不到:方案名拼错,或那条传输没登记
中间件锚点 '…' 不存在。修法:把 before/after 改成已注册中间件的键,或去掉定位参数改为追加。 锚点是别的管道侧的键,或还没注册
before 与 after 只能给一个。 ArgumentException,两个定位参数只留一个
冻结图只能编译一次,失败的那次也算一次。修法:把 BuildAsync 放到配置的最后再调;失败后要重试就重新 CreateBuilder… 建图失败后别原地重试——传输的 InitializeAsync 按契约只跑一次
Saga 声明的 'X' 线身份是 'a',图上登记的却是 'b'。… AddSaga 里的 On<TEvent> 身份与 AddMessage<T> / 命名策略不一致
Saga 处理事件时容器里没有 ISagaStore。修法(任选一条):①… ②… ③… 三条都在文案里,照它选
有模块声明需要调度(RequireScheduler()),但图上没有任何调度实现。修法(三条任选一条):… 注册 IMessageScheduler / 传输实现原生调度 / 去掉 RequireScheduler()
传输 'x' 声明了原生调度(NativeSchedule),但还没有传输实现调度端口,用不了。… 声明了却没实现,今天确实没有这种传输——照修法走外部调度器
图上点的是调度实现 'A',容器里注册的却是 'B'。… UseScheduler<A>() 与容器注册不一致。只留一处
约定数组里有 null。 去掉这条或换成真实现
SendOptions.ExpirationTime 还没落地(信封没有承载过期时刻的字段)。修法:暂时别设它。 见 §12

运行期还会撞到两条与传输有关的:

你会看到 为什么
传输 'scheme' 拒绝了发往 'address' 的消息:Kind。修法:查看传输的错误日志;可重试的故障应由重试策略承接,而不是在这里吞掉。 出站端口返回未受理。停机后发、连接断了、库 unavailable 都是这一类
请求在 N 秒内没有拿到响应。修法:确认该请求有处理器(IEventRequestHandler<TRequest, TResponse> 已登记),或把 SendOptions.RequestTimeout 调大——超时不会重投,等待方自己决定下一步。 见 §8

12. 声明而非约束(公开面里今天不成立的几项)

逐条实测过才写在这里。"有 API 但走不通"与"没有 API"是两种缺口,分开列:

面 现状 缺的是什么
TransportCapability.NativeSchedule 核心有读者:调度解析挑声明它的传输,挑不着走 MessageScheduler;声明了却没端口时 fail-closed 指路。契约基座没有它的用例(这条缺席由 CapabilityCaseTests 钉着) ITransportPort 的调度 API。补它才有用例
SendOptions.ExpirationTime 给了就拒。信封上没有承载过期时刻的字段 加字段 + 三个传输各落一处;拒得掉是因为"设了不生效"比"没有这个属性"难查得多
MessageMetadata.IsInternal 只进 Describe() 输出。本域没有 host/service 标识,没有跨服务路由表 等跨服务边界落地,它才从声明变成规则
跨程序集 metadata(生成器写的 Sparkdo.Messaging.{Schema,Module,Handler}.v1) 只有写方:三条真写进产物、非空程序集都带,但"消费方读引用程序集清单"这一步还没做,schema 版本不受支持时的 fail-closed 也没做 编译期读引用程序集清单的那段分析器
多态 MessageMetadata.Enclosed 有值但没有多地址投递 真实多态消费方

TransportCapability.Batch、IBatchEventHandler、AddBatchHandler 已在 2026-10-16 摘掉:它的"批" 是一条数组消息的载荷,与传输批量收发无关,声明它也没有任何契约问责。真要原生批量,得先有 "成批取投 + 一批一次结算"的端口,连同一道能力档用例一起回来。

13. 出证与门禁(仓库侧)

门禁 判据
公开面 test/Sparkdo.Messaging.PublicApi.Tests/ApiBaselines/Sparkdo.Messaging.approved.txt 逐成员比对,只读源树那一份。更新:$env:SPARKDO_UPDATE_PUBLIC_API_BASELINES=1
AOT / 裁剪 test/Sparkdo.Messaging.AotSmoke 是发布门禁而不是样例:gate-messaging.ps1 -Tier Full 真发布两条腿、真运行产物、断言零 IL 告警
分配 四条"判据·"用例在 -c Release 下跑(Debug 自跳,跳过文案写着真正的跑法):dotnet test src/messaging/Sparkdo.Messaging.slnx -c Release
源码形状 一文件一类型、文件名=类型名、命名空间口径;门禁自带双向自检(能报、也放行合法改动)
生成器 快照默认严格:缺基线即失败。刷新要显式开 $env:SPARKDO_MESSAGING_SNAPSHOT_UPDATE
端到端 test/Sparkdo.Messaging.SmokeApp 刻意不调 AddHandler——处理器必须由生成器抵达,这是它存在的全部理由

设计口径在 src/messaging/docs/messaging-next-design.md;性能读数在 src/messaging/docs/perf-baselines.md; 容量与故障读数的跑法在 src/messaging/docs/baselines/。

不做什么

没有 DI 之外的容器集成、没有属性/约定扫描(除了生成器那一条编译期发现)、没有消息版本协商、 没有跨服务路由表、没有内建调度器(走适配包)、没有 OpenTelemetry 依赖、没有"自动拓扑清理"—— 队列与表都是部署物,消费者退出不该拆别人的信箱。

Product Compatible and additional computed target framework versions.
.NET net11.0 is compatible. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (10)

Showing the top 5 NuGet packages that depend on Sparkdo.Messaging:

Package Downloads
Sparkdo.Messaging.EntityFrameworkCore

Sparkdo Messaging 的 EF Core 持久化:Inbox 去重、Outbox 台账与工作单元同事务。

Sparkdo.Messaging.Visualization

Sparkdo Messaging 可视化契约:冻结图的快照投影、确定性 JSON、泳道布局与 SVG 输出。不含 ASP.NET 依赖。

Sparkdo.Messaging.Scheduling.Scheduler

Sparkdo Messaging 的调度适配:把 Sparkdo.Scheduler 接成消息的延迟与 Cron 投递。

Sparkdo.Messaging.Testing

Sparkdo Messaging 的传输契约测试基座:一组可复用的用例与运行器,任何传输实现跑过它才算出证。

Sparkdo.Messaging.Reliability

Sparkdo Messaging 可靠性设施的默认实现:指数退避重试、进程内 Inbox 与 Outbox。

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
3.0.0-preview.1 94 10/30/2026