1 Protobuf 概述
Protocol Buffer (简称Protobuf) 是Google出品的性能优异、跨语言、跨平台的序列化库。在网络通信和通用数据交换等应用场景中经常使用的技术是 JSON
或 XML
,在微服务架构中通常使用另外一个数据交换的协议的工具ProtoBuf。
ProtoBuf也是我们做微服务开发,进行Go进阶实战中,必知必会的知道点。
ProtoBuf全称:protocol buffers,直译过来是:“协议缓冲区”,是一种与语言无关、与平台无关的可扩展机制,用于序列化结构化数据。
- 和
json\xml
最大的区别是:json,ProtoBuf是二进制格式。 ProtoBuf
相比于json\XML
,更小(3 ~ 10倍)、更快(20 ~ 100倍)、更为简单。- 我们只需要定义一次数据结构,就可以使用ProtoBuf生成源代码,轻松搞定在各种数据流和各种语言中写入、读取结构化数据。
官方的发布日志中列举了 proto3 的改变:
- 移除了原始值字段的出现逻辑。
- 移除了required字段
- 移除了缺省值
- 移除了unknown字段 (3.5中又加上了)
- 移除了扩展,使用Any代替
- 修复了未知的枚举值的语义
- 添加了map类型
- 添加了一些标准类似,比如time、动态数据的呈现
- 可以使用 JSON 编码代替二进制 proto 编码
2001年初,Protobuf 首先在 Google 内部创建, 我们把它称之为 proto1,一直以来在 Google 的内部使用,其中也不断的演化,根据使用者的需求也添加很多新的功能,一些内部库依赖它。几乎每个 Google 的开发者都会使用到它。
Google 开始开源它的内部项目时,因为依赖的关系,所以他们决定首先把 Protobuf 开源出去。 proto1 在演化的过程中有些混乱,所以Protobuf 的开发者重写了 Protobuf 的实现,保留了 proto1 的大部分设计,以及 proto1 的很多的想法。但是开源的 proto2 不依赖任何的 Google 的库,代码也相当的清晰。2008年7月7日,Protobuf 开始公布出来。
Protobuf 公布出来也得到了大家的广泛的关注, 逐步地也得到了大家的认可,很多项目也采用 Protobuf 进行消息的通讯,还有基于 Protobuf 的微服务框架 GRPC。在使用的过程中,大家也提出了很多的意见和建议,Protobuf 也在演化,于 2016 年推出了 Proto3。 Proto3 简化了 proto2 的开发,提高了开发的效能,但是也带来了版本不兼容的问题。
2 protobuf环境配置
我们以ubuntu为例进行protobuf的环境安装。值得一提的是,目前机构培训和学习一般使用较旧的版本如v2.5.0
版本。目前最新版本用CMake
安装
最新版本
下载地址:https://github.com/protocolbuffers/protobuf
利用CMake安装:执行下面命令: 1
2
3
4
5
6
7
8
9
10
11
12git clone git@github.com:protocolbuffers/protobuf.git
#下载完成后,执行下述命令
cmake . -Dprotobuf_BUILD_TESTS=OFF
cmake --build . --parallel 10
#测试
ctest --verbose
#安装至user目录下
sudo cmake --install .
#用makefile测试安装也可
make VERBOSE=1 test
sudo make install
出现错误1: 1
2
3
4
5
6
7
8
9
10
11
12CMake Error at cmake/gtest.cmake:7 (message):
Cannot find third_party/googletest directory that's needed to build tests.
If you use git, make sure you have cloned submodules:
git submodule update --init --recursive
If instead you want to skip tests, run cmake with:
cmake -Dprotobuf_BUILD_TESTS=OFF
Call Stack (most recent call first):
CMakeLists.txt:291 (include)cmake .
更改为cmake . -Dprotobuf_BUILD_TESTS=OFF
出现错误2: 1
2
3
4
5
6CMake Error at third_party/utf8_range/CMakeLists.txt:31 (add_subdirectory):
The source directory
/home/project/protobuf/sourcecode/protobuf/third_party/abseil-cpp
does not contain a CMakeLists.txt file.git clone https://github.com/abseil/abseil-cpp.git
因为protobuf依赖第三方库abseil,我们cloe以后,要对其进行编译安装: 1
2
3
4
5
6cd absil-cpp
makdir build
cd build
cmake -DABSL_BUILD_TESTING=ON -DABSL_USE_GOOGLETEST_HEAD=ON -DCMAKE_CXX_STANDARD=14 ..
cmake --build . --target all
sudo make install
2.5.0版本安装
下载v2.5.0版本的protobuf后,执行下述命令 1
2
3
4
5
6#安装必要的库
apt-get install autoconf automake libtool curl make g++ unzip
./autogen.sh
./configure
make
make install
3 安装go
- 步骤1:先安装golang
1
sudo apt-get install golang-go
步骤2:将go的path添加进
~/.bashrc
,并进行source ~/.bashrc
。可用go env
查看go的路径1
2
3export GOROOT=/opt/go
export GOPATH=~/GOPATH
export PATH=$PATH:$GOROOT/bin:$GOPATH步骤3:安装
protoc-gen-go
:Protobuf 核心的工具集是 C++ 语言开发的,官方的 protoc 编译器中并不支持 Go 语言,需要安装一个插件才能生成 Go 代码。用如下命令安装:此命令会将1
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
protoc-gen-go
可执行文件安装在$GOROOT/bin
目录下,因此需要将其添加进PATH(步骤2)。当编译器调用时传递了--go_out 命令行标志时protoc
就会使用该插件。--go_out
告诉编译器把 Go 源代码写到哪里。编译器会为每个.proto
文件生成一个单独的源代码文件。
如果出现
go: google.golang.org/protobuf/cmd/protoc-gen-go@latest: module google.golang.org/protobuf/cmd/protoc-gen-go: Get "https://proxy.golang.org/google.golang.org/protobuf/cmd/protoc-gen-go/@v/list": dial tcp 172.217.160.81:443: i/o timeout
错误 可尝试设置Go代理重新安装
1 go env -w GOPROXY=https://goproxy.io,direct
4 protobuf是什么?作用?
Protocol Buffers(简称 Protobuf)是一种轻便高效的能够序列化结构数据的协议工具,可以用于结构化数据串的序列化。适合做数据存储或 RPC 数据交换格式。可用于通讯协议、数据存储等领域的语言无关、平台无关、可扩展的序列化结构数据格式。
和json\xml
最大的区别是json,ProtoBuf是经过编码压缩的二进制格式。 因此ProtoBuf
相比于json\XML
,其体积更小(3 ~ 10倍)、速度更快(20 ~ 100倍)、也更为简单。**
Protocol Buffer 的序列化 和反序列化简单、速度快的原因
- 使用二进制的形式,比json用文本形式更接近计算机处理语言
- 编码 / 解码 方式简单(只需要简单的数学运算 = 位移等等)。
- 采用 Protocol Buffer 自身的框架代码 和 编译器 共同完成。
Protocol Buffer 的数据压缩效果好(即序列化后的数据量体积小)的原因是
- 采用了独特的编码方式,如 Varint、Zigzag 编码方式等等
- 采用
T - L - V
的数据存储方式:减少了分隔符的使用 & 数据存储得紧凑
- 采用
Protobuf 2和3的区别
- protoful文件的第一行需要指定您正在使用proto3语法:如果不这样做,protocol buffer编译器将假定使用的是proto2。这必须是文件的第一个非空、非注释行。
- proto3取消了proto2的required,而proto3的singular就是proto2的optional。
- proto3 repeated标量数值类型默认packed,而proto2默认不开启。
- proto3增加了Kotlin,Ruby,Objective-C,C#,Dart的支持。
- proto2可以选填default,而proto3只能使用系统默认的。(序列化后如果是默认值是不会占用空间的,对于proto2来说处理就很麻烦了)
- proto3必须有一个零值,以便我们可以使用 0 作为数字默认值。零值需要是第一个元素,以便与proto2语义兼容,其中第一个枚举值始终是默认值。proto2则没有这项要求。
- proto3在3.5版本之前会丢弃未知字段。但在 3.5 版本中,重新引入了未知字段的保留以匹配 proto2 行为。在 3.5 及更高版本中,未知字段在解析过程中保留并包含在序列化输出中。
- proto3移除了proto2的扩展,新增了Any(仍在开发中)和JSON映射。
Protobuf中每个字段后的序号作用?
每个字段有唯一编号,在二进制流中标识该字段,可以看后面protobuf 编解码原理去了解字段的作用。
- 消息被使用了,字段就不能改了,改了会造成数据错乱(常见坑),服务器和客户端很多bug,是proto buffer 文件更改,未使用更改后的协议导致。
- 1 到 15 范围内的字段编号需要一个字节进行编码,编码结果将同时包含编号和类型
- 16 到 2047 范围内的字段编号占用两个字节。因此,非常频繁出现的 message 元素保留字段编号 1 到 15。
- 字段最小数字为1,最大字段数为2^29 - 1。(原因在编码原理那章讲解过,字段数字会作为key,key最后三位是类型)
- 19000 through 19999 (FieldDescriptor::kFirstReservedNumber through FieldDescriptor::kLastReservedNumber这些数字不能用,这些是保留字段,如果使用会编译器会报错
protobuf和json对比
可以从优缺点来对比protobuf和json,相较于json,protobuf具有优点:
- 在性能上,其使用编码进行二进制数据流形式传输,压缩性好,能够一定程度上减小流量,从而节省网络带宽和省电。其序列化和烦序列化的速度要比json快2-100倍,传输的速度也更加快。
- 在便捷性上,使用较为简单,能够依靠protoc自动生成序列化和反序列化的目标代码;
- 维护成本低,只需要维护指定的
.protoc
文件即可,加密性较好,只有通过proto
文件才能了解数据结构 - 兼容性较好,跨平台,能够支持各种主流语言。
缺点:
- 自解释性差:只有通过
proto
文件才能了解数据结构,这一点源于它的加密性好,才导致自解释性差。
一般来说,客户端与服务器用的是json,而服务器与服务器之间用protobuf,该策略的原因上面对比已经分析出来了:后端服务之间的RPC调用可能会传输大量数据,如果全部用纯文本的形式来表示数据那么不管是网络带宽还是性能可能都会差强人意,protobuf更适合。而客户端更多与人相关,使用对人较友好的json语句更为稳妥。
5 protobuf的序列化和反序列化原理
protobuf之所以序列化和反序列化快,体积小,在于其采用了独特的编码,采用T - L - V
的数据存储方式,减少了分隔符的使用,使得数据存储得紧凑。
在protobuf,其使用标识 - 长度 - 字段值 表示每个字段,所有字段拼接成一个 字节流,从而 实现 编码存储 的功能
- Tag: field_number << 3 | wire type
- length:可选字段,目前只有类型2需要,例如字符串,length会存储字符串长度。
- value:不同类型的value值会有不同的编码方式。下面对每种类型进行逐一讲解。
5.1 wireType=0时的编码方式
采用了两种编码方式:Varint & Zigzag
varint
- Varint编码方式:一种变长的编码方式。将数据按7个bit为一组进行分组, 每分组前加1bit标示是否有下一组数据。依靠这种编码技术能够省去不必要的存储空间。
这样就可以用更少的字节表示数字,达到压缩的目的。
- 采用 Varint编码,对于很小的 int32 类型 数字,则可以用 1个字节来表示
- 虽然大的数字会需要 5 个 字节 来表示,但大多数情况下,消息都不会有很大的数字,所以采用 Varint方法总是可以用更少的字节数来表示数字
- Varint解码方式
Zigzag
Varint 编码方式的不足是如果采用 Varint编码方式 表示一个负数,那么一定需要 5 个 byte。因为最高位bit是1。例如int32类型 -1: 100000000 00000000 00000000 00000001
,使用varint编码ceil(4*8/7) = 5
protobuf会先采用 Zigzag 编码,再采用 Varint编码,Zigzag的原理是使用 无符号数 来表示 有符号数字;
- Zigzag 编码 是补充 Varint编码在 表示负数 的不足,从而更好的帮助 Protocol Buffer进行数据的压缩
- 所以,如果提前预知字段值是可能取负数的时候,记得采用sint32 / sint64 数据类型
5.2 wireType=2时的编码方式
wireType=2时的编码方式,采用T-L-V
的格式存储。这里,我们主要讲解常用的讲解三种数据类型的Value:
- String类型
- 嵌套消息类型(Message)
- 通过packed修饰的 repeat 字段(即packed repeated fields)
String类型
字段值(即V) 采用UTF-8编码
嵌套消息类型(Message)
即字面意思内部消息编码的T - L -V组成外部消息的v
通过packed修饰的 repeat 字段
repeated 修饰的字段有两种表达方式: 1
2
3
4
5
6
7
8
9
10
11
12message Test
{
// 表达方式1:不带packed=true
repeated int32 Car = 4 ;
// 表达方式2:带packed=true,proto 2.1 开始可使用
repeated int32 Car = 4 [packed=true];
}
// 在代码中给`repeated int32 Car`附上3个字段值:3、270、86942
Test.setCar(3);
Test.setCar(270);
Test.setCar(86942);
问题:对于同一个 repeated字段、多个字段值来说,他们的Tag都是相同的,会导致Tag的冗余,即相同的Tag存储多次;
解决方案:采用带packed=true 的 repeated 字段存储方式,即将相同的 Tag 只存储一次、记一个长度Length字段 :Tag - Length - Value -Value -Value。
通过采用带packed=true
的 repeated
字段存储方式,从而更好地压缩序列化后的数据长度。
特别注意 packed修饰只用于基本类型的repeated字段 用在其他字段,编译 .proto 文件时会报错
总结
- protobuf编码/解码 方式简单,只需要简单的数学运算、位移等,序列化 & 反序列化速度很快
- protobuf采用了独特的编码方式,如Varint、Zigzag编码方式等等,采用T - L - V 的数据存储方式,数据存储得紧凑,数据压缩效果好
使用建议 根据上面的序列化原理分析,有以下使用建议:
建议1:字段标识号(Field_Number)尽量只使用 1-15,且不要跳动使用 因为Tag里的Field_Number是需要占字节空间的。如果Field_Number>16时,Field_Number的编码就会占用2个字节,那么Tag在编码时也就会占用更多的字节;如果将字段标识号定义为连续递增的数值,将获得更好的编码和解码性能
建议2:若需要使用的字段值出现负数,请使用 sint32 / sint64,不要使用int32 / int64 因为采用sint32 / sint64数据类型表示负数时,会先采用Zigzag编码再采用Varint编码,从而更加有效压缩数据
建议3:对于repeated字段,尽量增加packed=true修饰 因为加了packed=true修饰repeated字段采用连续数据存储方式,即T - L - V - V -V方式
6 protobuf语法
定义一个消息类型
假设现在要定义一个“搜索请求”的消息格式,每一个请求含有一个查询字符串、查询结果所在的页数,以及每一页多少条查询结果。可以采用如下的方式来定义消息类型的 .proto 文件了: 1
2
3
4
5
6
7
8
9
10syntax = "proto3";
package Request;
option go_package="."
message SearchRequest {
string query = 1;
int32 page_number = 2;
int32 result_per_page = 3;
}
分配标识号
可以看到消息定义的每个字段都有一个唯一的数字标识符。这个标识符用于在消息的二进制格式中标识字段, 一旦消息类型被使用后不可以再修改。
注意标识符的值在 1 和 15 之间时,编码只需一个字节。标识符 在16 到 2047 之间将占用两个字节。因此应该将从 1 到 15 的标识符分派给最频繁出现的消息元素。记得保留一些空间给未来可能添加的频繁出现的元素。
最小的标识号可以从 1 开始,最大到 \(2^{29} - 1\)(536,870,911),另外19000 到 19999(FieldDescriptor::kFirstReservedNumber through FieldDescriptor::kLastReservedNumber)
不能使用,Protobuf协议实现中对这些进行了预留。
指定字段规则
消息字段有以下两种属性:
singular
:一个格式良好的消息应该有 0 个或者 1 个这种字段(但是不能超过 1 个)。(没有使用 repeated 默认属于这种属性)repeated
(数组形式):在一个格式良好的消息中,这种字段可以重复任意多次(包括 0 次)。重复的值的顺序会被保留。(在 go 里面会被转化为数组)
在 proto3 中,repeated
的标量域默认情况下会使用packed
编码(见上编码原理)
定义多个消息类型
在一个 .proto 文件中可以定义多个消息类型。在定义多个相关的消息的时候,这一点特别有用——例如,如果想定义与 SearchResponse消息类型对应的回复消息格式的话,可以将它添加到相同的 .proto 文件中,如: 1
2
3
4
5
6
7
8
9message SearchRequest {
string query = 1;
int32 page_number = 2;
int32 result_per_page = 3;
}
message SearchResponse {
...
}
添加注释
向 .proto 文件添加注释,可以使用 C/C++/Java 风格的双斜杠(//) 语法格式,如: 1
2
3
4
5message SearchRequest {
string query = 1;
int32 page_number = 2; // Which page number do we want?
int32 result_per_page = 3; // Number of results to return per page.
}
保留标识符(Reserved)
当更新消息类型,需要彻底删除或者注释掉一个字段时,以后的用户在更新这个类型的时候可以重用这些标识号。如果他们后来使用同一个文件的旧版本加载,会导致严重的问题,包括数据损坏、隐私错误等等。现在有一种确保不会发生这种情况的方法就是为字段 tag(reserved name 可能会 JSON 序列化的问题)指定 reserved 标识符,protocol buffer 的编译器会警告未来尝试使用这些域标识符的用户。 1
2
3
4message Foo {
reserved 2, 15, 9 to 11;
reserved "foo", "bar";
}
从 .proto 生成的文件
当用 protocol buffer 编译器来运行 .proto 文件时,即protoc **.proto --*_out="."
,编译器将选择的编程语言,生成相应的代码,这些代码可以操作在 .proto 文件中定义的消息类型,包括获取、设置字段值,将消息序列化到一个输出流中,以及从一个输入流中解析消息。
- 对 C++ 来说,编译器会为每个 .proto 文件生成一个 .h 文件和一个 .cc 文件,.proto 文件中的每一个消息有一个对应的类。
- 对 Java 来说,编译器为每一个消息类型生成了一个 .java 文件,以及一个特殊的 Builder 类(该类是用来创建消息类接口的)。
- 对 Python 来说,有点不太一样——Python 编译器为 .proto 文件中的每个消息类型生成一个含有静态描述符的模块,该模块与一个元类(metaclass)在运行时(runtime)被用来创建所需的 Python 数据访问类。
- 对 Go 来说,编译器会位每个消息类型生成了一个 .pd.go 文件。
- 对于 Ruby 来说,编译器会为每个消息类型生成了一个 .rb 文件。
- 对 javaNano 来说,编译器输出类似于 java 但是没有 Builder 类
- 对于 Objective-C 来说,编译器会为每个消息类型生成了一个 pbobjc.h 文件和 pbobjcm 文件,.proto 文件中的每一个消息有一个对应的类。
- 对于 C# 来说,编译器会为每个消息类型生成了一个 .cs 文件,.proto 文件中的每一个消息有一个对应的类。
7 protobuf的字段类型与编程语言的对应
一个标量消息字段可以含有一个如下的类型——该表格展示了定义于.proto文件中的类型,以及与之对应的、在自动生成的访问类中定义的类型:
.proto | 使用技巧 | C++ | Java | Python | Go | Ruby | C# | PHP |
---|---|---|---|---|---|---|---|---|
double | double | double | float | float64 | Float | double | float | |
float | float | float | float | float32 | Float | float | float | |
int32 | 使用变长编码,对于负值的效率很低,如果值有可能有负值,使用sint32替代 | int32 | int | int | int32 | Fixnum 或者 Bignum(根据需要) | int | integer |
int64 | 使用变长编码,对于负值的效率很低,如果值有可能有负值,使用sint64替代 | int64 | long | int/long | int64 | Bignum | long | integer/string |
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 | |
bytes | 可能包含任意顺序的字节数据。 | string | ByteString | str | []byte | String (ASCII-8BIT) | ByteString | string |
- 在 java 中,无符号 32 位和 64 位整型被表示成他们的整型对应形式,最高位被储存在标志位中。
- 对于所有的情况,设定值会执行类型检查以确保此值是有效。
- 64 位或者无符号 32 位整型在解码时被表示成为 long,但是在设置时可以使用int型值设定,在所有的情况下,值必须符合其设置其类型的要求。
- python中 string 被表示成在解码时表示成 unicode。但是一个 ASCII string 可以被表示成 str 类型。
- Integer 在 64 位的机器上使用,string 在 32 位机器上使用
8 默认值
当一个消息被解析的时候,如果被编码的信息不包含一个特定的简单元素,被解析的对象所对应的字段被设置为默认值,对于不同类型指定如下:
- 对于string,默认是一个空 string
- 对于bytes,默认是一个空的 bytes
- 对于 bool,默认是 false
- 对于数值类型,默认是 0
- 对于枚举,默认是第一个定义的枚举值,必须为 0;
- 对于消息类型(message),如果没有被设置,确切的消息是根据语言确定的。
对于可重复的字段,默认值是空(通常情况下是对应语言中空数组)。
对于简单字段,一旦消息被解析,就无法判断这个字段时有设置值但是恰巧是默认值,还是根本没有被设置(例如 boolean 值是否被设置为 false)。另外,如果一个简单消息字段被设置为默认值,这个值不会被序列化传输。
9 更新一个消息类型
如果一个已有的消息格式已无法满足新的需求。例如,要在消息中添加一个额外的字段,但是同时旧版本写的代码仍然可用。不用担心,更新消息而不破坏已有代码是非常简单的。在更新时只要记住以下的规则即可:
不要更改任何已有的字段的数值标识。
如果增加新的字段,使用旧格式的字段仍然可以被新产生的代码所解析。应该记住这些元素的默认值,这样新代码就可以以适当的方式和旧代码生成的数据交互。相似的,通过新代码产生的消息也可以被旧代码解析:只不过新的字段会被忽视掉。注意,未被识别的字段会在反序列化的过程中丢弃掉,所以如果消息再被传递给新的代码,新的字段依然是不可用的(这和 proto2 中的行为是不同的,在 proto2 中未定义的域依然会随着消息被序列化)
非 required 的字段可以移除,只要它们的标识号在新的消息类型中不再使用(更好的做法可能是重命名那个字段,例如在字段前添加“OBSOLETE_”前缀,那样的话,使用的 .proto 文件的用户将来就不会无意中重新使用了那些不该使用的标识号)。
int32, uint32, int64, uint64, 和 bool 是全部兼容的,这意味着可以将这些类型中的一个转换为另外一个,而不会破坏向前、 向后的兼容性。如果解析出来的数字与对应的类型不相符,那么结果就像在 C++ 中对它进行了强制类型转换一样(例如,如果把一个 64 位数字当作 int32 来读取,那么它就会被截断为 32 位的数字)。
sint32 和 sint64 是互相兼容的,但是它们与其他整数类型不兼容。
string 和 bytes 是兼容的(只要 bytes 是有效的 UTF-8 编码)。
嵌套消息与 bytes 是兼容的(只要 bytes 包含该消息的一个编码过的版本)。
fixed32 与 sfixed32 是兼容的,fixed64 与 sfixed64 是兼容的。
枚举类型与 int32,uint32,int64 和 uint64 相兼容(注意如果值不相兼容则会被截断),然而在客户端反序列化之后他们可能会有不同的处理方式,例如,未识别的 proto3 枚举类型会被保留在消息中,但是他的表示方式会依照语言而定。int 类型的字段总会被保留。
10 字段类型
处理上面提到的一些基础类型以外,protobuf还支持一些其他结构类型。
枚举
当需要定义一个消息类型的时候,可能想为一个字段指定某“预定义值序列”中的一个值。例如,假设要为每一个 SearchRequest 消息添加一个 corpus 字段,而 corpus 的值可能是UNIVERSAL,WEB,IMAGES,LOCAL,NEWS,PRODUCTS 或 VIDEO 中的一个。 这时通过向消息定义中添加一个枚举(enum)并且为每个可能的值定义一个常量就可以了。
在下面的例子中,在消息格式中添加了一个叫做SexType 的枚举类型——它含有所有可能的值 ——以及一个类型为 SexType 的字段:
1 | syntax = "proto3";//指定版本信息,非注释的第一行 |
SexType 枚举的第一个常量映射为 0:每个枚举类型必须将其第一个类型映射为0,这是因为:
- 必须有一个 0 值,可以用这个 0 值作为默认值。
- 这个零值必须为第一个元素,为了兼容 proto2 语义,枚举类的第一个值总是默认值。
可以通过将相同值赋值给不同的枚举常量来定义别名. 为此需要设置allow_alias
选项为true, 否则当发现别名时protocol编译器会生成错误消息。 1
2
3
4
5
6
7
8
9
10
11
12enum EnumAllowingAlias {
option allow_alias = true;
UNKNOWN = 0;
STARTED = 1;
// 此时 RUNNING 是 STATRTED 的别名
RUNNING = 1;
}
enum EnumNotAllowingAlias {
UNKNOWN = 0;
STARTED = 1;
// RUNNING = 1; // Uncommenting this line will cause a compile error inside Google and a warning message outside.
}
如上例所示,可以外部声明枚举类型,然后message内定义一个枚举类型;当然,也可以在message中声明枚举类型然后定义。
当对一个使用了枚举的 .proto 文件运行 protocol buffer 编译器的时候,生成的代码中将有一个对应的 enum(对Java或C++来说)
Go不直接支持枚举的,并没有
enum
关键字
自定义类型
可以将其他消息类型用作自定义的字段类型。例如,假设在每一个 SearchResponse
消息中包含 Result
消息,此时可以在相同的 .proto
文件中定义一个Result
消息类型,然后在SearchResponse
消息中指定一个 Result
类型的字段,如:
1 | essage SearchResponse { |
导入其他文件中的类型
如果是希望导入其他 .proto 文件中的类型定义,可以在文件中添加一个导入声明: 1
2//在this.proto导入other_protos.proto
import "myproject/other_protos.proto";
这种导入方式存在一个问题:
this.proto
只能通过包名.消息或类型名称
访问other_protos.proto
中定义的消息或类型。无法访问在other_protos.proto
内import
的其他proto
文件里定义的消息或类型,即:只能访问直接import的proto文件里的类型或消息;解决方法:通过
import public
可解决。即如果在other_protos.proto
内使用import public
关键字导入其他proto
文件,那么在导入other_protos.proto
的proto
文件中可以访问其他proto
文件。这种方式就想是将proto
文件移到other_protos.proto
一样
1 | // new.proto文件 |
1 | // old.proto文件 |
1 | // 客户端 proto |
通过在编译器命令行参数中使用 -I/--proto_pathprotocal 编译器会在指定目录搜索要导入的文件。如果没有给出标志,编译器会搜索编译命令被调用的目录。通常只要指定 proto_path 标志为工程根目录,并且指定好导入的正确名称就好。
使用 proto2 的消息类型
导入 proto2 的消息类型并在 proto3 消息中使用是可以的,反之也如此。但是,proto2 的枚举不能在 proto3 语法中使用
Any
Protobuf中的Any类型可以理解为泛型类型,它可以存储任何消息类型的字段(int, char等类型必须先封装成消息类型)。Any类型字段也可以用Repeated
修饰。为了使用Any类型,你需要导入import google/protobuf/any.proto。
1 | import "google/protobuf/any.proto"; |
对于给定的消息类型的默认类型 URL 是 type.googleapis.com/packagename.messagename。
不同语言的实现会支持动态库以线程安全的方式去帮助封装或者解封装 Any 值。例如在 java 中,Any类型会有特殊的 pack() 和 unpack() 访问器,在C++中会有 PackFrom()
和 UnpackTo()
方法。 1
2
3
4
5
6
7
8
9
10
11
12
13
14// Storing an arbitrary message type in Any.
NetworkErrorDetails details = ...;
ErrorStatus status;
status.add_details()->PackFrom(details);
// Reading an arbitrary message from Any.
ErrorStatus status = ...;
for (const Any& detail : status.details()) {
if (detail.Is<NetworkErrorDetails>()) {
NetworkErrorDetails network_error;
detail.UnpackTo(&network_error);
... processing network_error ...
}
}
Oneof
如果消息中有很多可选字段,并且同时至多一个字段会被设置, 可以通过使用 Oneof 特性来强化这个行为并节省内存。
Oneof 字段就像可选字段, 除了它们会共享内存,并且同一时间最多一个字段会被设置。 设置其中一个字段会清除其它字段。 可以使用 case()
或者 WhichOneof()
方法检查哪个 oneof
字段被设置,这取决于使用什么编程语言。
因为 proto3
没有办法区分正常的值是否是设置了还是取得缺省值(比如 int64 类型字段,如果它的值是 0,无法判断数据是否包含这个字段,因为 0 既可能是数据中设置的值,也可能是这个字段的零值),所以可以通过 Oneof
取得这个功能,因为 Oneof
有判断字段是否设置的功能。
使用 Oneof
为了在 . proto 定义 Oneof 字段, 需要在名字前面加上 oneof
关键字, 比如下面例子的 test_oneof:
1
2
3
4
5
6message SampleMessage {
oneof test_oneof {
string name = 4;
SubMessage sub_message = 9;
}
}oneof
字段定义到test_oneof
中。可以增加任意类型的字段,但是不能使用repeated
关键字。 在产生的代码中, oneof 字段拥有同样的 getters
和setters
, 就像正常的可选字段一样,也有一个特殊的方法来检查到底哪个字段被设置。
Oneof 特性
- 设置 oneof 会自动清除其它 oneof 字段的值。所以设置多次后,只有最后一次设置的字段有值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16SampleMessage message;
message.set_name("name");
CHECK(message.has_name());
message.mutable_sub_message(); // Will clear name field.
CHECK(!message.has_name());
``
- 如果解析器遇到同一个 oneof 中有多个成员,只有看到的最后一个成员会被解析成消息。
- oneof 不支持 repeated.
- 反射 API 对 oneof 字段有效.
- 如果使用 C++,需确保代码不会导致内存泄漏。下面的代码会崩溃, 因为 sub_message 已经通过 set_name() 删除了
```cpp
SampleMessage message;
SubMessage* sub_message = message.mutable_sub_message();
message.set_name("name"); // Will delete sub_message
sub_message->set_... // Crashes here - 在 C++ 中,如果使用 Swap() 来交换两个带有 oneof 的消息,每个消息将会有另一个消息的 oneof,例如在下面的例子中,msg1 会拥有sub_message 并且 msg2 会有 name。
1
2
3
4
5
6
7SampleMessage msg1;
msg1.set_name("name");
SampleMessage msg2;
msg2.mutable_sub_message();
msg1.swap(&msg2);
CHECK(msg1.has_sub_message());
CHECK(msg2.has_name());
向后兼容性问题
当增加或者删除 oneof 字段时一定要小心。如果检查 oneof 的值返回 None/NOT_SET,它意味着 oneof 字段没有被赋值或者在一个不同的版本中赋值了。 没有办法知道是哪种情况,因为没有办法判断一个未知字段是否是 oneof 的成员。
Tag 重用问题
将字段移入或移除oneof:在消息被序列号或者解析后,可能会失去一些信息(有些字段也许会被清除) 删除一个字段或者加入一个字段:在消息被序列号或者解析后,这也许会清除现在设置的 oneof 字段 分离或者融合oneof:和移动普通字段一样有类似问题。
Map
如果希望创建一个关联映射,protocol buffer 提供了一种快捷的语法: 1
map<key_type, value_type> map_field = N;
- value_type 可以是任意类型。
例如,如果希望创建一个 project 的映射,每个 Projecct 使用一个 string 作为 key,可以像下面这样定义: 1
map<string, Project> projects = 3;
向后兼容性问题
map语法序列化后等同于如下内容,因此即使是不支持 map 语法的protocol buffer 实现也是可以处理数据的: 1
2
3
4
5
6message MapFieldEntry {
key_type key = 1;
value_type value = 2;
}
repeated MapFieldEntry map_field = N
定义服务(Service)
如果想要将消息类型用在 RPC (远程方法调用)系统中,可以在 .proto 文件中定义一个 RPC 服务接口,protocol buffer 编译器将会根据所选择的不同语言生成服务接口代码及存根。如,想要定义一个 RPC 服务并具有一个方法,该方法能够接收 SearchRequest 并返回一个 SearchResponse,此时可以在 .proto 文件中进行如下定义: 1
2
3service SearchService {
rpc Search (SearchRequest) returns (SearchResponse);
}
如果不想使用 gRPC,也可以使用 protocol buffer 用于自己的 RPC 实现。
11 实例
文章参考来源: