当前位置: 首页 > 工具软件 > pb > 使用案例 >

PB协议(一)什么是Pb协议(Protobuf),Pb协议如何使用,PB协议的数据类型

翟曦之
2023-12-01

相关参考链接

PB github指南

https://github.com/protocolbuffers/protobuf

 

PB编译器下载地址

https://github.com/protocolbuffers/protobuf/releases

 

PB官方文档

https://developers.google.com/protocol-buffers/docs/proto3

 

PB的官方文档需要翻墙才能访问(毕竟是谷歌出的协议),如需阅读中文文档,作为暖男的阿沛也给你做好了传送门:

PB官方文档(一)proto 开发者指南(翻译自官网)

 

一、什么是PB协议?
Protocol Buffer(PB协议)是用于序列化结构数据的高效的方法,有如XML,不过它更小、更快、也更简单。一旦定义了你自己的数据结构,然后就可以使用特殊生成的源代码轻松的在各种数据流和使用的各种高级语言之间读写你的结构化数据。

 

什么场景适合使用PB协议?

PB协议一般用于数据量较大的数据传输 以及 希望提高数据传输效率的场景,它本质上的工作其实是将你要传输的数据通过一套规则进行编码成二进制格式的数据,从而极大的压缩你要传输的内容,达到提高数据传输速度和效率的目的。

例如你的web接口如果返回的内容较多,导致数据在网络传输的时间过长,从而影响到用户体验的话,你可以使用响应格式为PB协议的web接口代替json格式的web接口,按照官网的说法,使用PB协议相比于json格式最多可以提高10倍的压缩和传输效率。当然除了提高数据在网络中的传输速度之外,顺带的好处是能帮你节省服务器的流量和带宽。

 

二、PB协议如何使用?

PB协议既然是一个用于提高通信效率的协议,就意味着客户端和服务端都需要规定一个统一的PB协议,具体来说就是proto文件和文件中定义的message类型的数据格式,这个数据格式指定了接口中会有哪些参数或返回值,以及参数和返回值的类型。定义好了proto文件和message之后,两端需要共用一份上述定义好了的proto文件作为他们通信的数据格式。

我们知道python通过json.loads/dump解析json格式,php使用json_encode/json_decode解析json格式,JS通过JSON.parse/stringify解析json格式。那么客户端或者服务端又是如何使用PB协议定义好的数据格式呢。实际上,PB提供了一个编译工具,可以将proto文件编译成对应语言的类文件或者对象,然后通过语言本身调用这些类或对象的方法和属性即可解析到PB协议中的请求/响应参数。

 

下面就开启我们的PB之旅

 

1. 安装PB协议编译器(protoc)

wget https://github.com/protocolbuffers/protobuf/releases/download/v21.1/protobuf-php-3.21.1.tar.gz

tar -zxvf protobuf-php-3.21.1.tar.gz

cd protobuf-3.21.1 && ./configure --prefix=/opt/soft/protobuf

make && make install

 

2. 通过一个例子开始

在 /tmp/pb 目录下创建一个 src 目录用来存放proto源文件,创建一个 /tmp/pb/php目录存放protoc编译后的php文件。

 

在/tmp/pb/src下创建一个example.proto 文件,内容如下。

syntax = "proto3";

message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
}

message MyRequest {
    string name = 1;
    int32 age = 2;
}

 

使用protoc编译器将这个proto文件编译为一个PHP文件。

protoc --proto_path=/tmp/pb/src --php_out=/tmp/pb/php /tmp/pb/src/example.proto

其中,--proto_path指明proto源文件的路径,--php_out指明编译后生成的php文件会放在哪个目录。

该命令会将 /tmp/pb/src/example.proto 文件编译生成 php文件,放到/tmp/pb/php 这个目录中。

 

三、PB协议的数据类型

 

1. message类型

定义一个message类型

syntax = "proto3";

message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
}

 

第一行指定使用 proto3 语法,不指定则默认使用proto2语法。

SearchRequest指定了一个消息类型(message),消息中包含若干个字段以及对应的字段类型。

消息定义中的每个字段都有一个唯一的编号。这些字段编号用于在消息体中标识您的字段,一旦该消息开始在项目中应用,您的消息类型不能再更改。

