Protocol buffers 是⼀种语⾔中⽴,平台⽆关,可扩展的序列化数据的格式,可⽤于通信协议,数据存储 等。Protocol buffers 在序列化数据具有灵活、⾼效的特点。
相⽐于 XML 来说,Protocol buffers 更加 ⼩巧,更加快速,更加简单。⼀旦定义了要处理的数据的数据结构之后,就可以利⽤ Protocol buffers 的 代码⽣成⼯具⽣成相关的代码。甚⾄可以在⽆需重新部署程序的情况下更新数据结构。只需使⽤ Protobuf 对数据结构进⾏⼀次描述,即可利⽤各种不同语⾔或从各种不同数据流中对你的结构化数据轻松 读写。
Protocol buffers 很适合做数据存储或 RPC 数据交换格式。可⽤于通讯协议、数据存储等领域的语⾔⽆ 关、平台⽆关、可扩展的序列化结构数据格式。
Protocol buffers在游戏和即时通信用的比较多。使用常见分析:
协议 | 场景 | 举例 |
---|---|---|
xml | 主要在本地使用 | UI,游戏信息 |
json | http api | HTTP网页注册账户 |
protobuf | 服务与服务的远程调用 | rpc,游戏,即时通讯,tars brpc |
要使用protobuf序列化方式,要先编写proto文件。
syntax="proto3"; // 版本,proto2和proto3
package IM.Login; // 类似CPP的命名空间
import "IM.BaseDefine.proto"; // 引用其他的proto文件
option optimize_for = LITE_RUNTIME; // 编译优化
// 一个类
message IMLoginReq{
// 各种字段
string user_name=1;
string password=2;
IM.BaseDefine.UserStatType online_status=3;
IM.BaseDefine.ClientType client_type=4;
string client_version=5;
}
然后利用工具生成.cc和.h文件。
protoc -I=$SRC_DIR --cpp_out=$DST_DIR $SRC_DIR/*.proto
最后让程序调用。
proto文件在发送端和接收端是公用的,及发送端和接收端使用的是同样的proto文件。
IDL是Interface description language的缩写,指接⼝描述语⾔。
可以看到,对于序列化协议来说,使⽤⽅只需要关注业务对象本身,即IDL定义(.proto),序列化和反序 列化的代码只需要通过⼯具⽣成即可。
protobuf不能完全替代json,比如对外注册,json只需要把格式提供给对方,而protobuf还需要一些复杂的流程,会降低可读性。
同一个proto文件可以生成不同的语言。
⾕歌开源的协议标准+⼯具。 安装⼯具 —> 根据编写的proto⽂件产⽣c++代码。
(1)下载。
wget https://github.com/protocolbuffers/protobuf/releases/download/v21.7/protobuf-cpp-3.21.7.tar.gz
(2)解压。
tar zxvf protobuf-cpp-3.21.7.tar.gz
(3)编译。时间可能会有点长。
cd protobuf-3.21.7/
./configure
make
sudo make install
sudo ldconfig
(4)查看版本信息。
protoc --version
(5)编写proto文件。 示例:
syntax="proto3"; // 版本,proto2和proto3
package IM.Login; // 类似CPP的命名空间
//import "IM.BaseDefine.proto"; // 引用其他的proto文件
option optimize_for = LITE_RUNTIME; // 编译优化
// 一个类
message IMLoginReq{
// 各种字段
string user_name=1;
string password=2;
//IM.BaseDefine.UserStatType online_status=3;
//IM.BaseDefine.ClientType client_type=4;
string client_version=5;
}
(6)将proto文件生成相应的.pb.cc文件和.pb.h。
protoc -I=$SRC_DIR --cpp_out=$DST_DIR $SRC_DIR/*.proto
SRC_DIR是.proto所在的路径。DST_DIR是.cc和.h生成的位置。
示例: 将指定proto⽂件⽣成.pb.cc和.pb.h 。
protoc -I=./ --cpp_out=./ test.proto
将对应⽬录的所有proto⽂件⽣成.pb.cc和.pb.h
protoc -I=./ --cpp_out=./ *.proto
(7)编译范例。
g++ -std=c++11 -o list_people list_people.cc addressbook.pb.cc -lprotobuf - lpthread
注意要有-lprotobuf来指定库。
protobuf option部分选项:
option optimize_for = LITE_RUNTIME;
optimize_for是⽂件级别的选项,Protocol Buffer定义三种优化级别 :PEED/CODE_SIZE/LITE_RUNTIME。缺省情况下是SPEED。
-rw-r--r-- 1 root root 89501198 10月 13 16:02 libprotobuf.a
-rwxr-xr-x 1 root root 986 10月 13 16:02 libprotobuf.la*
-rw-r--r-- 1 root root 15320786 10月 13 16:02 libprotobuf-lite.a
-rwxr-xr-x 1 root root 1021 10月 13 16:02 libprotobuf-lite.la*
lrwxrwxrwx 1 root root 26 10月 13 16:02 libprotobuf-lite.so -> libprotobuf-lite.so.32.0.7*
lrwxrwxrwx 1 root root 26 10月 13 16:02 libprotobuf-lite.so.32 -> libprotobuf-lite.so.32.0.7*
-rwxr-xr-x 1 root root 5827448 10月 13 16:02 libprotobuf-lite.so.32.0.7*
lrwxrwxrwx 1 root root 21 10月 13 16:02 libprotobuf.so -> libprotobuf.so.32.0.7*
lrwxrwxrwx 1 root root 21 10月 13 16:02 libprotobuf.so.32 -> libprotobuf.so.32.0.7*
-rwxr-xr-x 1 root root 33984952 10月 13 16:02 libprotobuf.so.32.0.7*
-rw-r--r-- 1 root root 130421776 10月 13 16:02 libprotoc.a
-rwxr-xr-x 1 root root 1002 10月 13 16:02 libprotoc.la*
lrwxrwxrwx 1 root root 19 10月 13 16:02 libprotoc.so -> libprotoc.so.32.0.7*
lrwxrwxrwx 1 root root 19 10月 13 16:02 libprotoc.so.32 -> libprotoc.so.32.0.7*
-rwxr-xr-x 1 root root 43255928 10月 13 16:02 libprotoc.so.32.0.7*
⼀个标量消息字段可以含有⼀个如下的类型——该表格展示了定义于.proto⽂件中的类型,以及与之对应 的、在⾃动⽣成的访问类中定义的类型:
.proto Type | Notes | C++ Type | Java Type | Go Type |
---|---|---|---|---|
double | double | double | float64 | |
float | float | float | float32 | |
int32 | 使⽤变⻓编码,对于负值的效率很低,如果你的域 有可能有负值,请使⽤sint64替代 | int32 | int | int32 |
uint32 | 使⽤变⻓编码 | uint32 | int | uint32 |
uint64 | 使⽤变⻓编码 | uint64 | long | uint64 |
sint32 | 使⽤变⻓编码,这些编码在负值时⽐int32⾼效的多 | int32 | int | int32 |
sint64 | 使⽤变⻓编码,有符号的整型值。编码时⽐通常的 int64⾼效。 | int64 | long | int64 |
fixed32 | 总是4个字节,如果数值总是⽐总是⽐228228⼤的 话,这个类型会⽐uint32⾼效。 | uint32 | int | uint32 |
fixed64 | 总是8个字节,如果数值总是⽐总是⽐2^56⼤的 话,这个类型会⽐uint64⾼效。 | uint64 | long | uint64 |
sfixed32 | 总是4个字节 | int32 | int | int32 |
sfixed64 | 总是8个字节 | int64 | long | int64 |
bool | bool | boolean | bool | |
string | ⼀个字符串必须是UTF-8编码或者7-bit ASCII编 码的⽂本。 | string | String | string |
bytes | 可能包含任意顺序的字节数据。 | string | ByteString | []byte |
变长编码:值小的时候,减少表示字节数。
主要说明varints和zigzag。
只讲解重点原理;把上面的各种变量类型归为6大类,除去官方不再推荐的deprecated还有四大类。
protobuf的高效表现在: (1)解析高效。 (2)字节数占用少。
Type | Meaning | Used For |
---|---|---|
0 | Varint | int32, int64, uint32, uint64, sint32, sint64, bool, enum |
1 | 64-bit | fixed64, sfixed64, double |
2 | Length- delimited (⻓度分割) | string, bytes, embedded messages, packed repeated fields |
3 | Start group | groups (deprecated) |
4 | End group | groups (deprecated) |
5 | 32-bit | fixed32, sfixed32, float |
总结下来就是: (1)变长编码类型Varints。 (2)固定32 bits类型。 (3)固定64 bits类型。 (4)有长度标记类型。
为什么设计变长编码: 普通的int数据类型,无论其值的大小,所占用的存储空间都是相等的。⽐如 不管是0x12345678 还是0x12都占⽤4字节,那能否让0x12在表示的时候只占⽤1个字节呢?是否可以根据数值的⼤⼩来动态地占⽤存储空间, 使得值⽐较⼩的数字占⽤较少的字节数, 值相对⽐ 较⼤的数字占⽤较多的字节数, 这即是变⻓整型编码的基本思想。
采⽤变⻓整型编码的数字, 其占⽤的字节数不是完全⼀致的, Varints 编码使⽤每个字节的最⾼有效 位作为标志位, ⽽剩余的 7 位以⼆进制补码的形式来存储数字值本身, 当最⾼有效位为 1 时, 代表其 后还跟有字节, 当最⾼有效位为 0 时, 代表已经是该数字的最后的⼀个字节。
在 Protobuf 中, 使⽤的是 Base128 Varints 编码, 在这种⽅式中, 使⽤ 7 bit (即7的2次⽅为128) 来存储数字, 在 Protobuf 中, Base128 Varints 采⽤的是⼩端序(即数字的低位存放在⾼地址)。 举例 来看, 对于数字 1, 假设 int 类型占 4 个字节, 以标准的整型存储, 其⼆进制表示应为:
00000000 00000000 00000000 00000001
可⻅, 只有最后⼀个字节存储了有效数值, 前 3 个字节都是 0, 若采⽤ Varints 编码, 其⼆进制形式为:
00000001
因为其没有后续字节, 因此其最⾼有效位为 0, 其余的 7 位以补码形式存放 1。
再⽐如数字 666, 其以 标准的整型存储, 其⼆进制表示为:
00000000 00000000 00000010 10011010
若采⽤ Varints 编码, 其⼆进制形式为:
10011010 00000101
还原可以得到正确的值:
00000010 10011010
从上⾯的编码解码过程可以看出, 可变⻓整型编码对于不同⼤⼩的数字, 其所占⽤的存储空间是 不同的。
通俗的说: 每个字节用7bit表示数值的信息,用1 bit标记结束(1表示没有结束,0表示结束,也就是最后一个字节的位置)。编码时从低位开始取7bit,放在高位。还原时从高位取,放到低位。
那么,如果一个值很大,比如0xFFFFFFFF,需要多少字节存储呢? 0xFFFFFFFF需要分配32个bit,使⽤base128 Varints 编码需要的字节数:32/7=4.57, 只要 不整除就要进位, 就是需要5个字节存储。从这⾥看得出来,小于等于28bit的整数适合使⽤变⻓编码, 如果整数都是32bit>= 变量 >28bit可以考虑使⽤fixed32, sfixed32等固定4字节的类型。
Varints 编码的实质在于去掉数字开头的 0, 因此可缩短数字所占的存储字节数, 在上⾯的例⼦ 中, 只举例说明了正数的 Varints 编码, 但如果数字为负数, 则采⽤ Varints 编码会恒定占⽤ 10 个字 节, 原因在于负数的符号位为 1, 对于负数其从符号位开始的⾼位均为 1, 在 Protobuf 的具体实现中, 会将此视为⼀个很⼤的⽆符号数, 以C++语⾔的实现为例, 对于 int32 类型的 pb 字段, 对于如下 定义的 proto:
message Tint32{
int32 n1 = 1;
}
Request 中包含类型为 int32 类型的字段, 当 a 为负数时, 其序列化之后将恒定占⽤ 10 个字节。 比如 对于 int32 类型的数字 -5, 其序列化之后的⼆进制为:
11111011 11111111 11111111 1111111 11111111 11111111 11111111 00000001
其原因在于 Protobuf 的内部将 int32 类型的负数转换为 uint64 来处理。
// 取 5 的 原 码 :
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000101
// 得 反 码 :
11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111010
// 对 反 码 加 1 最 后 得 补 码 :
11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111011
// 即 -5 在 计 算 机 ⾥ ⽤ ⼆ 进 制 表 示 结 果
11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111011
转成每7bit占⽤1个字节:
//( ⾼ 位 )
1 1111111 1111111 1111111 1111111 1111111 1111111 1111111 1111111 1111011
//(低位)
然后⾼地址存储到低地址,并且不是结束字节最⾼位为1,即是 :
// (低位)
11111011 11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111 00000001
//(⾼位)
转成16进制:fb ff ff ff ff ff ff ff ff 01 数据本身就占⽤了10字节。
转换后的 uint64 数值的⾼位全为 1, 相当于是⼀个 8 字节的很⼤的⽆符号数, 因此采⽤ Base128 Varints 编码后将恒定占⽤ 10 个字节的空间, 可⻅ Varints 编码对于表示负数毫⽆优势, 甚⾄⽐普通 的固定 32 位存储还要多占 4 个字节。Varints 编码的实质在于设法移除数字开头的 0 ⽐特, ⽽对于 负数, 由于其数字⾼位都是 1, 因此 Varints 编码在此场景下失效, Zigzag 编码便是为了解决这个问 题, Zigzag 编码的⼤致思想是⾸先对负数做⼀次变换, 将其映射为⼀个正数, 变换以后便可以使⽤ Varints 编码进⾏压缩, 这⾥关键的⼀点在于变换的算法, ⾸先算法必须是可逆的, 即可以根据变换后 的值计算出原始值, 否则就⽆法解码, 同时要求变换算法要尽可能简单, 以避免影响 Protobuf 编码、 解码的速度。
sint32 = Zigzag 编码 +varints编码合起来。 sint32 序列化:负数 -> Zigzag 编码 -> varints编码。 sint32 反序列化:varints解码 -> Zigzag 解码 -> 负数 。
重点在于:同样是表示-5,sint32只需要2个字节,int32需要11字节。 对于Zigzag的算法不必太细究。其⽬的是把多个1转成多个0表示。
⾸先来看⼀个例⼦,假设客户端和服务端使⽤ protobuf 作为数 据交换格式, proto 的具体定义为:
syntax = "proto3";
package pbTest;
message Request {
int32 age = 1;
}
Request 中包含了⼀个名称为 age 的字段, 客户端和服务端双⽅都⽤同⼀份相同的 proto ⽂件是没有任 何问题的, 假设客户端⾃⼰将 proto ⽂件做了修改, 修改后的 proto ⽂件如下:
syntax = "proto3";
package pbTest;
message Request {
int32 age_test = 1;
}
在这种情形下, 服务端不修改应⽤程序仍能够正确地解码,原因在于序列化后的 Protobuf 没有使⽤ 字段名称,⽽仅仅采⽤了字段编号。 与 json xml 等相⽐,Protobuf 不是⼀种完全⾃描述的协议格 式,即接收端在没有 proto ⽂件定义的前提下是⽆法解码⼀个 protobuf 消息体的, 与此相对的, json xml 等协议格式是完全⾃描述的,拿到了 json 消息体,便可以知道这段消息体中有哪些字段, 每 个字段的值分别是什么, 其实对于客户端和服务端通信双⽅来说, 约定好了消息格式之后完全没有必 要在每⼀条消息中都携带字段名称, Protobuf 在通信数据中移除字段名称, 这可以⼤⼤降低消息的⻓ 度, 提⾼通信效率, Protobuf 进⼀步将通信线路上消息类型做了划分, 如下表所示:
Type | Meaning | Used For |
---|---|---|
0 | Varint | int32, int64, uint32, uint64, sint32, sint64, bool, enum |
1 | 64-bit | fixed64, sfixed64, double |
2 | Length- delimited (⻓度分割) | string, bytes, embedded messages, packed repeated fields |
3 | Start group | groups (deprecated) |
4 | End group | groups (deprecated) |
5 | 32-bit | fixed32, sfixed32, float |
对于 int32, int64, uint32 等数据类型在序列化之后都会转为 Varints 编码。 Protobuf 除了存储字段的值之外, 还存储了字段的编号以及字段在通信线路上的格式类型(wire- type),具体的存储⽅式为: field_num << 3 | wire type 即将字段标号逻辑左移 3 位, 然后与该字段的 wire type 的编号按位或。接收端可以利⽤这些信息,结合 proto ⽂件来解码消息结构体。
上例子中,假设 age 为 5,由于 age 在 proto ⽂件中定义 的是 int32 类型, 因此序列化之后它的 wire type 为 0,其字段编号为 1,因此按照上⾯的计算⽅式, 即 1 << 3 | 0, 所以其类型和字段编号的信息只占 1 个字节, 即 00001000, 后⾯跟上字段值 5 的 Varints 编码, 所以整个结构体序列化之后为:
00001000 00000101
有了字段编号和 wire type,其后所跟的数据的⻓度便是确定的,因此 Protobuf 是⼀种⾮常紧密的数 据组织格式,其不需要特别地加⼊额外的分隔符来分割⼀个消息字段,这可⼤⼤提升通信的效率, 规避 冗余的数据传输。
(1)wire_type=0 的时候。 二进制结构为:Tag-Value。 value的编码也采⽤Varints编码⽅式,故不需要额外的位来表示整个value的⻓度。因为Varint的msb位标 识下⼀个字节是否是有效的就起到了指示⻓度的作⽤。
例如:
// 666 int1Size = 3 ⼗六进制:
08 9a 05
// 0x1 int1Size = 2 ⼗六进制:
08 01
(2)wire_type=1、5 的时候。⼆进制结构也为:Tag-Value。 因为都是取固定32位或者64位,因此也不需要额外的位来表示整个value的⻓度。
例如:
// 0x12 n1Size = 9 ⼗六进制:
09 12 00 00 00 00 00 00 00
// -5 n1Size = 9 ⼗六进制:
09 fb ff ff ff ff ff ff ff
// 0x12 n1Size = 5 ⼗六进制:
0d 12 00 00 00
// -5 n1Size = 5 ⼗六进制:
0d fb ff ff ff
(3)wire_type=2 的时候。⼆进制结构为:Tag-[Length]-Value 。 因为表示的是可变⻓度的值,需要有额外的位来指示⻓度。
例如:
// 1 str1Size = 3 ⼗六进制:
0a 01 31 //(这里1表示长度)
// 1234 str1Size = 6 ⼗六进制:
0a 04 31 32 33 34 //(这⾥4表示⻓度)
//⽼师 str1Size = 8 ⼗六进制:
0a 06 e8 80 81 e5 b8 88 //(这⾥6表示⻓度)
repeat也是这种模式,此时length代表元素个数。
message TRepeatedfields{
repeated int32 n1 = 1;
repeated Tbytes n2 = 2;
}
message Tbytes{
bytes n1 = 1;
}
(4)filed_num范围:
如果后⾯发现之前定义 message 需要增加字段了,这个时候就体现出 Protocol Buffer 的优势了,不需 要改动之前的代码。不过需要满⾜以下 10 条规则: (1)不要改动原有字段的数据结构。 (2) 如果您添加新字段,则任何由代码使⽤“旧”消息格式序列化的消息仍然可以通过新⽣成的代码进⾏分析。应该记住这些元素的默认值,以便新代码可以正确地与旧代码⽣成的消息进⾏交互。同样,由 新代码创建的消息可以由旧代码解析:旧的⼆进制⽂件在解析时会简单地忽略新字段。 (3)只要字段号在更新的消息类型中不再使⽤,字段可以被删除。您可能需要重命名该字段,可能会添加 前缀“OBSOLETE_”,或者标记成保留字段号 reserved ,以便将来的 .proto ⽤户不会意外重 复使⽤该号码。
syntax "proto3";
message Stock {
reserved 3, 4; //通过,隔开
int32 id = 1;
string symbol = 2; }
message Info {
reserved 2, 9 to 11, 15; // 可以通过to指定连续返回
// ...
}
(4)int32,uint32,int64,uint64 和 bool 全都兼容。这意味着您可以将字段从这些类型之⼀更改为另⼀ 个字段⽽不破坏向前或向后兼容性。如果⼀个数字从不适合相应类型的线路中解析出来,则会得到与 在 C++ 中将该数字转换为该类型相同的效果(例如,如果将 64 位数字读为 int32,它将被截断为 32 位)。 (5) sint32 和 sint64 相互兼容,但与其他整数类型不兼容。 (6) 只要字节是有效的UTF-8,string 和 bytes 是兼容的。 (7) 嵌⼊式 message 与 bytes 兼容,如果 bytes 包含 message 的 encoded version。 (8) fixed32与sfixed32兼容,⽽fixed64与sfixed64兼容。 (9) enum 就数组⽽⾔,是可以与 int32,uint32,int64 和 uint64 兼容(请注意,如果它们不适合,值将 被截断)。但是请注意,当消息反序列化时,客户端代码可能会以不同的⽅式对待它们:例如,未识 别的 proto3 枚举类型将保留在消息中,但消息反序列化时如何表示是与语⾔相关的。(这点和语⾔相 关,上⾯提到过了)Int 域始终只保留它们的值。 (10) 将单个值更改为新的成员是安全和⼆进制兼容的。如果您确定⼀次没有代码设置多个字段,则将多个 字段移⾄新的字段可能是安全的。将任何字段移到现有字段中都是不安全的。(注意字段和值的区 别,字段是 field,值是 value)
protobuf⼯程经验:
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。