Sparkdo.Messaging
3.0.0-preview.1
dotnet add package Sparkdo.Messaging --version 3.0.0-preview.1
NuGet\Install-Package Sparkdo.Messaging -Version 3.0.0-preview.1
<PackageReference Include="Sparkdo.Messaging" Version="3.0.0-preview.1" />
<PackageVersion Include="Sparkdo.Messaging" Version="3.0.0-preview.1" />
<PackageReference Include="Sparkdo.Messaging" />
paket add Sparkdo.Messaging --version 3.0.0-preview.1
#r "nuget: Sparkdo.Messaging, 3.0.0-preview.1"
#:package Sparkdo.Messaging@3.0.0-preview.1
#addin nuget:?package=Sparkdo.Messaging&version=3.0.0-preview.1&prerelease
#tool nuget:?package=Sparkdo.Messaging&version=3.0.0-preview.1&prerelease
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 | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net11.0 is compatible. |
-
net11.0
- Microsoft.Extensions.ObjectPool (>= 10.0.8)
- Sparkdo.Messaging.Abstractions (>= 3.0.0-preview.1)
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 |