1 到 15 范围内的字段编号需要一个字节进行编码,包括字段编号和字段类型(您可以在Protocol Buffer Encoding中找到更多相关信息)。16 到 2047 范围内的字段编号占用两个字节。因此,您应该为非常频繁出现的消息字段保留数字 1 到 15。您可以指定的最小字段编号是 1,最大的是 2 29 - 1,即 536,870,911。不能使用数字 19000 到 19999作为编号,它们是PB的保留编号。

可以在单个.proto文件中定义多种消息类型。如果您要定义多个相关消息,例如您想定义一个请求消息类型 SearchRequest 和 一个对应的响应消息类型SearchResponse,您可以将其添加到相同的.proto:

message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
}

message SearchResponse {
 ...
}

 

对于发送方而言,如果发送的消息数据中不包含message指定的字段,则应用程序仍会自动创建出这些字段并赋上默认值,这意味着接收方无论如何都会接收到message类型中它所指定的所有字段。

对于字符串,默认值为空字符串。

对于字节,默认值为空字节。

对于布尔值,默认值为 false。

对于数字类型,默认值为零。

对于enums,默认值是第一个定义的 enum value,它必须是 0。

对于消息字段,未设置该字段。它的确切值取决于语言。

接收方是无法判断一个字段值是因为发送方没有设置而被自动设为默认值,还是发送方本来就为该字段设置了这个值。

 

如果你想删除proto文件中某个message的一个字段

如果服务提供方(也就是客户端或者服务端)要删除一个已经在使用的消息类型的某些字段,这会导致服务调用方调用失败(意思是后端删除了一个PB字段,但是前端依旧保留这这个PB字段,此时前后端使用不一致的PB协议通信就会出错),为此要删除字段的一方可以在自己的proto文件中用reserved关键字指明哪些字段已经被移除(然后需要重新编译一次才能生效)。

message Foo {
  reserved 2, 15, 9 to 11;
  reserved "foo", "bar";
}

一个reserved关键字不能混合指明字段名和字段编号。

当然,一般我们会要求前后端使用保持一致的PB文件,一旦一方对proto文件进行了修改,就同步给另一方让其作出同样的修改。

 

 

2. 单值字段类型和多值字段类型

message消息类型中的字段可以是 singular 单值类型(默认,不用显式声明),和repeated多值类型,后者可以在消息体中为一个字段定义多个值,类似于数组,他们的顺序会被保存,在应用程序中可以通过索引下标进行检索,数值类型的repeated默认使用packed编码方式。

例如:

message SearchRequest {
  repeated string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
}

 

 

3. 枚举类型 enum

如果某个字段有多个可能的值,而且这些值数固定的(即常量),则可以使用 枚举类型(enum)。代码中,corpus字段为枚举类型Corpus,该字段的编号为4,Corpus中包含7个常量,它们的值是0~6(0~6是常量值,而非字段编号)。枚举类型必须有至少一个值,且第一个值为该枚举类型的默认值,该默认值必须是0。

message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
  enum Corpus {
    UNIVERSAL = 0;
    WEB = 1;
    IMAGES = 2;
    LOCAL = 3;
    NEWS = 4;
    PRODUCTS = 5;
    VIDEO = 6;
  }
  Corpus corpus = 4;
}

 

使用 allow_alias 允许为一个枚举值被多个枚举名称使用。例如下面,

enum EnumAllowingAlias {
    option allow_alias = true;
    UNKNOWN = 0;
    STARTED = 1;
    RUNNING = 1;   // 不会报错
}

enum EnumNotAllowingAlias {
    UNKNOWN = 0;
    STARTED = 1;
    RUNNING = 1;  // 报错,枚举值1重复
}

 

enum常量值一般为32位整型,且不推荐使用负数,因为枚举类型使用varint编码方式进行编码,使用负数时编码效率低下。

一个enum类型可以在一个message内部定义,也可以在message外定义,后者使得该枚举类型可以用于多个message中。枚举类型编译后生成的数据结构依编译后的语言而定。

 

