FlatBuffers:21.7k star,深入浅出FlatBuffers之Encode

FlatBuffers:21.7k star,深入浅出FlatBuffers之Encode一 FlatBuffers 生成二进制流 FlatBuffers 的使用和 Protocol buffers 基本类似 只不过功能比 Protocol buffers 多了一个解析 JSON 的功能 编写模式文件 描述数据结构和接口定义 使用

大家好,欢迎来到IT知识分享网。

一 FlatBuffers 生成二进制流

FlatBuffers 的使用和 Protocol buffers 基本类似。只不过功能比 Protocol buffers 多了一个解析 JSON 的功能。

  • 编写模式文件,描述数据结构和接口定义。
  • 使用flatc编译,生成相应语言的代码文件。
  • 解析JSON数据,把数据存储成对应的schema,并存入FlatBuffers二进制文件中。
  • 使用FlatBuffers支持的语言(如C++,Java等)生成的文件进行开发。

接下来简单的定义一个模式文件,来看看 FlatBuffers 的使用。

// Example IDL file for our monster's schema. namespace MyGame.Sample; enum Color:byte { Red = 0, Green, Blue = 2 } union Equipment { Weapon } // Optionally add more tables. struct Vec3 { x:float; y:float; z:float; } table Monster { pos:Vec3; // Struct. mana:short = 150; hp:short = 100; name:string; friendly:bool = false (deprecated); inventory:[ubyte]; // Vector of scalars. color:Color = Blue; // Enum. weapons:[Weapon]; // Vector of tables. equipped:Equipment; // Union. path:[Vec3]; // Vector of structs. } table Weapon { name:string; damage:short; } root_type Monster; 

使用flatc编译之后,利用生成的文件就可以开始开发了。

import ( flatbuffers "github.com/google/flatbuffers/go" sample "MyGame/Sample" ) // 创建 `FlatBufferBuilder` 实例, 用它来开始创建 FlatBuffers ,初始化大小 1024 // buffer 的大小会根据需要自动增长,所以不必担心空间不够 builder := flatbuffers.NewBuilder(1024) weaponOne := builder.CreateString("Sword") weaponTwo := builder.CreateString("Axe") // 创建第一把武器,剑 sample.WeaponStart(builder) sample.Weapon.AddName(builder, weaponOne) sample.Weapon.AddDamage(builder, 3) sword := sample.WeaponEnd(builder) // 创建第二把武器,斧 sample.WeaponStart(builder) sample.Weapon.AddName(builder, weaponTwo) sample.Weapon.AddDamage(builder, 5) axe := sample.WeaponEnd(builder) 

在序列化 Monster 之前,我们需要首先序列化包含在 Monster 其中的所有对象,即我们使用深度优先,先根遍历序列化数据树。这在任何树结构上通常很容易实现。

// 对 name 字段赋值 name := builder.CreateString("Orc") // 这里需要注意的是,由于是 PrependByte,前置字节,所以循环的时候需要反向迭代 sample.MonsterStartInventoryVector(builder, 10) for i := 9; i >= 0; i-- { builder.PrependByte(byte(i)) } inv := builder.EndVector(10) 

上面是代码中,我们序列化了两个内置的数据类型(字符串和数组)并获取了它们的返回值。这个值是序列化数据的偏移量,表示它们的存储位置,得到这个偏移量以后,以便我们在向 Monster 添加字段时可以参考它们。

这里的建议是,如果要创建请求对象的队列(例如表,字符串队列或其他队列),可以先把它们的偏移量收集到临时数据结构中,然后创建一个包含其偏移量的附加底层去存储所有的偏移量。

如果不是从现有数据库创建一个数据库,而是逐个序列化元素,请注意顺序,缓冲区是从后往前构建的。

// 创建 FlatBuffer 数组,前置这些武器。 // 注意:因为我们前置数据,所以插入的时候记得要逆序插入。 sample.MonsterStartWeaponsVector(builder, 2) builder.PrependUOffsetT(axe) builder.PrependUOffsetT(sword) weapons := builder.EndVector(2) 

FlatBuffer 底层现在就包含了它们的偏移量。

另外需要注意的是,处理结构体和表是完全不同的,因为结构体完全是在数据库中的存储。例如,要为上面的路径字段创建一个数据库:

sample.MonsterStartPathVector(builder, 2) sample.CreateVec3(builder, 1.0, 2.0, 3.0) sample.CreateVec3(builder, 4.0, 5.0, 6.0) path := builder.EndVector(2) 

上面已经序列化好了非标量的字段,接下来可以继续序列化标量字段了:

// 构建 monster 通过调用 `MonsterStart()` 开始, `MonsterEnd()` 结束。 sample.MonsterStart(builder) vec3 := sample.CreateVec3(builder, 1.0, 2.0, 3.0) sample.MonsterAddPos(builder, vec3) sample.MonsterAddName(builder, name) sample.MonsterAddColor(builder, sample.ColorRed) sample.MonsterAddHp(builder, 500) sample.MonsterAddInventory(builder, inv) sample.MonsterAddWeapons(builder, weapons) sample.MonsterAddEquippedType(builder, sample.EquipmentWeapon) sample.MonsterAddEquipped(builder, axe) sample.MonsterAddPath(builder, path) orc := sample.MonsterEnd(builder) 

还是需要注意如何在 table 中创建 Vec3 struct。与 table 不同,struct 是标量的简单组合,它们总是在联编方式存储内,就像标量本身一样。

重要提醒:与 struct 不同,你不应该调用序列化表或其他对象,这就是为什么我们在 start 之前就创建好了这个怪物引用的所有字符串/向量/表的原因。如果尝试在 start 和在它们之间的任何一个之间创建,根据您的语言获得断言/异常/恐慌。

在 schema 中定义了 hp 和 mana 的值,如果初始化的时候不需要默认改变,就不需要再把值添加到 buffer 中。这样这个字段就不会写入到 buffer 中,可以节省传输成本,减少 buffer 的所以设置好一个合理的默认值可以节省一定的空间。当然,不必担心缓冲区中没有存这个值,得到的时候会从另外一个地方把默认值读取出来的。

这也意味着不用担心添加很多仅在少数实例中使用的字段,它们都默认使用默认值,是不会占用缓冲区的大小的。

在完成序列化之前,再回顾一下FlatBuffer union设备。每个FlatBuffer union都有两个部分(具体描述可以看前一篇文章)。第一个是隐藏字段_type,它是为了保存union所引用的表的类型而生成的。这使您可以知道在运行时投入哪种类型。第二个字段是,union的数据。

所以我们还需要添加2个字段,一个是Equipped Type,另一个是Equipped union。具体代码在这里(上面已经初始化):

sample.MonsterAddEquippedType(builder, sample.EquipmentWeapon) // Union type sample.MonsterAddEquipped(builder, axe) // Union data 

创建缓冲区之后,你就可以获得整个数据相对于根的偏移量,通过调用finish方法结束创建,这个偏移量会保存在一个变量中,如下代码,偏移量会保存在orc变量中:

// 调用 `Finish()` 方法告诉 builder,monster 构建完成。 builder.Finish(orc) 

现在,buffer就已经构建完成了,可以通过网络发送出去,也可以通过压缩存储起来。最后通过下面这个方法完成最后一步:

// 这个方法必须在 `Finish()` 方法调用之后,才能调用。 buf := builder.FinishedBytes() // Of type `byte[]`. 

至此,可以把二进制字节写入到文件中,通过网络发送它们了。请一定要保证发送的文件模式(或者传输协议)是二进制,而不是文本。如果在文本格式下传输 FlatBuffer,则将 buffer 将会损坏,这将导致您在其他方面读取缓冲区时很难发现问题。

二 FlatBuffers 读取二进制流

FlatBuffers:21.7k star,深入浅出FlatBuffers之Encode

上一章讲了如何利用FlatBuffers把数据转为二进制流,这一节讲如何读取。

读取之前还是保证通过二进制模式读取的,其他读取方式都读取不到正确的数据。

import ( flatbuffers "github.com/google/flatbuffers/go" sample "MyGame/Sample" ) // 先准备一个二进制数组,存储 buffer 二进制流 var buf []byte = /* the data you just read */ // 从 buffer 中拿到根访问器 monster := sample.GetRootAsMonster(buf, 0) 

这里 offset 默认是 0,如果想要直接从builder.Bytes开始读取数据,那么需要确定一个 offset 跳过builder.Head()。由于 builder 构造的时候是逆向构造的,所以 offset 肯定不会是 0 。

由于导入了 flatc 编译出来的文件,里面已经包含了 get 和 set 方法了。带 deprecated 的默认不会生成对应的方法。

hp := monster.Hp() mana := monster.Mana() name := string(monster.Name()) // Note: `monster.Name()` returns a byte[]. pos := monster.Pos(nil) x := pos.X() y := pos.Y() z := pos.Z() 

接下来代码获取 pos 的是nil,如果你的程序对性能要求特别高的时候,可以确定一个指针指标,这样就可以重用,减少因为很多alloc小对象,垃圾恢复时造成的性能问题。小对象特别多,还会造成GC相关的问题。

invLength := monster.InventoryLength() thirdItem := monster.Inventory(2) 

读取的读取方式和一般读取的实现一样的,这里就不再赘述了。

weaponLength := monster.WeaponsLength() weapon := new(sample.Weapon) // We need a `sample.Weapon` to pass into `monster.Weapons()` // to capture the output of the function. if monster.Weapons(weapon, 1) { secondWeaponName := weapon.Name() secondWeaponDamage := weapon.Damage() } 

表的数组,和一般数组的基本用法也是一样的,唯一就是里面的区别都是对象,按照对应的处理方式即可。

最后就是union的读取方式。我们知道union会包含2个字段,一个类型和一个数据。需要通过类型去判断反序列化什么数据。

// 新建一个 `flatbuffers.Table` 去存储 `monster.Equipped()` 的结果。 unionTable := new(flatbuffers.Table) if monster.Equipped(unionTable) { unionType := monster.EquippedType() if unionType == sample.EquipmentWeapon { // Create a `sample.Weapon` object that can be initialized with the contents // of the `flatbuffers.Table` (`unionTable`), which was populated by // `monster.Equipped()`. unionWeapon = new(sample.Weapon) unionWeapon.Init(unionTable.Bytes, unionTable.Pos) weaponName = unionWeapon.Name() weaponDamage = unionWeapon.Damage() } } 

通过unionType对应不同类型,反序列化不同类型的数据。毕竟一个union里面只装一个表。

三 可变的 FlatBuffers

FlatBuffers:21.7k star,深入浅出FlatBuffers之Encode

从上面的使用方式来看,发送方准备好缓冲区二进制流,发送给使用方,使用方拿到缓冲区二进制流以后,完成读取出数据。如果使用方还想把缓冲区稍作更改发送给下一个使用方,只能重新创建一个全新的缓冲区,然后把要更改的字段在创建的时候改掉,再传给下一个使用方。

如果只是小改一个字段,导致又要重新创建一个很大的缓冲区,这会非常不方便。如果要改很多字段,可以考虑从 0 开始新建缓冲区,因为这样更高效,API 也更叫通用。

如果想创建可变的 flatbuffer,需要在 flatc 编译 schema 的时候添加–gen-mutable编译参数。

编译代码会使用 mutate 而不是 set 来表示这是一个特殊情况,尽量避免与重构 FlatBuffer 数据的默认方式中断。

mutating API 暂时不支持 golang。

请注意,table 中任何 mutate 函数都会返回布尔值,如果我们尝试设置一个不存在于 buffer 中的字段,则返回 false。不存在于 buffer 中的字段有 2 种情况,一种情况是本身没有设置值,另外一种情况是值和默认值相同。例如上面里面的 mana = 150,它是由于是默认值,不会是存储在 buffer 中的。如果调用 mutate 方法,会返回 false,而它们的值不会被修改。

