Protobuf从入门到“顺手”

Posted AI蜗牛之家

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Protobuf从入门到“顺手”相关的知识,希望对你有一定的参考价值。

文章目录

很多项目采用Protobuf进行消息的通讯,还有基于Protobuf的微服务框架GRPC,最近在使用一些框架的时候,顺手梳理了一下protobuf的一些语言特性和一些实用技巧。全文基于最新protobuf3,并用python举例

1.概述

序列化(serialization、marshalling)的过程是指将数据结构或者对象的状态转换成可以存储(比如文件、内存)或者传输的格式(比如网络)。反向操作就是反序列化(deserialization、unmarshalling)的过程。

JSON是一种更轻量级的基于文本的编码方式,经常用在client/server端的通讯中。YAML类似JSON,新的特性更强大,更适合人类阅读,也更紧凑。

Protobuf是google提出的消息通讯规范,支持很多语言,比如C++、C#、Dart、Go、Java、Python、Rust等,同时也是跨平台的,所以得到了广泛的应用。Protobuf包含序列化格式的定义、各种语言的库以及一个IDL编译器。正常情况下你需要定义proto文件,然后使用IDL编译器编译成你需要的语言。

1.1.proto格式

先来看一个非常简单的例子。假设你想定义一个“搜索请求”的消息格式,每一个请求含有一个查询字符串、你感兴趣的查询结果所在的页数,以及每一页多少条查询结果。可以采用如下的方式来定义消息类型的.proto文件了:

syntax = "proto3";
message SearchRequest 
  string query = 1;  // 注释
  int32 page_number = 2;
  int32 result_per_page = 3;

message SearchResponse 
 ... //这里省略了

  • 第一行指定protobuf的版本,这里是以proto3格式定义。你还可以指定为proto2。如果没有指定,默认以proto2格式定义。
  • 它定义了一个message类型: SearchRequest, 它包含三个字段query、page_number、result_per_page,它会被protoc编译成不同的编程语言的相应对象,比如Java中的class、Go中的struct等。在一个.proto文件中可以定义多个消息类型,例如,如果想定义与SearchResponse消息类型对应的回复消息格式的话,你可以将它添加到相同的.proto文件中
  • 字段是以[ "repeated" ] type fieldName "=" fieldNumber [ "[" fieldOptions "]" ] ";"格式定义的。这个例子是一个简单的例子,采用了type fieldName "=" fieldNumber格式定义的。
  • 指定字段类型 在上面的例子中,所有字段都是标量类型:两个整型(page_number和result_per_page),一个string类型(query)。当然,你也可以为字段指定其他的合成类型,包括枚举(enumerations)或其他消息类型。
  • 分配标识号 正如你所见,在消息定义中,每个字段都有唯一的一个数字标识符。这些标识符是用来在消息的二进制格式中识别各个字段的,一旦开始使用就不能够再改变。注:[1,15]之内的标识号在编码的时候会占用一个字节。[16,2047]之内的标识号则占用2个字节。所以应该为那些频繁出现的消息元素保留 [1,15]之内的标识号。切记:要为将来有可能添加的、频繁出现的标识号预留一些标识号。 最小的标识号从1开始,最大到2^29 - 1, or 536,870,911。不可以使用其中的[19000-19999]( (从FieldDescriptor::kFirstReservedNumberFieldDescriptor::kLastReservedNumber))的标识号

1.2.proto编译

可以将这个proto编译成Python的代码(参考链接参考连接),因为这里我们使用了python_out输出格式。(安装proto编译器)

protoc -I=$SRC_DIR --python_out=$DST_DIR $SRC_DIR/addressbook.proto
其中-I指定protoc的搜索import的proto的文件夹,可以有多个-I参数。在MacOS操作系统中protobuf把一些扩展的proto放在了/usr/local/include对应的文件夹中。

从.proto文件生成了什么?

当用protocol buffer编译器来运行.proto文件时,编译器将生成所选择语言的代码,这些代码可以操作在.proto文件中定义的消息类型,包括获取、设置字段值,将消息序列化到一个输出流中,以及从一个输入流中解析消息。

  • 对C++来说,编译器会为每个.proto文件生成一个.h文件和一个.cc文件,.proto文件中的每一个消息有一个对应的类。
  • 对Python来说,有点不太一样——Python编译器为.proto文件中的每个消息类型生成一个含有静态描述符的模块,,该模块与一个元类(metaclass)在运行时(runtime)被用来创建所需的Python数据访问类。
  • 对Java来说,编译器为每一个消息类型生成了一个.java文件,以及一个特殊的Builder类(该类是用来创建消息类接口的)。
  • 对go来说,编译器会位每个消息类型生成了一个.pd.go文件。

2.支持类型

2.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. 如果被编码的信息不包含某个变量,该被解析的对象会自动设置一个默认值:
    对于string,默认是一个空string;
    对于bytes,默认是一个空的bytes
    对于bool,默认是false
    对于数值类型,默认是0
    对于枚举,默认是第一个定义的枚举值,必须为0;
    对于消息类型(message),域没有被设置,确切的消息是根据语言确定的,详见generated code guide
  2. 数据传输时,为尽可能减少传输数据量,如果值是默认值时,在传输的消息里面会省略该字段,所以如果接受端看不到某些字段,可能就是该原因。

