一份 Rust 序列化库的设计笔记,把一批长期存在的问题归到了架构上。它们不是实现缺陷,改不动的原因是稳定承诺。三个设计决定带来了大部分麻烦,一套特征服务所有格式、缓冲时丢信息、反序列化靠调用栈递归。对应的做法是重写一个库,保留使用体验,把底层数据流的方向倒过来,并写清因此得到的能力和付出的代价。
三个根因
一套特征同时服务自描述格式和需要提前知道类型的二进制格式。结果是某些功能只在部分格式可用,还要等到运行时报错。内部标签枚举、无标签枚举和扁平化都要先把值缓冲起来再决定怎么处理。缓冲装不下格式知道的一切,错误丢失位置,高精度数字只能靠魔法键走私。递归让每层嵌套都占栈。格式侧加了递归上限,写到动态值那条路径没有上限,深嵌套能直接打挂进程。解析也没法边收边处理。
扁平化的坑很典型。结构体嵌套一个映射,单独解析完全正常,扁平之后键就变成了字符串,类型对不上,报错还指到文档末尾。
换掉数据流方向
新库把方向倒过来。原来是类型驱动求值,反序列化时由类型问格式要什么,格式回调访问者,层层递归。现在格式推事件,遇到嵌套值不递归进去,而是交回一个新的接收器,驱动把所有状态放在堆上,实际放在一个 arena 里。序列化方向相反,发射器交出嵌套值,不是递归进入。
这个结构带来几个直接后果。嵌套不再消耗栈,限额交给用户按层设置,和栈空间脱钩。状态在驱动里,解析可以等输入,它是可发送的,能跨线程等待 IO,也适合接进异步运行时。扁平化不再缓冲,标签写在后面也能给出路径。错误带行号列号和路径,下面这条来自项目文档。
invalid value: must be between 1 and 65535 at line 10 column 8 (path: listeners[1].port)
原库在同类错误上给出的位置常常是文档末尾,因为值被缓冲之后,格式知道的位置信息已经丢了。
扩展与格式覆盖
核心数据模型只剩原子、映射和序列三种。日期时间之类做成扩展值,认识的格式保留原样,不认识的写成字符串。层是中间件,可以加路径、限额、改键名、脱敏,不用碰具体代码路径。派生属性从字符串改成真正的 Rust 表达式。适配器是类型,能套进 Option 和 Vec,校验也是一种适配器。格式覆盖很全,JSON 家族、CBOR、MessagePack、YAML、TOML、XML、plist、CSV 都有,XML 是原库明确不支持的领域。需要提前知道类型的二进制格式被有意排除。
代价写清楚了
新设计靠动态分发和堆上的接收器发射器,运行时开销明显。实测数据里,JSON 读取从快三成三到慢六成不等,平均慢一成,写入平均打平,YAML 和 TOML 明显更快。派生代码的编译时间比原来短,因为它不做全量单态化。内部用了 unsafe 把借用的接收器链放堆上。最大的成本是生态。孤儿规则让原库的地位很难动摇,迁移要从验证概念开始。
什么时候值得看一眼
要处理不可信输入、深嵌套、需要边收边解析、或者必须支持 XML 的项目,值得读一遍设计。已经在原库生态里且没有这些痛点的,迁移收益抵不上成本。
常见问题
深嵌套真的会打挂进程吗?
会。递归路径上没有上限时,栈先耗尽。新库把状态放堆上,嵌套深度和栈空间脱钩。
性能差一成能接受吗?
看负载。 reading 平均慢一成,写入打平,YAML 和 TOML 还更快。解析不在关键路径上就没必要在意。
XML 支持是顺手做的吗?
不是。XML 需要按命名空间匹配名称,原库以不自描述为由明确不支持,新库把它当成必须补上的一块。
能两个库混用吗?
可以互相桥接,迁移期常用这种做法。长期维护两套序列化路径的成本要提前算。