如果发送方序列化一个未定义的enum常量值,则反序列化时不会报错,而是将其反序列化为其底层的整型。例如上面代码的Corpus包含 0~6 这7个常量值,如果发送方发送了一个 corpus=9,那么反序列化时不会报错,如何根据corpus=9做出相应的行为可以取决于应用程序本身。

 

您可以在枚举类型中使用reserved指明有哪些常量值被删除。

enum Foo {
  reserved 2, 15, 9 to 11, 40 to max;
  reserved "FOO", "BAR";
}

 

 

4,. 嵌入消息类型

如果一个消息类型需要包含(或者说复用)其他消息类型的字段,则可以在一个消息类型中嵌入另一个消息类型。

message SearchResponse {
  repeated Result results = 1;
}

message Result {
  string url = 1;
  string title = 2;
  repeated string snippets = 3;
}

此时,searchResponse.results会被解析为一个Result对象类型。

 

你也可以在message中定义要嵌套的message类型,并使用它。

message SearchResponse {
  message Result {
    string url = 1;
    string title = 2;
    repeated string snippets = 3;
  }
  repeated Result results = 1;
}

 

如果一个message A要嵌入另一个message B定义的message C,则可以使用_Parent_._Type_的方式嵌入。

 

message SomeOtherMessage {
  SearchResponse.Result result = 1;
}

 

5. 引用其他proto文件的message

如果一个message类型需要引用其他proto文件的message类型(例如某个文件的message要嵌入另一个proto文件的message),可以使用import语法引用。

import "myproject/other_protos.proto";

引用的路径是一个相对路径,而它的实际路径是编译时 --proto_path= 所指定的路径。例如 --proto_path=/tmp/pb/src,则实际import的路径就是:/tmp/pb/src/myproject/other_protos.proto。

如果 -- proto_path指定的也是一个相对路径,那么引用的绝对路径就是 执行 protoc 编译命令时所处的目录地址 + -- proto_path的相对路径 + import的相对路径

如果一个A.proto引用了 dir1/B.proto 的某个message类型,但是B.proto文件的位置被移动到了 dir2/B2.proto,可以在不更改A.proto的import语句的情况下仍保持正确的引用路径关系。解决方法在 dir1 保留一个占位符文件,文件名仍然为B.proto,dir1/B.proto的内容为空,但它需要使用 import public语法引用 dir2/B2.proto 文件,而B2.proto文件的内容(message类型)就是原本B.proto的内容。

 

如下所示:

dir1/B.proto

// dir1/B.proto
// 该文件会被其他proto文件引用
import public "dir2/B2.proto";    // 引用移动了位置的proto文件
import "other.proto";

 

dir2/B2.proto

// dir2/B2.proto
// B定义的所有message类型被定义在该文件
...

 

client.proto

// client.proto
import "dir1/B.proto";
// 在client.proto文件中,可以自动引用到 B2.proto ,但是无法自动引用other.proto
// 因为 在dir1/B.proto 中是使用 import public 引用B2.proto,而other.proto则是用普通的import引用的

 

文件A 在 import引用了一个proto文件B.proto之后,使用引入的message时需要遵循如下原则,如果被引用的B.proto文件声明了package名,则使用B的message时要带上package。如果没有声明package,则无需带上。

例如:

/tmp/pb/src/example.proto

 

syntax = "proto3";
package foo.bar;    // 声明了包名(类似于命名空间)
message SearchRequest {
 string query = 1;
 int32 page_number = 2;
 int32 result_per_page = 3;
}

message MyRequest {
  string name = 1;
  int32 age = 2;
}

 

/tmp/pb/src/es.proto

syntax = "proto3";

// 没声明包名
message ESRequest{
  string keyword=1;
}

 

/tmp/pb/src/example2.proto

syntax = "proto3";

import "example.proto";
import "es.proto";

message SearchESRequest {
    foo.bar.SearchRequest searchReq = 1;    // 嵌入类型SearchRequest
    ESRequest esReq = 2;    // 嵌入类型ESRequest
    string keyword_no = 3;
}

 

6. 更改消息类型和字段

对于一个已经编译完并已经投入使用的message类型(也就是说它已经编译成了php/go/java/c++/python文件),如果你希望对其增加字段或做出其他修改怎么办?

 

记住以下原则:

1、不要修改已存在字段的字段编号。