解决这个问题的一个方法是在FlatBufferBuilder上调用ForceDefaults来强制设置所有字段可写。这当然会增加buffer的大小,不过对于可变buffer是可以接受的。

如果这种方法还不能被接受,就调用对应的 API (–gen-object-api) 或者静态方法。目前 C++ 版本的 API 在这方面支持的最全。

四 FlatBuffers 编码原理

FlatBuffers:21.7k star,深入浅出FlatBuffers之Encode

根据上面简单实用的流程,我们一步一步的来看看源码。

4.1 新建FlatBufferBuilder

builder := flatbuffers.NewBuilder(1024) 

第一步新建FlatBufferBuilder,在builder中,会初始化我们最终的序列化好,使用little endian的二进制流,二进制流是从高内存地址向低内存地址写入。

type Builder struct { // `Bytes` gives raw access to the buffer. Most users will want to use // FinishedBytes() instead. Bytes []byte minalign int vtable []UOffsetT objectEnd UOffsetT vtables []UOffsetT head UOffsetT nested bool finished bool } type ( // A SOffsetT stores a signed offset into arbitrary data. SOffsetT int32 // A UOffsetT stores an unsigned offset into vector data. UOffsetT uint32 // A VOffsetT stores an unsigned offset in a vtable. VOffsetT uint16 ) 

这里有 3 个特殊的类型:SOffsetT、UOffsetT、VOffsetT。SOffsetT 存储是一个有符号的偏移量,UOffsetT 存储是数据库数据的无符号偏移量,VOffsetT 存储 vtable 中的无符号偏移量。

Builder 中的字节是最终序列化的二进制流。新建 FlatBufferBuilder 就是初始化 Builder 结构体:

func NewBuilder(initialSize int) *Builder { if initialSize <= 0 { initialSize = 0 } b := &Builder{} b.Bytes = make([]byte, initialSize) b.head = UOffsetT(initialSize) b.minalign = 1 b.vtables = make([]UOffsetT, 0, 16) // sensible default capacity return b } 

4.2 序列化标量数据

标量包括以下这些类型的数据:Bool、uint8、uint16、uint32、uint64、int8、int16、int32、int64、float32、float64、byte。这些类型的数据的序列化方法都是一样的,这里以 PrependInt16 为例:

func (b *Builder) PrependInt16(x int16) { b.Prep(SizeInt16, 0) b.PlaceInt16(x) } 

具体实现就调用了2个函数,一个是 Prep() ,另一个是 PlaceXXX()。Prep() 是一个公共函数,序列化所有标量都会调用这个函数。

func (b *Builder) Prep(size, additionalBytes int) { // Track the biggest thing we've ever aligned to. if size > b.minalign { b.minalign = size } // Find the amount of alignment needed such that `size` is properly // aligned after `additionalBytes`: alignSize := (^(len(b.Bytes) - int(b.Head()) + additionalBytes)) + 1 alignSize &= (size - 1) // Reallocate the buffer if needed: for int(b.head) <= alignSize+size+additionalBytes { oldBufSize := len(b.Bytes) b.growByteBuffer() b.head += UOffsetT(len(b.Bytes) - oldBufSize) } b.Pad(alignSize) } 

Prep() 函数的第一个参量是 size,这里的 size 是字节单位,有多少个字节大小,这里的 size 就是多少。例如 SizeUint8 = 1、SizeUint16 = 2、SizeUint32 = 4、SizeUint64 = 8其他类型以此类推。比较特殊的3个offset,大小也是固定的,SOffsetT int32,它的size = 4;UOffsetT uint32,它的size = 4;VOffsetT uint16,它的size = 2。

Prep()方法有两个作用:

  1. 所有的横向动作。
  2. 内存不足时申请额外的内存空间。

在添加完additional_bytes个字节之后,还需要继续添加size个字节。这里需要调整的是最后这个大小字节,实际也比如要添加对象的大小,Int就是4个字节。最终的效果是分配additional_bytes之后offset是size的整数倍,需要计算对齐的字节数在两个单词里面实现的:

 alignSize := (^(len(b.Bytes) - int(b.Head()) + additionalBytes)) + 1 alignSize &= (size - 1) 

以后,如果需要,还需要重新分配缓冲区:

func (b *Builder) growByteBuffer() { if (int64(len(b.Bytes)) & int64(0xC0000000)) != 0 { panic("cannot grow buffer beyond 2 gigabytes") } newLen := len(b.Bytes) * 2 if newLen == 0 { newLen = 1 } if cap(b.Bytes) >= newLen { b.Bytes = b.Bytes[:newLen] } else { extension := make([]byte, newLen-len(b.Bytes)) b.Bytes = append(b.Bytes, extension...) } middle := newLen / 2 copy(b.Bytes[middle:], b.Bytes[:middle]) } 

GrowthByteBuffer() 方法会扩容到原来 2 倍的大小。意义在于最后的复制操作:

copy(b.Bytes[middle:], b.Bytes[:middle]) 

旧的数据实际上会被复制到新扩容以后堆栈的消耗,因为构建缓冲区是从后往前构建的。

Prep()最后一步就是在当前的偏移量中添加0:

func (b *Builder) Pad(n int) { for i := 0; i < n; i++ { b.PlaceByte(0) } } 

在上面的例子中,hp = 500,500的二进制是,由于当前缓冲区中是2个字节,所以逆序存储500,为1111 0100 0000 0001。根据上面提到的字符规则,500的类型是Sizeint16 ,字节数为2,当前偏移了133个字节(为何是133个字节,在下面会提到,这里先暂时接受这个数字),133 + 2 = 135个字节,不是Sizeint16的倍数了,所以需要字节对齐,对齐的效果就是添加 0 ,对齐到 Sizeint16 的整数倍,根据上面的规则,alignSize算出来为 1 ,则要另外添加 1 个字节的 0 。

此时500最终在二进制流中表示的结果为:

FlatBuffers:21.7k star,深入浅出FlatBuffers之Encode

500 = 1111 0100 0000 0001 0000 0000 = 244 1 0 

最后还要提一下标量的值默认的问题,我们知道在 flatbuffer 中,默认值是不会存储在二进制流中,那它存储在哪里呢?它实际上会被 flatc 文件直接编译到代码文件中。我们还是以这里的hp为例,它的默认值为100。

我们在给 Monster 测序 hp 的时候,会调用 MonsterAddHp() 方法:

func MonsterAddHp(builder *flatbuffers.Builder, hp int16) { builder.PrependInt16Slot(2, hp, 100) } 

具体实现可以快速看到,默认值就直接写好了,默认值 100 会被当做入参传到 builder 中。

func (b *Builder) PrependInt16Slot(o int, x, d int16) { if x != d { b.PrependInt16(x) b.Slot(o) } } 

在准备插槽的时候,如果序列化的值和默认值相当的话,是不会继续往下存入到二进制流中,对应代码就是上面这个如果判断。只有不等于默认值,才会继续PrependInt16 () 操作。

所有标量序列化的最后一步是将offset记录到vtable中:

func (b *Builder) Slot(slotnum int) { b.vtable[slotnum] = UOffsetT(b.Offset()) } 

slotnum 是调用者会提交进来的,这个值也不需要我们开发者关心,因为这个值是 flatc 自动按照 schema 生成的 num。

table Monster { pos:Vec3; // Struct. mana:short = 150; hp:short = 100; name:string; friendly:bool = false (deprecated); inventory:[ubyte]; // Vector of scalars. color:Color = Blue; // Enum. weapons:[Weapon]; // Vector of tables. equipped:Equipment; // Union. path:[Vec3]; // Vector of structs. } 

在 Monster 的定义中,hp 从 pos 往下数,从 0 开始,数到 hp 就是第 2 个,所以 hp 在 builder 的 vtable 中,排在第二个插槽的位置,vtable[2] 中存储的值就是它对应的偏移量。

4.3 序列化存储

存储中存储具有连续的标量,并且存储中还有一个 SizeUint32 代表存储的大小。存储中存储并不是在其父类中内联,而是通过引用偏移偏移的方式。

在上面的例子中,数据库其实分为3类,考虑标量数据库,表数据库,结构数据库。真正序列化数据库的时候,不用里面具体装的是什么。这替代数据库的序列化方法都是一样的,都是调用的下面这个方法:

func (b *Builder) StartVector(elemSize, numElems, alignment int) UOffsetT { b.assertNotNested() b.nested = true b.Prep(SizeUint32, elemSize*numElems) b.Prep(alignment, elemSize*numElems) // Just in case alignment > int. return b.Offset() } 

这个方法的参与有3个参数,元素的大小,元素个数,对齐字节。

在上面的例子中,标量集群 InventoryVector 里面装的都是 SizeInt8 ,大约一个字节,所以调整也是 1 个字节(选集群里面最大的占用字节数);表集群 WeaponsVector 里面装的都是 Weapons table 类型,table 的元素大小为 string + Short = 4 字节,对齐方式也是 4 字节;struct 堆栈 PathVector,里面装的都是 Path 类型的 struct,struct 的元素大小为 SizeFloat32 * 3 = 4 * 3 = 12 字节,每个屏幕大小只有 4 字节。

StartVector() 方法会先判断一下当前正在构建是否存在唤醒的情况:

func (b *Builder) assertNotNested() { if b.nested { panic("Incorrect creation order: object must not be nested.") } } 

Table/Vector/String这三者是不能在这里创建的,builder中的嵌套也标记了当前是否是唤醒的状态。如果会调用循环创建,报panic。

接下来就是两个 Prep() 操作,这里会先进行 SizeUint32 再进行对齐的 Prep,原因是对齐有可能会大于 SizeUint32。

准备好对齐空间和计算好偏移了以后,就是往磁盘里面序列化放元素的过程,调用各种 PrependXXXX() 方法,(上面举例提到了 PrependInt16() 方法,其他类型都类似,这里就不再赘述了了)。

阵列中装载完数据以后,最后一步需要调用一次EndVector()方法,结束阵列的序列化:

func (b *Builder) EndVector(vectorNumElems int) UOffsetT { b.assertNested() // we already made space for this, so write without PrependUint32 b.PlaceUOffsetT(UOffsetT(vectorNumElems)) b.nested = false return b.Offset() } 

EndVector() 内部会调用 PlaceUOffsetT() 方法:

func (b *Builder) PlaceUOffsetT(x UOffsetT) { b.head -= UOffsetT(SizeUOffsetT) WriteUOffsetT(b.Bytes[b.head:], x) } func WriteUOffsetT(buf []byte, n UOffsetT) { WriteUint32(buf, uint32(n)) } func WriteUint32(buf []byte, n uint32) { buf[0] = byte(n) buf[1] = byte(n >> 8) buf[2] = byte(n >> 16) buf[3] = byte(n >> 24) } 

PlaceUOffsetT() 方法主要是设置 builder 的 UOffset,SizeUOffsetT = 4 字节。把磁盘阵列的长度队列化到二进制流中。磁盘阵列的长度为 4 字节。

上面的例子中,偏移到 InventoryVector 的 offset 是 60,添加 10 个 1 字节的标量元素以后,就到 70 字节了,由于alignment = 1,小于 SizeUint32 = 4,所以按照 4 字节垂直,距离 70最近的,且是 4 字节倍数的就是 72,所以对齐需要另外添加 2 字节的 0 。最终在二进制流里面的表现是:

FlatBuffers:21.7k star,深入浅出FlatBuffers之Encode

10 0 0 0 0 1 2 3 4 5 6 7 8 9 0 0 

4.4 字符串序列化

字符串可以看成字节存储,只是在字符串结尾处有一个空字符串标识符。字符串也不能在其父类中内联存储,也是通过引用偏移偏移的方式。

所以序列化字符串和序列化数据库是相当多的。

func (b *Builder) CreateString(s string) UOffsetT { b.assertNotNested() b.nested = true b.Prep(int(SizeUOffsetT), (len(s)+1)*SizeByte) b.PlaceByte(0) l := UOffsetT(len(s)) b.head -= l copy(b.Bytes[b.head:b.head+l], s) return b.EndVector(len(s)) } 

具体实现代码和序列化阵列的流程基本一致,多的接下来一一解释。同样是先Prep(),对齐,和阵列不同,字符串的字节是null结束符,所以阵列的最后一个字节要加一个字节的0。所以多了b.PlaceByte(0)。

copy(b.Bytes[b.head:b.head+l], s)就是把字符串复制到相应的offset中。

最后b.EndVector()同样是把长度再放到二进制流中。注意 2 处处理长度的位置,Prep() 中是考虑了补的 0,所以 Prep() 的时候 len(s) + 1,最后 EndVector() 是不考虑构成 0 的,所以用的是 len(s)。

还是拿上面例子中具体的例子来说明。

weaponOne := builder.CreateString("Sword") 

最开始我们就序列化了剑字符串。这个字符串对应的ASCII码是,83 119 111 114 100。由于字符串附加在补一个0,所以整个字符串在二进制流中应该是83 119 111 114 100 0 。考虑一下对齐,由于是 SizeUOffsetT = 4 字节,字符串当前的偏移量是 0,再加上字符串长度 6 以后,距离 6 最近的 4 的倍数再是 8,所以要添加 2 个字符最后加上上字符串长度 5 (这里算长度不要包含字符串长度的 0)

所以最终剑字符串在二进制流中如下排列:

FlatBuffers:21.7k star,深入浅出FlatBuffers之Encode

5 0 0 0 83 119 111 114 100 0 0 0 

4.5 序列化结构

struct 总是在联内联方式存储在它们的父级(结构,表或向量)中,以实现最大程度的紧凑性。struct 定义了一个一致的内存布局,其中所有字段都具有大小对齐,并且 struct 伴随最大标量成员对齐。这种做法独立于基本编译器的对齐规则,以保证跨平台兼容的布局。这个布局在生成的代码中构建。接下来看看如何构建的。

序列化结构十分简单,直接序列化成二进制,插入插槽即可:

func (b *Builder) PrependStructSlot(voffset int, x, d UOffsetT) { if x != d { b.assertNested() if x != b.Offset() { panic("inline data write outside of object") } b.Slot(voffset) } } 

具体实现中可以先检查一下入参里面2个UOffsetT是否成功,其次再看看当前是否有中断,没有撤销还要再检查一下UOffsetT是否和实际序列化以后的偏移匹配,如果以上判断都通过了,就生成插槽——在vtable中记录offset。

builder.PrependStructSlot(0, flatbuffers.UOffsetT(pos), 0)

调用的时候会计算一次 struct 的 UOffsetT (32位,4字节)

func CreateVec3(builder *flatbuffers.Builder, x float32, y float32, z float32) flatbuffers.UOffsetT { builder.Prep(4, 12) builder.PrependFloat32(z) builder.PrependFloat32(y) builder.PrependFloat32(x) return builder.Offset() }

由于是 float32 类型,所以大小是 4 字节,struct 有 3 个变量,所以总大小是 12 字节。可以看出 struct 的值是直接装入内存中的,没有进行任何处理,而且也不涉及创建的问题,因此可以在其他结构中内联(inline)。并且存储的顺序和字段的顺序相同。

1.0 浮点类型转成二进制为:000000000000000000000 2.0 浮点类型转成二进制为:000000000000000000000 3.0 浮点类型转成二进制为:000000000000000000000
FlatBuffers:21.7k star,深入浅出FlatBuffers之Encode

0 0 128 63 0 0 0 64 0 0 64 64 

4.6 序列化表

与 struct 不同,table 不是内联内联的方式存储在它们的父项中,而是通过引用偏移量。在 table 中有一个 SOffsetT,这是 UOffsetT 的带符号的版本,代表的偏移量是有方向的。由于vtable可以在存储在任何位置,所以它的偏移量应该是从存储对象开始,减少vtable开始,即计算对象和vtable之间的偏移量。

序列化表要分为3步,第一步 StartObject :

func (b *Builder) StartObject(numfields int) { b.assertNotNested() b.nested = true // use 32-bit offsets so that arithmetic doesn't overflow. if cap(b.vtable) < numfields || b.vtable == nil { b.vtable = make([]UOffsetT, numfields) } else { b.vtable = b.vtable[:numfields] for i := 0; i < len(b.vtable); i++ { b.vtable[i] = 0 } } b.objectEnd = b.Offset() b.minalign = 1 } 

序列化表第一步是初始化vtable。初始化之前先做异常判断,判断是否调用。接下来就是初始化vtable空间,这里初始化用UOffsetT = UOffsetT uint32防止溢出。StartObject()入参是字段的个数,注意union是2个字段。

每个表都会有自己的vtable,其中存储着每个字段的偏移量,这就是上面slot函数的作用,多层的插槽都记录到vtable中。vtable相同的会共享同一个vtable。

第二步就是添加每个字段。添加字段的顺序可以是无序的,因为flatc编译以后把每个字段在插槽里面的顺序以后排列好,不会因为我们调用序列化方法的顺序而改变,举个例子:

func MonsterAddPos(builder *flatbuffers.Builder, pos flatbuffers.UOffsetT) { builder.PrependStructSlot(0, flatbuffers.UOffsetT(pos), 0) } func MonsterAddMana(builder *flatbuffers.Builder, mana int16) { builder.PrependInt16Slot(1, mana, 150) } func MonsterAddHp(builder *flatbuffers.Builder, hp int16) { builder.PrependInt16Slot(2, hp, 100) } func MonsterAddName(builder *flatbuffers.Builder, name flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(name), 0) } func MonsterAddInventory(builder *flatbuffers.Builder, inventory flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(inventory), 0) } func MonsterStartInventoryVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { return builder.StartVector(1, numElems, 1) } func MonsterAddColor(builder *flatbuffers.Builder, color int8) { builder.PrependInt8Slot(6, color, 2) } func MonsterAddWeapons(builder *flatbuffers.Builder, weapons flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(7, flatbuffers.UOffsetT(weapons), 0) } func MonsterStartWeaponsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { return builder.StartVector(4, numElems, 4) } func MonsterAddEquippedType(builder *flatbuffers.Builder, equippedType byte) { builder.PrependByteSlot(8, equippedType, 0) } func MonsterAddEquipped(builder *flatbuffers.Builder, equipped flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(9, flatbuffers.UOffsetT(equipped), 0) } func MonsterAddPath(builder *flatbuffers.Builder, path flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(10, flatbuffers.UOffsetT(path), 0) } func MonsterStartPathVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { return builder.StartVector(12, numElems, 4) 

上面是 Monster table 中所有字段的序列化实现,我们可以看每个函数的第一个参数,是对应于 vtable 中插槽的位置。0 – pos,1 – mana,2 – hp,3 – name ,(没有4-友善,因为已经被弃用了),5-库存,6-颜色,7-武器,8-装备类型,9-装备,10-路径。怪物中总共有11个字段(其中有一个废弃字段,union算2个字段),最终序列化需要序列化10个字段。这也是为什么id只能往后递增,不能往前加,也不能删除废弃字段的原因,因为插槽的位置一旦定下来了,就不能改变。有了id,字段名变更不会影响了。

另外,从序列化列表中也能看出序列化表中是不能检索序列化表/字符串/向量类型的,它们不能内联,必须在根创建对象之前先创建好。inventory是标量备份,先序列化好了以后,在 Monster 中引用 offset 的。weapons 是 table 备份,同样也是先序列化好了,再引用 offset 的。path 是 struct 同样是引用。pos 是 struct ,直接 inline 在 table 中。equipped 是 union也是直接内联在表中。

func WeaponAddName(builder *flatbuffers.Builder, name flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(name), 0) } 

序列化武器表中的名称,计算偏移量是计算相对位置,不是相对于缓冲区消耗的偏移量,而是相对于当前写入位置的偏移量:

// PrependSOffsetT prepends an SOffsetT, relative to where it will be written. func (b *Builder) PrependSOffsetT(off SOffsetT) { b.Prep(SizeSOffsetT, 0) // Ensure alignment is already done. if !(UOffsetT(off) <= b.Offset()) { panic("unreachable: off <= b.Offset()") } // 注意这里要计算的是相当于当前写入位置的 offset off2 := SOffsetT(b.Offset()) - off + SOffsetT(SizeSOffsetT) b.PlaceSOffsetT(off2) } // PrependUOffsetT prepends an UOffsetT, relative to where it will be written. func (b *Builder) PrependUOffsetT(off UOffsetT) { b.Prep(SizeUOffsetT, 0) // Ensure alignment is already done. if !(off <= b.Offset()) { panic("unreachable: off <= b.Offset()") } // 注意这里要计算的是相当于当前写入位置的 offset off2 := b.Offset() - off + UOffsetT(SizeUOffsetT) b.PlaceUOffsetT(off2) } 

对于其他标量类型,直接计算偏移量即可,唯独需要注意 UOffsetT、SOffsetT 这两个。

序列化表的最后一步就是EndObject():

func (b *Builder) EndObject() UOffsetT { b.assertNested() n := b.WriteVtable() b.nested = false return n } 

最后结束序列化的时候,也需要先判断是否中断。需要的是WriteVtable()。在看WriteVtable()具体实现的时候重要的是,需要先介绍一下vtable的数据结构。

vtable 的元素都是 VOffsetT 类型,它是 uint16。第一个元素是 vtable 的大小(以字节为单位),包括自身。第二个是对象的大小,以字节为单位(包括 vtable 偏移量) )。这个大小可以用于流式传输,知道要读取多少字节才能访问对象的所有内联内联字段。第三个是 N 个偏移量,其中 N 是编译构建此缓冲区的代码编译时(因此,表的大小为 N + 2)时在 schema 中声明的字段数量(包括不推荐使用的字段)。每个以 SizeVOffsetT 字节为宽度。见下图:

FlatBuffers:21.7k star,深入浅出FlatBuffers之Encode

一个对象的第一个元素是SOffsetT,object和vtable之间的偏移量,可正可负。第二个元素就是object的数据数据。在读取对象的时候,会先比较一下SOffsetT,防止产生新的代码读取旧数据的情况。如果要读取的字段在offset中超出了磁盘的范围,或者vtable的边界为0,则表示该对象中不存在该字段,并返回该字段的默认值。如果没有超出范围,则读取该字段的偏移量。

FlatBuffers:21.7k star,深入浅出FlatBuffers之Encode

接下来详细看看WriteVtable()的具体实现:

func (b *Builder) WriteVtable() (n UOffsetT) { // 1. 添加 0 对齐标量,对齐以后写入 offset,之后这一位会被距离 vtable 的 offset 重写覆盖掉 b.PrependSOffsetT(0) objectOffset := b.Offset() existingVtable := UOffsetT(0) // 2. 去掉末尾 0 i := len(b.vtable) - 1 for ; i >= 0 && b.vtable[i] == 0; i-- { } b.vtable = b.vtable[:i+1] // 3. 从 vtables 中逆向搜索已经存储过的 vtable,如果存在相同的且已经存储过的 vtable,直接找到它,索引指向它即可 // 可以查看 BenchmarkVtableDeduplication 的测试结果,通过索引指向相同的 vtable,而不是新建一个,这种做法可以提高 30% 性能 for i := len(b.vtables) - 1; i >= 0; i-- { // 从 vtables 筛选出一个 vtable vt2Offset := b.vtables[i] vt2Start := len(b.Bytes) - int(vt2Offset) vt2Len := GetVOffsetT(b.Bytes[vt2Start:]) metadata := VtableMetadataFields * SizeVOffsetT vt2End := vt2Start + int(vt2Len) vt2 := b.Bytes[vt2Start+metadata : vt2End] // 4. 比较循环到当前的 b.vtable 和 vt2,如果相同,offset 就记录到 existingVtable 中,只要找到一个就可以 break 了 if vtableEqual(b.vtable, objectOffset, vt2) { existingVtable = vt2Offset break } } if existingVtable == 0 { // 5. 如果找不到一个相同的 vtable,只能创建一个新的写入到 buffer 中 // 写入的方式也是逆向写入,因为序列化的方向是尾优先。 for i := len(b.vtable) - 1; i >= 0; i-- { var off UOffsetT if b.vtable[i] != 0 { // 6. 从对象的头开始,计算后面属性的偏移量 off = objectOffset - b.vtable[i] } b.PrependVOffsetT(VOffsetT(off)) } // 7. 最后写入两个 metadata 元数据字段 // 第一步,先写 object 的 size 大小,包含 vtable 偏移量 objectSize := objectOffset - b.objectEnd b.PrependVOffsetT(VOffsetT(objectSize)) // 8. 第二步,存储 vtable 的大小 vBytes := (len(b.vtable) + VtableMetadataFields) * SizeVOffsetT b.PrependVOffsetT(VOffsetT(vBytes)) // 9. 最后一步,修改 object 中头部的距离 vtable 的 offset 值,值是 SOffsetT,4字节 objectStart := SOffsetT(len(b.Bytes)) - SOffsetT(objectOffset) WriteSOffsetT(b.Bytes[objectStart:], SOffsetT(b.Offset())-SOffsetT(objectOffset)) // 10. 最后,把 vtable 存储在内存中,以便以后“去重”(相同的 vtable 不创建,修改索引即可) b.vtables = append(b.vtables, b.Offset()) } else { // 11. 如果找到了一个相同的 vtable objectStart := SOffsetT(len(b.Bytes)) - SOffsetT(objectOffset) b.head = UOffsetT(objectStart) // 12. 修改 object 中头部的距离 vtable 的 offset 值,值是 SOffsetT,4字节 WriteSOffsetT(b.Bytes[b.head:], SOffsetT(existingVtable)-SOffsetT(objectOffset)) } // 13. 最后销毁 b.vtable b.vtable = b.vtable[:0] return objectOffset } 

接下来分步来解释一下:

第 1 步,添加 0 个字符标量,扫描以后写入偏移量,之后这一位会被离开 vtable 的偏移量重写覆盖掉。b.PrependSOffsetT(0)

FlatBuffers:21.7k star,深入浅出FlatBuffers之Encode

Weapon在schema中的定义如下:

table Weapon { name:string; damage:short; } 

Weapon有2个字段,一个是name,一个是damage。name是字符串,需要在表中之前创建,并且在表中只能引用它的offset。我们这里先创建好“sword”的string,offset为12,所以在剑对象中,需要引用12这个偏移量,当前偏移量为24,重量为12,等于12,所以这里填上12,表示的意义是往前偏移12存储的数据才是这里的名称。 Damage 是一个短的,直接内嵌在剑对象中即可。加上 4 字节的 2 个 0,开头还要再加上 4 字节的当前偏移偏移量。注意,这个时候的偏移量是针对 buffer 尾部来说的,而不是针对 vtable 东方的偏移量。当前 b.offset() 为 32,所以填充 4 字节的 32 。

第3步,从vtables中逆向搜索已经存储过的vtable,如果存在相同的并且已经存储过的vtable,直接找到它,索引指向它即可。查看BenchmarkVtableDeduplication的测试结果,通过索引指向相同的vtable,虽然不是新建一个,这种做法可以提高 30% 的性能。

这一步就是找到vtable。如果没有找到就新建vtable,如果找到了就修改索引指向它。

先假设没有找到。走到第5步。

当前vtable中存的值是[24,26],即剑对象中的名称和伤害的偏移量。从对象的头开始,计算后面属性的偏移量。off = objectOffset – b.vtable[i]对应这里上面代码的第6步。

第6步到第8步得到的结果是下图:

FlatBuffers:21.7k star,深入浅出FlatBuffers之Encode

从右往左计算剑的偏移量,当前剑的偏移量是32,偏移6个字节到损坏字段,继续再偏移2个字节到名称字段。所以vtable中总共4个字节为8 0 6 0 。sword 对象整个大小为 12 字节,包括头的偏移量。最后填入 vtable 的大小,8 字节大小。

FlatBuffers:21.7k star,深入浅出FlatBuffers之Encode

最后一步需要sword对象头部的offset,修改成距离vtable的offset。由于当前vtable在低地址,所以修改sword对象在它的右边,offset为正数,offset = vtable size = 8字节。对应代码实现见第9步。

如果之前在vtables中找到了一样的vtable,那么就在对象的头部的offset改成距离vtable的offset即可,对应代码第12步。

FlatBuffers:21.7k star,深入浅出FlatBuffers之Encode

这里可以用 ax 对象的例子来说明找到相同 vtable 的情况。由于 Sword 对象和 ax 对象都是 Weapon 类型的,所以对象内部的字段偏移结构应该是完全一样的,故共享一个结构的 vtable。sword 对象先创建,vtable紧接在它后面,再创建的ax对象,所以ax对象前面的offset为负数。这里为-12。

12 的原码 = 00000000 00000000 00000000 00001100 12 的反码 =     12 的补码 =    

逆向存储即为 244 255 255 255 。

4.7 结束序列化

func (b *Builder) Finish(rootTable UOffsetT) { b.assertNotNested() b.Prep(b.minalign, SizeUOffsetT) b.PrependUOffsetT(rootTable) b.finished = true } 

结束序列化的时候,还需要执行两步操作,一是字节扫描,二是指向根对象的偏移量。

FlatBuffers:21.7k star,深入浅出FlatBuffers之Encode

由于在 schema 中我们定义了 root 对象为 Monster,序列化完 Monster 对象之后,又紧接着生成了它的 vtable,所以这里 root 表的 offset 为 32 。

至此,整个Monster就序列化完成了,最终形成的二进制缓冲区如下:

FlatBuffers:21.7k star,深入浅出FlatBuffers之Encode

二进制流上面标号的数字,为字段的偏移值。二进制流下面标号是字段名。

五 FlatBuffers 解码原理

FlatBuffers:21.7k star,深入浅出FlatBuffers之Encode

flatBuffers解码的过程就很简单了。由于序列化的时候保存好各个字段的偏移量,反序列化的过程其实就是把数据从指定的偏移量中读出。

对于标量,分2种情况,有默认值和没有默认值。在上面的例子中,Mana字段我们序列化的时候,就直接采用的默认值。在flatbuffer的二进制流中可以看到Mana字段都是0 ,offset 也为 0,其实这个字段采用的是默认值,在读取的时候,会直接从 flatc 编译后的文件中记录的默认值中读取出来。

Hp 字段有默认值,但是在序列化的时候我们并没有用默认值,而是重新给了他一个新值,这个时候,二进制流中就会记录 Hp 的偏移量,数值存储在二进制流中。

反序列化的过程是把二进制流从根表往后读。从vtable中读取应答的偏移量,然后在应答的对象中找到应答的字段,如果是引用类型,string/vector/table,读取出offset,再次找到 offset 对应的值,读取出来。如果是非引用类型,根据 vtable 中的 offset ,找到对应的位置直接读取即可。

整个反序列化的过程零拷贝,不占用任何内存资源。而flatbuffer可以读取任意字段,而不是像JSON和protocol buffer需要读取整个对象以后才能获取某个字段。flatbuffer的主要优势就在反面序列化在这里了。

六.FlatBuffers性能

由此看来,flatbuffer的优势是在反序列化上,那我们就来对比对比,性能究竟强多少。

FlatBuffers:21.7k star,深入浅出FlatBuffers之Encode

FlatBuffers:21.7k star,深入浅出FlatBuffers之Encode

  1. 编码性能:flatbuf 的编码性能精度 protobuf 低。在 JSON、protobuf 和 flatbuf 中,flatbuf 的编码性能最差,JSON 两者之间。
  2. 编码后的数据长度:由于通常情况下,传输的数据都会做压缩。在不压缩的情况下,flatbuffer的数据长度是纵向的,原因也很简单,因为二进制流内部填充了很多字节的0,并且原始数据也没有采取特殊的压缩处理,整个数据膨胀的更大了。不管不压缩,flatbuffer的数据长度都是极限的。JSON经过压缩以后,数据长度会近似于protocol buffer。protocol buffer由于自身编码压缩,再经过GZIP这些压缩算法压缩以后,长度最终维持最小。
  3. 解码性能:flatbuffer是一种读取解码的二进制格式,因而解码性能要高很多,大概大概protobuf快几倍的样子,从而比JSON快的就多了。

结论是,如果需要重新依赖反序列化的场景,可以考虑使用 flatbuffer。protobuf 字节表现出各方面的均衡的能力。

六 FlatBuffers 优点

FlatBuffers:21.7k star,深入浅出FlatBuffers之Encode

读完本篇 FlatBuffers 编码原理以后,读者应该能够明白以下几点:

FlatBuffers 的 API 也很繁琐,创建 buffer 的 API 和 C++ 的 Cocos2D-X 里面创建 sprite 有点类似。可能就是天生比较游戏而生的吧。

与protocol buffers相比,FlatBuffers的优点有以下这些:

  1. 不需要解析/拆包就可以访问化数据
    访问序列化数据甚至系统数据都不需要解析。为此序列,我们不需要耗费时间去初始化解析器(意味着构建复杂的字段映射)和解析数据。
  2. 直接使用内存
    FlatBuffers数据使用自己的内存图表,不需要分配其他更多的内存。我们不需要像JSON那样在解析数据的时候,为整个系统数据分配额外的内存对象。FlatBuffers实际上是零拷贝+随机-access 读取版本的 protobuf。

FlatBuffers 提供的优点并非没有任何妥协。它的缺点也正好为了它的优点而做出牺牲。

  1. 无可执行性的
    flatBuffers和protocol buffers组织数据的形式都使用二进制数据的形式,这就意味着调试程序的开销会增加。(一定的流程图也有一定的优点,有一定的安全性)
  2. API 略繁琐
    由于二进制协议的构造方法,数据必须以“从内到外”的方式插入。构建 FlatBuffers 对象比较麻烦。
  3. 格式兼容
    在处理转换器二进制数据时,我们必须考虑结构进行更改的可能性。从我们的模式中添加或删除字段必须小心。读取旧版本对象时,错误的模式更改可能会导致出错了但是没有提示。
  4. 由于 flatbuffer 为了反序列化的性能而牺牲了序列化的性能,所以牺牲了一些序列化时的性能,序列化的数据长度有限,性能也最差。

七 最后

最后的最后,邻近的文章结束,又发现了一个性能和特点和 Flatbuffers 类似的开源库

FlatBuffers:21.7k star,深入浅出FlatBuffers之Encode

FlatBuffers:21.7k star,深入浅出FlatBuffers之Encode

Cap’n Proto 是一种疯狂快速的数据交换格式,并且也同样可用于 RPC 系统中。这里有一篇性能对比的文章,《Cap’n Proto: Cap’n Proto, FlatBuffers, and SBE》,感兴趣的同学当可以额外的阅读材料看看。


参考资料:

  • flatbuffers 官方文档
  • flatcc 官方文档
  • 使用 FlatBuffers Cap’n Proto 提高 Facebook 在 Android 上的性能:Cap’n Proto、FlatBuffers 和 SBE
  • 使用 flatbuffer 能在真实游戏项目的数据读写过程中提速多少?
  • FlatBuffers 体验
  • 在Android中使用FlatBuffers

GitHub 仓库:Halfrost-Field

关注:halfrost·GitHub

来源: https: //halfrost.com/flatbuffers_encode/

免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。 本文来自网络,若有侵权,请联系删除,如若转载,请注明出处:https://yundeesoft.com/160236.html

(0)
上一篇 2024-12-03 20:00
下一篇 2024-12-03 20:15

相关推荐

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

关注微信