Sparkdo.Messaging.Abstractions 3.0.0-preview.1

This is a prerelease version of Sparkdo.Messaging.Abstractions.
dotnet add package Sparkdo.Messaging.Abstractions --version 3.0.0-preview.1
                    
NuGet\Install-Package Sparkdo.Messaging.Abstractions -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.Abstractions" 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.Abstractions" Version="3.0.0-preview.1" />
                    
Directory.Packages.props
<PackageReference Include="Sparkdo.Messaging.Abstractions" />
                    
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.Abstractions --version 3.0.0-preview.1
                    
#r "nuget: Sparkdo.Messaging.Abstractions, 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.Abstractions@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.Abstractions&version=3.0.0-preview.1&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Sparkdo.Messaging.Abstractions&version=3.0.0-preview.1&prerelease
                    
Install as a Cake Tool

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