笔者:心绪Source
未经许可,严禁转载
首先附上谷歌提供的序列化/反序列化对比图:
笔者在开始使用时踩到了不少坑,并对原项目增加了 Span 支持,以带来更高效的序列化。
下载并配置 flatc
首先我们要下载 flatc.exe,此程序用于将 .fbs 文件解析为供 C# 项目使用的 .cs 文件。
FlatBuffers GitHub 仓库
来到 FlatBuffers 在 GitHub 中的开源界面,点击右侧的 Releases,在分发界面中下载 25.2.10 版本:
在 flatc.exe 同级目录下写一个 .bat 命令:
@echo off
chcp 65001
rem chcp 936 > nul
set "rootPath=E:\替换为项目根目录\"
for /r "%rootPath%" %%i in (*.fbs) do (
if not exist "%%~dpiFlat_CS_Auto\" mkdir "%%~dpiFlat_CS_Auto\"
flatc --csharp --gen-onefile -I "%rootPath%" -I "%%~dpi" -o "%%~dpiFlat_CS_Auto\" "%%i"
echo %%i 生成完成
)
pause
rem echo 完整路径: "%%i"
rem echo 驱动器: %%~di
rem echo 路径: %%~pi
rem echo 文件名(带扩展名): %%~nxi
rem echo 文件名(不带扩展名): %%~ni
rem echo 扩展名: %%~xi
rem echo 文件大小: %%~zi 字节
rem echo 最后修改时间: %%~ti
此 .bat 命令会从根目录开始查找所有 .fbs 文件,在每个文件的同级目录生成 Flat_CS_Auto 文件夹,并将生成的 .cs 文件放入其中。
--gen-onefile:如果一个 .fbs 定义了多个类型,只生成一个 .cs 文件,而不是每个类型生成一个。
-I:输入文件所在的文件夹。命令传入了项目根路径与 .fbs 所在路径,因此 .fbs 引用其他 .fbs 时,只需使用从根目录开始的相对路径。
-o:生成的 .cs 文件输出位置。
- 最后的
%%i:当前 .fbs 文件名,程序会从 -I 指定的路径中依次寻找它所引用的文件。
.fbs 语法
// example IDL file
include "BasicData.fbs";
namespace MyGame;
enum Color : byte { Red = 1, Green, Blue }
struct Vec2 {
x : float;
y : float;
}
table Monster {
pos : Vec2;
pos_array : [Vec2];
mana : short = 150;
name : string;
friendly : bool = false;
inventory : [float];
color : Color = Blue;
dic_key : [string];
dic_value : [int];
}
- 支持
// 代码注释。
- 除注释外不可使用中文。
include 后面跟相对路径,相对于 .bat 命令中 -I 后面的路径。
namespace 可以指定命名空间,生成的 C# 代码会自动放在此命名空间下。
struct 是结构体。结构体中只能有基本类型,结构体可以相互嵌套;结构体数据会被直接嵌入父数据,解码比表更快。
table 是表。表内可以有任意类型对象;表数据不会被嵌入父数据,而是单独存在,父数据仅持有表指针,所以解码时要先从父数据中获取指针,再查找表数据。
- 字段格式为
字段名 : 字段类型。字段名必须使用小写,单词之间可以使用 _ 分隔;[字段类型] 代表数组,= 后面跟默认值。
- 如果字段类型来自引用的其他文件,需要写成
命名空间.类型;同命名空间可以省略命名空间。
接下来调用 .bat 生成 .cs 文件并将其放入工程中。此时可能会报错,先不用处理:
可以看到生成了我们需要的三个类型,而 Monster 是表,所以还会额外生成检查器,用于检查一段字节是否为 Monster 类型。
引入 FlatBuffers C# 源码
接下来回到 GitHub 获取 C# 源代码:
下载与 flatc.exe 相同版本的源代码,解压后找到:
flatbuffers-master\net\FlatBuffers
将整个 FlatBuffers 文件夹作为代码的一部分放入项目中,之前的报错此时应该都已修复。
如果使用 Unity,还需要添加以下宏:
在 Project Settings → Player → Other Settings → Script Compilation 中添加:
CodeUnity Scripting Define Symbols
UNSAFE_BYTEBUFFER
BYTEBUFFER_NO_BOUNDS_CHECK
ENABLE_SPAN_T
UNSAFE_BYTEBUFFER:使用不安全代码操作底层字节数组,可以带来可观的性能提升。
BYTEBUFFER_NO_BOUNDS_CHECK:禁用字节数组的边界检查断言,在普通代码中可能带来较小的性能提升。
ENABLE_SPAN_T:启用使用 Span<T> 代替 T[] 读取和写入内存块。也可以通过提供 ByteBufferAllocator 的自定义实现,直接写入共享内存或其他类型的内存。ENABLE_SPAN_T 还需要定义 UNSAFE_BYTEBUFFER,或者使用 .NET Standard 2.1。
对于某些操作,同时使用 UNSAFE_BYTEBUFFER 和 BYTEBUFFER_NO_BOUNDS_CHECK 可以带来约 15% 的性能提升,但这样做存在潜在风险,请自行评估。
扩展 FlatBufferBuilder
找到 FlatBufferBuilder 脚本,将类型声明改为:
public partial class FlatBufferBuilder
然后新建一个扩展脚本,将下面的代码复制进去:
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
namespace Google.FlatBuffers
{
public partial class FlatBufferBuilder
{
/// <summary>
/// 创建指针数组的指针
/// </summary>
/// <param name="offsets">全是指针的数组</param>
/// <returns></returns>
public VectorOffset CreateVector_Offset(Span<int> offsets)
{
NotNested();
int oneObjectSize = sizeof(int);
int allObjectSize = oneObjectSize * offsets.Length;
StartVector(oneObjectSize, offsets.Length, oneObjectSize);
Prep(oneObjectSize, allObjectSize);
for (int i = 0; i < offsets.Length; i++)
{
int off = offsets[i];
if (off != 0)
off = Offset - off + oneObjectSize;
PutInt(off);
}
return EndVector();
}
/// <summary>
/// 创建基本数据数组的指针
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="datas">全是基础类型的数组</param>
/// <returns></returns>
public unsafe VectorOffset CreateVector_Data<T>(Span<T> datas) where T : unmanaged
{
NotNested();
int oneObjectSize = sizeof(T);
int allObjectSize = oneObjectSize * datas.Length;
StartVector(oneObjectSize, datas.Length, oneObjectSize);
Prep(oneObjectSize, allObjectSize);
Span<T> copyDatas = stackalloc T[datas.Length];
fixed (T* src = datas, dest = copyDatas)
Buffer.MemoryCopy(src, dest, allObjectSize, allObjectSize);
copyDatas.Reverse();
ToLittleEndian(copyDatas, oneObjectSize);
_space -= allObjectSize;
fixed (T* ptr = copyDatas)
{
Span<byte> datasByte = new Span<byte>(ptr, allObjectSize);
datasByte.CopyTo(_bb.ToSpan(_space, allObjectSize));
}
return EndVector();
}
public VectorOffset CreateVector_Builder(FlatBufferBuilder datasForm) =>
CreateVector_Data(datasForm.ToSpan());
private static void ToLittleEndian<T>(Span<T> array, int oneObjectSize)
where T : unmanaged
{
Span<byte> bytes = MemoryMarshal.AsBytes(array);
// 如果当前系统是大端序,则反转字节
if (oneObjectSize > 1 && !BitConverter.IsLittleEndian)
{
for (int i = 0; i < array.Length; i++)
{
var elementSpan = bytes.Slice(i * oneObjectSize, oneObjectSize);
elementSpan.Reverse();
}
}
}
public override string ToString()
{
var span = ToSpan();
StringBuilder sb = new StringBuilder(span.Length);
for (int i = 0; i < span.Length; i++)
sb.Append(span[i]);
return sb.ToString();
}
private static readonly List<FlatBufferBuilder> pool =
new List<FlatBufferBuilder>(26);
public static FlatBufferBuilder TakeOut(int size)
{
if (size <= 0) size = 1024;
int minGap = int.MaxValue;
int minGapIndex = -1;
int maxSize = size;
int maxSizeIndex = -1;
lock (pool)
{
for (int i = pool.Count - 1; i >= 0; i--)
{
var builder = pool[i];
int gap = builder.DataBuffer.Length - size;
if (gap >= 0 && gap < minGap)
{
// 差距在一个 size 以内,直接返回
if (gap < size)
{
minGapIndex = i;
break;
}
minGap = gap;
minGapIndex = i;
}
if (builder.DataBuffer.Length > maxSize)
{
maxSize = builder.DataBuffer.Length;
maxSizeIndex = i;
}
}
FlatBufferBuilder ret;
if (minGapIndex != -1)
{
ret = pool[minGapIndex];
pool.RemoveAt(minGapIndex);
}
else if (maxSizeIndex != -1)
{
ret = pool[maxSizeIndex];
pool.RemoveAt(maxSizeIndex);
}
else
{
ret = new FlatBufferBuilder(size * 2);
}
return ret;
}
}
public static void PutIn(FlatBufferBuilder builder)
{
builder.Clear();
lock (pool)
{
pool.Add(builder);
}
}
public Span<byte> ToSpan() =>
DataBuffer.ToSpan(DataBuffer.Position, Offset);
}
}
序列化与反序列化 Monster
接下来序列化一个 Monster 类型:
System.Diagnostics.Stopwatch stopwatch = new System.Diagnostics.Stopwatch();
stopwatch.Start();
// 生成序列化字节数组缓存,容量不够时大小会翻倍
FlatBufferBuilder builder = new FlatBufferBuilder(1024);
// 假设这些是我们的程序数据
List<Vector2> posArray = new List<Vector2>()
{
new Vector2(1.3f, 4.2f),
new Vector2(5.3f, 8.4f),
new Vector2(10.11f, 31.24f)
};
string name = "心绪Source 请勿转载";
Span<float> inventory = new float[] { 3.14f, 12.009f, 2025 };
Dictionary<string, int> dic = new Dictionary<string, int>()
{
{ "你好", 1 },
{ "世界", 2 },
{ "!!", 3 }
};
// 在 FlatBuffers 中,非标量数据不能嵌套序列化,每一个都必须单独序列化后获取指针,
// 再将指针数组传递给需要存储的父对象。
// 标量类型数组区分大小端后直接复制到缓存;非标量数组存储时,需要遍历数组,
// 重新计算相对于数组头的偏移。
// 开始创建结构体数组
Monster.StartPosArrayVector(builder, posArray.Count);
// 创建时字节流正向添加,取出时字节流反向取出,所以取出时从高索引到低索引,
// 最后仍然是正向的。
for (int i = 0; i < posArray.Count; i++)
Vec2.CreateVec2(builder, posArray[i].x, posArray[i].y);
VectorOffset posArrayOffset = builder.EndVector();
StringOffset nameOffset = builder.CreateString(name);
// 往数组中添加基本数据时使用 Put,而不是 Add。StartVector 已经将数据对齐,
// 每次调用 Add 都会尝试重新对齐。
Monster.StartInventoryVector(builder, inventory.Length);
for (int i = 0; i < inventory.Length; i++)
builder.PutFloat(inventory[i]);
VectorOffset inventoryOffset = builder.EndVector();
// 字符串指针数组使用栈空间,避免在堆上开辟额外空间
Span<int> keyOffsetArray = stackalloc int[dic.Count];
// 同时记录 key 和 value,避免遍历字典两次
Span<int> valueArray = stackalloc int[dic.Count];
int index = 0;
foreach (var kv in dic)
{
keyOffsetArray[index] = builder.CreateString(kv.Key).Value;
valueArray[index] = kv.Value;
index++;
}
// FlatBuffers 不能嵌套序列化非标量,无法在创建数组的同时创建 string,
// 因此使用扩展方法创建指针数组。
VectorOffset dicOffsetKey = builder.CreateVector_Offset(keyOffsetArray);
// 基本类型使用此方法,比上方 Inventory 数组的创建方法更快。
VectorOffset dicOffsetValue = builder.CreateVector_Data(valueArray);
// 开始生成 Monster 对象,完成之前不能再创建非标量
Monster.StartMonster(builder);
Monster.AddPos(builder, Vec2.CreateVec2(builder, 1.2f, -2.4f));
Monster.AddPosArray(builder, posArrayOffset);
Monster.AddMana(builder, 10);
Monster.AddName(builder, nameOffset);
Monster.AddFriendly(builder, true);
Monster.AddInventory(builder, inventoryOffset);
Monster.AddColor(builder, MyGame.Color.Red);
Monster.AddDicKey(builder, dicOffsetKey);
Monster.AddDicValue(builder, dicOffsetValue);
int endOffset = Monster.EndMonster(builder).Value;
// 将完成指针放入缓存,反序列化需要此指针
builder.Finish(endOffset);
Debug.Log("序列化一次时间" + stopwatch.ElapsedMilliseconds);
stopwatch.Restart();
using (var fileStream = new FileStream(@"D:\test.txt", FileMode.Create, FileAccess.Write))
{
fileStream.Write(builder.DataBuffer.ToSpan(builder.DataBuffer.Position, builder.Offset));
}
byte[] buffer;
using (var fileStream = new FileStream(@"D:\test.txt", FileMode.Open, FileAccess.Read))
{
buffer = ArrayPool<byte>.Shared.Rent((int)fileStream.Length);
fileStream.Read(buffer);
Debug.Log("长度" + buffer.Length);
}
Monster monster = Monster.GetRootAsMonster(new ByteBuffer(buffer));
StringBuilder sb = new StringBuilder();
sb.Append("Position: ").Append(monster.Pos.Value.X).Append(' ')
.Append(monster.Pos.Value.Y).AppendLine();
sb.Append("Position Array:").AppendLine();
for (int i = monster.PosArrayLength - 1; i >= 0; i--)
{
var pos = monster.PosArray(i);
sb.Append(" ").Append(pos.Value.X).Append(' ')
.Append(pos.Value.Y).AppendLine();
}
sb.Append("Mana: ").Append(monster.Mana).AppendLine();
sb.Append("Name: ").Append(monster.Name).AppendLine();
sb.Append("Friendly: ").Append(monster.Friendly).AppendLine();
sb.Append("Inventory:").AppendLine();
for (int i = monster.InventoryLength - 1; i >= 0; i--)
{
sb.Append(" ").Append(monster.Inventory(i)).AppendLine();
}
sb.Append("Color: ").Append(monster.Color).AppendLine();
sb.Append("Dictionary:").AppendLine();
for (int i = monster.DicKeyLength - 1; i >= 0; i--)
{
sb.Append(" Key: ").Append(monster.DicKey(i))
.Append(", Value: ").Append(monster.DicValue(i)).AppendLine();
}
Debug.Log(sb.ToString());
Debug.Log("反序列化一次时间" + stopwatch.ElapsedMilliseconds);
stopwatch.Restart();
移除默认值判断
我们还应当在 FlatBufferBuilder.cs 文件中注释掉所有的:
if (ForceDefaults || x != d)
if (x != d)
这两个判断会在单独添加基础类型时检查值是否为默认值,如果是就不添加。但在工程中,如果有不想添加的值,我们根本不会调用添加方法;而且该判断还会比较两个浮点数是否相同,既不准确又浪费性能。
性能与复用
上面的单次测试中,序列化时间为 16 ms,反序列化时间为 15 ms,字节流大小为 256 字节。批量测试中,1 万+小对象的序列化耗时约为 140 ms,且没有产生额外 GC。
FlatBufferBuilder 对象可以复用。Clear 只会将指针置零,无需清理对象。初次创建 FlatBufferBuilder 时,应当指定合适的初始容量,防止扩容时丢弃旧数组。
结论
序列化与反序列化非常快,过程中基本没有额外堆空间分配。Protocol Buffers 的序列化与反序列化会生成辅助对象,而辅助对象都是引用类型;大量使用小 message 时会带来 GC 压力。
代价是产生的字节流完全没有压缩,比 Protocol Buffers 产生的字节流大约多一倍;API 也比大部分库复杂。与 JSON 相比,数据对人类完全不可读。序列化过程中非标量值不能更改,例如 string 必须先在缓存中创建并获取指针,再由父对象保存该指针;只能修改父对象的指向,缓存空间中原有的 string 无法删除,最终序列化的字节流可能产生额外垃圾数据。
总体来说,是以较小的代价换来了巨大的性能提升。
参考资料