2.2.枚举

当需要定义一个消息类型的时候,可能想为一个字段指定某“预定义值序列”中的一个值,也就是我们常说的枚举类型。

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;

这里尤其注意,枚举的序号中必须包含0,这里跟变量的序号从1开始不同,注意区分。另外枚举常量必须在32位整型值的范围内。因为enum值是使用可变编码方式的,虽支持负数,但是对负数不够高效,不推荐在enum中使用负数。更多详情参考generated code guide

小贴士:
同上一节说明,如果数据传输时值是默认值,该字段就回被省略,这对枚举类型字段分析时很不方便,所以可以用序号0表示一个默认弃用的类型(比如上面例子中把UNIVERSAL = 0;下标0用DEFAULT占位,其他枚举类型顺延即可),这样正常情况下就不会出现默认值,该值也会出现在变量列表中

2.3.内置封装类型

2.3.1. Any类型

Any 类型可以表述任何message数据。

# protobuff文件Status.proto
import "google/protobuf/any.proto";

message Status 
  string message = 1;
  google.protobuf.Any details = 2;

message ErrorDetails
  int status;

python的用法:

def serial():
  errorStatus = Status_pb2.ErrorStatus()
  errorStatus.message = "run time error"
  errorDetails = Status_pb2.ErrorDetails()
  errorDetails.status=-1
  errorStatus.details.Pack(errorDetails)  # Any类型打包存在details变量中
  return errorStatus.SerializeToString()

def parse():
  errorStatus = Status_pb2.ErrorStatus()
  tmessage.ParseFromString(protobufdata)
  message = tmessage.tmessage
  
  errorDetails = Status_pb2.ErrorDetails()
  tmessage.details.Unpack(errorDetails)
  status = errorDetails.status

更多详情可以参考例子:python protobuf泛型类Any使用

2.3.2. Oneof 类型

如果你的消息中有很多可选字段, 并且同时至多一个字段会被设置, 你可以加强这个行为,使用oneof特性节省内存.

message Foo 
  oneof test_oneof 
     string name = 1;
     int32 serial_number = 2;
  

python举例

message = Foo()
message.name = "Bender"
assert message.HasField("name")
message.serial_number = 2716057
assert message.HasField("serial_number")
assert not message.HasField("name")

更多详情参考Python Generated Code

2.3.3.Map 映射类型

proto中的map与python中的字典或者json类似,每个key对应其value存储

message MyMessage 
  map<int32, int32> mapfield = 1;

Python中Map用法与dict类似

# Assign value to map
m.mapfield[5] = 10

# Read value from map
m.mapfield[5]

# Iterate over map keys
for key in m.mapfield:
  print(key)
  print(m.mapfield[key])

# Test whether key is in map:
if 5 in m.mapfield:
  print(“Found!”)

# Delete key from map.
del m.mapfield[key]

Map的字段可以是repeated。
序列化后的顺序和map迭代器的顺序是不确定的,所以你不要期望以固定顺序处理Map

2.4.类型的引用

Result消息类型与SearchResponse是定义在同一文件中的。如果想要使用的消息类型已经在其他.proto文件中已经定义过了呢?
你可以通过导入(importing)其他.proto文件中的定义来使用它们。要导入其他.proto文件的定义,你需要在你的文件中添加一个导入声明,如:

import "myproject/other_protos.proto";

另外,可以在本消息中引用其他消息的字段,在下面的例子中,Result消息就定义在SearchResponse消息内:

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

message SomeOtherMessage 
  SearchResponse.Result result = 1;

另外,消息类型可以是嵌套的。

3.python中的一些常用用法

3.1.json与message转换

通过Parse可以实现json数据类型到message格式的快速转换,方便一些测试等。

举例如下:

# proto定义
message Thing 
    string first = 1;
    bool second = 2;
    int32 third = 3;

json => message使用Parse,同理dict => message使用ParseDict:

import json
from google.protobuf.json_format import Parse

message = Parse(json.dumps(
    "first": "a string",
    "second": True,
    "third": 123456789
), Thing())

print(message.first)  # "a string"
print(message.second) # True
print(message.third)  # 123456789

message => dict,通过MessageToDict实现,同理message => json也可以通过MessageToJson实现:

from google.protobuf.json_format import MessageToDict

message_as_dict = MessageToDict(message)
message_as_dict['first']  # == 'a string'
message_as_dict['second'] # == True
message_as_dict['third']  # == 123456789

这里可以注意下,proto中推荐使用驼峰命名格式,如果使用了下划线,proto中在将message转换为json或者dict时,默认会自动转为驼峰,比如原始变量para_list会被转换成paraList,为避免出现如上现象,可以再使用MessageToDict或者MessageToJson时,函数中添加preserving_proto_field_name=True,更多详情参考:google.protobuf.json_format¶  JSON to Protobuf in Python¶
另外,MessageToDict在使用时会有一写默认的转化,比如int64会自动转成string,具体详情:JSON Mapping

4.更多详情和大神帖参考

以上是关于Protobuf从入门到“顺手”的主要内容,如果未能解决你的问题,请参考以下文章

Protobuf从入门到“顺手”

Protobuf 从入门到实战

Protobuf 从入门到实战

Rust从入门到精通04-数据类型

protobuf和json的一些整理

十五.ProtoBuf3的基础总结