Protobuf介绍

Protobuf是Protocol Buffers的简称,它是Google公司开发的一种数据描述语言,用于描述一种轻便高效的结构化数据存储格式,并于2008年对外开源。Protobuf可以用于结构化数据串行化,或者说序列化。它的设计非常适用于在网络通讯中的数据载体,很适合做数据存储或 RPC 数据交换格式,它序列化出来的数据量少再加上以 K-V 的方式来存储数据,对消息的版本兼容性非常强,可用于通讯协议、数据存储等领域的语言无关、平台无关、可扩展的序列化结构数据格式。开发者可以通过Protobuf附带的工具生成代码并实现将结构化数据序列化的功能。
Protobuf中最基本的数据单元是message,是类似Go语言中结构体的存在。在message中可以嵌套message或其它的基础数据类型的成员。
教程中将描述如何用protocol buffer语言构造你的protocol buffer数据,包括.proto文件的语法以及如何通过.proto文件生成数据访问类。教程中使用的是proto3版本的protocol buffer语言

为什么使用Protocol Buffer?

在回答这个问题之前,我们还是先给出一个在实际开发中经常会遇到的系统场景。比如:我们的客户端程序是使用Java开发的,可能运行自不同的平台,如:Linux、Windows或者是Android,而我们的服务器程序通常是基于Linux平台并使用C++开发完成的。在这两种程序之间进行数据通讯时存在多种方式用于设计消息格式,如:

  1. 直接传递C/C++语言中一字节对齐的结构体数据,只要结构体的声明为定长格式,那么该方式对于C/C++程序而言就非常方便了,仅需将接收到的数据按照结构体类型强行转换即可。事实上对于变长结构体也不会非常麻烦。在发送数据时,也只需定义一个结构体变量并设置各个成员变量的值之后,再以char*的方式将该二进制数据发送到远端。反之,该方式对于Java开发者而言就会非常繁琐,首先需要将接收到的数据存于ByteBuffer之中,再根据约定的字节序逐个读取每个字段,并将读取后的值再赋值给另外一个值对象中的域变量,以便于程序中其他代码逻辑的编写。对于该类型程序而言,联调的基准是必须客户端和服务器双方均完成了消息报文构建程序的编写后才能展开,而该设计方式将会直接导致Java程序开发的进度过慢。即便是Debug阶段,也会经常遇到Java程序中出现各种域字段拼接的小错误。
  2. 使用SOAP协议(WebService)作为消息报文的格式载体,由该方式生成的报文是基于文本格式的,同时还存在大量的XML描述信息,因此将会大大增加网络IO的负担。又由于XML解析的复杂性,这也会大幅降低报文解析的性能。总之,使用该设计方式将会使系统的整体运行性能明显下降。

对于以上两种方式所产生的问题,Protocol Buffer均可以很好的解决,不仅如此,Protocol Buffer还有一个非常重要的优点就是可以保证同一消息报文新旧版本之间的兼容性。至于具体的方式我们将会在后续的博客中给出。

Protobuf使用

使用Protobuf的流程基本就是:先创建.proto文件定义消息格式,然后用内嵌的protoc编译。

创建proto文件

创建.proto文件,其实就相当于定义数据结构,规定一下我们发消息的格式和内容是什么。

The definitions in a .proto file are simple: you add a message for each data structure you want to serialize, then specify a name and a type for each field in the message.

声明开头

.proto文件以包声明开头,这有助于防止不同项目之间的命名冲突。

syntax = "proto3";//决定了proto文档的版本号
package GamePlayerTest;//命名空间

/*引用另一个.proto文件中定义*/
import "TestMsg2.proto";
(1)定义一个最基本的message
message TestMsg1
{
    //浮点数
    float testF = 1;
    double testD = 2;
    //变长编码
    //Protobuf会自动优化,可以尽量少的使用字节数,来存储内容
    int32 testInt32 = 3;  //不太适用于负数
    int64 testInt64 = 4;
    
    //更适用于负数
    sint32 testSInt32 = 5;
    sint64 testSInt64 = 6;
    
    //无符号变长编码
    uint32 testUInt32 = 7;
    uint64 testUInt64 = 8;
    
    //固定字节数类型
    fixed32 testFixed32 = 9; //通常用于表示大于2的28次方的数 uint
    fixed64 testFixed64 = 10; //通常用于表示大于2的56次方的数 ulong
    
    sfixed32 testSFixed32 = 11; //int
    sfixed64 testSFixed64 = 12; //long
}

这里将给出以上消息定义的关键性说明。

  1. message是消息定义的关键字,等同于C++/C#中的struct/class,或是Java中的class。
  2. TestMsg1为消息的名字,等同于结构体名或类名。
  3. required前缀叫字段规则或者限定符,表示该字段为必要字段,既在序列化和反序列化之前该字段必须已经被赋值。与此同时,在Protocol Buffer中还存在另外两个类似的关键字,optionalrepeated,带有这两种限定符的消息字段则没有required字段这样的限制。相比于optionalrepeated主要用于表示数组字段。
    • required:不可增加或删除的字段,必须初始化
    • optional:可选字段,可删除,可以不初始化
    • repeated:可重复字段(相当于List)
  4. floatdoubleint64string……分别表示消息的字段类型,在Protocol Buffer中存在一张类型对照表,既Protocol Buffer中的数据类型与其他编程语言(C++/Java)中所用类型的对照。该对照表中还将给出在不同的数据场景下,哪种类型更为高效。该对照表将在后面给出。
  5. testFtestDtestInt32testInt64……分别表示消息字段名,等同于Java中的域变量名,或是C++中的成员变量名。
  6. 标签数字123……则表示不同的字段在序列化后的二进制数据中的布局位置。在该例中,testD字段编码后的数据一定位于testF之后。需要注意的是该值在同一message中不能重复。另外,对于Protocol Buffer而言,标签值为1到15的字段在编码时可以得到优化,既标签值和类型信息仅占有一个byte,标签范围是16到2047的将占有两个bytes,而Protocol Buffer可以支持的字段数量则为2的29次方减一。有鉴于此,我们在设计消息结构时,可以尽可能考虑让repeated类型的字段标签位于1到15之间,这样便可以有效的节省编码后的字节数量。
(2)定义含有枚举字段的message
enum TestEnum1
{
    NORMAL = 0;
    BOSS = 5;
}
message TestMsg1
{
    //枚举
    TestEnum1 test_enum1 = 16;
}
  1. enum是定义枚举类型的关键字,相当于C++中的enumTestEnum1为枚举的名字,但不同点在于枚举值之间的分隔符是分号,不是逗号。
  2. test_enum1为枚举变量的名字,NORMALBOSS都是枚举值,01表示枚举值所对应的实际整型值,可以为枚举值指定任意的整型数值,不是必须从0开始定义。但是,proto2语法中首行的枚举值总是默认值0,如果为了兼容,则0值必须作为定义的首行
(3)定义含有嵌套消息字段的message

TestMsg1.proto文件:

import "TestMsg2.proto";//引用另一个.proto文件中定义
//嵌套消息
message TestMsg2
{
    int32 test_int32 = 1;
}
message TestMsg1
{
    TestMsg2 test_msg2 = 17;
    //嵌套枚举
    enum TestEnum2
    {
        NORMAL = 0;
        BOSS = 1;
    }
    TestEnum2 test_enum2 = 18;
    GameSystemTest.HeartMsg heart_msg = 19;
}

TestMsg2.proto文件:

syntax = "proto3";
package GameSystemTest;

message HeartMsg
{
    int64 time = 1;
}
  1. TestMsg1消息的定义中包含另外一个消息类型作为其字段,如TestMsg2 test_msg2
  2. 上例中的TestMsg1TestMsg2被定义在同一个.proto文件中,那么我们是否可以包含在其他.proto文件中定义的message呢?Protocol Buffer提供了另外一个关键字import,这样我们便可以将很多通用的message定义在同一个.proto文件中,而其他消息定义文件可以通过import的方式将该文件中定义的消息包含进来,如:
import "TestMsg2.proto";//引用另一个.proto文件中定义

相关问题

定义字段的规则

message的字段必须符合以下规则:

  • singular:一个遵循singular规则的字段,在一个结构良好的message消息体(编码后的message)可以有0或1个该字段(但是不可以有多个)。这是proto3语法的默认字段规则。(这个理解起来有些晦涩,举例来说上面例子中三个字段都是singular类型的字段,在编码后的消息体中可以有0或者1个query字段,但不会有多个)
  • repeated:遵循repeated规则的字段在消息体重可以有任意多个该字段值,这些值的顺序在消息体重可以保持。(就是数组类型的字段)
默认值

当时一个被编码的message体中不存在某个message定义中的singular字段时,在message体解析成的对象中,相应字段会被设置为message定义中该字段的默认值。默认值依类型而定:

  • 对于字符串,默认值为空字符串。
  • 对于字节,默认值为空字节。
  • 对于bools,默认值为false。
  • 对于数字类型,默认值为零。
  • 对于枚举,默认值是第一个定义的枚举值,该值必须为0。
  • 对于消息字段,未设置该字段。它的确切值取决于语言。有关详细信息,请参阅代码生成指南。
公共依赖项

默认情况下,您只能使用直接导入的.proto文件中的定义。但是,有时你可能需要将.proto文件移动到新位置。现在,你可以在旧位置放置一个虚拟.proto文件,在文件中使用import public语法将所有导入转发到新位置,而不是直接移动.proto文件并在一次更改中更新所有调用点。任何导入包含import public语句的.proto文件的人都可以传递依赖导入公共依赖项。例如

// new.proto
// All definitions are moved here
// old.proto
// This is the proto that all clients are importing.
import public "new.proto";
import "other.proto";
// client.proto
import "old.proto";
// You use definitions from old.proto and new.proto, but not other.proto
使用proto2的消息类型

可以导入proto2版本的消息类型到proto3的消息类型中使用,当然也可以在proto2消息类型中导入proto3的消息类型。但是proto2的枚举类型不能直接应用到proto3的语法中。

更新Message

如果一个现存的消息类型不再满足你当前的需求,比如说你希望在消息中增加一个额外的字段–但是仍想使用由旧版的消息格式生成的代码,不用担心!只要记住下面的规则,在更新消息定义的同时又不破坏现有的代码就非常简单。

  • 不要更改任何已存字段的字段编号。
  • 如果添加了新字段,任何由旧版消息格式生成的代码所序列化的消息,仍能被依据新消息格式生成的代码所解析。你应该记住这些元素的默认值这些新生成的代码就能够正确地与由旧代码序列化创建的消息交互了。类似的,新代码创建的消息也能由旧版代码解析:旧版消息(二进制)在解析时简单地忽略了新增的字段,查看下面的未知字段章节了解更多。
  • 只要在更新后的消息类型中不再重用字段编号,就可以删除该字段。你也可以重命名字段,比如说添加OBSOLETE_前缀或者将字段编号设置为reserved,这些未来其他用户就不会意外地重用该字段编号了。
未知字段

未知字段是格式良好的协议缓冲区序列化数据,表示解析器无法识别的字段。例如,当旧二进制文件解析具有新字段的新二进制文件发送的数据时,这些新字段将成为旧二进制文件中的未知字段。
最初,proto3消息在解析期间总是丢弃未知字段,但在3.5版本中,我们重新引入了未知字段的保留以匹配proto2行为。在版本3.5及更高版本中,未知字段在解析期间保留,并包含在序列化输出中。

映射类型

如果你想创建一个映射作为message定义的一部分,protocol buffers提供了一个简易便利的语法。

map<key_type, value_type> map_field = N;

key_type可以是任意整数或者字符串(除了浮点数和bytes以外的所有标量类型)。注意enum不是一个有效的key_typevalue_type可以是除了映射以外的任意类型(意思是protocol buffers的消息体中不允许有嵌套map)。
举例来说,假如你想创建一个名为projects的映射,每一个Project消息关联一个字符串键,你可以像如下来定义:

map<string, Project> projects = 15;
  • 映射里的字段不能是follow repeated规则的(意思是映射里字段的值不能是数组)。
  • 映射里的值是无序的,所以不能依赖映射里元素的顺序。
  • 生成.proto的文本格式时,映射按键排序。数字键按数字排序。
  • 从线路解析或合并时,如果有重复的映射键,则使用最后看到的键。从文本格式解析映射时,如果存在重复键,则解析可能会失败。
  • 如果未给映射的字段指定值,字段被序列化时的行为依语言而定。在C++, Java和Python中字段类型的默认值会被序列化作为字段值,而其他语言则不会。

完整的文件内容

syntax = "proto3";//决定了proto文档的版本号
package GamePlayerTest;//命名空间

import "TestMsg2.proto";//引用另一个.proto文件中定义

enum TestEnum1
{
    NORMAL = 0;
    BOSS = 5;
}

//消息类
message TestMsg1
{
    //成员类型 成员名称 = 唯一编号
    
    //浮点数
    float testF = 1;
    double testD = 2;
    //变长编码
    //Protobuf会自动优化,可以尽量少的使用字节数,来存储内容
    int32 testInt32 = 3;  //不太适用于负数
    int64 testInt64 = 4;
    
    //更适用于负数
    sint32 testSInt32 = 5;
    sint64 testSInt64 = 6;
    
    //无符号变长编码
    uint32 testUInt32 = 7;
    uint64 testUInt64 = 8;
    
    //固定字节数类型
    fixed32 testFixed32 = 9; //通常用于表示大于2的28次方的数 uint
    fixed64 testFixed64 = 10; //通常用于表示大于2的56次方的数 ulong
    
    sfixed32 testSFixed32 = 11; //int
    sfixed64 testSFixed64 = 12; //long
    
    //数组
    repeated int32 arr_int32 = 13;
    repeated string arr_string = 14;
    //字典
    map<int32,string> map1 = 15;

    //枚举
    TestEnum1 test_enum1 = 16;
    
    //嵌套消息
    message TestMsg2
    {
        int32 test_int32 = 1;
    }
    
    TestMsg2 test_msg2 = 17;
    //嵌套枚举
    enum TestEnum2
    {
        NORMAL = 0;
        BOSS = 1;
    }
    
    TestEnum2 test_enum2 = 18;

    GameSystemTest.HeartMsg heart_msg = 19;
}
syntax = "proto3";
package GameSystemTest;

message HeartMsg
{
    int64 time = 1;
}

类型对照表

.proto TypeNotesC++ TypeJava TypePython Type[2]Go TypeRuby TypeC# TypePHP Type
doubledoubledoublefloatfloat64Floatdoublefloat
floatfloatfloatfloatfloat32Floatfloatfloat
int32使用变长编码,对于负值的效率很低,如果你的域有可能有负值,请使用sint64替代int32intintint32Fixnum 或者 Bignum(根据需要)intinteger
uint32使用变长编码uint32intint/longuint32Fixnum 或者 Bignum(根据需要)uintinteger
uint64使用变长编码uint64longint/longuint64Bignumulonginteger/string
sint32使用变长编码,这些编码在负值时比int32高效的多int32intintint32Fixnum 或者 Bignum(根据需要)intinteger
sint64使用变长编码,有符号的整型值。编码时比通常的int64高效。int64longint/longint64Bignumlonginteger/string
fixed32总是4个字节,如果数值总是比总是比228大的话,这个类型会比uint32高效。uint32intintuint32Fixnum 或者 Bignum(根据需要)uintinteger
fixed64总是8个字节,如果数值总是比总是比256大的话,这个类型会比uint64高效。uint64longint/longuint64Bignumulonginteger/string
sfixed32总是4个字节int32intintint32Fixnum 或者 Bignum(根据需要)intinteger
sfixed64总是8个字节int64longint/longint64Bignumlonginteger/string
boolboolbooleanboolboolTrueClass/FalseClassboolboolean
string一个字符串必须是UTF-8编码或者7-bit ASCII编码的文本。stringStringstr/unicodestringString(UTF-8)stringstring
bytes可能包含任意顺序的字节数据。stringByteStringstr[]byteString(ASCII-8BIT)ByteStringstring

参考链接

  1. https://protobuf.dev/getting-started/csharptutorial/
  2. https://blog.csdn.net/qq_31347869/article/details/93189219
  3. https://www.cnblogs.com/orangeform/archive/2013/01/02/2841485.html
  4. https://blog.csdn.net/weixin_46272577/article/details/124358078
  5. https://blog.csdn.net/xu_cxiang/article/details/106492879
Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