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.h与util.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 的键(
vec、foo)需要真实写入缓冲区;不过通过键池化(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(FlexBuffersBuilder与FlexBuffers类)。
其他语言的实现
FlexBuffers 不止 C++ 与 Java:仓库还提供了 python/flatbuffers/flexbuffers.py(含BitWidth、Type枚举及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 的通用约定:所有数据通过偏移访问、所有标量按自身大小对齐、一律使用小端序。但有三点本质差异:
- 构建方向相反:FlatBuffers 从缓冲区末尾向前构建,FlexBuffers 则从前向后构建——子节点先于父节点写入,根数据落在缓冲区最后一个字节附近。
- 标量位宽可变:整数/浮点按 8/16/32/64 位动态编码,当前位宽由父容器决定;向量会统一为其所有元素选定一个最小可用位宽,编码器自动完成,用户通常无需干预。
- 偏移只有一种: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 的BitWidth与Type枚举):
- 低 2 位:子元素的位宽(8/16/32/64)。仅在子元素通过偏移访问时(如子向量)使用,内联类型时忽略;
- 高 6 位:实际类型。例如
FBT_INT = 1、FBT_UINT = 2、FBT_FLOAT = 3、FBT_STRING = 5、FBT_MAP = 9、FBT_VECTOR = 10、FBT_BLOB = 25、FBT_BOOL = 26等。
上述例子中的类型字节4即表示:8 位宽(值为 0,内联类型不使用)+ 类型SL_INT(值为 1)。
类型化向量与固定长度向量
- TypedVector(
TYPE_VECTOR_INT/TYPE_VECTOR_UINT/TYPE_VECTOR_FLOAT/TYPE_VECTOR_KEY):与普通向量相同但省略类型字节,类型由父节点提供的向量类型决定,仅对少数类型开放以换取可观的空间节省; - FixedTypedVector(
TYPE_VECTOR_INT2至TYPE_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、字符串与键
- Blob(
TYPE_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并强制对齐)。
这样你就能在「强类型的表」中安全地嵌入一段「自由的动态数据」,同时享受零拷贝访问。
效率优化建议
官方文档总结了若干经过实践验证的优化原则,配合源码可以进一步理解其原理:
- 优先使用 Vector 而非 Map:向量远比 Map 高效。对于小型对象,与其用
x/y/z三个键的 Map,不如直接用向量;更好的是 TypedVector;最好的是固定长度 TypedVector(如坐标、颜色)。 - 善用 Map 的向量兼容性:Map 向后兼容向量,可以直接按向量迭代——只迭代值用
map.Values(),需要并行访问键则用map.Keys()(见 include/flatbuffers/flexbuffers.h)。如果打算访问大部分元素,按迭代器顺序遍历比逐个按键查找更快,因为按键查找涉及对键向量的二分搜索(源码 python/flatbuffers/flexbuffers.py 的_LowerBound/_BinarySearch同样体现了这一设计)。 - 避免位宽污染:不要把需要大位宽的值(如 double)混入大量小值向量——向量元素会统一升级位宽。此时应改用
IndirectDouble。整数会自动选择最小位宽(存一个值很小的int64_t实际只占几个比特);double 在可无损表示时会被自动降为 float,但这种情况很少。嵌套的向量/Map 因为按偏移存储,通常不影响外层向量位宽。 - 大数组用 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_VECTOR或FBT_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),仅供参考