1. FlatBuffers генерируют бинарный поток
Использование FlatBuffers в основном аналогично буферам протокола. Просто у функции на одну функцию разбора JSON больше, чем у буферов протокола.
- Напишите файлы схемы, описывающие структуры данных и определения интерфейсов.
- Скомпилируйте FLATC для генерации файлов кода на соответствующем языке.
- Проанализируйте данные JSON, сохраните данные в соответствующей схеме и сохраните их в двоичном файле 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)
Еще нужно обратить внимание на то, как создается структура Vec3 в таблице. В отличие от таблиц, структуры представляют собой простые композиции скаляров, которые всегда хранятся в строке, как и сами скаляры.
Важное напоминание: В отличие от struct , вы не должны вкладывать сериализуемые таблицы или другие объекты, поэтому мы создали все строки/векторы/таблицы, на которые ссылается этот монстр, перед start . Если вы попытаетесь создать любой из них между началом и концом, вы получите утверждение/исключение/панику в зависимости от вашего языка.
Значения хп и маны по умолчанию определены в схеме, если вам не нужно их менять при инициализации, вам не нужно добавлять значения в буфер. Таким образом, это поле не будет записано в буфер, что может сократить потребление энергии при передаче и уменьшить размер буфера. Таким образом, установка разумного значения по умолчанию может сэкономить определенное количество места. Конечно, не беспокойтесь о том, что это значение не хранится в буфере, значение по умолчанию будет считано из другого места при получении.
Это также означает, что вам не нужно беспокоиться о добавлении большого количества полей, которые используются только в нескольких случаях, все они используют значение по умолчанию по умолчанию и не будут занимать размер буфера..
Прежде чем закончить сериализацию, давайте еще раз рассмотрим оборудование объединения FlatBuffer. Каждое объединение FlatBuffer состоит из двух частей (подробное описание см.предыдущий пост). Первое скрытое поле_type
, который создается для хранения типа таблицы, на которую ссылается объединение. Это позволяет узнать, какой тип выбрасывать во время выполнения. Второе поле — это данные объединения.
Так что нам также нужно добавить 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)
К настоящему времени буфер создан и может быть отправлен по сети или сжат и сохранен. Наконец, выполните последний шаг следующим способом:
// 这个方法必须在 `Finish()` 方法调用之后,才能调用。
buf := builder.FinishedBytes() // Of type `byte[]`.
На этом этапе вы можете записать двоичные байты в файл и отправить их по сети.Убедитесь, что режим файла (или транспортный протокол) отправлен в двоичном формате, а не в текстовом.. Если вы передаете FlatBuffer в текстовом формате, буфер будет поврежден, что затруднит обнаружение проблем при чтении буфера на другой стороне.
2. FlatBuffers читает бинарный поток
В предыдущей главе говорилось о том, как использовать 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)
Смещение по умолчанию здесь равно 0, если вы хотите напрямуюbuilder.Bytes
Начните читать данные, затем вам нужно передать смещение, чтобы пропуститьbuilder.Head()
. Так как билдер построен в обратном порядке, 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.Если ваша программа предъявляет особенно высокие требования к производительности, вы можете передать переменную-указатель, чтобы ее можно было повторно использовать и уменьшить множество проблем с производительностью, вызванных выделением небольших объектов и сборкой мусора. Если слишком много мелких объектов, это также вызовет проблемы, связанные с 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()
}
Основное использование табличного массива такое же, как и обычного массива, с той лишь разницей, что в нем есть объекты, с которыми можно обращаться согласно соответствующему методу.
Последнее, как читается союз. Мы знаем, что объединение будет содержать 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, соответствующие разным типам. Ведь в объединении всего одна таблица.
3. Переменные плоские буферы
Из приведенного выше использования отправитель подготавливает буферный двоичный поток и отправляет его пользователю, после того как пользователь получает буферный двоичный поток, он считывает из него данные. Если потребитель все же хочет передать небольшое изменение буфера следующему потребителю, он может только воссоздать новый буфер, а затем изменить поле, которое будет изменено при его создании, и затем передать его следующему потребителю.
Если это всего лишь небольшое изменение в одном поле, это вызовет пересоздание большого буфера, что очень неудобно. Если вы хотите изменить множество полей, вы можете рассмотреть возможность создания нового буфера с нуля, потому что это более эффективно, а API более общий.
Если вы хотите создать изменяемый плоский буфер, вам нужно добавить его, когда flatc компилирует схему.--gen-mutable
параметры компиляции.
Скомпилированный код будет использовать mutate вместо set, чтобы указать, что это особый вариант использования, постарайтесь избежать путаницы со способом построения данных FlatBuffer по умолчанию.
Мутирующий API пока не поддерживает golang..
Обратите внимание, что любая функция mutate в таблице вернет логическое значение, и если мы попытаемся установить поле, которого нет в буфере, она вернет false.Есть два случая для полей, которые не существуют в буфере, один состоит в том, что нет установленного значения, а другой - что значение совпадает со значением по умолчанию.. Например, мана = 150 в приведенном выше примере, поскольку это значение по умолчанию, оно не будет храниться в буфере. Если вызывается метод mutate, он вернет false и их значения не изменятся.
Один из способов исправить это — вызвать ForceDefaults для FlatBufferBuilder, чтобы сделать все поля доступными для записи. Это, конечно, увеличит размер буфера, но это приемлемо для изменяемых буферов.
Если этот метод не принимается, вызовите соответствующий API (--gen-object-api) или метод отражения. Текущая версия API для C++ имеет наиболее полную поддержку в этом отношении.
4. Принцип кодирования FlatBuffers
В соответствии с описанным выше простым и практичным процессом, давайте шаг за шагом рассмотрим исходный код.
1. Создайте новый FlatBufferBuilder
builder := flatbuffers.NewBuilder(1024)
Первым шагом является создание нового FlatBufferBuilder.В построителе мы инициализируем окончательный сериализованный двоичный поток, используя прямой порядок байтов.Двоичный поток записывается из старших адресов памяти в младшие адреса памяти.
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
}
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)
}
Конкретная реализация вызывает две функции: 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() — это размер, где размер — это единица измерения байтов, сколько байтов имеется, а размер — сколько. Например, SizeUint8 = 1, SizeUint16 = 2, SizeUint32 = 4, SizeUint64 = 8. Другие типы и так далее. Три специальных смещения также имеют фиксированный размер: SOffsetT int32, его размер = 4; UOffsetT uint32, его размер = 4; VOffsetT uint16, его размер = 2.
Метод Prep() имеет две функции:
- Все действия по выравниванию.
- Запрашивать дополнительное место в памяти, когда памяти недостаточно.
после добавленияadditional_bytes
После байтов продолжайте добавлять байты размера. Здесь необходимо выровнять последний байт размера, который на самом деле является размером добавляемого объекта, например, Int равен 4 байтам. Конечным эффектом является присвоениеadditional_bytes
После того, как смещение является целым числом, кратным размеру, вычисление количества байтов для выравнивания реализовано в двух предложениях:
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])
}
Метод GrowByteBuffer() расширится до удвоенного исходного размера. Следует отметить окончательную операцию копирования:
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 — 111110100. Поскольку в текущем буфере 2 байта, 500 хранится в обратном порядке, то есть 1111 0100 0000 0001. Согласно упомянутым выше правилам выравнивания, тип 500 — Sizeint16, количество байт — 2, а текущее смещение — 133 байта (почему 133 байта, о чем будет сказано ниже, и это число временно принято здесь), 133 + 2 = 135 байт, что не кратно Sizeint16, поэтому требуется выравнивание по байтам. Эффект выравнивания заключается в добавлении 0 и выравнивании до целого числа, кратного Sizeint16. Согласно приведенным выше правилам, alignSize вычисляется как 1, то есть дополнительный Add 1 байт 0.
Тогда окончательный результат 500, представленный в двоичном потоке:
500 = 1111 0100 0000 0001 0000 0000
= 244 1 0
Наконец, давайте поговорим о значении скаляров по умолчанию.Мы знаем, что в flatbuffer значение по умолчанию не сохраняется в двоичном потоке, так где же оно хранится? На самом деле он будет скомпилирован непосредственно в файл кода файлом flatc. Возьмем в качестве примера hp, его значение по умолчанию равно 100.
Когда мы сериализуем hp в Monster, мы вызываем метод MonsterAddHp():
func MonsterAddHp(builder *flatbuffers.Builder, hp int16) {
builder.PrependInt16Slot(2, hp, 100)
}
Конкретную реализацию можно увидеть с первого взгляда, значение по умолчанию записывается напрямую, а значение по умолчанию 100 будет передано разработчику в качестве входного параметра.
func (b *Builder) PrependInt16Slot(o int, x, d int16) {
if x != d {
b.PrependInt16(x)
b.Slot(o)
}
}
При подготовке слота, если сериализованное значение равно значению по умолчанию, оно больше не будет храниться в двоичном потоке, и соответствующий код будет приведен выше, если судить. Операция PrependInt16() продолжится, только если она не равна значению по умолчанию.
Последним шагом любой скалярной сериализации является запись смещения в виртуальную таблицу:
func (b *Builder) Slot(slotnum int) {
b.vtable[slotnum] = UOffsetT(b.Offset())
}
slotnum передается вызывающей стороной, и нашим разработчикам не нужно заботиться об этом значении, потому что это значение автоматически генерируется flatc в соответствии со схемой.
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.
}
В определении монстра хп отсчитывается от поз, начиная с 0, а отсчет до хп идет вторым, поэтому хп находится в vtable билдера, и ранжируется во втором слоте, который хранится в vtable[ 2]. Значением является соответствующее смещение.
3. Сериализация массива
Непрерывные скаляры хранятся в массиве, а также сохраняется SizeUint32, представляющий размер массива. Массив хранится не внутри своего родительского класса, а по ссылке на смещение offset.
В приведенном выше примере массив фактически разделен на три категории: скалярный массив, массив таблиц и массив структур. Фактически, при сериализации массива вам не нужно учитывать, что в нем содержится. Методы сериализации этих трех массивов одинаковы, и все они вызывают следующий метод:
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 Type, размер элемента таблицы string+short=4 байта, и выравнивание тоже 4 байта массив структур PathVector, который заполняется структурами типа Path, размер элемента структуры SizeFloat32 * 3 = 4 * 3 = 12 байт, но размер выравнивания всего 4 байта.
Метод StartVector() сначала определит, является ли текущая сборка вложенной:
func (b *Builder) assertNotNested() {
if b.nested {
panic("Incorrect creation order: object must not be nested.")
}
}
Таблица/Вектор/Строка не может быть вложена и создана, а вложенность в построителе также отмечает, является ли текущее состояние вложенным. Если создан вложенный цикл, здесь будет сообщено о панике.
Далее следуют две операции 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() внутренне вызывает метод PlaceUOffset():
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() в основном предназначен для установки UOffset компоновщика, SizeUOffsetT = 4 байта. Сериализация длины массива в двоичный поток. Длина массива 4 байта.
В приведенном выше примере смещение к InventoryVector равно 60. После добавления 10 1-байтовых скалярных элементов оно достигнет байтов 70. Поскольку выравнивание = 1, оно меньше, чем SizeUint32 = 4, поэтому оно выравнивается по 4 байтам, а расстояние равно 70. Ближайшее и кратное 4 байтам равно 72, поэтому для выравнивания требуются дополнительные 2 байта 0. Итоговая производительность в бинарном потоке:
10 0 0 0 0 1 2 3 4 5 6 7 8 9 0 0
4. Сериализировать строку
Строки можно рассматривать как массивы байтов с пустым строковым идентификатором в конце строки. Строки также не могут быть встроены в их родительский класс, в том числе по ссылке на смещение offset.
Таким образом, сериализация строки аналогична сериализации массива.
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(), сначала выравнивание, и, в отличие от массива, конец строки является нулевым ограничителем, поэтому к последнему байту массива необходимо добавить байт 0 . Итак, еще одна фразаb.PlaceByte(0)
.
copy(b.Bytes[b.head:b.head+l], s)
Это копирование строки по соответствующему смещению.
Наконецb.EndVector()
То же самое, чтобы поместить длину в двоичный поток. Обратите внимание, что в двух местах, где обрабатывается длина, 0 в конце учитывается в Prep(), поэтому len(s) + 1 используется в Prep(), а последний 0 не учитывается в EndVector(), поэтому используется len(s). ).
Давайте возьмем конкретный пример из приведенного выше примера, чтобы проиллюстрировать.
weaponOne := builder.CreateString("Sword")
Вначале мы сериализовали строку Sword. Код ASCII, соответствующий этой строке, — 83 119 111 114 100. Поскольку в конце строки стоит 0, вся строка должна быть 83 119 111 114 100 0 в двоичном потоке. Рассмотрим выравнивание.Поскольку SizeUOffsetT = 4 байта, текущее смещение строки равно 0. После добавления длины строки, равной 6, ближайшее кратное 4 6 равно 8, поэтому добавьте 2 байта 0 в конце. Наконец, добавьте длину строки 5 (обратите внимание, что длина не включает 0 в конце строки).
Таким образом, окончательная строка Sword упорядочивается в двоичном потоке следующим образом:
5 0 0 0 83 119 111 114 100 0 0 0
5. Сериализация структуры
структуры всегда хранятся внутри своего родителя (структуры, таблицы или вектора) для максимальной компактности. Структура определяет согласованную структуру памяти, в которой все поля выравниваются по размеру, а структура выравнивается по своему наибольшему скалярному члену. Эта практика выполняется независимо от правил выравнивания базового компилятора, чтобы гарантировать кросс-платформенную совместимость макета. Этот макет встроен в сгенерированный код. Далее посмотрим, как он устроен.
Сериализация структуры очень проста, сериализуйте ее прямо в двоичный файл и вставьте в слот:
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)
}
}
В конкретной реализации он сначала проверяет, равны ли два UOffsetT во входных параметрах, а затем проверяет, существует ли в настоящее время вложенность.Если вложенности нет, проверяется, соответствует ли UOffsetT фактическому сериализованному смещению.Если приведенные выше суждения все Пройдено, слот сгенерирован - смещение записывается в vtable.
builder.PrependStructSlot(0, flatbuffers.UOffsetT(pos), 0)
При вызове 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 байта, а в структуре 3 переменных, поэтому общий размер 12 байт. Видно, что значение struct напрямую помещается в память без какой-либо обработки и не связано с проблемой вложенного создания, поэтому оно может быть встроено в другие структуры. И порядок хранения такой же, как и порядок полей.
1.0 浮点类型转成二进制为:00111111100000000000000000000000
2.0 浮点类型转成二进制为:01000000000000000000000000000000
3.0 浮点类型转成二进制为:01000000010000000000000000000000
0 0 128 63 0 0 0 64 0 0 64 64
6. Сериализация таблицы
В отличие от структур, таблицы не хранятся внутри своего родителя, а смещаются по ссылке. В таблице есть 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, где UOffsetT = UOffsetT uint32 используется для предотвращения переполнения. Входной параметр StartObject() — это количество полей, обратите внимание, что объединение — это 2 поля.
Каждая таблица будет иметь свою собственную виртуальную таблицу, в которой хранится смещение каждого поля Это функция функции слота выше, а выходные слоты записываются в виртуальную таблицу. Одна и та же виртуальная таблица будет использовать одну и ту же виртуальную таблицу.
Второй шаг — добавить каждое поле. Порядок добавления полей может быть неупорядоченным, так как порядок каждого поля в слоте устанавливается после компиляции 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)
Приведенное выше представляет собой реализацию сериализации всех полей в таблице монстров. Мы можем увидеть первый параметр каждой функции, что соответствует положению слота в VTable. 0 - POS, 1 - Мана, 2 - л.с., 3 - имя, (нет 4 - дружелюбный, потому что он устарел), 5 - инвентарь, 6 - цвет, 7 - оружие, 8 - Weitytype, 9 - оборудованные Отказ Всего в общей сложности 11 полей монстра (есть одно отброшенное поле, а союз составляет 2 поля), а окончательная сериация должна сериализировать 10 полей.Вот почему идентификатор может быть увеличен только назад, не пересылается, и отбрасываемые поля не могут быть удалены, потому что после исправления положения слота она не может быть изменена. С id изменение имени поля не повлияет на него.
Кроме того,Из списка сериализации также видно, что сериализованные табличные/строковые/векторные типы не могут быть вложены в сериализованную таблицу, они не могут быть встроенными и должны быть созданы до создания корневого объекта. Inventory — это скалярный массив, после сериализации на смещение ссылается Monster. оружие представляет собой табличный массив, который также сначала сериализуется, а затем ссылается на него по смещению. путь является структурой, а также ссылкой. pos — это структура, непосредственно встроенная в таблицу. оснащены объединением, а также непосредственно встроены в таблицу.
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). Этот размер можно использовать для потоковой передачи, зная, сколько байт нужно прочитать, чтобы получить доступ ко всем встроенным полям объекта. Третье — N смещений, где N — количество полей (включая устаревшие поля), объявленных в схеме при компиляции кода, построившего этот буфер (следовательно, размер таблицы равен N + 2). Каждое из них имеет размер SizeVOffsetT байт. Увидеть ниже:
Первым элементом объекта является SOffsetT, смещение между объектом и виртуальной таблицей, которое может быть положительным или отрицательным. Второй элемент — это данные данных объекта. При чтении объекта сначала сравнивается SOffsetT, чтобы новый код не считывал старые данные. Если поле, которое нужно прочитать, выходит за границы массива по смещению или если запись vtable равна 0, это означает, что поле не существует в этом объекте, и возвращается значение поля по умолчанию. Если не за пределами диапазона, прочитайте смещение поля.
Давайте подробно рассмотрим конкретную реализацию 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)
Оружие определяется в схеме следующим образом:
table Weapon {
name:string;
damage:short;
}
Оружие имеет 2 поля, одно имя и одно урон. name — это строка, которую необходимо создать перед созданием таблицы, и в таблице можно ссылаться только на ее смещение. Сначала мы создали здесь строку «меч», смещение равно 12, поэтому в объекте меча нам нужно сослаться на смещение 12. Текущее смещение равно 24 минус 12, что равно 12, поэтому заполните здесь 12. , значение: Данные, хранящиеся со смещением вперед, равным 12, являются здесь именем. урон - это короткое замыкание, которое может быть непосредственно встроено в объект меча. Добавьте 2 нуля для 4-байтового выравнивания и добавьте текущее смещение в 4 байта в начале.Обратите внимание, что смещение в это время соответствует концу буфера, а не смещению виртуальной таблицы.. В настоящее время b.offset() равно 32, поэтому добавьте 4 байта из 32.
Третий шаг — обратный поиск vtables, которые были сохранены из vtables.Если есть та же vtable, которая была сохранена, найдите ее напрямую, и индекс может указывать на нее. Вы можете просмотреть результаты теста BenchmarkVtableDeduplication, который может повысить производительность на 30 % за счет индексирования той же виртуальной таблицы вместо создания новой.
Этот шаг должен найти vtable. Если вы не найдете новую виртуальную таблицу, если она будет найдена, измените индексную точку на нее.
Предположим, не найдено. Перейти к шагу 5.
Текущее значение, хранящееся в виртуальной таблице, равно [24,26], что является смещением имени и урона в объекте меча. Начиная с головы объекта, вычисляются смещения следующих свойств.off = objectOffset - b.vtable[i]
. Это соответствует шагу 6 приведенного выше кода.
Результатом шагов с 6 по 8 является следующее изображение:
Вычислите смещение меча справа налево, текущее смещение меча равно 32, сместите 6 байтов в поле Damage и продолжайте смещать 2 байта в поле имени. Таким образом, последние 4 байта в vtable равны 8 0 6 0 . Полный размер объекта меча составляет 12 байт, включая смещение заголовка. Наконец, заполните размер vtable размером 8 байт.
Последний шаг должен исправить смещение головы объекта меча на смещение от vtable. Поскольку текущая виртуальная таблица находится по младшему адресу, объект меча находится справа от нее, смещение — положительное число, смещение = размер vtable = 8 байт. См. шаг 10 для соответствующей реализации кода.
Если вы ранее нашли ту же VTable в Vtables, то его можно изменить с смещения VTable в компенсационной головке объекта, соответствующий код на этапе 12.
Здесь можно использовать пример объекта топора, чтобы проиллюстрировать случай нахождения той же виртуальной таблицы. Поскольку и объект меча, и объект топора имеют тип Weapon, структура смещения поля внутри объекта должна быть точно такой же, поэтому они имеют общую структуру vtable. Объект меча создается первым, виртуальная таблица создается сразу после него, а затем создается объект топора, поэтому смещение головы объекта топора отрицательно. Здесь -12.
12 的原码 = 00000000 00000000 00000000 00001100
12 的反码 = 11111111 11111111 11111111 11110011
12 的补码 = 11111111 11111111 11111111 11110100
Обратный магазин 244 255 255 255 .
7. Завершить сериализацию
func (b *Builder) Finish(rootTable UOffsetT) {
b.assertNotNested()
b.Prep(b.minalign, SizeUOffsetT)
b.PrependUOffsetT(rootTable)
b.finished = true
}
Когда сериализация завершена, необходимо выполнить два шага: один — выравнивание байтов, а другой — сохранить смещение, указывающее на корневой объект.
Так как мы определили корневой объект как Monster в схеме, и после сериализации объекта Monster, его виртуальная таблица была сгенерирована немедленно, поэтому смещение корневой таблицы здесь равно 32.
На данный момент весь Monster сериализован, и окончательный бинарный буфер выглядит следующим образом:
Число, отмеченное в двоичном потоке на приведенном выше рисунке, является значением смещения поля. Под двоичным потоком указано имя поля.
5. Принцип декодирования FlatBuffers
Процесс декодирования flatBuffers очень прост. Поскольку при сериализации сохраняется смещение каждого поля, процесс десериализации фактически заключается в чтении данных с указанного смещения.
Для скаляров есть 2 случая, со значением по умолчанию и без него. В приведенном выше примере, когда мы сериализуем поле Mana, мы напрямую используем значение по умолчанию. В бинарном потоке flatbuffer видно, что в поле Mana все 0, и смещение тоже 0. На самом деле это поле использует значение по умолчанию, при чтении оно будет считываться прямо из значения по умолчанию, записанного в файл, скомпилированный flatc.
Поле Hp имеет значение по умолчанию, но мы не использовали значение по умолчанию при сериализации, а дали ему новое значение, в это время смещение Hp будет записываться в бинарный поток, а значение так же будет храниться в бинарный поток.
Процесс десериализации заключается в чтении двоичного потока в обратном направлении из корневой таблицы. Прочитайте соответствующее смещение из vtable, а затем найдите соответствующее поле в соответствующем объекте.Если это ссылочный тип, строка/вектор/таблица, прочитайте смещение, снова найдите значение, соответствующее смещению, и прочитайте его. Если это нессылочный тип, по смещению в vtable найдите соответствующую позицию и прочитайте ее напрямую.
Весь процесс десериализации является нулевым копированием и не потребляет никаких ресурсов памяти. И flatbuffer может считывать произвольные поля, в отличие от буферов JSON и протоколов, которым необходимо считывать весь объект, чтобы получить поле. Основное преимущество flatbuffer заключается в десериализации.
6. Производительность FlatBuffers
Поскольку преимущество flatbuffer заключается в десериализации, давайте сравним и сравним, насколько высока производительность.
-
Производительность кодирования: Flatbuf имеет более низкую производительность кодирования, чем protobuf. Среди JSON, protobuf и flatbuf у flatbuf худшая производительность кодирования, а JSON находится между ними.
-
Длина закодированных данных: Обычно передаваемые данные сжимаются. В случае отсутствия сжатия длина данных плоского буфера самая большая, и причина очень проста, потому что двоичный поток заполнен большим количеством выровненных по байтам нулей, а исходные данные не требуют специальной обработки сжатия, все расширение данных больше. Независимо от сжатия или нет, длина данных плоского буфера самая большая. После сжатия JSON длина данных будет аналогична буферу протокола. Буфер протокола сжимается из-за собственной кодировки.После сжатия с помощью алгоритмов сжатия GZIP длина всегда сводится к минимуму.
-
Производительность декодирования: flatbuffer — это двоичный формат, который не требует декодирования, поэтому производительность декодирования намного выше, вероятно, в сотни раз быстрее, чем protobuf, и намного быстрее, чем JSON.
Вывод таков: если вам нужен сценарий, в значительной степени зависящий от десериализации, вы можете рассмотреть возможность использования flatbuffer. Protobuf демонстрирует сбалансированную способность во всех аспектах.
6. Преимущества и недостатки FlatBuffers
Прочитав этот принцип кодирования FlatBuffers, читатели должны понять следующие моменты:
API FlatBuffers тоже громоздкий API для создания буферов аналогичен созданию спрайтов в Cocos2D-X на C++. Может быть, он был рожден для игры.
По сравнению с буферами протоколов, FlatBuffers имеют следующие преимущества:
- Доступ к сериализованным данным без разбора/распаковки
Для доступа к сериализованным данным или даже к иерархическим данным синтаксический анализ не требуется. Благодаря этому нам не нужно тратить время на инициализацию синтаксического анализатора (имеется в виду построение сложных сопоставлений полей) и синтаксический анализ данных. - использовать память напрямую
Данные FlatBuffers используют собственный буфер памяти, и им не нужно выделять дополнительную память. Нам не нужно выделять дополнительные объекты памяти для всей иерархии данных при анализе данных, таких как JSON.FlatBuffers — это версия Protobuf с нулевым копированием и произвольным доступом к чтению..
Преимущества, предлагаемые FlatBuffers, не бескомпромиссны. Его недостатки также являются жертвами его достоинств.
- нечитаемый
И плоские буферы, и буферы протоколов организуют данные в виде двоичных данных, что затрудняет отладку программ. (Это тоже в определенной степени преимущество, и есть некая "безопасность") - API немного громоздкий
Из-за того, как устроены бинарные протоколы, данные должны быть вставлены «наизнанку». Создать объект FlatBuffers сложнее. - Обратная совместимость
Имея дело со структурированными двоичными данными, мы должны учитывать возможность внесения изменений в структуру. Добавлять или удалять поля из нашей схемы нужно с осторожностью. Плохие изменения схемы могут привести к ошибкам при чтении старых версий объектов без предупреждения. - жертвуя производительностью сериализации
Поскольку flatbuffer жертвует некоторой производительностью сериализации ради производительности десериализации, сериализованные данные имеют наибольшую длину и наихудшую производительность.
7. Наконец
Наконец, ближе к концу статьи я нашел еще одну библиотеку с открытым исходным кодом с аналогичной Flatbuffers производительностью и характеристиками.
Cap'n Proto — это безумно быстрый формат обмена данными, который также может использоваться в системах RPC. Вот статья сравнения производительности,Cap'n Proto: Cap'n Proto, FlatBuffers и SBE, и заинтересованные студенты могут ознакомиться с ним в качестве дополнительных материалов для чтения.
Ссылка:
Официальная документация плоских буферов
официальная документация flatcc
Improving Facebook's performance on Android with FlatBuffers
Cap'n Proto: Cap'n Proto, FlatBuffers, and SBE
Насколько быстрее использование flatbuffer может ускорить чтение и запись данных в реальных игровых проектах?
Опыт FlatBuffers
Использование FlatBuffers в Android
Репозиторий GitHub:Halfrost-Field
Follow: halfrost · GitHub
Source: Буфер HAL frost.com/flat…