FlatBuffers FlexBuffers 完全指南:无模式、零拷贝的二进制序列化格式
2026/9/10 15:16:46 网站建设 项目流程

FlatBuffers FlexBuffers 完全指南:无模式、零拷贝的二进制序列化格式

【免费下载链接】flatbuffersFlatBuffers: Memory Efficient Serialization Library项目地址: https://gitcode.com/GitHub_Trending/fl/flatbuffers

FlexBuffers 是 FlatBuffers 项目内置的一种无模式(schema-less)二进制序列化格式,专为「无法预知数据结构」的场景设计。本文以其官方文档 docs/source/flexbuffers.md 为骨架,结合 include/flatbuffers/flexbuffers.h 的源码实现、docs/source/internals.md 的编码规范与 tests/flexbuffers_test.cpp 的测试用例,系统讲解 FlexBuffers 的定位、C++/Java 使用方式、底层二进制布局与效率优化技巧,帮助你决定何时使用它,以及如何在 FlatBuffers 项目中内嵌自由格式数据。

FlexBuffers 的定位:为什么需要一种无模式格式

FlatBuffers 的核心理念是「强类型 + 模式(schema)驱动」:用.fbs文件定义表、字段类型与默认值,换来极致的性能与数据一致性。但现实中有大量数据在写代码时无法预知结构,例如日志字段、用户自定义属性、AI 模型输出、动态配置等。

FlexBuffers 正是为这种场景设计的独立二进制格式,它有两条使用路径:

  • 独立使用:作为一个完整的自描述序列化格式,单独编码、传输与解码;
  • 嵌套使用:作为 FlatBuffers 表中的一个字段([ubyte] (flexbuffer))内嵌,让结构化数据中携带一段自由格式数据。

与普通 FlatBuffers 相比,FlexBuffers 放弃了强类型,却保留了 FlatBuffers 最独特的优势——访问数据时无需解析、无需拷贝、无需对象分配。这意味着你可以直接对内存中的字节做随机访问,甚至可以将大量自由格式数据直接 mmap 到内存中使用,这是大多数动态格式(如 JSON)无法做到的。

同时,由于采用自动位宽收缩与字符串/键池化,FlexBuffers 在很多场景下生成的二进制体积甚至小于普通 FlatBuffers。需要明确的是:官方文档明确指出FlexBuffers 的读写速度仍然慢于普通 FlatBuffers,因此建议仅在确实需要无模式能力时才使用它。

快速上手(C++):三行代码序列化一个整数

C++ 使用只需包含头文件flexbuffers.h,它内部依赖flatbuffers.hutil.h

#include "flatbuffers/flexbuffers.h" flexbuffers::Builder fbb; fbb.Int(13); fbb.Finish();

完成编码后,通过fbb.GetBuffer()即可拿到承载编码结果的std::vector<uint8_t>,随后可以写入文件、发送到网络,或存储到父级 FlatBuffer 中。就这个例子而言,整个缓冲区只有3 个字节

读取同样简单:

auto root = flexbuffers::GetRoot(my_buffer); int64_t i = root.AsInt64(); // 13

这里体现了 FlexBuffers 的一个关键设计:整数只按实际需要的大小存储,不对 int8/int16/int32/int64 做区分。因此无论底层存了多少位,你都可以用AsInt64()读取;如果缓冲区里实际存的是浮点数或数字字符串,AsInt64()甚至会在读取时自动转换,无法转换时返回 0。若想先探明内部真实类型,可以调用root.GetType()root.IsInt()等方法(对应源码 include/flatbuffers/flexbuffers.h 中Reference类提供的能力)。

与 FlatBuffers 最大的不同在于根节点约束:FlatBuffers 要求根必须是表(table),而FlexBuffers 允许任意值作为根,哪怕只是一个孤独的整数。

构建复杂值:Map、Vector 与类型混用

通过Builder的 Lambda 接口可以构建任意嵌套结构。例如构造等价于 JSON{ vec: [ -100, "Fred", 4.0 ], foo: 100 }的值:

fbb.Map([&]() { fbb.Vector("vec", [&]() { fbb.Int(-100); fbb.String("Fred"); fbb.IndirectFloat(4.0f); }); fbb.UInt("foo", 100); }); fbb.Finish();

其中 Map 构造器使用 C++11 Lambda 聚合子节点,如果你更喜欢传统风格,也可以改用StartMap()/EndMap()StartVector()/EndVector()的 start/end 调用(见源码 include/flatbuffers/flexbuffers.h)。