2、更新是增加字段时,使用旧message序列化的数据可以被新message格式反序列化而无需做任何改变(也就是说可以用新的message格式解析旧message格式下的数据),因为旧message所缺失的字段会自动用新message的默认值构建。使用新message序列化的数据也可以被旧message反序列化,旧message在反序列化时会忽略新字段。

3、更新是移除字段时,有2种做法:一种是新message将要移除的字段置为reserved,并且该字段对应的编号不能被日后的其他字段使用;另一种是为该字段重命名,例如加一个"OBSOLETE_"前缀。

4、int32, uint32, int64, uint64,和bool是全部兼容的。这意味着如果更新需求是将上述其中的一种类型改为另一种类型是不会破坏向前兼容和向后兼容性的。当然,如果一个64位数字当做int32读取,则该数字会被截断为32位数字。

5、string和bytes是兼容的——只要bytes是有效的UTF-8编码。

6、嵌套消息与bytes是兼容的——只要bytes包含该消息的一个编码过的版本。

7、将一个单值字段移入到一个oneof类型(或者将单值字段转为oneof类型)是安全的。

 

7. Any类型

如果message中的某个字段可以是任意类型,你可以使用PB协议提供的Any类型标注该字段。

使用Any类型需要引入google/protobuf/any.proto。

 

import "google/protobuf/any.proto";

message ErrorStatus {
  string message = 1;
  repeated google.protobuf.Any details = 2;
}

Any类型其实会以bytes的方式进行编码和反编码。不同语言中有不同的方法可以对any类型进行序列化和反序列化,例如在java语言中,是使用pack()和unpack()方法。

 

8. Oneof类型

如果message中的某个字段可能是一个多类型或者某个字段会包含业务上含义不同的字段,并且这个字段在接口中最多只出现其中的1种,就可以使用oneof标注。使用oneof类型可以帮助应用节省内存。

message SampleMessage {
  oneof test_oneof {  // 意味着test_onefo这个字段可能是name或sub_message中的一个
    string name = 4;
    SubMessage sub_message = 9;
  }
}

要传输的数据中设置了oneof中的任何一个字段,则oneof中的其他字段会被自动清除注销。可以使用类似于case()或WhichOneof()方法得知接口传过来的是oneof中的哪个字段。

在编译后的代码中,oneof字段和普通字段一样可以使用访问器和修改器方法来访问和修改oneof字段的值。

如果接口数据中包含oneof的多个字段,在反序列化时只有最后一个字段被保留。

oneof字段类型不能是repeated多值类型。

关于将单值字段移入和移出oneof等兼容性,请参考官方文档。

 

9. map类型

map<key_type, value_type> map_field = N;

 

key_type可以是整型或字符串型。value_type可以是任意类型,但不能是map类型。

 

map<string, Project> projects = 3;

map字段不能是repeated类型。

map字段序列化和反序列化之后,内部的属性顺序是不确定的。

从线上序列化和反序列化得到的map,如果里面有重复的key值,则最后一个生效。如果是从文本中得到的map,如果里面有重复的key值则会在解析时报错。

如果一个map中的某个key没有value,则序列化之后的value值取决于编译的语言。

如果您的后端语言不支持map类型,则proto中可以用下面的写法代替map。

message MapFieldEntry {
  key_type key = 1;
  value_type value = 2;
}

repeated MapFieldEntry map_field = N;

 

8. package

为了避免两个proto文件的message存在引用关系时,存在重复message名导致报错的问题,可以使用package指定proto文件所属的包。只要两个proto属于不同的包,那么即使有重复的message类型名称也可以通过带上package包名来区分。

package foo.bar;
message Open { ... }

然后你可以在另一个proto文件中引用这个Open消息类型。

message Foo {
  ...
  foo.bar.Open open = 1;
  ...
}

 

以上是Protobuf的基本介绍和常见的数据类型,如果想要了解更多其他内容,可以参考官方文档或者中文文档:

PB官方文档(一)proto 开发者指南(翻译自官网)

本文转载自:
张柏沛IT技术博客 > PB协议(一)什么是Pb协议(Protobuf),Pb协议如何使用,PB协议的数据类型

 类似资料: