本文将探讨在基于WASM的牧云插件系统开发中序列化方法的选择。我们将对比不同序列化方法在性能、安全性、易用性等方面的优缺点,并解释我们为什么最终选择了哪种序列化方法。
这是系列文章“主机Agent插件引擎开发故事”的第七篇,后续将会持续更新。该系列文章将带领您深入探究长亭牧云团队主机Agent插件引擎的开发历程,内容涵盖技术选型、插件接口设计、组件通信框架等多个方面,并详细讲解背后的原理和实现方式,无论您是网络安全专业人员还是对技术开发感兴趣的读者,都可以从中得到收获。我们希望通过分享在开发过程中面临的挑战、解决方案以及实践经验,提供深入见解和有价值的技术参考,帮助读者了解如何构建高效可靠的安全产品,共同推动安全技术社区的发展。
在之前的文章《牧云插件系统技术选型之插件通信框架大PK》中讲述了主机Agent插件使用的通信框架,其中用到了我们自己发明的 bincode-rpc
。在 bincode-rpc
中,需要考虑的一个重大问题就是通用的 param
和 result
参数应该使用何种方式传递数据。
已知我们的目标场景是:安全(永远是第一位的)、大量小消息交换、少量复杂结构大消息交换、跨平台、高性能、单语言。
一个简单且通用的方式是放一个有公共类型标准的通用数据交换格式,在运行时根据约定解析出想要的字段,对结构和数据类型做动态的转换。这个简单方式的一个最经典的例子就是 JSON
,类似的格式还有 BSON
和 CBOR
,以及 MessagePack
。
通用数据交换格式主要的问题是空间效率和时间效率都相对较低,提供的数据类型相对较少,与编程语言内置类型不完全对应,对复杂数据结构表示有时存在歧义,反序列化回来的时候需要手动进行一些转换。
JSON 是最为广泛使用的通用数据交换格式。serde_json 是世界上最快的 JSON
序列化/反序列化库,但即使和类似格式的 cbor
相比性能也有所落后。因此其并不适合我们的场景。
BSON 是二进制格式的 JSON
格式变种,BSON
的类型是 JSON
类型的超集(几乎),增加了日期类型、字节数组类型、具体数字类型等,去除了通用数字类型(number
)。BSON
最初用于 MongoDB
文档数据库的存储和网络传输格式,为提高存储和扫描效率设计,速度更快体积更小,但由于一些索引信息的存在,有时体积会大于 JSON
。MongoDB
官方提供了 BSON
序列化反序列化的 Rust
实现,维护良好。
cbor 是 RFC 8949
的实现,标准稳定,格式类似 JSON
,无需声明 schema
,基于 serde
实现,相比 serde_json
性能更好,但仓库已停止维护,同时 Rust
社区内没有其他成熟活跃的 cbor
实现。在序列化和反序列化速度上,耗时大约是 bincode
的 3 倍。
MessagePack 以前我们也在用,最主要的原因是 C
语言实现确实很快,主要目的是为了拓展 Go
与 Lua
的复杂数据结构通信能力(中间涉及 CGO
和 Lua
的 C API
打交道的部分,相关的内容和场景在前面的文章中有详述),但在我们新的场景下并没有特别的优势。一来,据说作为一种二进制序列化格式甚至可能比 Gzip
压缩的 JSON
格式数据还要大。二来,广泛反映兼容性存在很大的问题。值得一提的是,MessagePack
支持数字类型基于变长编码的压缩。
另一种思路是使用为专门场景设计的,数据类型更丰富,相对没有那么通用的序列化方法,可以很好地解决传统序列化中通用数据交换格式的一些痛点。
传统序列化的一个主要缺点是需要相当长的时间从类型的序列化值读取、解析和重新构造类型。例如,在 JSON
中,字符串通过用双引号将内容括起来来编码,并且转义其中的无效字符:
1{ "line": "\"All's well that ends well\"" }
2 ^^ ^ ^
数字转换为字符:
1{ "pi": 3.1415926 }
2 ^^^^^^^^^
甚至在大多数情况下隐式的字段名称也会转换为字符串:
1{ "message_size": 334 }
2 ^^^^^^^^^^^^^^^
所有这些字符不仅占用了空间,而且还占用了时间。每次我们阅读并解析 JSON
的时候都必须处理这些字符以找出实际的值是什么,并在内存中重新构造它们。一个 f32
类型的变量只有四个字节,但它使用九个字节进行编码,我们必须将这九个字符在反序列化时重新处理,变成正确的 f32
类型。
这种反序列化消耗的时间会很快累积起来,在数据密集型应用程序中这部分处理占据了程序运行时间的主要部分。
零拷贝反序列化的技术是解决这类问题的主要思路,重新定义数据的二进制表示方法是另一种思路。
依据我们的场景,主要考虑的是 rkyv
/ bincode
/Protocol Buffers
/ FlatBuffers
。
rkyv 是其中最快的,序列化/反序列化/结果大小/内存消耗/社区维护综合表现是最优的,采取在代码内部声明 schema
的形式。但是默认无格式检查,内部使用 unsafe 方法实现,提供了 safe
和 unsafe
的两种序列化接口函数。格式检查需要单独增加,检查方法相对麻烦,加上检查之后综合表现仍然是最好的,但 bincode
与加上检查版本的 rkyv
差距非常小。在 benchmark
中,耗时比 bincode
减少了约 100 微秒。
bincode 是一种专为 Rust
设计的紧凑序列化/反序列化方法。速度很快,内存消耗也很小,采取在代码内部声明 schema
的形式,带有完整格式检查,使用 safe
代码实现,可选大小限制,承诺序列化后的占用空间小于等于在 Rust
中运行时的空间占用,综合表现很优秀。缺点是仅适用于 Rust
,以及序列化内容大小没有特别的优势(尽管也支持数字类型变长编码)。对于大量小消息的 Rust
程序本机通信来说,是非常合适的。同时,其提供了流式的序列化反序列化API,非常不错。除此之外,Google
的 tarpc
依赖了该库,这一事实为未来的发展和复杂场景的兼容性问题提供了一些信心。
prost 是成熟且流行的 Protocol Buffers
实现,需要单独的 proto 文件声明 schema,相比这里列出的其他序列化方法较为重量级。Protocol Buffers
的序列化方法通过为整数提供变长编码提高了空间效率,但这种编码本身的编解码需要处理,还存在大量冗余字段。由于格外强调语言中性和平台中性,整体效率上在一众二进制序列化方法中并不特别出色,胜在广泛支持和良好的兼容性。
flatbuffers 是专注于 Memory Efficient
的序列化库,确实基本实现了设计目标,但在大型复杂结构的测试数据上出现了明显的性能退步。在序列化和反序列化速度上,耗时大约是 bincode
的 4 倍。与 Protocol Buffers
对比,一般情况下序列化更慢(编码处理更多),反序列化更快(FlatBuffers 是一种无需解码的二进制格式),因此在设计上适合读多写少的场景。优势似乎主要在读快以及空间效率高,但这对于本机频繁通信来说是没有意义的。
还有一些特别的序列化方式,似乎在某些方面表现极为出众,我们将逐一讲述不选择他们的理由。
Abomonation:最快,近三年没有维护,非常不安全(每次序列化和反序列都要使用 unsafe 块),完全使用指针技巧完成,为了方便处理内存对齐问题导致存在数据放大,大量小字符串时问题尤其严重。
Apache Thrift:整合了 RPC,内置了复杂的传输层和网络模型相关的东西,这些都是我们不想要的。
Borsh:号称为安全攸关的系统设计,但其关键特性,即提供的序列化对象与其二进制表示的双射特性对于本地通信来说实在是没有什么意义。而处理速度在大多数情况下都没有其他类似的库快。观察其应用,似乎主要用于区块链相关项目的通信协议。
Cap'n Proto:采用了类似 C 结构体的布局方法,因此是平台无关的,也是语言无关的。这也是一种无需解码的格式。但当用于编码含有多维向量的大型数据结构时,速度明显减慢,而且占用了非常大的空间。
postcard:专注于提供 no_std
的体验,就性能来说与 bincode
相近或更优。但 no_std
本身并不是我们关注的重点,因此获得的好处与我们无关,因此作出的牺牲却可能是我们需要的。
众所周知,每次涉及到一个新的系统做关键的技术选型的时候,都是团队内部鸡飞狗跳的时候。往大了说,这涉及未来数年的成本和效益;往再大了说,这体现的是个人的审美和技术能力;往小了说,也许只是支持各种技术的忠实拥趸在互怼。
我们想补充的一个原则是:要安全,不要黑魔法。过度使用 Tricks
虽然能获得超规格的收益,但最终也可能会获得超规格的成本。
综上,考虑到成熟/安全/快速的平衡,在我们的场景下选择 bincode
作为序列化方法较为合适。
* 除官方文档外,主要参考 rust_serialization_benchmark 的测试方法和结果。
系列文章目录:【预告】主机Agent插件引擎开发故事汇总