几个值得注意的特性:

  • 键必须显式存储:与 FlatBuffers 不同,Map 的键(vecfoo)需要真实写入缓冲区;不过通过键池化(key pooling),多个相同结构的对象只需存一份键。
  • 键无任何限制:FlexBuffers 对字段名没有预定义约束,你可以动态使用任意键。
  • 允许混用类型:上面的 Vector 同时包含整数、字符串和浮点数;FlatBuffers 的向量做不到这一点。
  • TypedVector 变体:如果向量内元素类型单一,可以使用TypedVector,它省略类型字节,占用更少内存。
  • IndirectFloat的意义:它把值改为「按偏移存储」而非内联。表面无差别,实际有两个收益:一是重复出现的大值(尤其是 double 或 64 位整数)可以共享同一份存储(配合ReuseValue,见 include/flatbuffers/flexbuffers.h);二是向量内元素位宽由最大元素决定——单个 double 会把整个向量撑到 64 位,此时把 double 改为间接存储,可以让一批小整数继续享受 8 位编码,显著省空间。

读取复杂结构

auto map = flexbuffers::GetRoot(my_buffer).AsMap(); map.size(); // 2 auto vec = map["vec"].AsVector(); vec.size(); // 3 vec[0].AsInt64(); // -100 vec[1].AsString().c_str(); // "Fred" vec[1].AsInt64(); // 0 (Number parsing failed). vec[2].AsDouble(); // 4.0 vec[2].AsString().IsTheEmptyString(); // true (Wrong Type). vec[2].AsString().c_str(); // "" (This still works though). vec[2].ToString().c_str(); // "4" (Or have it converted). map["foo"].AsUInt8(); // 100 map["unknown"].IsNull(); // true

这段代码揭示了 FlexBuffers 读取端的宽容语义:

  • 对不存在的键map["unknown"]会返回一个 null 引用,IsNull()为 true,而不是抛异常;
  • 类型不匹配时各As*方法会尽力转换:"Fred"解析为数字失败返回 0,4.0转字符串返回空串,ToString()则会把任意类型统一转成std::string"4")——源码中Reference::ToString()对 Map/Vector 还会递归展开(include/flatbuffers/flexbuffers.h)。

Java 用法:与 C++ 实现一一对应

Java 实现与 C++ 高度对齐。构建同样的 JSON{ vec: [ -100, "Fred", 4.0 ], foo: 100 }

FlexBuffersBuilder builder = new FlexBuffersBuilder(ByteBuffer.allocate(512), FlexBuffersBuilder.BUILDER_FLAG_SHARE_KEYS_AND_STRINGS); int smap = builder.startMap(); int svec = builder.startVector(); builder.putInt(-100); builder.putString("Fred"); builder.putFloat(4.0); builder.endVector("vec", svec, false, false); builder.putInt("foo", 100); builder.endMap(null, smap); ByteBuffer bb = builder.finish();

读取:

FlexBuffers.Map map = FlexBuffers.getRoot(bb).asMap(); map.size(); // 2 FlexBuffers.Vector vec = map.get("vec").asVector(); vec.size(); // 3 vec.get(0).asLong(); // -100; vec.get(1).asString(); // "Fred"; vec.get(1).asLong(); // 0 (Number parsing failed). vec.get(2).asFloat(); // 4.0 vec.get(2).asString().isEmpty(); // true (Wrong Type). vec.get(2).asString(); // "" (This still works though). vec.get(2).toString(); // "4.0" (Or have it converted). map.get("foo").asUInt(); // 100 map.get("unknown").isNull(); // true

可以看到 Java 端的asLong/asFloat/asString/toString与 C++ 端的AsInt64/AsDouble/AsString/ToString一一对应,同样的宽松转换与 null 语义。Java 实现位于 java/src/main/java(FlexBuffersBuilderFlexBuffers类)。

其他语言的实现

FlexBuffers 不止 C++ 与 Java:仓库还提供了 python/flatbuffers/flexbuffers.py(含BitWidthType枚举及Object/Sized/Blob/String/Key/Vector/TypedVector/Map等读取端类型体系,并有配套测试 tests/py_flexbuffers_test.py)、TypeScript 实现 ts/flexbuffers.ts 与 ts/flexbuffers 目录、Swift 实现 swift/Sources/FlexBuffers、Rust 实现 rust/flexbuffers(Cargo 包flexbuffers)等,API 风格均对齐 C++ 参考实现。

二进制编码原理:FlexBuffers 为何如此紧凑

