这章是接上一章,使用RPC包,序列化中没有详细去讲,因为这一块需要看的和学习的地方很多。并且这一块是RPC中可以说是最重要的一块,也是性能的重要影响因子。今天这篇主要会讲其使用方式。
文章目录
- Protocol Buffers V3 背景以及概念
- 如何使用?
- 分配字段编号
- 指定字段规则
- 数据类型
- `标量类型`
- `复合类型`
- 枚举
- 其他消息类型
- 嵌套类型
- 更新消息类型
- 未知字段
- Any
- oneof
- oneof 特性
- 向后兼容性问题
- Maps
- JSON 映射
Protocol Buffers V3 背景以及概念
序列化是系统通信的基础组件,在大数据
、AI框架
和云原生
等分布式系统
中广泛使用。
当对象需要跨进程
、跨语言
、跨节点传输
、持久化
、状态读写
、复制
时,都需要进行序列化
,其性能
和易用性
影响运行效率
和开发效率
。
但是对于序列化框架而言,业内将其分为两类:静态序列化框架,动态序列化框架。
其中各有优缺点:
- 静态序列化框架
- 不支持对象引用和多态、需要提前生成代码等原因,无法作为领域对象直接面向应用进行跨语言开发
- 常见的有:
protobuf
、flatbuffer
、thrift
- 动态序列化框架
- 提供了易用性和动态性,但不支持跨语言,且性能存在显著不足,并不能满足高吞吐、低延迟和大规模数据传输场景需求
- 常见的有:
JDK序列化
、Kryo
、Fst
、Hessian
、Pickle
但是前几天阿里推出了Fury,号称比
JDK快了170倍
,并且兼具静态序列化和动态序列化的优点。这个下一章会讲。目前的gRPC框架
是用protobuf
搭建的。所以咱们先让自己的项目成功运行起来再说。所以这个后面再说。
Protocol Buffers
是google开源
的一种结构数据序列化机制,可跨语言、跨平台。
相比XML
、JSON
、Thrift
等其他序列化格式,Protocol Buffers的序列化和反序列化性能是很高的,且Protocol Buffers序列化后是二进制流,因此数据大小和传输速度是很好的。
所以它非常适合在数据存储
或 RPC 数据交换
的场景下使用。
以下使用手法是翻译自官网
如何使用?
定义一个 .proto
文件
定义一个搜索请求消息格式,其中每个搜索请求都包含:
- 一个查询词字符串
- 你感兴趣的查询结果所在的特定页码数
- 每一页应展示的结果数
syntax = "proto3";
message SearchRequest {
string query = 1;
int32 page_number = 2;
int32 result_per_page = 3;
}
- 文件的第一行指定使用 proto3 语法。
如果不这样写,protocol buffer编译器将假定你使用 proto2。这个声明必须是文件的第一个非空非注释行。
SearchRequest
消息定义指定了三个字段(名称/值对) ,每个字段表示希望包含在此类消息中的每一段数据。每个字段都有一个名称和一个类型
指定字段类型:
- 两个整数
page_number
和result_per_page
和一个字符串query
(上面的例子) - 但是也可以为字段指定组合类型,包括枚举和其他消息类型
proto3语法主要包括:
- 消息(
message
)定义 - 服务(
service
)定义 - 其他部分语法
一个message内的字段一般包含:
- 数据类型
- 字段名
- 字段编号tag
添加更多消息类型:
可以在一个.proto
文件中定义多个消息类型。定义与 SearchRequest 消息类型对应的应答消息格式SearchResponse,就可以将其添加到同一个.proto文件中。
message SearchRequest {
string query = 1;
int32 page_number = 2;
int32 result_per_page = 3;
}
message SearchResponse {
...
}
添加注释:
要给你的.proto
文件添加注释,需要使用C/C++
风格的//
和/* ... */
语法。
保留字段:
- 如果通过完全删除字段或将其注释掉来更新消息类型,那么未来的用户在对该类型进行自己的更新时可以重用字段号。
- 如果其他人以后加载旧版本的相同
.proto
文件,这可能会导致严重的问题,包括数据损坏,隐私漏洞等等。
分配字段编号
消息定义中的每个字段都有一个 唯一的编号
。
这些字段编号用来在消息二进制格式中标识字段,在消息类型使用后就不能再更改。
注意
:
- 范围
1
到15
中的字段编号需要一个字节
进行编码,包括字段编号和字段类型 - 范围
16
到2047
的字段编号采用两个字节
应该为经常使用的消息元素保留数字1到15的编号。切记为将来可能添加的经常使用的元素留出一些编号。
可以指定的最小字段数是
1
,最大的字段数是
2
29
−
1
可以指定的最小字段数是1,最大的字段数是 2^{29}−1
可以指定的最小字段数是1,最大的字段数是229−1
即536,870,911。
也不能使用19000
到19999
,它们是预留给Protocol Buffers
协议实现的。
如果你在你的.proto
文件中使用了预留的编号Protocol Buffers
编译器就会报错。
同样,你也不能使用任何之前保留的字段编号。
指定字段规则
消息字段可以是下列字段之一:
singular
: 格式正确的消息可以有这个字段的零个或一个(但不能多于一个)。这是 proto3语法的默认字段规则。repeated
: 该字段可以在格式正确的消息中重复任意次数(包括零次)。重复值的顺序将被保留。
确保这种情况不会发生的一种方法是指定已删除字段
的字段编号(和/
或名称
,这也可能导致 JSON 序列化问题)是保留的reserved
如果将来有任何用户尝试使用这些字段标识符,protocol buffer编译器
将发出提示。
message Foo {
reserved 2, 15, 9 to 11;
reserved "foo", "bar";
}
注意:不能在同一个reserved
语句中混合字段名
和字段编号
。
当你使用 protocol buffer 编译器
来运行.proto文件
时,编译器用你选择的语言生成你需要使用文件中描述的消息类型,包括获取和设置字段值,将消息序列化为输出流,以及从输入流解析消息的代码。
- 对
C++
来说,编译器会为每个.proto文件生成一个.h文件和一个.cc文件,.proto文件中的每一个消息有一个对应的类。 - 对于
Java
,编译器生成一个.java 文件,每种消息类型都有一个类,还有一个特殊的 Builder 类用于创建消息类实例。 - 对于
Kotlin
,除了 Java 生成的代码之外,编译器还生成一个每种消息类型的 .kt 文件,包含一个 DSL,可用于简化消息实例的创建。 Python
稍有不同ー Python 编译器为.proto文件中的每个消息类型生成一个带静态描述符的模块,然后与 metaclass 一起使用,在运行时创建必要的 Python 数据访问类。- 对于
Go
,编译器为文件中的每种消息类型生成一个类型(type)到一个.pb.go 文件。 - 对于
Ruby
,编译器生成一个.rb 文件,其中包含一个包含消息类型的 Ruby 模块。 - 对于
Objective-C
,编译器从每个.proto文件生成一个 pbobjc.h 和 pbobjc.m 文件,.proto文件中描述的每种消息类型都有一个类。 - 对于
C#
,编译器生从每个.proto文件生成一个.cs 文件。.proto文件中描述的每种消息类型都有一个类。 - 对于
Dart
,编译器为文件中的每种消息类型生成一个.pb.dart 文件。
数据类型
数据类型:标量类型
和复合类型
标量类型
当解析消息时,如果编码消息不包含特定的 singular 元素,则解析对象中的相应字段将设置为该字段的默认值。
- 对于字符串,默认值为空字符串。
- 对于字节,默认值为空字节。
- 对于布尔值,默认值为 false。
- 对于数值类型,默认值为零。
- 对于枚举,默认值是第一个定义的枚举值,该值必须为0。
- 对于消息字段,未设置该字段。其确切值与语言有关。
- repeated 字段的默认值为空(通常是适当语言中的空列表)。
请注意
,对于标量消息字段,一旦消息被解析,就无法判断字段是显式设置为默认值(例如,是否一个布尔值是被设置为false
)还是根本没有设置: 在定义消息类型时应该牢记这一点。例如,如果你不希望某个行为在默认情况下也发生,那么就不要设置一个布尔值,该布尔值在设置为false
时会开启某些行为。还要注意,如果将标量消息字段设置为默认值,则该值将不会在传输过程中序列化。
复合类型
复合类型包括:枚举、嵌套其他message、Any(Map,Oneof)等
枚举
在定义消息类型时,你可能希望其中一个字段只能是预定义的值列表中的一个值。
可以通过在消息定义中添加一个枚举,为每个可能的值添加一个常量来非常简单地完成这项工作。
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;
}
Corpus enum
的第一个常量映射为零: 每个 enum
定义必须包含一个常量,该常量映射为零作为它的第一个元素。
- 必须有一个零值,这样我们就可以使用0作为数值默认值。
- 零值必须是第一个元素,以便与 proto2语义兼容,其中第一个枚举值总是默认值。
你可以通过将相同的值分配给不同的枚举常量来定义别名。为此,你需要将 allow _ alias
选项设置为 true
,否则,当发现别名时,protocol 编译器
将生成错误消息。
内部定义
message MyMessage1 {
enum EnumAllowingAlias {
option allow_alias = true;
UNKNOWN = 0;
STARTED = 1;
RUNNING = 1;
}
}
message MyMessage2 {
enum EnumNotAllowingAlias {
UNKNOWN = 0;
STARTED = 1;
// RUNNING = 1; // Uncommenting this line will cause a compile error inside Google and a warning message outside.
}
}
注意:枚举的常数必须在32位整数的范围内。
由于枚举值在传输时使用
变长编码
,因此负值效率低,因此不推荐使用。
可以在消息定义中定义枚举,如上面的例子所示,也可以在外面定义——这样就可以在.proto
文件中的消息定义中重用这些枚举。
外部定义
enum Ezarten {
option allow_alias = true; //开启枚举值重复开关
ZARTEN1 = 0;
ZARTEN2 = 1;
ZARTEN3 = 2;
ZARTEN4 = 2; //开启option allow_alias = true后枚举值可以重复
}
//定义一个message类型
message ZartenOne {
string name = 1;
int32 age = 2;
int32 height = 3;
Ezarten ezarten = 4;
}
可以使用_MessageType_._EnumType_
语法,使用在一个消息中声明的enum类型
作为不同消息中的字段类型。
使用消息内的枚举
若枚举定义在内部,其他message要使用这个枚举,可以使用 “message名.枚举名”的形式:
//定义一个message类型
message ZartenOne {
string name = 1;
int32 age = 2;
int32 height = 3;
enum Ezarten {
ZARTEN1 = 0;
ZARTEN2 = 1;
ZARTEN3 = 2;
}
Ezarten ezarten = 4;
}
//定义一个message类型
message ZartenTwo {
string name = 1;
int32 age = 2;
int32 height = 3;
ZartenOne.Ezarten ezarten = 4;
}
当对一个使用了枚举的
.proto文件
运行protocol buffer
编译器的时候,对于Java
,Kotlin
,或C++
生成的代码中将有一个对应的enum
,或者对于Python
会生成一个特殊的EnumDescriptor
类,它被用于在运行时生成的类中创建一组带有整数值的符号常量。
生成的代码可能会受到特定于语言的枚举数限制(单种语言的数量低于千)
反序列化
过程中,不可识别的枚举值将保留在消息中,尽管当消息被反序列化时,这种值的表示方式依赖
于语言。
- 开放枚举:在支持值超出指定符号范围
(如 C++ 和 Go)
的开放枚举类型
的语言中,未知枚举值仅存储为其底层的整数表示形式。 - 闭合枚举类型:在具有
闭合枚举类型
(如 Java)
的语言中,枚举中的一个类型将用于表示一个无法识别的值,并且可以使用特殊的访问器访问底层的整数。
在这两种情况下,如果消息被序列化,那么不可识别的值仍然会与消息一起被序列化。
其他消息类型
你可以使用其他消息类型作为字段类型:
message SearchResponse {
repeated Result results = 1;
}
message Result {
string url = 1;
string title = 2;
repeated string snippets = 3;
}
如果你希望用作字段类型的消息类型已经在另一个.proto文件中定义了,该怎么办?
你可以通过 import
来使用来自其他.proto
文件的定义。要导入另一个.proto
的定义,你需要在文件顶部添加一个import
语句
import "myproject/other_protos.proto";
默认情况下,只能从直接导入的
.proto
文件中使用定义。但是,有时你可能需要将.proto
文件移动到新的位置。你可以在旧目录放一个占位的.proto
文件使用import public
概念将所有导入转发到新位置,而不必直接移动.proto
文件并修改所有的地方。
import public
依赖项可以被任何导入包含import public
语句的proto
的代码传递依赖。
语法:
import
import public
- 相同的是,在
文件A
中两者可以直接引用它们上一级proto文件B的内容。 - 不同的是,若文件B内使用了
import
引用文件C,则文件A不能使用文件C的内容;
若文件B内使用了import public引用文件C,则文件A可以使用文件C的内容。
类似于编程语言中的类是否可以继承的含义。
文件:new.proto
所有的定义都被移到了这里
文件:old.proto
这是所有客户端都要导入的原型
import public "new.proto";
import "other.proto";
文件:client.proto
你可以使用old.proto
和new.proto
,但是不能使用 other.proto
protocol
编译器使用命令行-I/--proto_path
参数指定的一组目录中搜索导入的文件。如果没有给该命令行参数,则查看调用编译器的目录。
一般来说,你应该将
--proto_path
参数设置为项目的根目录并为所有导入使用正确的名称。
嵌套类型
方式1:
message SearchResponse {
message Result {
string url = 1;
string title = 2;
repeated string snippets = 3;
}
repeated Result results = 1;
}
方式2:
要在其父消息类型之外重用此消息类型,通过_Parent_._Type_使用。
message SomeOtherMessage {
SearchResponse.Result result = 1;
}
方式3:一层又一层嵌入其中
message Outer { // Level 0
message MiddleAA { // Level 1
message Inner { // Level 2
int64 ival = 1;
bool booly = 2;
}
}
message MiddleBB { // Level 1
message Inner { // Level 2
int32 ival = 1;
bool booly = 2;
}
}
}
更新消息类型
在不破坏任何现有代码的情况下更新消息类型非常简单:只需记住以下规则:
- 不要更改任何现有字段的字段编号
- 如果添加新字段
- 那么任何使用“旧”消息格式通过代码序列化的消息仍然可以通过新生成的代码进行解析。
- 你应该记住这些元素的
默认值
,以便新代码能够正确地与旧代码生成的消息交互。 - 类似地,新代码创建的消息可以通过旧代码解析: 旧的二进制文件在解析时直接忽略新字段。
- 你应该记住这些元素的
- 那么任何使用“旧”消息格式通过代码序列化的消息仍然可以通过新生成的代码进行解析。
- 字段可以被删除,只要字段编号不再用于你更新的消息类型。
- 你可能希望改为重命名字段,或者为其添加"
OBSOLETE_
“前缀,或者声明字段编号为reserved
,以便.proto
的未来用户不可能不小心重复使用这个编号。
- 你可能希望改为重命名字段,或者为其添加"
int32
、uint32
、int64
、uint64
和bool
都是兼容的——这意味着你可以在不破坏向前或向后兼容性的情况下将一个字段从这些类型中的一个更改为另一个。- 如果一个数字被解析到一个并不适当的类型中,你会得到与在 C++ 中将数字转换为该类型相同的效果
- 例如,如果一个64位的数字被读作 int32,它将被截断为32位
sint32
和sint64
相互兼容,但与其他整数类型不兼容string
和bytes
是兼容的,只要字节是有效的UTF-8
- 如果字节包含消息的编码版本,则
嵌入的消息与bytes兼容
fixed32
与sfixed32
兼容fixed64
与sfixed64
兼容。- 对于
string
、bytes
和消息字段
,optional
字段与repeated
字段兼容。- 给定重复字段的序列化数据作为输入
- 如果该字段是
基本类型
字段,期望该字段为可选字段的客户端将接受最后一个输入值 - 如果该字段是
消息类型
字段,则合并所有输入元素
- 如果该字段是
- 注意,这对于数字类型,包括
bools
和enums
通常是不安全的。- 重复的数值类型字段可以按
packed
的格式序列化,如果是optional
字段,则无法正确解析这些字段
- 重复的数值类型字段可以按
- 给定重复字段的序列化数据作为输入
Enum
在格式方面与int32
、uint32
、int64
和uint64
兼容(请注意,如果不适合,值将被截断)。- 但是要注意,当消息被反序列化时,客户端代码可能会区别对待它们:
- 例如,未被识别的
proto3
enum
将保留在消息中,但是当消息被反序列化时,这种类型的表示方式依赖于语言。Int
字段总是保留它们的值。
- 例如,未被识别的
- 但是要注意,当消息被反序列化时,客户端代码可能会区别对待它们:
- 将单个值更改为新的
oneof
成员是安全的,并且二进制兼容。- 如果确保没有代码一次设置多个字段,那么将
多个字段
移动到新的oneof字段
中可能是安全的。 - 将任何字段移动到现有的字段中都是不安全的。
- 如果确保没有代码一次设置多个字段,那么将
未知字段
未知字段是格式良好的协议缓冲区序列化数据,表示解析器不识别的字段。
当旧二进制
解析由新二进制发送的带有新字段
的数据时,这些新字段
将成为旧二进制中的未知字段
。
在3.5版本
中,我们重新引入了未知字段的保存来匹配 proto2
行为。在3.5及以后的版本中,解析期间保留未知字段,并将其包含在序列化输出中
Any
Any
消息类型允许你将消息作为嵌入类型使用,而不需要其.proto
定义。Any
包含一个任意序列化的字节消息,以及一个解析为该消息的类型作为消息的全局唯一标识符的URL
要使用Any
类型,需要导入google/protobuf/any.proto
给定消息类型的默认类型 URL
是type.googleapis.com/_packagename_._messagename_
import "google/protobuf/any.proto";
message ErrorStatus {
string message = 1;
repeated google.protobuf.Any details = 2;
}
不同的语言实现将支持运行库助手以类型安全的方式打包和解包 Any值:
-
在java中,Any类型会有特殊的pack()和unpack()访问器,
-
在C++中,Any类型会有特殊的PackFrom()和UnpackTo()方法。
oneof
如果你有一条包含多个字段的消息,并且最多
同时设置其中一个字段,那么你可以通过使用oneof
来实现并节省内存,优化
oneof
字段类似于常规字段,只不过oneof
中的所有字段共享内存,而且最多可以同时设置一个字段。
设置其中的任何成员都会自动清除所有其他成员。
根据所选择的语言,可以使用特殊 case()
或 WhichOneof()
方法检查 oneof
中的哪个值被设置
在生成的代码中,其中一个字段具有与常规字段相同的 getter
和 setter
你还可以获得一个特殊的方法来检查其中一个设置了哪个值
message SampleMessage {
oneof test_oneof {
string name = 4;
SubMessage sub_message = 9;
}
}
然后将其中一个字段添加到该字段的定义中。
你可以添加任何类型的字段,除了map字段
和repeated
字段
oneof有很多特性,具体的个人建议去看文档:
oneof 特性
- 设置一个字段将自动清除该字段的所有其他成员。
- 因此,如果你设置了多个 oneof字段,那么只有最后设置的字段仍然具有值。
- 如果解析器在连接中遇到同一个成员的多个成员,则只有最后看到的成员用于解析消息。
- oneof 不支持repeated。
- 反射 api 适用于 oneof 字段。
- 如果将 oneof 字段设置为默认值(例如将 int32 oneof 字段设置为0) ,则将设置该字段的“ case”,并在连接上序列化该值。
- 如果使用 C++ ,确保你的代码不会导致内存崩溃。
- 因为通过调用 set_name()方法已经删除了 sub_message
SampleMessage message;
SubMessage* sub_message = message.mutable_sub_message();
message.set_name("name"); // 删除name
sub_message->set_... // 这里的崩溃
- 在C++中,如果你使用
Swap()
两个oneof
消息,每个消息,两个消息将拥有对方的值
SampleMessage msg1;
msg1.set_name("name");
SampleMessage msg2;
msg2.mutable_sub_message();
msg1.swap(&msg2);
CHECK(msg1.has_sub_message());
CHECK(msg2.has_name());
向后兼容性问题
添加或删除一个字段时要小心。
- 如果检查
one of
的值返回None
/NOT_SET
,这可能意味着one of
没有被设置,或者它已经被设置为one of
的不同版本中的一个字段。 - 这没有办法区分,因为没有办法知道未知字段是否是 oneof 的成员。
标签重用问题:
- 将字段
移入
或移出
oneof
:在序列化和解析消息之后,你可能会丢失一些信息(某些字段将被清除)。但是,你可以安全地将单个字段移动到新的oneof
字段中,并且如果已知只设置了一个字段,则可以移动多个字段。 - 删除一个
oneof
字段再添加回来:这可能会在消息被序列化和解析后清除当前设置的 oneof 字段。 - 拆分或合并
oneof
:这与移动常规字段有类似的问题。
Maps
如果你想创建一个关联映射作为你数据定义的一部分。
map<key_type, value_type> map_field = N;
- 其中
key_type
可以是任何整型或字符串类型(除了浮点类型和字节以外的任何标量类型) value_type
可以是除另一个映射以外的任何类型。
map<string, Project> projects = 3;
- 映射字段不能重复。
- 映射值的有线格式排序和映射迭代排序是未定义的,因此不能依赖于映射项的特定排序。
- 当为
.proto
生成文本格式时,映射按键排序。数字键按数字排序。 - 当从连接解析或合并时,如果有重复的映射键,则使用最后看到的键。当从文本格式解析映射时,如果有重复的键,解析可能会失败
- 如果为映射字段提供了键但没有值,则该字段序列化时的行为与语言相关。在
C++
、Java
、Kotlin
和Python
中,类型的默认值是序列化的,而在其他语言中,没有任何值是序列化的。
JSON 映射
proto3
支持 JSON
的规范编码,使得系统之间更容易共享数据。下表按类型逐一描述了编码。
如果 json 编码
的数据中缺少某个值,或者该值为 null,那么在解析为protocol buffer
时,该值将被解释为适当的默认值。如果一个字段在 protocol buffer
中具有默认值,为了节省空间,默认情况下 json
编码的数据中将省略该字段。具体实现可以提供在JSON编码
中可选的默认值。
一个proto3协议 JSON 实现可能提供以下选项:
- 提供默认值的字段:
在proto3 JSON 输出中
,值为默认值的字段被省略。可以提供一个选项,用默认值覆盖此行为和输出字段。 - 忽略位置字段:
在缺省情况下
,Proto3 JSON 解析器应该拒绝未知字段,但在解析过程中可能会提供一个忽略未知字段的选项。 - 使用 proto 字段名而不是小驼峰名称:
默认情况下
,proto3 JSON 打印机应该将字段名转换为lowerCamelCase
,并使用它作为JSON 名称
。- 可以提供一个选项,用原型字段名作为
JSON
名。需要协议3 JSON 解析器同时接受转换后的
lowerCamelCase `名称和原始字段名称。
- 可以提供一个选项,用原型字段名作为
- 以整数而不是字符串形式展示枚举值:
在 JSON 输出中
,默认情况下使用枚举值的名称。可以提供一个选项来代替使用枚举值的数值。
剩下的可以看:官方文档