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 Type Notes C++ Type Java Type Python Type[2] Go Type Ruby Type C# Type PHP Type
double double double float float64 Float double float
float float float float float32 Float float float
int32 使用变长编码,对于负值的效率很低,如果你的域有可能有负值,请使用sint64替代 int32 int int int32 Fixnum 或者 Bignum(根据需要) int integer
uint32 使用变长编码 uint32 int int/long uint32 Fixnum 或者 Bignum(根据需要) uint integer
uint64 使用变长编码 uint64 long int/long uint64 Bignum ulong integer/string
sint32 使用变长编码,这些编码在负值时比int32高效的多 int32 int int int32 Fixnum 或者 Bignum(根据需要) int integer
sint64 使用变长编码,有符号的整型值。编码时比通常的int64高效。 int64 long int/long int64 Bignum long integer/string
fixed32 总是4个字节,如果数值总是比总是比228大的话,这个类型会比uint32高效。 uint32 int int uint32 Fixnum 或者 Bignum(根据需要) uint integer
fixed64 总是8个字节,如果数值总是比总是比256大的话,这个类型会比uint64高效。 uint64 long int/long uint64 Bignum ulong integer/string
sfixed32 总是4个字节 int32 int int int32 Fixnum 或者 Bignum(根据需要) int integer
sfixed64 总是8个字节 int64 long int/long int64 Bignum long integer/string
bool bool boolean bool bool TrueClass/FalseClass bool boolean
string 一个字符串必须是UTF-8编码或者7-bit ASCII编码的文本。 string String str/unicode string String (UTF-8) string string
bytes 可能包含任意顺序的字节数据。 string ByteString str []byte String (ASCII-8BIT) ByteString string

关于默认值的一些说明:

  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.更多详情和大神帖参考

猜你喜欢

转载自blog.csdn.net/u014665013/article/details/119523028