FlexBuffers 的详细编码规范记录在 docs/source/internals.md 的 FlexBuffers 章节。它继承了 FlatBuffers 的通用约定:所有数据通过偏移访问、所有标量按自身大小对齐、一律使用小端序。但有三点本质差异:

  1. 构建方向相反:FlatBuffers 从缓冲区末尾向前构建,FlexBuffers 则从前向后构建——子节点先于父节点写入,根数据落在缓冲区最后一个字节附近。
  2. 标量位宽可变:整数/浮点按 8/16/32/64 位动态编码,当前位宽由父容器决定;向量会统一为其所有元素选定一个最小可用位宽,编码器自动完成,用户通常无需干预。
  3. 偏移只有一种:FlexBuffers 只有无符号偏移,表示「从自身存储地址向负方向偏移的字节数」——子数据总是存储在父数据之前。

向量的字节布局

向量是理解 FlexBuffers 的核心(Map 本质上是两个向量的组合)。例如整数值1, 2, 3编码为:

uint8_t 3, 1, 2, 3, 4, 4, 4
  • 第一个3大小字段,位于向量之前——父节点的偏移指向第一个元素而非大小字段,因此大小字段实际处于索引-1的位置;
  • 元素1, 2, 3之后是类型字节:这是无类型向量(SL_VECTOR),每个元素跟一个类型字节,类型字节永远是uint8_t,即使元素本身是更宽的标量。

类型字节的构成

每个类型字节由两部分组成(完整取值见 include/flatbuffers/flexbuffers.h 的BitWidthType枚举):

  • 低 2 位:子元素的位宽(8/16/32/64)。仅在子元素通过偏移访问时(如子向量)使用,内联类型时忽略;
  • 高 6 位:实际类型。例如FBT_INT = 1FBT_UINT = 2FBT_FLOAT = 3FBT_STRING = 5FBT_MAP = 9FBT_VECTOR = 10FBT_BLOB = 25FBT_BOOL = 26等。

上述例子中的类型字节4即表示:8 位宽(值为 0,内联类型不使用)+ 类型SL_INT(值为 1)。

类型化向量与固定长度向量

  • TypedVectorTYPE_VECTOR_INT/TYPE_VECTOR_UINT/TYPE_VECTOR_FLOAT/TYPE_VECTOR_KEY):与普通向量相同但省略类型字节,类型由父节点提供的向量类型决定,仅对少数类型开放以换取可观的空间节省;
  • FixedTypedVectorTYPE_VECTOR_INT2TYPE_VECTOR_FLOAT4):长度为 2/3/4 的固定长度向量,连大小字段都不存,适合常见的点坐标或颜色数据(RGBA 四元素),空间进一步压缩。

标量、布尔与 null

  • 整数(TYPE_INT/TYPE_UINT)与浮点(TYPE_FLOAT)可按前述位宽内联存储,也可通过TYPE_INDIRECT_*按偏移存储——后者适合把昂贵的 64 位(甚至 32 位)量放进小位宽向量/Map 中,并支持多处处共享同一值;
  • 布尔(TYPE_BOOL)与 null(TYPE_NULL)编码为内联的无符号整数(bool 用 0/1 表示)。

Blob、字符串与键

  • BlobTYPE_BLOB):编码类似向量,但元素固定为uint8_t。父位宽只决定大小字段的宽度,因此blob 可以很大而不会把元素撑宽
  • 字符串TYPE_STRING):类似 blob,额外多一个0终止字节,且必须为 UTF-8 编码(便于不支持 UTF-8 指针的语言转换成本地字符串);
  • TYPE_KEY):类似字符串但不存大小字段,因为 Map 查找不需要大小,可以更紧凑;代价是数据内不能包含值为 0 的字节(长度只能靠strlen判定)。虽然键也可以在 Map 之外使用,但官方建议普通场景优先用字符串。

Map 的布局与二分查找

Map 与无类型向量类似,但大小字段前有两个前缀

index字段
-3指向键向量(keys vector)的偏移(可在多个表之间共享)
-2键向量的字节宽度
-1大小(从这里开始与TYPE_VECTOR兼容)
0元素
Size类型字节

键向量是键的 TypedVector。键与对应值都必须按strcmp排序存储,这样查找才能使用二分搜索。键向量与值向量分离的原因在于:键向量可以被多个值向量共享,也能在代码中被单独当作向量处理。

文档给出了{ foo: 13, bar: 14 }的完整字节级示例:

0 : uint8_t 'b', 'a', 'r', 0 4 : uint8_t 'f', 'o', 'o', 0 8 : uint8_t 2 // key vector of size 2 // key vector offset points here 9 : uint8_t 9, 6 // offsets to bar_key and foo_key 11: uint8_t 2, 1 // offset to key vector, and its byte width 13: uint8_t 2 // value vector of size // value vector offset points here 14: uint8_t 14, 13 // values 16: uint8_t 4, 4 // types

注意编码器在 include/flatbuffers/flexbuffers.h 的EndMap中会对键值对自动排序并检测重复键(HasDuplicateKeys()),从而保证二分查找的正确性。

根节点的编码

由于根没有父节点,其位宽只能自描述。缓冲区最后一个字节是根的字节宽度,倒数第二个字节是根的类型,之前的数据是根的值。例如根为整数13

uint8_t 13, 4, 1 // Value, type, root byte width.

将 FlexBuffers 嵌套进 FlatBuffer

.fbs模式中,可以用flexbuffer属性把某个字段声明为 FlexBuffers 数据:

a:[ubyte] (flexbuffer);

解析器对此有严格校验:源码 src/idl_parser.cpp 明确要求flexbuffer属性只能应用于vector of ubyte,否则报错。启用后:

  • 代码生成器会为该字段生成专用访问器,直接返回 FlexBuffers 根引用,例如a_flexbuffer_root().AsInt64(),免去手工调用GetRoot的步骤;
  • 在 JSON 解析路径中,flexbuffers::Builder会参与把 JSON 值编码为内嵌 FlexBuffer(见 src/idl_parser.cpp,构建时使用BUILDER_FLAG_SHARE_ALL并强制对齐)。

这样你就能在「强类型的表」中安全地嵌入一段「自由的动态数据」,同时享受零拷贝访问。

效率优化建议

官方文档总结了若干经过实践验证的优化原则,配合源码可以进一步理解其原理:

  1. 优先使用 Vector 而非 Map:向量远比 Map 高效。对于小型对象,与其用x/y/z三个键的 Map,不如直接用向量;更好的是 TypedVector;最好的是固定长度 TypedVector(如坐标、颜色)。
  2. 善用 Map 的向量兼容性:Map 向后兼容向量,可以直接按向量迭代——只迭代值用map.Values(),需要并行访问键则用map.Keys()(见 include/flatbuffers/flexbuffers.h)。如果打算访问大部分元素,按迭代器顺序遍历比逐个按键查找更快,因为按键查找涉及对键向量的二分搜索(源码 python/flatbuffers/flexbuffers.py 的_LowerBound/_BinarySearch同样体现了这一设计)。
  3. 避免位宽污染:不要把需要大位宽的值(如 double)混入大量小值向量——向量元素会统一升级位宽。此时应改用IndirectDouble。整数会自动选择最小位宽(存一个值很小的int64_t实际只占几个比特);double 在可无损表示时会被自动降为 float,但这种情况很少。嵌套的向量/Map 因为按偏移存储,通常不影响外层向量位宽。
  4. 大数组用 Blob:存储大量字节数据应使用 blob。若用 TypedVector,大小字段的位宽可能让体积超过预期,且不兼容memcpy;超过 64k 元素的大规模(u)int16_t数组也建议存成二进制 blob。blob 的构建与使用方式与字符串类似。

测试与验证

仓库通过 tests/flexbuffers_test.cpp 对上述行为做了完整验证:FlexBuffersTest()覆盖键/字符串复用、blob 访问等场景;FlexBuffersDeprecatedTest()则专门验证已废弃的FBT_VECTOR_STRING_DEPRECATED类型(该类型在 include/flatbuffers/flexbuffers.h 中被标记为 DEPRECATED,建议改用FBT_VECTORFBT_VECTOR_KEY)。Python 侧有 tests/py_flexbuffers_test.py,Rust 侧则通过flexbufferscrate 提供等价的测试矩阵。

小结

FlexBuffers 是 FlatBuffers 家族中「以空间换约束」的互补成员:它牺牲强类型,换来零解析、零拷贝、零分配的随机访问,以及通过自动位宽收缩、键/字符串池化实现的极致紧凑体积;又通过 Map 的排序键向量 + 二分查找、TypedVector/FixedTypedVector/Blob 等特化类型,把无模式数据的读写效率推向极致。当你面对动态结构数据、需要 mmap 大规模自由格式数据、或想在强类型 FlatBuffer 中内嵌一段动态负载时,FlexBuffers 是官方提供的首选方案——但请记住它的定位:仅在确实需要无模式能力时使用,常规场景下普通 FlatBuffers 依然更快。

【免费下载链接】flatbuffersFlatBuffers: Memory Efficient Serialization Library项目地址: https://gitcode.com/GitHub_Trending/fl/flatbuffers

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询