找回密码
 立即注册
查看: 222|回复: 1

ProtoBuf 你需要知道的二三事

[复制链接]
发表于 2023-8-18 11:34 | 显示全部楼层 |阅读模式
1. 前言

ProtoBuf 以其优秀的运行效率、优秀的网络带宽,以及优秀的多语言平台撑持,已经逐渐成为主流的网络协议了。但是很多小白,甚至有必然基础的使用者仅仅逗留在使用层面,对其内部实现道理,以及一些细节并不是很了解,导致不能百分百阐扬 ProtoBuf 的优势。本文从 Unity 游戏开发的角度,挑选出容易被忽略掉而又很重要的点来介绍,以助使用者能正确使用 ProtoBuf。
1.1. 下载

源码可以从 GitHub 上下载。
本文用的是 v3.6.1 版本:https://github.com/protocolbuffers/protobuf/tree/v3.6.1。
1.2. 编译

Windows 平台下为了生成 VS 工程,需要手写一个批措置文件,放到“/cmake/build/”文件夹下。
cmake -G ”Visual Studio 15 2017” -DCMAKE_BUILD_TYPE=RELEASE -Dprotobuf_BUILD_TESTS=OFF ../
pausev3.6.1 撑持的最高 VS 版本是 VS2017,因此在执行批措置前,请先确认安装了 VS2017。
生成完成,因为主要是给 Unity 使用,因此主要存眷“/cmake/build/protobuf.sln”(C++ 工程)和“csharp/src/Google.Protobuf.sln”(C#工程)这两个工程。

  • “/cmake/build/protobuf.sln”里面主要存眷 protoc.vcxproj,这是 proto 编译器源码,可以将 proto3 语言编译成 C++、C#等编程语言。
  • “csharp/src/Google.Protobuf.sln”里面主要存眷 Google.Protobuf.csproj,这是 Protobuf.dll 源码,将生成的 dll 放到 Unity 的 Plugins 文件夹下,就可以使用 ProtoBuf 的序列化和反序列化的功能了。
2. Proto3 语法部门

目前的 ProtoBuf 协议遵循 Proto3 语法,通过使用 Proto 语言定义的协议布局体,可以规定端与端之间交流的数据格式。按照规定好的协议格式,分歧端之间可以无障碍交流,也就是打包数据和解析数据无障碍。
2.1. Message 基础语法

ProtoBuf 里面的 Message 对应着 C++、C#就是 struct 或者 class 的概念,下面看一个例子。
message Test1
{
    int32 a = 1;
    string b = 2;
}布局定义一目了然,不再赘述组成部门。
斗劲特殊的是,每个字段后面城市有个“=”,在“=”后面会跟一个数字,这个数字不是赋值,它叫做字段编号(Field Number)。因为这个 Message 布局最终是为了用来序列化成二进制数据的,为了版本间的前后兼容性,他就需要像其他序列化格式一样有 Key-Value 的布局。比如 Json 里面的 Key 就是冒号前的部门,Value 就是冒号后的部门。这里的字段编号,就可以理解为类似 Json 里面的 Key,暗示这个字段的独一性,因此同一个 Message 里字段编号不能反复
2.2. 基础类型

Proto类型说明C#类型C++类型
double双精度浮点数,定长编码,8字节doubledouble
float单精度浮点数,定长编码,4字节floatfloat
int64长整型,变长编码longint64
int32整形,变长编码intint32
uint64无符号长整型,变长编码ulonguint64
uint32无符号整型,变长编码uintuint32
sint64有符号长整型,变长编码longint64
sint32有符号整型,变长编码intint32
fixed64固定长度长整型,定长编码,8字节ulonguint64
fixed32固定长度整形,定长编码,4字节uintuint32
sfixed64固定长度有符号长整型,定长编码,8字节longint64
sfixed32固定长度有符号整形,定长编码,4字节intint32
boolboolbool
stringUTF-8编码,或者7位ASCII,长度不超过232stringstring
bytes长度不超过232的字符串ByteStringstring
2.3. 特殊类型

2.3.1. Any

这个类型可以用来存任意类型的值,Any 内部实现道理就是“一个描述类型的字符串 + 一个byte数组”
Proto 布局如下。
message Any
{
    string type_url = 1;
    bytes value = 2;
}C#代码布局如下。
public sealed partial class Any : pb::IMessage<Any>
{
    private string typeUrl_ = ””;
    private pb::ByteString value_ = pb::ByteString.Empty;
}
其本质上还是一个普通的 Message。
此中 TypeUrl 的格式为“URL+ 斜杠 + 类型名称”。
{URL}/{类型名称}反序列化的时候可以先获取其类型名称,然后通过反射获得类型对象,然后再将二进制的 byte 数组反序列化为对象。
public static string GetTypeName(string typeUrl)
{
    ProtoPreconditions.CheckNotNull(typeUrl, nameof(typeUrl));
    // 获得斜杠后面的字符串,也就是类型名称
    int lastSlash = typeUrl.LastIndexOf(&#39;/&#39;);
    return lastSlash == -1 ? ”” : typeUrl.Substring(lastSlash + 1);
}
2.3.2. MapField

ProtoBuf 撑持序列化字典类型的数据,也就是使用键值对的形式保留的数据。使用 MapField 类型就可以保留键值对。
message Test2
{
    map<string, int> map_member = 1;
}这段代码编译成 C#,此中的 map<string, int> 将会编译为 MapField<string, int>。MapField 本质上就是一个 IDictionary。
public sealed class MapField<TKey, TValue> : IDeepCloneable<MapField<TKey, TValue>>, IDictionary<TKey, TValue>, IEquatable<MapField<TKey, TValue>>, IDictionary
{
    ......
}
但是要知道,protoc 编译器有一个潜法则,MapField 的 Key 只能是*int*或者string,除了这两种类型的 key,编译时候城市报错。


2.3.3. RepeatedField

Proto3 语法里有一个 repeated 关键字,加上这个关键字的类型城市被编译为数组。
message Test3
{
    repeated int32 ids = 1;
}这个属于基础语法,不再赘述。
2.3.4. 枚举类型

枚举定义类似 C++ 和 C#。
enum TestEnum
{
    E1 = 0;
    E2 = 1;
    E3 = 2;
}需要注意的是,枚举字段的名称全局范围内不能不异。如下两个枚举的定义是不合法的。
enum TestEnum1
{
    E1 = 0;
    E2 = 1;
    E3 = 2;
}
enum TestEnum2
{
    E3 = 0;
    E4 = 1;
    E5 = 2;
}上面代码中,TestEnum1 和 TestEnum2 都包含一个叫做 E3 的枚举字段,这就会导致 Proto 语言编译掉败。
3. 编码部门

为什么 ProtoBuf 序列化出来的二进制文件,大小会如此小?为什么 ProtoBuf 撑持的基础类型里没有 short,也没有 char/byte 这些内存占用小的类型?原因都和 ProtoBuf 的编码方式有关。
Message 里面的每一个字段的编码方式都按照以下格式来编码。


中间的[长度]是可选项,有些编码方式里可以舍弃,后文会详细解释。
此中 Tag 包含两个部门:

  • 字段编号(Field Number)
  • 线性类型(Wire Type)
这里 Proto 定义了六种线性类型,此中 StartGroup 和 EndGroup 两种类型已被遗弃,所以常用的线性类型为 4 种。这些线性类型主要用来告诉 ProtoBuf 要如何编码这种类型的数据。下面是线性类型和数据类型之间的关系。
线性类型枚举值对应的数据类型
Varint0int32、int64、uint32、uint64、sint32、sint64、bool、enum
Fixed641fixed64、sfixed64、double
Length-Delimited2string、bytes、内嵌message、带repeated关键字的字段
StartGroup3遗弃
EndGroup4遗弃
Fixed325fixed32、sfixed32、float
从枚举值可以猜测出来,线性类型最少要占用 3 个 bit,因为最大值 5 也就是二进制的 101。下面通过代码看一下如何获得一个 Tag。
private const int TagTypeBits = 3;

public static uint MakeTag(int fieldNumber, WireType wireType)
{
    return (uint) (fieldNumber << TagTypeBits) | (uint) wireType;
}
从代码可知,Tag 是由最多 29 位字段编号和 3 位线性类型组成的(为什么是“最多”,看完 3.1.小节就知道了)。


因此,字段编号的取值范围是 1 到 536870911(  2^{29} = 536870912  )。此中 ProtoBuf 规定,19000 到 19999 已经被内部占用,是保留编号。
仅仅一个 Tag 就占了 32 位,显然太大了,因此,ProtoBuf 使用“Varints 编码”方式去压缩 Tag 占用的空间。同理,int、uint、long、ulong 也可以使用“Varints 编码”去压缩占用空间,下文会详细解释。
这里还有一个结论需要知道,字段编号的数值要尽量小。后续介绍完“Varints 编码”就知道为什么要尽量小了,这里先按下不表。

  • 字段编号取值范围[1, 15],Tag 占用 1 个字节;
  • 字段编号取值范围[16, 2047],Tag 占用 2 个字节;
  • 字段编号取值范围[2048, 262143],Tag 占用 3 个字节;
  • ......
3.1. Varints 编码

在开发中,可能会发现一个现象,我们平时用的数字一般不会太大,而那些不是很大的数字转换为二进制数据,其有效数字前会有很多的 0,这些 0 在序列化里其实是没有意义的,其实可以舍去。
Varints 编码就是基于这个思想来对整型数进行的编码,从而实现压缩带宽和内存占用的。
法式每次能读到内存的最小单元是字节(Byte),也就是 8 位(8 bits)。Varints 规定,法式每次读出来的一个字节开头的第一位暗示是否还需要继续读下一个字节,类似于字段终止符的概念,这个位被称为 MSB(Most Significant Bit);剩下的 7 位才是整数的组成数据。



  • MSB = 1,读取完当前字节后,需要继续读取下一个字节;
  • MSB = 0,读取完当前字节后,拼接之前读出来的字节,即为最终整数值。
比如,整数 666 在内存里是怎么存储的呢?首先看一下,666 按 32 位的二进制方式存储,如下所示。


既然 MSB = 0 的感化是打断继续读字节,那么需要打断的就是那些高位的 0,所以我们就需要从低位读起,这样才能打断高位的那些 0 的读取操作,也就是要将二进制数据按 7 位一组倒置一下挨次。


在上面的基础上加上 MSB 就是最终编码 9A 05。


读取 Varints 编码的代码如下,可以参考一下,不再赘述其详细过程。
internal uint ReadRawVarint32()
{
    // 如果读取5字节后,已经超过buffer的尺寸了
    if (bufferPos + 5 > bufferSize)
    {
        return SlowReadRawVarint32();
    }

    int tmp = buffer[bufferPos++];
    if (tmp < 128)
    {
        // 取值范围[0, 127]
        return (uint) tmp;
    }
    int result = tmp & 0x7f;
    if ((tmp = buffer[bufferPos++]) < 128)
    {
        // 取值范围[128, 16383]
        result |= tmp << 7;
    }
    else
    {
        result |= (tmp & 0x7f) << 7;
        if ((tmp = buffer[bufferPos++]) < 128)
        {
            // 取值范围[16384, 2097151]
            result |= tmp << 14;
        }
        else
        {
            result |= (tmp & 0x7f) << 14;
            if ((tmp = buffer[bufferPos++]) < 128)
            {
                // 取值范围[2097152, 268435455]
                result |= tmp << 21;
            }
            else
            {
                result |= (tmp & 0x7f) << 21;
                // 取值范围[268435456, 4294967295]
                result |= (tmp = buffer[bufferPos++]) << 28;
                if (tmp >= 128)
                {
                    // Discard upper 32 bits.
                    // Note that this has to use ReadRawByte() as we only ensure we&#39;ve
                    // got at least 5 bytes at the start of the method. This lets us
                    // use the fast path in more cases, and we rarely hit this section of code.
                    for (int i = 0; i < 5; i++)
                    {
                        if (ReadRawByte() < 128)
                        {
                            return (uint) result;
                        }
                    }
                    throw InvalidProtocolBufferException.MalformedVarint();
                }
            }
        }
    }
    return (uint) result;
}
使用这种编码方式,就可以把有效数字提取出来,按 7 位一个字节的方式组织成二进制数据。通过这种编码方式可知。

  • 整数值范围[0, 127],占用内存 1 字节。
  • 整数值范围[128, 16383],占用内存 2 字节。
  • 整数值范围[16384, 2097151],占用内存 3 字节。
  • 整数值范围[2097152, 268435455],占用内存 4 字节。
  • 整数值范围[268435456, 4294967295],占用内存 5 字节。
因此,为了尽可能提升数据压缩率,使用 ProtoBuf 存储的整数值要尽量小。当一个 int 值大于 268435455 时,使用 ProtoBuf 存储,比起简单的二进制存储反而更浪费内存。
3.2. Zigzag 编码

使用 Varints 编码有个问题就是负数编码出来必然是 5 字节的,因为整型的最高位是符号位,值为 1,属于有效数字,不能被舍弃。
为了解决这个问题,ProtoBuf 在使用 Varints 编码负整数前,会先使用 Zigzag 编码对整数做一次措置。
Zigzag 编码的核心思想就是,将符号位从最高位移动到最低位,其余数据位都往左移一位。
比如,-666 通过 Zigzag 编码后得到 1333,过程如下。


颠末 Zigzag 编码得的值,再用 Varints 编码进行编码,就得到最终编码值了,过程和 3.1.小节不异不再赘述。
Zigzag 编码的代码如下所示。
internal static uint EncodeZigZag32(int n)
{
    // Note:  the right-shift must be arithmetic
    return (uint) ((n << 1) ^ (n >> 31));
}
在 Varints 编码前会按照符号位使用 Zigzag 编码的类型为:sint32sint64
这里需要提一下,int32 和 int64 两种 ProtoBuf 基础类型,无论值为正还是值为负,仅使用 Varints 编码进行编码。那么当其值为负数时,将会占用 5 字节,浪费带宽和内存空间。因此,int32 和 int64 如果确定有可能为负数,请使用 sint32 和 sint64 代替
3.3. Length-Delimited 编码

以上的 Varints 编码,在一个字段里仅需要“Tag”和“值”两部门就够了,但是 Length-Delimited 编码需要“Tag”、“长度”和“值”三部门。


此中“Tag”和“长度”都用 Varints 编码,“值”就是最简单的二进制编码。序列化和反序列化时,都是按照“长度”值来对后面的“值”数据进行操作的。道理斗劲简单不再赘述,相信大师都能想象得出这个序列化和反序列化过程。
3.4. Fixed32 & Fixed64

WireType 为 Fixed32 和 Fixed64 的数据,不进行任何形式的编码,就是简单还原其二进制数据即可。
此中需要强调的是 float 类型和 double 类型,这些浮点数类型都是不颠末编码压缩的,因此,如果为了内存占用小,尽量使用整型替换浮点型
4. 编译器部门

下面先看一下编译器部门的整体代码布局。
4.1. 编译入口

打开 protoc 项目的 http://main.cc 文件,这个文件里包含了 protoc 编译器的 main 函数。


main 函数最后一行的函数调用 cli.Run 就是编译入口。
int main(int argc, char* argv[]) {
    (...)

    return cli.Run(argc, argv);
}
接下来的调用栈为:


4.2. 生成 CSharp 代码

下面主要看 CSharp 的函数调用,也就是从 http://csharp_generator.cc 的 Generator::Generate 函数开始。
bool Generator::Generate(
    const FileDescriptor* file,
    const string& parameter,
    GeneratorContext* generator_context,
    string* error) const {

    (...)

    string filename_error = ””;
    std::string filename = GetOutputFile(file,
        cli_options.file_extension,
        cli_options.base_namespace_specified,
        cli_options.base_namespace,
        &filename_error);

    (...)

    GenerateFile(file, &printer, &cli_options);

    return true;
}
代码第 7 到 12 行调用了 http://csharp_helpers.cc 的 GetOutputFile 函数,设置要生成的代码文件名为 filename,这里建议不要等闲改动,否则当呈现多个 proto 文件互相 import 时候,还需要手动去解决 FileDescriptor 的类型错误。
代码第 18 行调用 GenerateFile 函数继续生成 CSharp 代码。
void GenerateFile(const google::protobuf::FileDescriptor* file,
                  io::Printer* printer,
                  const Options* options) {
    ReflectionClassGenerator reflectionClassGenerator(file, options);
    reflectionClassGenerator.Generate(printer);
}
GenerateFile 函数调用了 ReflectionClassGenerator 的 Generate 函数。
void ReflectionClassGenerator::Generate(io::Printer* printer) {
    WriteIntroduction(printer);

    // 生成FileDescriptor
    WriteDescriptor(printer);

    printer->Outdent();
    printer->Print(”}\n”);

    const string& descCond = options()->descriptor_condition;

    if (!descCond.empty())
    {
        printer->Print(”#endif //$cond$\n\n”,”cond”, descCond);
    }

    // 生成枚举
    if (file_->enum_type_count() > 0) {
        printer->Print(”#region Enums\n”);
        for (int i = 0; i < file_->enum_type_count(); i++) {
            EnumGenerator enumGenerator(file_->enum_type(i), this->options());
            enumGenerator.Generate(printer);
        }
        printer->Print(”#endregion\n”);
        printer->Print(”\n”);
    }

    // 按照Message生成类
    if (file_->message_type_count() > 0) {
        printer->Print(”#region Messages\n”);
        for (int i = 0; i < file_->message_type_count(); i++) {
            MessageGenerator messageGenerator(file_->message_type(i), this->options());
            messageGenerator.Generate(printer);
        }
        printer->Print(”#endregion\n”);
        printer->Print(”\n”);
    }

    // TODO(jtattermusch): add insertion point for services.

    if (!namespace_.empty()) {
        printer->Outdent();
        printer->Print(”}\n”);
        printer->Print(”#if PROFILER\n”);
        printer->Print(”}\n”);
        printer->Print(”#endif\n”);
    }
    printer->Print(”\n”);
    printer->Print(”#endregion Designer generated code\n”);
}
ReflectionClassGenerator::Generate 函数主要分为三部门:

  • 生成 FileDescriptor;
  • 生成枚举;
  • 生成类。
下面主要看一下生成类的代码,也就是代码第 33 行的调用 messageGenerator.Generate。
void MessageGenerator::Generate(io::Printer* printer) {
    (...)

    // 所有静态字段和属性

    // 生成Parser静态变量
    printer->Print(
        vars,
        ”private static readonly pb::MessageParser<$class_name$> _parser = new pb::MessageParser<$class_name$>(() => new $class_name$());\n”);

    printer->Print(
        vars,
        ”public static pb::MessageParser<$class_name$> Parser { get { return _parser; } }\n\n”);

    (...)

    // 生成无参构造函数和OnConstruction的partial函数
    printer->Print(
        vars,
        ”public $class_name$() {\n”
        ”  OnConstruction();\n”
        ”}\n\n”
        ”partial void OnConstruction();\n\n”);
    // 生成Clone函数
    GenerateCloningCode(printer);

    // 生成Field或者Property
    for (int i = 0; i < descriptor_->field_count(); i++) {
        const FieldDescriptor* fieldDescriptor = descriptor_->field(i);
        (...)
        std::unique_ptr<FieldGeneratorBase> generator(
            CreateFieldGeneratorInternal(fieldDescriptor));
        // 按照分歧的FieldGeneratorBase子类生成成员
        generator->GenerateMembers(printer);
    }

    // 生成oneof的属性
    for (int i = 0; i < descriptor_->oneof_decl_count(); i++) {
        (...)
    }

    // 尺度函数

    // 生成框架函数:Equals、GetHashCode、ToString
    GenerateFrameworkMethods(printer);
    // 生成序列化函数:WriteTo、CalculateSize
    GenerateMessageSerializationMethods(printer);
    // 生成各种MergeFrom函数
    GenerateMergingMethods(printer);

    // 生成嵌套枚举类型和类类型
    if (HasNestedGeneratedTypes()) {
        (...)
        for (int i = 0; i < descriptor_->enum_type_count(); i++) {
            EnumGenerator enumGenerator(descriptor_->enum_type(i), this->options());
            enumGenerator.Generate(printer);
        }
        for (int i = 0; i < descriptor_->nested_type_count(); i++) {
            // 不要为Map生成嵌套类型……
            if (!IsMapEntryMessage(descriptor_->nested_type(i))) {
                MessageGenerator messageGenerator(
                    descriptor_->nested_type(i), this->options());
                messageGenerator.Generate(printer);
            }
        }
    }

    (...)
}
代码第 6 行到第 13 行都是生成静态变量 Parser 的代码,凡是做 ProtoBuf 反序列化使用的函数 Parser.ParseFrom 就是它的一部门。
扩展:Parser.ParseFrom 底层其实是调用的 MergeFrom 系列函数,后续会提到。
这里注意一下代码第 9 行调用了 new 来创建对象,这里可以按照业务逻辑的需要使用对象池等手段做优化。
代码第 25 行调用了 GenerateCloningCode 函数来生成 Clone 函数,用来克隆 ProtoBuf 的对象。
这里注意一下,Clone 函数里面调用了 new 来创建对象,这里可以按照业务逻辑的需要使用对象池等手段做优化。
接下来的代码生成逻辑,对照着生成的 ProtoBuf 代码和上面的注释就可以了解其过程,不再赘述。
4.3. 项目优化

有的时候需要按照项目定制编译出的 C++、C#代码,就需要去自行改削编译器代码来满足需求,以上内容描述了 protoc 编译器代码的主逻辑线,可以按照需要去优化代码。
比如,我们知道 ProtoBuf 在 C#里面,是有 GC 的,为了优化 GC,就要去改削此中的 Protobuf.dll 源码和编译器源码。
Protobuf.dll 的源码布局就不详述了,只要知道:

  • 序列化的入口是 MessageExtensions.ToByteArray;
  • 反序列化的入口是 MessageParser.ParseFrom;
然后一路往下看源码就大白了。
5. 总结

综上所述,使用 ProtoBuf 前你需要知道的事情大部门都已经标成黑体了,下面汇总一下。

  • Any 内部实现道理就是“一个描述类型的字符串 + 一个 byte 数组”;
  • MapField 的 Key 只能是 int 或者 string;
  • 枚举字段的名称全局范围内不能不异;
  • 同一个 Message 里字段编号不能反复;
  • 字段编号的数值要尽量小;
  • 使用 ProtoBuf 存储的整数值要尽量小;
  • int32 和 int64 如果确定有可能为负数,请使用 sint32 和 sint64 代替;
  • 如果为了内存占用小,尽量使用整型替换浮点型;
  • 要有按照需求魔改 ProtoBuf 的能力。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
发表于 2023-8-18 11:34 | 显示全部楼层
大佬细啊,学习了
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-5-3 03:03 , Processed in 0.192955 second(s), 27 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表