跳到主要内容
版本:1.4.0

Xlang 实现指南

概览

本指南描述当前 xlang 运行时的所有权模型。该模型由参考 Java 运行时采用,并由 Dart 运行时重写版本镜像实现。

编码格式由 Xlang Serialization Spec 定义。本文关注的是服务边界、操作流程与内部所有权。新运行时不必使用相同的类名, 但应保持相同的控制流:

  • 根级操作留在运行时门面上
  • 嵌套载荷处理留在显式的读写上下文中
  • 类型元信息留在类型解析层
  • serializer 只关注载荷本身

如果本指南与编码格式规范冲突,应以 docs/specification/xlang_serialization_spec.md 为准;如果与某个语言运行时的实现细节冲突,则以该语言当前运行时代码为准。

事实来源

按以下顺序使用这些来源:

  1. docs/specification/xlang_serialization_spec.md
  2. 该语言当前的运行时实现
  3. integration_tests/ 下的跨语言测试

对于 Dart,运行时形态主要围绕以下组件展开:

  • Fory
  • WriteContext
  • ReadContext
  • RefWriter
  • RefReader
  • TypeResolver
  • StructCodec

运行时所有权模型

Fory 是根操作门面

Fory 持有一个运行时实例可复用的运行时服务。

在 Dart 中,Fory 恰好持有四个运行时成员:

  • Buffer
  • WriteContext
  • ReadContext
  • TypeResolver

在 Java 中,Fory 还持有 JITContextCopyContext 等运行时本地服务,但所有权规则相同:Fory 是根级门面,而不是嵌套 serializer 执行工作的地方。

Fory 负责:

  • 为根级操作准备共享 buffer
  • 写入和读取根级 xlang 头部位图
  • 将嵌套值编码委托给 WriteContext
  • 将嵌套值解码委托给 ReadContext
  • 通过 TypeResolver 持有注册能力
  • 在顶层 finally 中重置操作级上下文状态

嵌套 serializer 不得回调根级 serialize(...)deserialize(...) 入口。

WriteContextReadContext 持有操作级状态

WriteContextReadContextFory 为一次根级操作准备,并在复用前由 Foryfinally 块中重置。

prepare(...) 应只绑定当前 buffer 和根级操作输入。reset() 应清理操作级可变状态。

这些操作级状态包括:

  • 当前 buffer
  • 当前活动的 RefWriterRefReader
  • meta string 状态
  • 共享 TypeDef 状态
  • 以对象 identity 为键的操作级临时状态
  • compatible struct slot 状态
  • 逻辑对象图深度

无论是生成的还是手写的 serializer,都应把这些 context 视为操作级服务的唯一来源。serializer 不得把环境运行时状态保存在 thread local、全局变量或 serializer 实例字段中。

WriteContext

WriteContext 持有写侧的全部操作级状态:

  • 当前 Buffer
  • RefWriter
  • MetaStringWriter
  • 共享 TypeDef 写入状态
  • 根级 trackRef 模式
  • 递归深度与限制
  • compatible 写入使用的本地 struct slot 状态

它暴露一组一次性基础类型 helper,例如:

  • writeBool
  • writeInt32
  • writeVarUint32

这些 helper 只是便捷方法。若 serializer 需要执行大量重复的基础类型 IO,应缓存 final buffer = context.buffer; 并直接调用 buffer 方法。

ReadContext

ReadContext 持有读侧的全部操作级状态:

  • 当前 Buffer
  • RefReader
  • MetaStringReader
  • 共享 TypeDef 读取状态
  • 递归深度与限制
  • compatible 读取使用的本地 struct slot 状态

它暴露对应的一次性基础类型 helper,例如:

  • readBool
  • readInt32
  • readVarUint32

生成的 struct serializer 在构造目标实例后,应立即调用 context.reference(value),这样回引用才能解析到该对象。

引用跟踪

引用处理被拆分到两个显式服务之后:

  • RefWriter 写入 null、ref 与 new-value 标记,并按对象 identity 记住已经写出的对象。
  • RefReader 解码这些标记,预留读取侧引用 ID,并解析已经实例化过的对象。

xlang 的引用标记如下:

  • NULL_FLAG (-3)
  • REF_FLAG (-2)
  • NOT_NULL_VALUE_FLAG (-1)
  • REF_VALUE_FLAG (0)

关键行为:

  • 基础值永远不使用引用跟踪
  • 生成的 struct 内部由字段元信息控制引用行为
  • 根级 trackRef 只用于顶层对象图以及没有字段元信息的容器根对象
  • 若 serializer 在所有嵌套读取完成之前就分配了对象,则必须通过 context.reference(...) 尽早绑定该对象

类型解析

TypeResolver 持有:

  • 内建类型解析
  • 按数值 ID 或 namespace + typeName 注册
  • serializer 查找
  • struct 元数据查找
  • 类型元信息编码与解码
  • 包名、类型名、字段名的规范化编码 meta string
  • 用于 named 类型解析的已编码名称查找
  • struct、compatible struct、enum、ext、union 等形式的编码类型判定

在 Java xlang 模式中,具体实现是 XtypeResolver。在 Dart 中,相同的所有权仍由内部 TypeResolver 持有。

serializer 自身不负责解析类元数据。它们通过当前 context 请求读写嵌套值,再由 context 将类型相关工作委托给 TypeResolver

根帧职责

每个根载荷都以一个 1-byte 位图开头,该位图由 Fory 自身写入和读取,而不是由 serializer 负责。

当前 xlang 根级位定义如下:

BitMeaning
0null root payload
1xlang payload
2out-of-band buffers in use

应将根级位图与逐对象引用标记区分开:

  • 根级位图描述整个载荷
  • 引用标记一次只描述一个嵌套值

序列化流程

根级写路径

当前根级写流程如下:

  1. Fory.serialize(...)serializeTo(...) 准备目标 buffer。
  2. Fory 调用 writeContext.prepare(...)
  3. Fory 写入根级位图。
  4. Fory 将根对象委托给 WriteContext
  5. writeContext.reset()finally 中执行。

对于非空根值,WriteContext.writeRootValue(...) 会执行:

  1. 引用/null 包装
  2. 类型元信息写入
  3. 载荷写入

载荷 serializer 只负责自身类型的载荷。它们不写根级位图,也不负责注册或类型头编码。

嵌套写入使用 WriteContext

重要规则:

  • 当嵌套 serializer 需要引用处理或类型元信息时,必须使用 WriteContext 的 helper,例如 writeRef(...)writeNonRef(...) 以及容器 helper
  • 重复的基础类型写入应直接走 buffer
  • 嵌套 serializer 的流程应保持直线式,不要仅为了清理操作级状态而添加内部 try/finally
  • 顶层 Fory.serialize(...) 持有操作重置所需的 finally

反序列化流程

根级读路径

当前根级读流程与写流程对称:

  1. Fory.deserialize(...)deserializeFrom(...) 读取根级位图。
  2. 若根值为 null,立即返回。
  3. Fory 校验 xlang 模式与其他根帧要求。
  4. Fory 调用 readContext.prepare(...)
  5. Fory 委托给 ReadContext
  6. readContext.reset()finally 中执行。

ReadContext 持有引用预留与载荷实例化

ReadContext.readRef() 执行标准的 xlang 读取序列:

  1. 消费下一个引用标记
  2. 在适当情况下立即返回 null 或回引用
  3. 为新的可引用值预留新的读取侧引用 ID
  4. 读取类型元信息
  5. 读取载荷
  6. 将预留的读取侧引用 ID 绑定到完成构造的对象

基础类型和类字符串的热点路径应直接从 buffer 读取;复杂载荷则委托给解析出的 serializer。

流与 Buffer 字节读取

实现必须由字节所有者层负责字节可用性,由 serializer 负责字符串、二进制、基础类型数组、 压缩和集合语义。

分配前读取检查所需的字节所有者基础操作,是类似 checkReadableBytes(byteCount) 的可读性检查。该设计不要求实现额外的通用 read context 方法。可读性检查成功后,serializer 使用现有的本地 buffer 读取、复制或解码路径。

可读性检查只处理字节,不得解码字符串、基础类型数组的元素数量、压缩模式或集合容量策略。

对于按字节计数的大值,每种实现都应先调用字节所有者的可读性检查,再分配变长结果。 这适用于二进制值、字符串、decimal 或 metadata body,以及编码 body 以字节计量的基础类型编码数组。 对于多字节基础类型编码数组,应将编码字节数(而非仅逻辑元素数)与可读字节数比较。

  1. 在 serializer 中校验编码字节数。对于定长基础类型数组,应在分配前检查溢出和元素对齐, 例如 wireByteCount % elementByteWidth == 0,然后根据编码字节数计算逻辑元素数。
  2. 分配变长结果前,无条件调用 checkReadableBytes(wireByteCount)。对于由 buffer 支撑的输入,该检查通常只需一次边界比较即可返回。由 stream 支撑的输入也使用同一个调用; 当已缓存字节足够时,字节所有者走快速路径,否则填充读取 buffer,直至请求的编码 body 可读或记录到输入错误。
  3. 证明可读后,只分配一次最终值,再从当前可读 buffer 复制或解码到最终结果中。

checkReadableBytes 不是 ensureCapacity(wireByteCount) 操作。在 stream 模式下, 它结束时可能已经让字节所有者在读取 buffer 中持有完整的编码 body,但该 buffer 必须随着 stream 字节成功读入而扩容。它应从当前已证明的 buffer 容量开始增长(例如容量翻倍), 仅在有界增长步骤达到当前目标时才以目标为上限。当 stream 实现本身是调用方持有的受信任代码时, 字节所有者可以将自身的可用性信号作为一次性增长提示;若该提示不存在或不足,则必须退回到根据 已缓存字节进行有界增长。在输入字节或所有者自身的增长提示足以证明所需中间 buffer 容量之前, 不得按攻击者声明的长度预留空间。stream 慢路径可以付出一次额外的中间 buffer 复制; 这仍优于 serializer 自行累积数据块并反复扩展最终容器。

对于按字节计数的值,serializer 不应在调用 checkReadableBytes 前先测试 availableBytes(),从而重复字节所有者的快速路径分支。将该分支留在字节所有者中, 可以让所有语言遵循相同的正确性规则,并让 serializer 热点路径专注于自身的编码语义。

对于基础类型编码数组:

  • 应比较并证明编码字节数,而不仅是逻辑元素数。
  • 压缩、位打包、字节序转换以及其他基础类型数组编码语义仍由 serializer 负责; checkReadableBytes 仅证明编码字节存在。
  • 对于压缩或转换过的 body,serializer 仍须在分配或返回最终值前校验解码长度和编码特有的 metadata。

通用 serializer 结构如下:

wireByteCount = readVarUint32()
elementWidth = primitiveWireElementWidth(kind)
validate wireByteCount and element alignment
elementCount = wireByteCount / elementWidth

ctx.checkReadableBytes(wireByteCount)
result = allocatePrimitiveResult(elementCount)
copy or decode wireByteCount bytes from the current readable buffer into result
advance the reader index by wireByteCount
return result

字节值是同一策略在 elementWidth == 1 时的特例。此时 serializer 结构如下:

byteCount = readVarUint32()

ctx.checkReadableBytes(byteCount)
result = allocateBytes(byteCount)
copy byteCount bytes from the current readable buffer into result
advance the reader index by byteCount
return result

该策略避免了三种低效实现:

  • 在编码 body 可读之前分配完整、连续的最终值
  • 在 stream 慢路径上扩展或反复复制最终结果容器
  • 当字节所有者能够一次性证明可读性并提供普通 buffer 读取时,仍在 serializer 中添加临时分块 buffer

当目标表示形式不能直接接收字节时,临时 buffer 仍然适用,例如字符串转码、压缩、 无法原地执行的字节序转换、位打包值,或者 stream API 无法读入调用方提供目标的运行时。

对于定长基础类型数组,在完整编码字节数成功读取前,不得向调用方暴露最终结果。

对于 list、set、map 和其他容器 reader,声明的逻辑元素数不是编码字节数, 因此所有元素、数据块、可空性、引用和类型分派语义仍必须由 serializer 负责。 但它仍是按数量预分配时的正确分配证明:校验非零数量并读取分配前由 serializer 持有的 header 或类型元信息之后,应先调用 checkReadableBytes(logicalCount), 再按该数量分配、预留底层容量或提供容量提示。字节所有者处理 buffer 与 stream 的可读性; 随后容器 serializer 按声明数量分配,并通过普通所有者路径读取元素。

该检查不是对完整容器 body 的校验,只用于防止很短或截断的输入触发按较大数量预分配。 数据块大小、重复 key、元素值语义和协议严格性仍由容器/map serializer 持有, 且只应在保护真正的所有者不变量时进行校验。

执行实例化的 reader 还应在分配或提供容量提示前,预留根操作级的对象图估算内存预算。 预算状态属于 ReadContext 或等价的根读取状态,而不是环境 thread-local 状态。 根门面只设置或重置每次操作的预算,不得预先预留根类型或根对象自身的字节。 maxGraphMemoryBytes 默认固定为 128 MiB;正数配置会覆盖默认值; 显式的非正数配置无效,必须在创建运行时时拒绝。不得从根输入大小推导该预算, 也不得为其添加动态的 stream 已读字节计数。 由于每个根值的预算固定,读取状态不应把配置的最大值复制到第二个活动限制字段中。 应使用现有配置;若无法取得配置,则只保留一个配置最大值字段和一个可变的剩余预算。

Read context 或等价读取状态只持有原始字节预留。它不得暴露带数量的算术 helper, 也不得暴露集合、map、数组、struct 或对象的语义预留 API。具体 serializer 和生成的 serializer 所有者负责计算其分配路径的存储常量与公式,包括按数量计算字节时的溢出检查。 不得为了该功能给读取状态增加与内存预算无关的 API,包括引用发布控制、临时所有者控制、 serializer 所有者控制、转换 helper,或用于编码所实例化值种类的 API。 这些决策由具体 serializer 和生成的 serializer 负责。

该预算是实例化对象图所有者的近似门槛,主要覆盖集合、map、数组、struct 和对象。 它不衡量精确堆字节数,实际进程内存可能更高。存储或分配值的所有者应且仅应预留一次自身存储。 根门面只重置预算,不得预留根值存储。由引用支撑的容器、map、set 和对象/引用数组, 应预留非零的所有者自身开销和引用 slot;每个被引用的堆所有者在实例化时再预留自己的浅层自身开销。 内联/值容器预留元素存储;内联/值 map 预留 key 与 value 存储;指针、box 和动态实例化所有者 预留其分配的堆存储或装箱存储。值 serializer(包括根读取路径以及生成的 struct/product 读取路径) 不预留自身存储。只有在引用对象运行时或动态/装箱实例化路径中,struct/record/POJO/tuple、 兼容、生成和动态对象所有者才预留非零的浅层自身开销及浅层字段存储。 父级不得递归计入子对象、集合、map、字符串、二进制或基础类型密集数组的内容。 enum/union 不作为独立所有者计入;专用字符串、二进制、基础类型标量、基础类型数组和基础类型 密集数组叶子所有者也不计入,但 vector 或值对象 list 等通用内联值容器仍须计入。 若无法低成本、可靠地查询引用 slot 大小,则使用 4 字节引用 slot。原生运行时可以使用保守的 下界估算,不应猜测不可移植的对象、容器、allocator、table、node、entry 或调试布局细节。 在比较预算或分配前拒绝算术溢出,并在分配底层存储或预留容量前保留现有的 checkReadableBytes 证明。 未计入预算的叶子所有者仍须受剩余输入字节约束。如果未读字节不足以容纳字符串、二进制值、 基础类型标量、基础类型数组或基础类型密集数组,运行时不得读取或创建该叶子值。

对于 TypeDef 或 TypeMeta body,应先通过字节所有者证明编码 metadata body 字节可读。 字段列表应在该 body 可读性检查之后分配,不应将单独的较小初始容量上限作为安全规则。

嵌套读取使用 ReadContext

重要规则:

  • 若 serializer 会提前分配结果对象,则必须在读取可能回指它的嵌套子对象之前调用 context.reference(obj)
  • 嵌套 serializer 的流程应保持直线式,不要添加内部 try/finally 来恢复操作级状态
  • 顶层 Fory.deserialize(...) 持有操作重置所需的 finally

深度跟踪

WriteContextReadContext 会显式跟踪逻辑对象深度。increaseDepth() 负责执行 Config.maxDepth 限制。

深度应显式保存在 context 上,而不是只依赖原生调用栈。同时,深度清理也不应依赖散落在 serializer 代码中的嵌套 try/finally。顶层 context reset 必须能够在失败后恢复操作级状态。

Struct 兼容性

struct 专属的 schema/version 包装与 compatible-field staging 应归属在 struct serializer 层,而不是 Fory 上,也不是公共 serializer API 上。

在 Dart 中,这个内部所有者是 StructCodec

StructCodec 负责:

  • 在兼容模式关闭且版本校验开启时写入 schema hash 包装
  • 在兼容模式开启时执行 compatible struct 字段重映射
  • 缓存 compatible 写布局与读布局
  • 为生成的 serializer 提供 compatible 读写 slot 状态
  • 在读取成功后记住远端 struct 元数据

Config.compatible 启用且 struct 被标记为 evolving 时:

  • 编码类型使用 compatible struct 形式
  • 运行时写入共享 TypeDef 元数据
  • 读取侧按标识符映射输入字段,并跳过未知字段

compatible 关闭而 checkStructVersion 启用时:

  • 运行时会在 struct 载荷前写入 schema hash
  • 读取侧会在读字段之前检查该 hash

远端 Metadata 资源限制

实现应在 cold metadata parse path 上限制收到的 metadata body 和 struct 字段列表。 maxTypeMetaBytes 限制一个 TypeDef 或 TypeMeta 的编码 body,不包含 8 字节 header 和扩展 size varint,并且应在复制或解压 body 前检查。maxTypeFields 限制一个收到的 struct metadata body 声明的字段数,并应在预留或分配字段列表前检查。

这些限制是运行时资源控制;它们不会改变编码格式、类型标识、动态加载、unknown-type 行为、 反序列化策略或 Schema 演进语义。Metadata cache hit 和生成的字段 reader 仍是 hot path,不应为这些限制增加额外工作。

远端 schema-version 限制属于同一个 cold metadata owner path。Header cache hit 必须跳过剩余 metadata body,并返回已缓存 metadata;不要重新执行 schema-limit 检查、hash 复验、分配或 policy 工作。Header miss 时,应在同一个具体 owner path 中完成处理:证明并读取 metadata body bytes,按 header 校验 body,校验字段数,通过现有注册与反序列化策略检查解析类型,必要时按原始编码字节比较精确本地 metadata,对非本地远端 metadata 检查 schema-version 限制,构建所需 read state,发布到持久 metadata cache,最后记录 schema count。失败或不兼容的 metadata 不得发布到持久 cache,也不得消耗 schema-version count。

编码字节与本地已注册 metadata 精确匹配的远端 metadata,在完成选择本地类型所需的现有类型与反序列化策略检查后,可以使用本地 metadata,且不消耗远端 schema-version 限制。这个 exact-local bypass 不只适用于 struct;当 named enum、ext 和 union metadata 带有 metadata body 且与本地编码字节匹配时,同样适用。纯 id-based enum、ext 和 typed-union value 不携带 TypeDef 或 TypeMeta body,必须继续走普通 type-id 加 user-type-id 路径。兼容 named enum、ext 和 union metadata 通常只有一个版本,但当它作为 shared metadata 发送且不精确匹配本地 metadata 时,仍会计入已接受远端 metadata 总数。maxTypeFields 只适用于 struct 字段列表。

