Akka.NET 源生成 MessagePack 序列化:SerializerV2 的验证门禁与 Artery 前置能力
Akka.NET 源生成 MessagePack 序列化:SerializerV2 的验证门禁与 Artery 前置能力
本文以 Akka.NET 开源仓库中的 messagepack-sourcegen-validation 变更提案(proposal.md 及其配套的 design.md 与 tasks.md)为主线,完整讲解面向用户的源生成(source-generated)MessagePack 序列化方案:包括 [AkkaSerializable] / [AkkaField] 等属性体系、AkkaSerializer 基类、Roslyn 增量生成器、显式注册模型、模式演进规则、联合与封闭泛型、跨程序集支持,以及它在经典远程传输、持久化、Akka.Delivery 与 DistributedData 上的集成验证。读完本文,你将掌握如何在 Akka.NET 1.6 中声明一个生成式序列化器、理解其底层设计约束(AOT、反射豁免、显式注册),并弄清这套能力为 Artery 传输层准备了哪些 V2 API 契约。
为什么需要"源生成 MessagePack 验证"
SerializerV2 是 Akka.NET 为下一代序列化基础设施(最终服务于 Artery 传输)引入的抽象:它基于 IBufferWriter<byte> 直接写入、支持 ReadOnlySequence<byte> 读取、提供精确 SizeHint 与字符串 manifest(见 SerializerV2.cs)。但在 Artery 信封(envelope)依赖它之前,这套 API 形状必须被"真实使用"证明——而不是只停留在设计图上。
messagepack-sourcegen-validation 正是这道验证门禁:它用一条完整的源生成 MessagePack 序列化路径去实际调用 V2 API,覆盖非平凡载荷、manifest、size hint、生成代码、AOT 导向模式,以及远程传输与持久化的集成。提案明确写道:"这不是迟来的优化,而是 V2 API 形状的验证门。" 它运行在原子化的 serializer-v2 基础之上,属于前置性验证工作。
从仓库源码看,这项验证已经落地为两个真实项目:
- Akka.Serialization.V2:面向用户的运行时包,拥有 MessagePack 依赖、属性定义、Akka 专属 MessagePack 辅助约定与生成器集成;
- Akka.Serialization.V2.Generators:Roslyn 增量源生成器,按功能拆分为
Facts、Extraction、Expansion、Emission、Validation、Placement、MetadataSchemas、Locations等多个部分文件。
核心 Akka 本体不引入 MessagePack 依赖——MessagePack 依赖被隔离在这个新包内,核心只拥有 SerializerV2 与兼容性基础设施。
总体架构与"不变"边界
在展开细节前,先明确这项变更的范围边界(提案 What Does Not Change):
- 核心 Akka 不新增 MessagePack 依赖;
- Artery 信封不在本变更内实现(本变更只是为其铺路);
- 现有经典远程传输、持久化、Akka.Delivery 与 DistributedData 的 protobuf 包装线格式默认不被替换;
- 经典远程传输与持久化的桥接工作归属前置的
serializer-v2基础; - V1 序列化器继续通过
SerializerV2派生体系与SerializerV1Adapter共存。
新能力只做一件事:为 Akka.NET 1.6 提供源生成的 MessagePack 序列化器,并用集成测试证明 SerializerV2 已为 Artery 就绪。从 tasks.md 的完成情况看,运行时包、生成器、属性与诊断、绝大多数集成验证、POC 基准均已实现;剩余未勾选项(经典远程传输收发、持久化事件/快照、Akka.Delivery/DistributedData 包装内的端到端验证、文档与 Akka.Hosting 扩展)是后续收尾项。
序列化器声明与显式注册模型
声明一个生成式序列化器
用户通过 [AkkaSerializer<TProtocol>] 在 partial 类上声明一个序列化模块,绑定一个协议标记接口。核心形状(design.md Decision 5):
[AkkaSerializer<IOrderProtocol>("orders", 120001)]
public sealed partial class OrderSerializer : AkkaSerializer
{
public static partial SerializerRegistration CreateRegistration();
}
要点(对应 Attributes.cs 中 AkkaSerializerAttribute<TProtocol> 的实现):
Name与SerializerId是必填、位置参数、只读的构造参数——没有命名属性形式、没有自动分配。原因有两层:源生成器在一次编译内的注册顺序是非确定性的,无法安全推导稳定 ID;而与其他地方(如 HOCONakka.actor.serialization-bindings/serializers)定义的 ID 或别名冲突在编译期不可见,生成器也无法校验它"打算选"的 ID。因此AKKASG001/AKKASG002只保留参数有效性校验(名字非空、ID 为正),存在性校验交给 C# 编译器。- 基类是非泛型的
AkkaSerializer(由早期泛型的MessagePackSerializer<TProtocol>改名而来,见 AkkaSerializer.cs),协议身份完全由属性类型参数承载——这消除了"基类类型实参"与"属性类型实参"可能不一致的状态,也规避了与MessagePack.MessagePackSerializer的双重using歧义(CS0104)。 - 协议类型实参必须是接口(否则
AKKASG033);[AkkaSerializer]类本身必须 partial、非泛型、派生自AkkaSerializer(否则AKKASG032);同一协议不能被两个序列化器绑定(AKKASG031)。
显式逐序列化器注册,无运行时程序集扫描
生成器不做运行时程序集扫描——这与 NativeAOT 与裁剪(trimming)目标冲突。取而代之,生成器在用户声明的 partial 类上生成每个序列化器自己的注册辅助方法,返回可复用的注册数据:
SerializerRegistration.Create(...)携带 alias、创建委托与UseFor类型集合(SerializerRegistration.cs);- 非宿主应用可把多个注册显式组合进一个
SerializationSetup:SerializerRegistration.CreateSetup(params SerializerRegistration[]); - Akka.Hosting 集成则把生成的注册喂入其序列化器累加器;
- 生成器不发射跨程序集聚合或生成的
CreateSetup()辅助方法。
CreateSetup(params ...) 在组合时执行一次启动期 one-owner 检查:任何消息类型只能有一个主人(owner)。两个相互不可见的程序集中的序列化器可能在编译期无法发现彼此占用同一类型,此检查就是该情况的最后防线——一次字典遍历、在组合时抛出 InvalidOperationException 并点名两个冲突注册的别名(对应 design.md Decision 19 的启动检查,测试见 SerializerRegistrationOneOwnerSpec.cs)。
打包:一个 NuGet 包同时携带运行时与生成器
Akka.Serialization.V2.csproj 通过 OutputItemType="Analyzer"、ReferenceOutputAssembly="false" 的 ProjectReference 引用生成器项目,并用 _AkkaSerializationV2PackGeneratorAnalyzer 打包目标把生成器 DLL 嵌入 analyzers/dotnet/cs;Akka.Serialization.V2.Generators 自身 IsPackable=false,不会出现在 nuspec 依赖里。最终用户只需引用 Akka.Serialization.V2 一个包,生成器即自动生效——无需手动安装单独的生成器包。
消息标记、字段索引与 MessagePack 线格式约定
[AkkaSerializable] 与 [AkkaField(index)]
[AkkaSerializable] 标记一个应被生成器处理的消息类型(类或结构体),[AkkaField(index)] 为属性分配稳定的零基字段索引。索引被编码为 MessagePack 载荷中的字段 ID,线格式不依赖构造函数或属性的数组位置——这是版本容忍的基础。
- 一个没有任何
[AkkaField]属性的[AkkaSerializable]类型默认被拒绝(AKKASG004 MissingFields),除非显式设置AllowEmpty = true——用于"到达即信号"的无字段协议消息(如 Artery 心跳ArteryHeartbeat/ArteryHeartbeatRsp,tasks.md 第 9 节)。AllowEmpty生成空 map 写入与跳过未知字段的读取(保持前向兼容)。 - 构造参数未被任何
[AkkaField]覆盖 →AKKASG027;字段索引重复 → 诊断;找不到能把每个必填参数按名字映射到[AkkaField]属性的可访问构造函数 →AKKASG026;静态或无 getter 的[AkkaField]属性 →AKKASG028。 - 初始切片强制不可变消息设计:记录类型/主构造函数、构造绑定不可变类、init-only 属性或字段赋值(
InitializerFieldNames混合构造方案,见 GeneratedMessagePackSerializerSpec.cs)。工厂方法、可变 setter 模型、继承繁重的对象图与任意多态发现不在首版范围内。
直接 MessagePack 游标:一次一读写器,按 ref 传递
生成器在热路径上直接使用 MessagePack-CSharp 的 MessagePackWriter / MessagePackReader 游标:每次 Serialize 调用创建一个 MessagePackWriter、每次 Deserialize 创建一个 MessagePackReader,然后通过生成的辅助方法按 ref 传递这两个游标。这避免了逐字段重建 reader/writer,同时保留 MessagePack-CSharp 的游标语义。设计明确不保留独立的 AkkaReader/AkkaWriter 公开包装类;需要检查或构造载荷的测试直接用 MessagePack-CSharp 游标(design.md Decision 2)。
AkkaSerializer 基类为此提供对称的读写辅助(AkkaSerializer.cs):DateTime 编码为 2 元素数组(ticks + kind)、DateTimeOffset 编码为 2 元素数组(ticks + offset 分钟数)、decimal 编码为 4 元素数组(lo/mid/hi/flags)、Guid 编码为 16 字节 bin;所有窄整数读取(short/sbyte/byte/ushort/uint/char)都做范围校验,绝不截断;float 读取接受 float64 但仅当其可无损落入 float32,超范围抛异常而非静默变成无穷。
IActorRef 字段:传输感知的 actor-ref 序列化
生成器原生支持 IActorRef 字段:写入时调用 Serialization.SerializedActorRef 语义(Serialization.SerializedActorPath),读取时通过序列化器持有的 ExtendedActorSystem 的 provider 解析路径。空路径表示 ActorRefs.NoSender/null(design.md Decision 7)。这让生成载荷能够"锻炼" Akka 传输感知的 actor-ref 序列化规则——正是 Artery 握手与控制消息需要的形态。
模式演进与版本容忍
设计采用"贴近传统 MessagePack schema 行为"的版本容忍策略(design.md Decision 4),并刻意不做额外的历史 schema 校验、换字段检测或 schema 注册表式检查:
- 生成的 reader 会跳过未知字段 ID——这是前向兼容路径(
reader.Skip()); - 字段 ID 一旦发布就不得复用为不同含义;重命名在字段 ID 不变时是安全的;删除字段则永久保留其 ID;
- 改变字段类型不兼容:应通过常规 MessagePack reader/type 校验在旧消息版本仍在流通期间失败;
- 新字段的规则是"设为可空";旧构建没有见过的 manifest 无法通过跳过键产生不存在的类型,因此新增联合成员与新增顶层消息一样遵循"先部署再发送/先部署再写入"(design.md Decision 17)。
MessagePackSizes(MessagePackSizes.cs)公开了生成序列化器使用的精确尺寸编码数学(nil=1 字节、bool=1 字节、double=9 字节、float=5 字节、变长整数按 MessagePackWriter.GetEncodedLength 等),供外部手写 formatter 在实现"精确或 UnknownSize"契约时组合复用,而不是手工推导 MessagePack 头尺寸。
集合支持:十种形状、统一线格式
生成器原生识别十种集合形状(design.md Decision 7.2 与 tasks.md 5.7):T[]、List<T>、IReadOnlyList<T>、Dictionary<TKey,TValue>、ImmutableArray<T>、ImmutableList<T>、ImmutableHashSet<T>、ImmutableDictionary<TKey,TValue>、IReadOnlyCollection<T>、IReadOnlyDictionary<TKey,TValue>。
所有形状共享完全相同的 MessagePack 线格式:一个 ImmutableList<int> 字段与同样数据的 List<int> 字段在线上逐字节相同,ImmutableArray<int> 与 int[] 亦然;区别只在反序列化时的内存构造:
IReadOnlyList<T>/IReadOnlyCollection<T>反序列化为具体List<T>;IReadOnlyDictionary<TKey,TValue>反序列化为具体Dictionary<TKey,TValue>;ImmutableList<T>/ImmutableHashSet<T>/ImmutableDictionary<TKey,TValue>通过类型自带的Builder(无容量参数,树/trie 结构)再.ToImmutable();重复键最后写入者胜出,重复的ImmutableHashSet<T>元素静默去重;ImmutableArray<T>通过ImmutableArray.CreateBuilder<T>(capacity)预置为线元素个数、再Builder.MoveToImmutable()——零拷贝移交而非防御性拷贝。
ImmutableArray<T> 是唯一的值类型(struct)集合形状,有一套专门语义:default(ImmutableArray<T>)(IsDefault 为 true)写为 MessagePack nil、读回 default,与引用集合的 null 语义精确镜像;ImmutableArray<T>.Empty(IsDefault false、Length 0)写为长度 0 的数组头、读回全新的非默认空数组,与 nil 情形在线上和反序列化后都保持区分。因此必填(非可空)ImmutableArray<T> 字段接受 nil 线值而不抛异常——正如必填 int 字段接受 0 一样。ImmutableArray<T>? 则经生成器现有的可空值类型机制组合。集合可任意嵌套(List<List<int>>、字典套列表、ImmutableList<ImmutableArray<int>> 等),不支持的元素/键/值类型会把整个字段折叠为 FieldKind.Unsupported(AKKASG003),而不会部分生成;范围外形状(HashSet<T>、ImmutableSortedSet<T>、ImmutableQueue<T> 等)保持 AKKASG003(wire 覆盖见 CollectionFieldSpec.cs 与 ImmutableCollectionFieldSpec.cs)。
联合与封闭泛型:受限多态的生成器实现
源生成器是语法驱动的,无法具体化开放泛型——它只能为编译期可见的封闭构造发射具体序列化代码。两条结构性能力遵循 System.Text.Json 源生成器([JsonDerivedType]、[JsonSerializable] 封闭构造)已经确立的封闭多态先例。
[AkkaUnion]:manifest 判别的闭联合
<a href="https://link.gitcode.com/i/8cc3ecbabd7f2636030cc5e3967ef114" target="_blank">AkkaUnion(Type first, params Type[] rest)] 在联合基接口/抽象类上声明一个封闭、显式枚举的具体 [AkkaSerializable] 成员集(类型级,静态类型为该联合的每个字段继承),或应用在单个 [AkkaField] 属性上(仅对该字段收窄成员集)。线格式是 manifest 判别的 2 条目 map:{1: 成员 manifest, 2: 内联成员字段 map}——刻意区别于信封帧 {1: serializerId, 2: manifest, 3: 不透明字节},因为联合成员在编译期结构内联编码,不经过运行时 Akka 序列化器查找([GeneratedUnionSpec.cs)。
写分派按精确运行时类型匹配:运行时类型不是声明成员(包括声明成员的未声明子类型)→ 序列化失败,绝不静默加宽。成员必须 [AkkaSerializable]、在联合内 manifest 唯一、可赋值给字段静态类型(AKKASG015/016/017/018/019);成员未密封时发射 Info 级建议(AKKASG025),因为未密封成员正是未声明子类型可能出现之处。辅助方法按(字段静态类型, 成员集)去重——一个类型级声明被多个字段复用时只发射一个辅助。
[AkkaUnion] 的无参形式(design.md Decision 21)含义是"生成器可见的所有 [AkkaSerializable] 实现者"(在分层代码库中,接口位于实现上游,无法命名引用它的程序集中的类型,显式列表在那里不可编译)。wire 格式不变。同时设计 Decision 17 明确:写入时运行时类型未声明 → 生成的代码抛 SerializationException;读取时未知 manifest → 同样抛异常;生成器不添加任何回退与 opt-in——"生成器抛异常,调用方决定后果"(经典远程传输入站解序列化失败会使关联失效,出站错误保持关联;持久化则恢复失败)。
[AkkaSerializable<T>]:封闭泛型注册与 ManifestPrefix 展开
[AkkaSerializable<Wrapper<IOrder>>(Manifest = ...)] 应用在 [AkkaSerializer] 类上,注册一个封闭泛型构造。每个注册构造像普通顶层消息一样拥有自己的 manifest 与分派臂,泛型字段按具体类型实参解析。开放定义只承载 [AkkaField] schema、从不被序列化;实现了协议接口却无封闭注册的泛型定义 → AKKASG022;字段类型是未注册的封闭构造 → AKKASG023。
ManifestPrefix(design.md Decision 18)把注册扩展为在类型实参的封闭集上展开:例如 [AkkaSerializable<Envelope<ICommsMessage>>(ManifestPrefix = "env")] 会注册 Envelope<AcceptCassette>、Envelope<OrderCancelled> 等每个协议成员的构造,各自带独立分派臂与派生 manifest。manifest 由固定公式导出:
manifest(显式注册, Manifest = "m") = "m"
manifest(M)(集合成员) = M 的顶层 manifest
manifest(G<M>, ManifestPrefix = "p") = "p" + "/" + manifest(M)
manifest(G<M1, M2>, ManifestPrefix = "p") = "p" + "/" + manifest(M1) + "/" + manifest(M2)
manifest(G<H<M>>, ManifestPrefix = "p") = "p" + "/" + manifest(H<M>)
规则应用于每个类型实参,多实参泛型按实参集合的笛卡尔积展开;展开数量以 Info 诊断(AKKASG042)报告。ManifestPrefix 用于无可展开集合的类型实参(如具体类)→ AKKASG040。注册语义也从"仅注册封闭泛型构造"扩展为"把该类型收养(adopt)进此序列化器",无论其是否实现协议——由此退休了 AKKASG034("注册无效果")。一个类型只能有一个主人:第二个序列化器收养同一类型是构建错误(AKKASG041);协议接口类型字段被隐式视为协议集合上的联合,无需 [AkkaUnion]。
跨程序集:从元数据提取 schema 与实现者发现
生成器无法注解核心 Akka 类型(如 Akka.Actor.Address)——[AkkaSerializable] 位于引用 Akka 的 Akka.Serialization.V2,直接注解 Address 会形成依赖环。三项机制解决跨程序集问题:
Formatter 逃生舱(Decision 11):<a href="https://link.gitcode.com/i/43678648db1d539fbe4b6f12cd142c1a" target="_blank">AkkaSerializerFormatter<TTarget, TFormatter>] 注册一个手写 IAkkaMessagePackFormatter<TTarget>([IAkkaMessagePackFormatter.cs),接口契约为 Write(ref MessagePackWriter, T)、T Read(ref MessagePackReader)、int SizeOf(T)(精确字节数或 UnknownSize)。注册是序列化器作用域的,覆盖生成器对该目标类型的每项字段种类推断(object 边界字段除外,它总是优先);Nullable<T> 匹配底层值类型。formatter 通过公开无参构造或接受 ExtendedActorSystem 的公开构造实例化一次(两者都有时优先后者);两者皆无 → AKKASG010。where TFormatter : IAkkaMessagePackFormatter<TTarget> 约束使不符合接口的 formatter 在属性使用处成为编译器错误。内置 AddressFormatter 与 ActorPathFormatter 随包分发:AddressFormatter 与 Artery 控制消息序列化器手写的 WriteAddress/ReadAddress/SizeOfAddress 逐字节一致([Protocol, System, Host-or-nil, Port-or-nil] 4 元素数组);ActorPathFormatter 写入单个传输感知字符串,与 IActorRef 字段同一约定(线程静态传输上下文 → ActorPath.ToSerializationFormatWithAddress,无传输作用域时回退到拥有系统的 Provider.DefaultAddress)。这意味着 Artery 握手消息此前被迫手写的格式现在可由生成序列化器逐字节复现。
从引用程序集元数据提取 schema(Decision 16):序列化器 B 中一个消息字段的类型可以位于程序集 A。生成器在 B 中从 A 的编译元数据读取该类型的属性、字段属性与构造器形状,然后在 B 内部发射私有 Write/Read/SizeOf 辅助——A 无需序列化器、无需引用生成器,A 与 B 运行时零耦合。本地声明保持优先;来自引用程序集的类型必须携带 <a href="https://link.gitcode.com/i/7b4e0aa4c872f3ff4ca1bffb920886ba" target="_blank">AkkaSerializable] 且对 B 可访问(public 或 InternalsVisibleTo),不可访问 → 新诊断 AKKASG039。两个程序集的两个序列化器各自生成逐字节相同的私有 codec 副本。实现细节(ComputeMetadataSchemas 阶段、广度优先的嵌套外部引用遍历、失败传播到固定点)见 [AkkaSerializerGenerator.MetadataSchemas.cs。
引用程序集的实现者发现与序列化器放置(Decision 19):生成器不仅在本编译内寻找协议的顶层消息,还会遍历每个本身引用 Akka.Serialization.V2 的引用程序集,从元数据收养其中每个 [AkkaSerializable] 协议实现者——无需属性 opt-in、无运行时扫描。遍历输出为排序的全限定类型名列表(值相等、可缓存)。AKKASG029(协议实现者缺 [AkkaSerializable])与 AKKASG012(manifest 唯一性)随之扩展到跨程序集集合。四条放置规则把错位序列化器变成编译期或启动期失败:下游编译对"实现上游已绑定协议的类型的程序集"报错(AKKASG043 ProtocolOwnedUpstream)、上游编译对"自身与引用程序集都无消息且无注册"的序列化器报警告(AKKASG044 SerializerHasNoMessages)、AKKASG031 跨程序集扩展(DuplicateProtocolBindingCrossAssembly)、运行时"不支持的生成序列化器类型"异常文本同时点名类型所在程序集与生成序列化器的程序集。生成器还把每引用程序集的遍历缓存为进程生命周期 ConditionalWeakTable(以 MetadataReference 为键),并因 IDE 内存压力下 Roslyn 弱符号缓存导致的缓存失效而特意弃用 IAssemblySymbol 键(tasks.md 15.14)。
object 字段即序列化边界:信封帧与尺寸契约
早期设计中 [AkkaEnvelopePayload] 标记包装字段为序列化器边界;后续(design.md Decision 20,PR #8518)移除了该属性:一个类型为 object(或 object?)的属性本身即边界。规则按泛型替换后的静态类型运行:
object→ 信封帧(serializer id、manifest、字节);- 序列化器自己的协议接口,或携带类型级
[AkkaUnion]的类型 → 联合帧; - 无封闭集的其他接口/抽象类 →
AKKASG003(提示:声明[AkkaUnion]、改用协议接口或把属性类型改为object); - 具体可序列化类型 → 内联编码。
泛型包装跟随其类型实参:Envelope<object> 是信封帧,Envelope<IComms> 是联合帧,Envelope<AcceptCassette> 内联。该规则进一步扩展到集合元素:List<object>/object[] 的每个元素都是独立信封边界(ObjectElementSpec.cs);object 键的字典被 AKKASG003 拒绝(哈希/相等查找的往返身份不稳定,且空键会在运行时使 Dictionary<TKey,TValue> 崩溃)。
信封帧的读写实现在 AkkaSerializer.cs 的 WriteEnvelopePayload/ReadEnvelopePayload/SizeOfEnvelopePayload:V2 载荷先写入可复用池化缓冲(AkkaPooledBufferWriter,ArrayPool 后备),因为 MessagePack bin 字段需要在载荷字节前写出长度;读取侧从 ReadOnlySequence<byte> 分派 V2 载荷以避免字节数组拷贝(tasks.md 6.13)。基类还内置信封嵌套深度守卫(MaxEnvelopePayloadDepth = 100):消息类型直接或传递性地把自己声明为信封载荷会导致无界递归,.NET 下栈溢出是进程级不可捕获崩溃,超过深度后抛普通可捕获的 SerializationException(与 protobuf 的递归限制一致)。
SerializerV2.SizeHint 是精确尺寸契约:非负值表示 Serialize 将写出的确切字节数,UnknownSize(-1)表示精确尺寸无法廉价计算。未知尺寸经嵌套生成值与信封载荷传递:任何嵌套字段或载荷序列化器返回 UnknownSize,外层生成序列化器也必须返回 UnknownSize。精确生成 SizeHint(tasks.md 5.12,经 Decision 11 扩展到 formatter 支持字段)允许调用方用 SizeHint 预置 initialCapacityHint,使异常路径只留给真正超大的消息。
超大载荷确定性(Decision 12):由调用方强加的容量上限(传输最大帧尺寸表达为 PooledPayloadWriter.maxCapacity)使任何编码超过上限的载荷在 Serialize 期间以类型化 PayloadSizeExceededException 失败,携带尝试尺寸与配置上限;下游永远观察不到截断或损坏的帧。写入方机制属 serializer-v2 的 PooledPayloadWriter 所有权契约(Akka.Tests.Serialization.PooledPayloadWriterSpec 覆盖),本变更钉住序列化器侧一半(OversizedPayloadDeterminismSpec.cs):异常从普通生成消息与携带信封载荷的包装的 Serialize 中传播出来;生成序列化器无状态,中途失败后实例完全可复用;writer 在 Reset() 后可复用(传输的"死信再复用"模式无需重新租用);失败后写入计数绝不超上限。
诊断体系:编译期把关,而非运行时爆雷
生成器以 AKKASGxxx 系列诊断把大量"编译成空分派或首封即运行时失败"的状态提前到编译期(完整定义见 AkkaSerializerGenerator.Diagnostics.cs,诊断测试见 AkkaSerializerGeneratorDiagnosticsSpec.cs)。主要分组:
| 类别 | 诊断 |
|---|---|
| 序列化器形状 | AKKASG032(partial/非泛型/派生自 AkkaSerializer)、AKKASG033(协议须为接口)、AKKASG031(协议唯一绑定,含跨程序集变体) |
| 消息/字段 | AKKASG004(缺字段,可用 AllowEmpty)、AKKASG005(字段索引重复)、AKKASG006(顶层消息缺 manifest)、AKKASG026/027/028(构造器/参数覆盖/属性可访问性) |
| 类型支持 | AKKASG003(不支持的字段类型,含跨程序集变体与多态提示文本)、AKKASG007(嵌套可序列化定义缺失,含跨程序集变体)、AKKASG022/023(泛型注册要求/未注册封闭泛型字段) |
| 联合 | AKKASG015/016/017/018/019(成员可序列化/manifest/碰撞/可赋值/非法成员集)、AKKASG025(成员未密封,Info) |
| 注册与放置 | AKKASG020/021(非法/重复封闭泛型注册)、AKKASG040(ManifestPrefix 无可展开集合)、AKKASG041(一类型多主人)、AKKASG042(展开数量,Info)、AKKASG043(上游已拥有协议)、AKKASG044(序列化器无消息,警告) |
| 跨程序集 | AKKASG039(引用程序集嵌套字段/联合成员不可访问)、AKKASG012(manifest 唯一性,跨程序集加宽) |
| 格式化器 | AKKASG008(formatter 不得抽象)、AKKASG009(同目标重复注册)、AKKASG010(无可用构造器)、AKKASG011(数组/封闭泛型目标) |
AKKASG030、AKKASG034、AKKASG035 是永久编号缺口——它们守卫的条件已分别被后续决策消除(AKKASG035 随 [AkkaEnvelopePayload] 移除而退休,被 AKKASG038 取代)。设计评审(issue #8384 / PR #8385)刻意推动"非法状态不可表示":能用编译器拒绝的状态绝不让生成器事后诊断。
集成验证与基准 POC 证据
验证矩阵
从 tasks.md 看,生成序列化器的验证覆盖:
Serialization.cs往返(GeneratedMessagePackSerializerSpec等);- 经典远程传输收发、持久化事件与快照的存/取(tasks 6.4-6.6 为未完成项);
- V1 与生成 V2 共存(
LegacyManifestDispatchSpec.cs、ManifestContractSpec.cs); - 超大载荷确定性(
OversizedPayloadDeterminismSpec); - Akka.Delivery 与 DistributedData 包装内的载荷验证(6.9-6.10 未完成;6.11-6.13 已完成,包括
bin字段零拷贝读取); - 跨程序集基线(CrossAssemblyBaselineSpec.cs)与 golden 输出(GoldenOutput 下的
.verified.txt)、wire 快照(WireSnapshots)与增量缓存场景(GeneratorIncrementalScenariosSpec)。
基准 POC:方向性证据
首轮 BenchmarkDotNet POC(tasks.md 第 7 节)使用真实 C# 协议族消息(SubmitOrder,含 Guid、long、decimal、DateTimeOffset、枚举与 IActorRef 字段,见 GeneratedMessagePackSerializerBenchmarks.cs)对比基线序列化器。需要强调的是:这是方向性 POC 证据,不是最终 Artery 性能证明(design.md Risks 一节明确此点)。记录在案的信号:
- 字段 ID map 实现下:生成 MessagePack 序列化约 585 ns、反序列化约 1.05 µs;Newtonsoft.Json 基线约 20.3 µs / 24.6 µs;生成分配约 904-920 B vs JSON 约 10.8-13.1 KB;载荷约 128-130 B vs JSON 约 411-413 B。
- 直接
MessagePackReader/MessagePackWriter重构后:序列化约 362 ns、反序列化约 612 ns,分配降至约 856-888 B。 - 嵌套信封场景(生成 V2 载荷、定制 V1 载荷、同形状 V1 载荷对比)报告于 tasks.md 的表格(如
NestedEnvelope_generated_payload_serialize均值 628.4 ns / 904 B 等,完整数据见 tasks.md 7.6 表格)。
从本提案到 Artery:一份能力清单
本提案本质上是在为 Artery 信封逐项确认 V2 API 细节。提案列出的验证点正是 Artery 的硬依赖:
- manifests——生成器按字符串 manifest 分派,V2 manifest 契约由此确立;
- size hints——精确或
UnknownSize的语义、未知尺寸在嵌套与信封间的传递性; - 未知尺寸回退——
bin字段长度先决下的暂存缓冲策略; - bytes-written/result 语义——
Serialize返回写入字节数并校验与实际一致(AkkaSerializer中不匹配即抛异常); - 超大载荷处理——编码期确定性失败 + writer 可复位复用;
- V1 共存——
SerializerV1Adapter路径持续可用,信封载荷帧可同时承载 V1 与 V2 载荷。
从源码结构看,本变更还验证了生成器在 AOT 场景的可行性:无运行时反射、显式注册、值相等的可缓存中间表示、ConditionalWeakTable 级程序集事实缓存——这些是为 NativeAOT/裁剪准备的形态。配合 openspec/IMPLEMENTATION_ORDER.md 中的总体推进顺序阅读,可以更清楚地看到它在 serializer-v2 基础与 Artery 信封之间的承前启后位置。
结语
messagepack-sourcegen-validation 是 Akka.NET 序列化现代化路线中"用真实使用证明 API"的一环:它交付了用户可直接使用的源生成 MessagePack 序列化器(Akka.Serialization.V2 包 + Roslyn 生成器),也替 Artery 提前确认了 SerializerV2 的每个关键契约。对于希望今天就用上生成式序列化的开发者,声明 [AkkaSerializer<TProtocol>] + [AkkaSerializable]/[AkkaField]、通过 CreateRegistration() 显式组合注册即可;对于关心演进兼容的团队,字段 ID 不可复用、未知字段跳过、manifest 先部署再发送等规则是设计红线。进一步的深入材料:属性与基类实现见 Attributes.cs 与 AkkaSerializer.cs,生成器分阶段实现见 AkkaSerializerGenerator.Emission.cs 等文件,测试与 wire 快照见 Akka.Serialization.V2.Tests,POC 基准见 GeneratedMessagePackSerializerBenchmarks.cs。