Sparkdo.Messaging.Abstractions
3.0.0-preview.1
dotnet add package Sparkdo.Messaging.Abstractions --version 3.0.0-preview.1
NuGet\Install-Package Sparkdo.Messaging.Abstractions -Version 3.0.0-preview.1
<PackageReference Include="Sparkdo.Messaging.Abstractions" Version="3.0.0-preview.1" />
<PackageVersion Include="Sparkdo.Messaging.Abstractions" Version="3.0.0-preview.1" />
<PackageReference Include="Sparkdo.Messaging.Abstractions" />
paket add Sparkdo.Messaging.Abstractions --version 3.0.0-preview.1
#r "nuget: Sparkdo.Messaging.Abstractions, 3.0.0-preview.1"
#:package Sparkdo.Messaging.Abstractions@3.0.0-preview.1
#addin nuget:?package=Sparkdo.Messaging.Abstractions&version=3.0.0-preview.1&prerelease
#tool nuget:?package=Sparkdo.Messaging.Abstractions&version=3.0.0-preview.1&prerelease
Sparkdo.Messaging.Abstractions
跨程序集边界的那一面:消息与身份怎么表示、处理器与总线长什么样、传输要实现哪三个端口、 载荷与消息头在谁的 ownership 之下。零依赖、零实现、零 DI——只引这个包不会被拖进内核。
NuGet 页上这一份就是包内 README;它与仓库里的是同一个文件,所以里面没有相对链接,
路径一律写成行内代码,需要跳转请回仓库 src/messaging。
| 项 | 值 |
|---|---|
| 包 | Sparkdo.Messaging.Abstractions |
| 交付框架 | net11.0(本域只有这一个框架) |
| 外部 NuGet 依赖 | 无 |
| AOT / 裁剪 | 兼容 |
| 公开面基线 | 由 Sparkdo.Messaging.PublicApi.Tests 的 approval 文件钉住 |
<PackageReference Include="Sparkdo.Messaging.Abstractions" Version="1.0.0" />
什么时候只引这个包
| 你是谁 | 引它的理由 |
|---|---|
| 发出一条消息的应用层/领域层 | 只要 IPublisher / ISender / IRequester,不该知道有几个传输 |
| 写传输的第三方 | ITransportPort + IInboundPort + IOutboundPort + IDeliveryLease 是全部要求 |
| 写序列化适配 | IMessageSerializer / IMessageSerializerRegistry / MessageContentType |
| 共享消息类型契约的类库 | 消息类型是 POCO,不需要引内核就能定义与发送 |
| 写测试夹具 | PayloadLease / MessageHeaders 与结算结论都是这里的 |
处理器本身(IEventHandler<T>)也在这个包:一个只声明处理器、不接总线的库同样只需要它。
1. 定义一个消息:POCO 就够
不需要基类、接口或属性。内核按 CLR 类型找路由,线身份由命名策略算出来:
public sealed record OrderPlaced(string OrderId, decimal Amount);
默认身份(DefaultNamePolicy,实现在内核包里)是 urn:message:{命名空间 kebab}:{类型名 kebab}:
| CLR 类型 | 默认线身份 |
|---|---|
Sparkdo.Orders.OrderPlaced |
urn:message:sparkdo.orders:order-placed |
Sparkdo.Orders.HTTPRequest |
urn:message:sparkdo.orders:http-request(连续大写串的最后一个才是词边界) |
Sparkdo.Orders.Order2Placed |
urn:message:sparkdo.orders:order2-placed(数字不切词) |
| 无命名空间的全局类型 | urn:message:global:... |
身份是线协议的一部分:队列名、话题、幂等键都从它派生。改了类型的命名空间或名字, 就等于换了一条队列——上一版攒下的消息没人认领。两种写法把它钉死:
[Message(Identity = "urn:message:sparkdo.orders:order-placed")]
public sealed record OrderPlaced(string OrderId, decimal Amount);
// 或在建图时覆盖(优先级更高):
builder.AddMessage<OrderPlaced>("urn:message:sparkdo.orders:order-placed");
身份来源的优先级:AddMessage<T>(identity) > [Message(Identity = …)] > 命名策略。
[Message] 三个属性
| 属性 | 作用 | 不写会怎样 |
|---|---|---|
Identity |
覆盖约定的线身份 | 走命名策略 |
ContentType |
这条消息默认用哪种载荷读写器(MIME 串,按名解析) | application/json |
Internal |
只是声明:进 graph.Describe() 的输出,今天没有任何路由规则读它 |
false |
[Message] 整体是可选的。ContentType 接受任意 MIME 串(不在 Json/Xml/Protobuf 三个驻留
实例里也照样成立),但建图期必须按它找到登记过的读写器,否则抛"没有可用的读写器"——不会静默
退回 JSON。SendOptions.ContentType 同理:它只改信封上的标签,不改写 bytes 的那个读写器。
两处默认命名会撞的形状
MessageIdentity 只用 Type.Name,不用 FullName:
| 形状 | 后果 | 做法 |
|---|---|---|
| 嵌套消息类型 | Billing.Outer.Inner 与 Shipping.Other.Inner 的身份都是 …:inner |
各给显式 Identity |
| 泛型消息类型 | Type.Name 带元数,身份里会出现 `1 |
用闭合的具体类型各发一条消息,或给显式 Identity |
(订阅者名不受这条影响:它走 FullName,闭合泛型的实参用 + 拼接并剥掉程序集版本,
所以队列名不会跟着版本号变。)
2. 值类型:身份、地址、标识
| 类型 | 形状 | 用法要点 |
|---|---|---|
MessageIdentity |
readonly struct,包一个 string Value |
线协议里的"说什么",进地址、进队列名 |
MessageId |
readonly struct,包一个 Guid |
MessageId.New() 生成 v7 GUID(时间有序、索引友好);ToString() 是 "D" 格式 |
MessageContentType |
sealed class,值比较 |
Json / Xml / Protobuf 三个已知值是驻留实例,Parse 把同样的字符串收进同一个实例,热路径比较退化成引用相等 |
EndpointAddress |
readonly struct,包一个绝对 URI 字符串 |
见下:构造函数不校验,Parse 才校验 |
var byParse = EndpointAddress.Parse("memory://topic/urn:message:sparkdo.orders:order-placed");
new EndpointAddress("不合法也收"); // 收下了——构造只是赋值
EndpointAddress.Parse("orders"); // ArgumentException:端点地址必须是绝对 URI,收到 'orders'。
byParse.Scheme; // "memory"("://" 之前那段;没有分隔符时是整个值)
Parse 只保证「绝对 URI」这一件事。四段文法不在 Parse 里校验——它在
MessagingAddresses(核心侧读回)与各传输自己的分解里。所以 memory://exchange/x 能
Parse 成功,之后要么被传输当场拒绝(Postgres 抛错并写出三种正确形态),要么被当成
认不出的地址退回按身份扇出(InMemory)。地址自己拼的时候,用 MessagingAddresses。
3. 地址文法:MessagingAddresses
四种形态,四个传输共用同一套({scheme} 由传输自己给:memory / rabbitmq / postgres):
| 形态 | 段 | 含义 |
|---|---|---|
{scheme}://topic/{消息身份} |
PublishSegment = "topic" |
发布:说什么。扇出给该身份的每一个订阅者 |
{scheme}://in/{消息身份}/{订阅者} |
SubscriptionSegment = "in" |
订阅:谁在听什么 |
{scheme}://temp/{消息身份}/{订阅者} |
TemporarySubscriptionSegment = "temp" |
听一次就散:登记随放手清掉。回投用它 |
{scheme}://dead/{消息身份}/{订阅者} |
DeadLetterSegment = "dead" |
死信读回。按线归档:订阅者段照收但不参与路由 |
EndpointAddress publish = MessagingAddresses.Publish("rabbitmq", identity);
EndpointAddress sub = MessagingAddresses.Subscription("rabbitmq", identity, "sparkdo.orders.order-placed-handler");
EndpointAddress dead = MessagingAddresses.DeadLetter("rabbitmq", identity, "sparkdo.orders.order-placed-handler");
// 反解:只认上面四种形态,认不出就 false,不猜
MessagingAddresses.TryReadIdentity(sub, out MessageIdentity back); // true,back == identity
// 热路径只判「是不是这个身份的发布形」:命中直接给身份,不切新串
MessagingAddresses.IsPublishAddressOf(publish, identity); // true
构造那四个方法时 scheme、subscriber 为空即抛(发布地址没有订阅者段)。
TryReadIdentity 拒绝的形状:不认识的第一段、发布地址尾巴里多出的 /、空身份、订阅者段之后还有一段。
IsPublishAddressOf 是热路径版本:只认发布形,形状与期望身份都对得上时不切新串。
订阅者为什么必须进地址:一条 broker 队列只能代表一个订阅者。队列名若只由身份决定, 三个订阅者就变成"三个消费者抢一条队列",每家只拿到三分之一——而"发布抵达每一个订阅者" 是核心的派发语义。反过来,"重启不丢"也靠它:同名重起才找得回自己那条队列。
4. 处理器契约
public interface IEventHandler<in TEvent> where TEvent : notnull
{
ValueTask HandleAsync(TEvent message, CancellationToken cancellationToken);
}
| 点 | 说法 |
|---|---|
返回 ValueTask |
别处 await 一次即可;不要把它 AsTask() 或存起来重复消费 |
| 上下文从哪来 | 接口不带上下文。要拿信封/载荷/工作单元,用内核那侧的 ReceiveContextAccessor(默认 Scoped;单例处理器要读它,宿主须把访问器注册成单例)或换 AddScopedHandler |
| 抛异常 | 就是"这条没成"——交给重试策略判走向。不要吞掉异常后正常返回:那等于 ack 掉一条没做完的投递 |
| 取消 | cancellationToken 必须真响应。宿主停机时在飞投递靠它收手,超时就是停机拖到 DrainTimeout |
| 幂等 | 至少一次投递是传输的语义,去重靠内核的 Inbox 中间件,不靠处理器自觉 |
请求/响应另有一对:
public interface IEventRequest<TResponse> : IEventRequest { } // 请求消息自己声明返回什么
public interface IEventRequestHandler<in TRequest, TResponse>
where TRequest : IEventRequest<TResponse>
{
ValueTask<TResponse> HandleAsync(TRequest request, CancellationToken cancellationToken);
}
只要"收到就处理、不用回"的请求处理器还有单泛型版 IEventRequestHandler<in TRequest>。
返回的响应会自动回投给请求方——前提是这条投递的信封里有回投地址;当事件投进来(没有
Response)时,返回值被丢弃,处理器照样跑完。
5. 总线契约:发布、发送、请求
public interface IMessageBus : IPublisher, IRequester, ISender { }
ValueTask PublishAsync<TMessage>(in TMessage message, in PublishOptions options, CancellationToken ct);
ValueTask SendAsync<TMessage>(in TMessage message, in SendOptions options, CancellationToken ct);
ValueTask<TResponse> RequestAsync<TRequest, TResponse>(in TRequest request, in SendOptions options, CancellationToken ct)
where TRequest : notnull, IEventRequest<TResponse>;
发布与发送走的是同一条派发路,PublishAsync 只是把 PublishOptions 折算成 SendOptions
再调同一个终端。差别只在能填哪些格子:PublishOptions 没有 Endpoint、Response、
MessageId、RequestTimeout。
| 想要的效果 | 写法 |
|---|---|
| 扇出给所有订阅者 | PublishAsync(msg, PublishOptions.Default, ct)——目的地是发布地址,扇出由传输按身份展开 |
| 只投给某一条订阅队列 | SendAsync(msg, new SendOptions { Endpoint = "memory://in/{身份}/{订阅者}" }, ct) |
| 直接投到某个队列(点对点) | 同上:Endpoint 指向那个 in 地址。Endpoint 是唯一的路由旋钮,它改的是 Envelope.Destination |
| 拿到响应 | RequestAsync<Req, Resp>(req, new SendOptions { RequestTimeout = … }, ct) |
不设 Endpoint 时,SendAsync 与 PublishAsync 落到的目的地是同一个发布地址——
"发送=单点投递"这件事要靠显式 Endpoint 才成立,不是默认行为。
SendOptions / PublishOptions 逐格
两个都是 readonly struct,属性全 init、默认 null,Default 就是零值。零值不是"设成 null",
而是"这一格让内核自己决定"。
| 属性 | 只有 Send 有 | 效果 |
|---|---|---|
Identity |
覆盖 Envelope.MessageType(改身份=改路由,路由按登记过的身份查,给错身份当场 fail closed) |
|
ContentType |
覆盖信封上的内容类型标签。换不了写 bytes 的那个读写器——它在建图期就随消息绑定好了 | |
MessageId |
✓ | 指定标识;不给就 MessageId.New() |
Endpoint |
✓ | 目的地字符串,EndpointAddress.Parse 之后写进 Envelope.Destination |
Response |
✓ | 回投地址。RequestAsync 自己会盖掉它(见下),手写才用得到 |
RequestTimeout |
✓ | 只被 RequestAsync 读;缺省 30 秒 |
Headers |
自定义消息头,见 §8 | |
ExpirationTime |
设了就抛:SendOptions.ExpirationTime 还没落地(信封没有承载过期时刻的字段)。修法:暂时别设它。 |
ExpirationTime 拒得掉是刻意的:属性在但设了不生效,比根本没有这个属性难查得多。
请求方拿到的两种失败,是两件事
| 情形 | 你拿到 | 说什么 |
|---|---|---|
| 没人回 | InvalidOperationException,文案带着 IEventRequestHandler 与 RequestTimeout 两个词 |
"没人回"。超时不重投,等的一方自己决定下一步 |
| 处理器终局失败 | MessagingFaultException(FaultedMessageId、FailureType) |
"回了,但回的是失败"。原因句子在 Message 里,没有独立的 Detail 属性 |
RequestAsync 会覆写 SendOptions.MessageId 与 Response:它自己生成关联标识、自己开回投订阅。
这两格在请求路径上不是调用方可控的输入。
6. Envelope:线上传的那 11 格
Envelope 是 readonly struct,全部 init。 typed 路径上由内核填写,调用方只在原样发布与
发件箱入队两处自己拼:
| 属性 | 谁写 | 备注 |
|---|---|---|
MessageId |
内核(或 SendOptions.MessageId) |
一条消息的身份证 |
MessageType |
内核(身份) | 路由键:graph.GetRoute(identity) |
ContentType |
内核 | 决定接收侧用哪个读写器解 |
Source / Destination |
内核 | 目的地是 EndpointAddress;Source 是发出端点地址 |
Response |
RequestAsync |
没有它这条投递就不是请求 |
CorrelationId |
回投侧 | 响应指向它请求的 MessageId;共享回投订阅按它认回等待方 |
Headers |
MessageHeaders.Encode |
ReadOnlyMemory<byte>;链路上下文与自定义头都在这一格里 |
DeliveryCount |
传输 | 第几次投递(int?)。重试上限的跨进程依据;进程内计数是兜底 |
7. 传输端口:写一个传输要满足什么
public interface ITransportPort : IAsyncDisposable
{
string Scheme { get; }
TransportCapability Capabilities { get; }
ValueTask InitializeAsync(TransportContext context, CancellationToken ct); // 恰好一次
ValueTask StartAsync(CancellationToken ct); // 幂等
ValueTask StopAsync(CancellationToken ct); // 幂等;停后拒收发
ValueTask<IOutboundPort> OpenOutboundAsync(EndpointAddress address, CancellationToken ct);
ValueTask<IInboundPort> OpenInboundAsync(EndpointAddress address, CancellationToken ct);
}
public interface IOutboundPort
{
ValueTask<SendReceipt> SendAsync(Envelope envelope, PayloadLease payload, CancellationToken ct);
}
public interface IInboundPort : IAsyncDisposable
{
IAsyncEnumerable<IDeliveryLease> ReceiveAsync(CancellationToken ct);
ValueTask DisposeAsync(); // 默认空实现:不放手也是合法的
}
public interface IDeliveryLease
{
Envelope Envelope { get; }
PayloadLease Payload { get; }
ValueTask SettleAsync(DeliveryOutcome outcome, CancellationToken ct); // 恰好一次
}
核心的调用顺序(写传输时要按这个前提设计资源):
| 步 | 调用 | 由谁触发 |
|---|---|---|
| 1 | InitializeAsync(new TransportContext(services), ct) |
建图,每个登记过的传输都调(不只默认那条) |
| 2 | StartAsync(ct) |
紧随其后 |
| 3 | OpenOutboundAsync(地址) |
每个去重后的地址一条出站端口 |
| 4 | OpenInboundAsync(地址) |
每个接收端点一条;回投另开一条 temp |
| 5 | StopAsync(ct) |
停机(宿主用 CancellationToken.None,不用停机令牌) |
| 6 | DisposeAsync() |
核心不调——传输实例的生命周期在登记它的人手里,谁 new 谁负责 |
三条容易踩空的口径:
| 口径 | 后果 |
|---|---|
ITransportPort.DisposeAsync 没有核心调用者 |
靠 StopAsync 收尾资源,别把它写在 DisposeAsync 里等谁来调 |
IInboundPort.DisposeAsync(放手)只有回投那条被调用 |
接收端点只散枚举器,不散端口。实现了它就要在契约测试里自证 released-port-leaves-no-trace |
TransportContext 目前只有 ServiceProvider 一格 |
三个传输都只用它取 ILoggerFactory。别假设还有别的东西可取 |
能力声明:声明什么就要被问责什么
[Flags] None = 0; Durable = 1; NativeSchedule = 4; Backpressure = 64;
| 位 | 谁读它 | 声明的含义 |
|---|---|---|
Durable |
契约测试基座(durable-survives-restart) |
旧实例整个释放后,未结算的投递新实例还取得到 |
Backpressure |
契约测试基座(backpressure-blocks-when-full) |
无人消费时写入最终会阻塞 |
NativeSchedule |
核心建图:调度解析挑声明它的传输;挑不着走 IMessageScheduler;声明了却没端口时 fail closed 点名传输 |
传输自己有到点投递的原语 |
位值 2 / 8 / 16 / 32 已删除(Ordered、Session、NativeDedup、Batch 都因为没有读者),
且位值不复用。加一位的前提是有一位读者,None 是合法声明。
结算:DeliveryOutcome
| 值 | 传输要做的事 |
|---|---|
Ack |
这条结束了,从队列消失 |
Retry |
放回队列并递增投递次数(不是结束:重投计数不能清) |
DeadLetter |
挪进死信那条线,且必须还读得回来——死信是去处,不是删除 |
| 新结论 | 认不得的结论一律当场抛。加一个值要先加进这个枚举,再落进每个传输 |
SettleAsync 恰好一次:同一租约第二次结算必须失败(基座 settlement-exactly-once 逐传输问责)。
8. MessageHeaders:线上那格字节
信封只有一个头槽位,格式与值域在这里闭合。
偏移 内容
0 版本 = 1(1 字节,FormatVersion)
1 条目数(int32,小端)
5 每条:键(int32 长度 + UTF-8 字节)+ 值类型标签(1 字节)+ 值
| 项 | 值 |
|---|---|
| 值域(闭合) | string、int、long、bool、byte[]、null;别的类型 NotSupportedException |
| 键上限 | 1 KiB(按 UTF-8 字节计) |
| 值上限 | 64 KiB |
| 框架保留键 | traceparent、tracestate、sparkdo.fault、sparkdo.fault.message |
| 给出保留键 | NotSupportedException:消息头的键 '…' 是框架保留的。修法:换一个自己的键——链路上下文与失败回投由框架写,它写的那一份才是读侧认的那一份。 |
// 写(调用方只能填自己的键;框架那四格是 Encode 的位置参数,由内核写)
var bytes = MessageHeaders.Encode(new Dictionary<string, object?> { ["tenant"] = "t1", ["attempt"] = 2 });
// 读:整本
Dictionary<string, object?> all = MessageHeaders.Decode(envelope.Headers.Span);
// 读:单键,不建字典,也不抛(版本不认识/截断/不是字符串/没这个键 → false)
bool has = MessageHeaders.TryGetString(envelope.Headers.Span, "tenant", out string? tenant);
三个必须知道的语义:
| 语义 | 说明 |
|---|---|
int 回来是 long |
写入按 8 字节小端存,解出来统一是 long。断言与取值都按 long 写 |
| 读侧解码是调用方的事 | 内核不替你解:"什么头有意义" 是业务侧决定。要 W3C 链路就 TryGetString(..., "traceparent", out _) |
Decode 严格、TryGetString 宽容 |
版本不认识、未知标签、截断、负长度一律抛——静默跳过坏条目等于伪造头 |
搬运由基座用例 headers-survive-the-round-trip 逐传输问责(含 null 值条目与键的大小写)。
RabbitMQ 把这段字节放进 AMQP 头 x-sparkdo-headers,Postgres 存成一列。
9. 载荷:PayloadBuffer 与 PayloadLease
typed 路径上你几乎不该碰这两个类型——发布、请求、调度、Saga 都在内核内部租借与归还。
需要碰的只有两种人:写 IMessageSerializer 的(拿到 IBufferWriter<byte>)和写传输的。
| 类型 | 规则 |
|---|---|
PayloadBuffer |
Rent() 是每线程一个的池化缓冲;Advance(n) 越界当场抛并写出已写多少;Dispose() 归还数组,只有第一次归还有效;ToLease() 零拷贝交接——交接之后 Dispose() 是空操作 |
PayloadLease |
Rent(span) 复制进池化数组;Memory 已按请求长度裁齐(不是数组长度);空载荷不占缓冲,Dispose() 不碰池;Dispose() 幂等(先清引用再归还) |
所有权转移点在 IOutboundPort.SendAsync:交接之后归传输/接收侧归还。释放之后 Memory 仍可读,
但内容不再有保证——这不是一个可依赖的状态,别把它当"缓冲区还留在原地"。
PayloadBuffer 自己实现 IBufferWriter<byte>,所以序列化器写它就行:
var buffer = PayloadBuffer.Rent();
serializer.Write(buffer, message); // 写进 IBufferWriter
var lease = buffer.ToLease(); // 交接,不复制
10. 序列化契约
public interface IMessageSerializer
{
MessageContentType ContentType { get; }
void Write<TMessage>(IBufferWriter<byte> writer, TMessage message);
TMessage Deserialize<TMessage>(ReadOnlySpan<byte> payload);
}
public interface IMessageSerializerRegistry
{
IMessageSerializer? Find(MessageContentType contentType); // 找不到返回 null,不抛
}
| 点 | 口径 |
|---|---|
| 写侧 | 拿到的是 IBufferWriter<byte>——直接 GetSpan/Advance,不要先 ToArray() 再写 |
| 读侧 | 给的是 ReadOnlySpan<byte>,出了这个方法就别留引用 |
| 一条内容类型一个读写器 | 同一内容类型登记两次,内核那个注册表当场抛("两处登记等于让同一种载荷有两种解法") |
| 找不到读写器 | 建图期抛并给出修法:调用 AddSerializer(contentType, serializer),或把消息默认内容类型改成已注册的那种 |
| 零拷贝失败也没关系 | Write 内部允许缓冲;PayloadBuffer 就是那个缓冲池 |
IMessageSerializerRegistry 的实现不在这个包(内核包里有 MessageSerializerRegistry,
JSON 桥在 Sparkdo.Messaging.Serialization.Json)。这里只给契约,就是为了上面那张表不因实现变化而改。
11. 故障与结论:SendReceipt / TransportFault
public readonly record struct SendReceipt(bool Accepted, TransportFault? Fault);
public readonly record struct TransportFault(TransportFaultKind Kind, bool Retryable);
public enum TransportFaultKind { Transient = 0, Permanent = 1, Canceled = 2 }
| 你是 | 看到的是 |
|---|---|
| 传输作者 | 返回 SendReceipt。不能受理就 (false, TransportFault(...)),不要自己抛——故障分类是重试策略的输入 |
| 调用方 | 永远看不到 receipt:内核把"未受理"折成 InvalidOperationException(带方案名、目的地与 Fault?.Kind),可重试的那一类应由重试策略承接而不是在这里吞 |
Accepted = true 不等于"有人收到":无人订阅的发布是受理的(基座 publish-without-subscriber-accepted)。
broker 档要先订阅、后发布。
12. 常见误用一览
| 误用 | 会发生什么 |
|---|---|
改消息类型的命名空间或名字而不钉 Identity |
换了一条队列,旧消息没人认领 |
用 EndpointAddress 构造函数拼业务地址 |
不校验,坏地址要到传输里才炸(或直接被当成"认不出"退回按身份扇出) |
给 Headers 里塞 traceparent |
NotSupportedException——那格归框架写 |
给 Headers 的值放自定义对象/枚举 |
不在闭合值域里就抛。枚举先 .ToString(),数字用 int/long |
以为 SendAsync 天然单点投递 |
不设 Endpoint 时它与 PublishAsync 落到同一个发布地址 |
处理器里 catch { } 后正常返回 |
一条没做完的投递被 ack 掉。规格明禁"吞异常后 ack" |
SettleAsync 调两次 |
第二次失败——这是契约,不是实现 bug |
释放 PayloadLease 后继续读 Memory |
读到的是不保证的内容 |
这包刻意没有的东西
没有 DI 扩展、没有默认实现、没有 IMessageBus 的任何实现、没有 builder、没有配置对象。
它的全部价值是:这个包里的类型是线协议的一部分,改它要走公开面基线(approval 文件)
与逐传输的契约测试,不能顺手改。
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net11.0 is compatible. |
-
net11.0
- No dependencies.
NuGet packages (2)
Showing the top 2 NuGet packages that depend on Sparkdo.Messaging.Abstractions:
| Package | Downloads |
|---|---|
|
Sparkdo.Messaging
Sparkdo Messaging 内核:冻结图、派发与接收管道、命名、端点运行时。 |
|
|
Sparkdo.Messaging.Serialization.Json
Sparkdo Messaging 的 JSON 载荷读写器:把 Sparkdo.Json 的门面适配成消息总线的内容类型接缝。 |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 3.0.0-preview.1 | 110 | 10/30/2026 |