Meta string 与共享类型元信息

xlang 类型元信息由两类显式状态支撑:

  • MetaStringWriterMetaStringReader 负责去重与解码命名空间和类型名字串
  • 共享 TypeDef 的读写状态负责跟踪已声明的 TypeDef 元数据

所有权规则:

  • 规范化后的编码名称由 TypeResolver 持有
  • 每次操作的动态 meta string ID 由 MetaStringWriterMetaStringReader 持有
  • 共享类型定义表属于操作级 context 状态

Xlang 模式下的 enum

在 xlang 模式中,enum 按数值 tag 序列化,而不是按名称序列化。

在 Java 中:

  • 默认 tag 是声明顺序对应的 ordinal
  • @ForyEnumId 可将其覆盖为稳定的显式 tag
  • serializeEnumByName(true) 影响的是 Java native 模式,而不是 xlang 模式

即便配置接口或注解形式不同,其他运行时也应保持相同的编码规则。

Out-Of-Band Buffer Object

buffer object 的处理也遵循同样的拆分原则:

  • 一个根级 bit 声明当前是否使用 out-of-band buffer
  • 嵌套 buffer object 载荷仍按单个值决定是 in-band 还是 out-of-band
  • serializer 应使用读写 context helper,而不是绕过运行时

代码生成

Dart 的常规集成路径如下:

  1. 使用 @ForyStruct 标注 struct
  2. 使用 @ForyField 标注字段覆盖
  3. 运行 build_runner
  4. 在源码库中私有绑定生成的元数据,并通过 Fory.register(...) 注册生成的类型

生成代码应产出:

  • 私有 serializer 类
  • 私有元数据常量
  • 每个被标注库对应的私有生成安装 helper
  • 保持 serializer factory 私有的生成绑定安装逻辑

生成代码不应创建公共全局 registry,也不应创建第二套公共 API 族。

目录布局

在每个 Dart package 的 lib/ 树下,只允许一层嵌套源码目录。

允许:

  • lib/fory.dart
  • lib/src/<file>.dart
  • lib/src/<area>/<file>.dart

不允许:

  • lib/src/<area>/<subarea>/<file>.dart

新运行时的 serializer 设计规则

任何新的 xlang 运行时都应遵循以下规则,即便其表面 API 有所不同:

  1. 根级操作保留在运行时门面上,嵌套载荷处理放在显式读写 context 中。
  2. 引用跟踪放在专门的读侧/写侧服务之后,使禁用引用跟踪的路径保持低成本。
  3. 让 serializer 只负责载荷。类型元信息、注册与根帧包装属于运行时与类型解析层。
  4. 显式跟踪操作级状态。不要依赖隐式 thread-local 运行时状态。
  5. 在实例化新对象之前预留读取侧引用 ID,并在嵌套子对象可能回指它时尽早绑定部分构造对象。
  6. 将操作准备与操作清理分离。prepare(...) 负责绑定当前操作输入,reset() 负责清理操作级状态。
  7. 保持根级位图、逐对象引用标记、类型头与载荷字节之间的边界清晰。
  8. 内部命名应停留在序列化领域。优先使用 codecbindinglayoutslots 这类词,避免使用 session 这类 RPC 风格术语,或 plan 这类含义模糊的控制流词汇。
  9. 每次 xlang 协议或所有权模型变更后,都要运行跨语言测试矩阵,并同时更新本指南与 Xlang Serialization Spec

验证

对于 Dart 运行时改动,至少运行:

cd dart
dart run build_runner build --delete-conflicting-outputs
dart analyze
dart test

若要覆盖生成代码的使用方,也应运行:

cd dart/packages/fory-test
dart run build_runner build --delete-conflicting-outputs
dart test