#binary-format #binary-encoding #serialization #schema #evolution #binary #serde

fcode

支持模式演化的Serde二进制序列化/反序列化策略

1 个稳定版本

1.0.0 2021年2月27日

#2186编码

Download history 18/week @ 2024-03-30 5/week @ 2024-04-06

216 每月下载量

MIT 许可证

58KB
1.5K SLoC

Fcode

Fcode是Rust的Serde框架的序列化/反序列化对。Fcode将数据序列化为二进制格式,允许进行一些模式演化。

API

请参阅https://docs.rs/fcode

动机

编写此库的初衷是一个需要通过TCP进行高吞吐量通信,具有合理低延迟要求的多个应用程序的项目。项目经历了以下阶段

  • 原型最初使用Bincode。Bincode非常快且简单,但它不支持任何模式演化。这对于只需要序列化数据的单个二进制文件来说是可以的。然而,我希望能添加字段并执行滚动更新。

  • 然后我将整个项目移到了使用stepancheg的实现的协议缓冲区。我不太喜欢这个实现,主要是因为每个结构体都有一些额外的字段用于账务管理;我总是使用..Default::default()来创建,这使得很容易错过某个新添加的字段。

  • 我尝试了Google Flatbuffers。 “零拷贝读取”的想法听起来非常好,但我想在我的代码中进一步使用生成的对象,将它们存储在映射中,通过队列等。这是不可能的与Flatbuffers(好吧,与它的任何实现一样)。因此,我最终将我想再次使用的每个对象都编写成了普通的Rust结构体,并添加了一个手动转换层。所以最终还是多了一个序列化/反序列化步骤,这有点违背了初衷。此外,我不太喜欢特定的实现,如果某些事情不太对,它会导致panic,或者做一些疯狂的事情,比如盲目地转换枚举。

  • 最后,我又回到了使用Prost实现的协议缓冲区。我对这个实现相当满意。生成的结构体既整洁又清晰,并且很容易扩展。

我仍然有一些关于整洁的Prost生成的结构体的问题。

  • 枚举字段转换为,这是正确的,因为枚举可能通过新的值进行扩展,接收者可能不知道这些值。但这不是很方便,因为每次访问都需要另一个解码步骤。此外,我有一些枚举永远不会扩展(例如,买卖),我更愿意得到反序列化错误。Serde将枚举反序列化为它们的正确类型,并允许使用#[serde(other)]属性来处理未知值。

  • 单例字段与枚举有类似的问题。并且类型的嵌套很麻烦,即单例字段不能作为顶级消息。

  • 必需/可选字段的故事很烦人。在proto2语法中,可以声明必需与可选,并且运行良好。但根据Google,不能在以后添加必需字段,其实施将实际上对缺失的必需字段进行抱怨(Prost做正确的事,即初始化为0/假/空)。proto3语法不区分必需与可选,但有时你希望有一个显式的可选标量而不是额外的布尔值;此外,Prost使所有嵌套结构都是可选的,这在某些时候也很烦人。

  • 在一个案例中,我在消息中嵌入blob,在这种情况下,零拷贝编码/解码实际上是很有益的。使用protobufs,我将它们复制到消息中,然后在序列化期间再次复制到发送缓冲区。使用Serde(大多数格式)我可以选择。

所以最终我咬紧牙关,编写了我想要的序列化格式。我使用Serde,因为它提供这些很好的衍生宏,并且它被广泛使用。

线格式

线格式部分由Serde的序列化器接口约束,部分由期望的演变约束。它最终接近Google的protobuf,在空间上具有相似的结果。

每个值都以一个标签字节开始。标签字节的低3位指定线类型。然后,如果线类型表示应跟随后跟一个varint,则标签字节中的高5位是该varint的一部分(值的最低4位和停止位)。因此,布尔值和小于16的整数值在线上只占用一个字节。

可能的线类型有

名称 后续内容
0 整数 varint的剩余位
1 fixed32 4字节小端
2 fixed64 8字节小端
3 序列 varint长度,后跟N个单独编码的项目
4 字节 varint长度,后跟N个字节
5 变体 varint判别器,后跟一个单一的项目
6 保留
7 保留

使用此方案,总是可以在不知道Rust类型的情况下跳过一项。这对于结构中的新字段和未知的枚举变体很重要。

所有整数都编码为varint。有符号整数首先使用zig-zag方法编码为无符号整数(与protobufs相同),因此发送者和接收者必须就有符号性达成一致。布尔值编码为整数0或1,解码为0或非0。单元类型编码为整数0,但解码器只是跳过该字段而不检查线类型。解码器还允许固定32位和固定64位线类型分别为32位和64位整数,以防有一天我们可以向serde暗示值必须以这种方式编码。

除了这个5位额外字段外,varint的编码与protobufs相同,每字节7位信息,第7位为连续位,最低位优先。例如,值10042(0b10011100111010)将被编码为

  11010000        11110011        00000100
  -               -               -
  |-> continue    |-> continue    |-> stop

   ----            -------         -------
    |-> bit 0-3      |-> bit 4-10    |-> bit 11-17

       ---
        |-> wire type 0 = integer

  -> D0 F3 04

浮点类型 f32f64 被编码为 fixed32 和 fixed64 小端值,与 protobufs 相同。

结构体被编码为序列:字段计数随后是字段,按照字典顺序。相同的格式用于元组、元组结构体、数组和真实序列(VecVecDeque),因此所有这些类型都是可互换的。

映射被编码为交替键和值的序列。长度指定编码值的总数(即映射长度 * 2)。

字符串和 blob 被编码为字节计数随后是内容。内容不会被其他方式编码。请注意,serde-derive 通常将 Vec<u8>&[u8] 作为序列序列化 - 有关详细信息,请参阅 serde_bytes crate。

枚举值使用区分器和内容进行编码。即使在单元变体的情况下,内容始终存在。请注意,当使用 serde-derive 时,区分器(据我所知)不是 在代码中可选设置的 "枚举值",而是变体的词法索引。

最后,newtype 结构体和 newtype 变体(Foo(i32)MyEnum::Foo(i32))被编码为内部值。因此,单项命名字符串不能扩展,但任何类型都可以升级为 newtype 结构体。

性能

简单的性能测量表明,fcode 比 bincode 慢约 2 倍(取决于使用的类型)。它似乎比 protobufs(Prost 实现)快得多,比 JSON 快得多。线尺寸与 protobufs 非常相似。

未来工作

没有具体计划。

如果在某个时候我们可以通过一些属性将固定 32 位和固定 64 位整数告诉 Serde,那将非常棒;varints 对于一般情况很好,但有些整数总是很大(ID,nano Posix 时间戳),在这种情况下 varint 编码效率不高。

同样,将标量序列打包在一起会很好,特别是在小端机器上,这样我们就可以直接引用读取缓冲区。

我在考虑是否要编写专门的 derive 宏来解决 Serde 之外的问题。但是,那样就会打开许多更多可能性,可能需要一个更优化的整个格式。

依赖项

~0.4–1MB
~23K SLoC