很多项目采用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::kFirstReservedNumber 到 FieldDescriptor::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 |
关于默认值的一些说明:
- 如果被编码的信息不包含某个变量,该被解析的对象会自动设置一个默认值:
对于string,默认是一个空string; 对于bytes,默认是一个空的bytes 对于bool,默认是false 对于数值类型,默认是0 对于枚举,默认是第一个定义的枚举值,必须为0; 对于消息类型(message),域没有被设置,确切的消息是根据语言确定的,详见generated code guide - 数据传输时,为尽可能减少传输数据量,如果值是默认值时,在传输的消息里面会省略该字段,所以如果接受端看不到某些字段,可能就是该原因。
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)
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类似
m.mapfield[5] = 10
m.mapfield[5]
for key in m.mapfield:
print(key)
print(m.mapfield[key])
if 5 in m.mapfield:
print(“Found!”)
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格式的快速转换,方便一些测试等。
举例如下:
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)
print(message.second)
print(message.third)
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?
4.更多详情和大神帖参考
|