1 概述
Protocol Buffers(官方地址:https://developers.google.com/protocol-buffers)是Google开发的一种比XML和Json更小、更快、更简单、可跨语言、跨平台且扩展性好的序列化数据的机制。使用时只要使用.proto文件一次定义好数据的结构化方式,然后借助工具便可以针对 Java、Python、Objective-C 或 C++等语言(proto3还可支持 Dart、Go、Ruby 和 C#)使用命令生成特殊的源代码将数据结构或对象转换成二进制串写入和将序列化后生成的二进制串转换回数据结构或对象。
Protocol Buffers在传输数据量较大的长连接的场景应用比较多,例如即时通信、消息推送等。在性能和使用方面比XML和Json序列化后数据体积小、序列化速度快、不同语言的源代码可自动简单生成、多平台仅需维护一套协议.proto文件、可兼容直接对数据结构进行追加更新,序列化后数据是二进制数据流所以传输过程保密性较强,不过也表示出其可读性差,需要通过.proto文件才能解析,在通用性方面不如XML和Json,因为XML和Json已是多种行业标准,而目前Protocol Buffers仅是Google在使用。
2 下载
Protocol Buffers的下载地址:https://github.com/protocolbuffers/protobuf/releases,选择你当前系统的包下载后,然后将\bin添加到环境变量就可以,命令中输入命令查看其版本:
3 语法
我们以Java语言作为实例讲解,新建文件addressbook.proto,其内容如下所示:
syntax = "proto2"; // 指定语法版本是2还是3,默认是2
package tutorial; // 包名
option java_multiple_files = true; // 生成的Java代码允许多个文件
option java_package = "com.zyx.myapplication.protos"; // 生成的Java代码的包名
option java_outer_classname = "AddressBookProtos"; // 生成的Java代码的类名
message Person { // 定义 Person 消息对象
optional string name = 1;
optional int32 id = 2;
optional string email = 3;
enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}
message PhoneNumber {
optional string number = 1;
optional PhoneType type = 2 [default = HOME];
}
repeated PhoneNumber phones = 4;
}
message AddressBook { //定义AddressBook 消息对象
repeated Person people = 1;
}
3.1 syntax
syntax关键字用于指定Protocol Buffers的语法版本是proto2还是proto3,如果缺少默认是proto2,并且会在后面命令生成代码时出现警告:
[libprotobuf WARNING T:\src\github\protobuf\src\google\protobuf\compiler\parser.cc:649] No syntax specified for the proto file: nebula.auth.proto. Please use 'syntax = "proto2";' or 'syntax = "proto3";' to specify a syntax version. (Defaulted to proto2 syntax.)
3.2 package
package关键字用于指定Protocol的包名,为了防止不同的.proto项目间命名发生冲突。
3.3 option
option关键字可以有多个选项,例如上述中的java_multiple_files、java_package 和 java_outer_classname,其作用可见注释。更多的option选项可前往官网查阅:https://developers.google.com/protocol-buffers/docs/proto#options
3.4 message
message关键字用于定义消息对象的数据结构,其内部可嵌套和支持枚举,每个字段可4分部,分别是:修饰符+类型+名称+编号。
3.4.1 字段修饰符
required:对象里有且只有一个必须赋值的字段,在proto3中已不支持。
optional:对象里非必须赋值的字段
repeated:对象里数组字段的声明
3.4.2 字段类型
.proto基本数据类型对应于各平台的基本数据类型如下:
.proto 类型 | 备注 | C++类型 | Java类型 |
double | double | double | |
float | float | float | |
int32 | 32位整型,如果有可能有负值,请使用sint32替代 | int32 | int |
int64 | 64位整型,如果有可能有负值,请使用sint64替代 | int64 | long |
uint32 | 无符号32位整型 | uint32 | int[1] |
uint64 | 无符号64位整型 | uint64 | long[1] |
sint32 | 有符号32位整型,在负数值时比int32更高效 | int32 | int |
sint64 | 有符号64位整型,在负数值时比int64更高效 | int64 | long |
fixed32 | 总是4个字节,在数值总是比2^28大的时会比uint32高效 | uint32 | int[1] |
fixed64 | 总是8个字节,在数值总是比2^56大的时会比uint32高效 | uint64 | long[1] |
sfixed32 | 总是4个字节 | int32 | int |
sfixed64 | 总是8个字节 | int64 | long |
bool | bool | boolean | |
string | 字符串必须始终包含UTF-8编码或7位ASCII文本 | string | String |
bytes | 可以包含任意字节序列 | string | ByteString |
3.4.3 字段名称
就是给字段一个可视化的名称。不过要注意的是,因为Protobuf不是一种完全自描述的协议格式,序列化后的Protobuf是没有使用到字段名称,而仅仅采用了字段编号。也正是如此,所以Protobuf可以大大降低消息的长度, 提高通信效率。
3.4.4 字段编号
消息定义中的每个字段都有一个唯一编号,这些数字用于标识消息二进制格式,并且一旦使用后便不能再更改。
需要注意的是:
- 1到15范围内的字段号需要一个字节进行编码,包括字段号和字段类型16到2047范围内的字段号需要两个字节。因此应该为频繁出现的消息元素保留1到15个字段,请记住为将来可能添加的频繁出现的元素留出一些空间。
- 不能使用19000到19999之间的数字,因为这是 Protobuf 协议实现中对这些标识号进行了预留。
4 编译.proto文件
Protoco Buffer提供多种开发语言的API,以Java为例,只要运行如下命令即可对.proto进行编译并生成对应的.java文件:
protoc $SRC_DIR\XXX.proto --java_out=$DST_DIR $SRC_DIR
其中,$SRC_DIR是proto文件所在的目录,$DST_DIR $SRC_DIR是Java文件输出的目录,例如:
protoc D:\MyApplication\app\src\main\java\com\zyx\myapplication\addressbook.proto --java_out=D:\MyApplication\app\src\main\java
5 Android中使用
5.1 将生成的.java文件放入到Android项目中
因为我们在.proto中将java_multiple_files设为了true,所以运行命令后会生成多个.java文件,将所有的.java文件拷贝到Android工程目录下:
5.2 依赖protobuf-java
在Gradle中添加 protobuf-java依赖(其版本必须要和下载的ProtocoBuffer版本一致):
implementation 'com.google.protobuf:protobuf-java:3.18.0'
5.3 构造消息对象:
Person.PhoneNumber.Builder phoneNumberBuilder = Person.PhoneNumber.newBuilder();
phoneNumberBuilder.setNumber("0000-1234567");
phoneNumberBuilder.setType(Person.PhoneType.WORK);
Person.Builder personBuilder = Person.newBuilder();
personBuilder.setName("子云心");
personBuilder.setId(1001);
personBuilder.setEmail("abc@test.com");
personBuilder.addPhones(phoneNumberBuilder);
AddressBook.Builder addressBookBuilder = AddressBook.newBuilder();
addressBookBuilder.addPeople(personBuilder);
AddressBook addressBook = addressBookBuilder.build();
此时addressBook变量的值若将其打印出来应该是(中文被转成8进程字符串):
people {
name: "\345\255\220\344\272\221\345\277\203"
id: 1001
email: "abc@test.com"
phones {
number: "0000-1234567"
type: WORK
}
}
5.4 序列化和反序列化消息
private void serialize(Message addressBook) {
// 序列化
byte[] addressBookByteArray = addressBook.toByteArray();
// 反序列化
try {
AddressBook AddressBookInfo = AddressBook.parseFrom(addressBookByteArray);
Person personInfo = AddressBookInfo.getPeople(0);
String name = personInfo.getName();
int id = personInfo.getId();
String email = personInfo.getEmail();
Person.PhoneNumber phoneNumber = personInfo.getPhones(0);
String number = phoneNumber.getNumber();
Person.PhoneType phoneType = phoneNumber.getType();
} catch (InvalidProtocolBufferException e) {
e.printStackTrace();
}
}
5.5 通过输入/输出流序列化和反序列化消息
private void serializeToStream(Message addressBook) {
// 序列化
ByteArrayOutputStream output = new ByteArrayOutputStream();
try {
addressBook.writeTo(output);
} catch (IOException e) {
e.printStackTrace();
}
byte[] addressBookByteArray = output.toByteArray();
// 反序列化
ByteArrayInputStream input = new ByteArrayInputStream(addressBookByteArray);
try {
AddressBook AddressBookInfo = AddressBook.parseFrom(input);
Person personInfo = AddressBookInfo.getPeople(0);
String name = personInfo.getName();
int id = personInfo.getId();
String email = personInfo.getEmail();
Person.PhoneNumber phoneNumber = personInfo.getPhones(0);
String number = phoneNumber.getNumber();
Person.PhoneType phoneType = phoneNumber.getType();
} catch (IOException e) {
e.printStackTrace();
}
}
5.6 将消息对象格式化成Json或XML
在Gradle中增加protobuf-java-format的依赖后:
implementation 'com.googlecode.protobuf-java-format:protobuf-java-format:1.4'
可以将已经构造好的消息对象格式化成Json或XML的字符串格式:
private String serializeToJson(Message addressBook) {
JsonFormat jsonFormat = new JsonFormat();
String personJson = jsonFormat.printToString(addressBook);
return personJson;
}
private String serializeToXml(Message addressBook) {
XmlFormat xmlFormat = new XmlFormat();
String xmlJson = xmlFormat.printToString(addressBook);
return xmlJson;
}
6 原理
Protocol Buffers小而快主要是因为两点
- 传输过程并不会将字段名称一起传递,而是只传递字段值,在另一端接收到字段值后再通过约定好的.proto文件进行每个字段解析和赋值。
- 字段值使用了两种关键的编码方式:Varints编码和Zigzag编码。
6.1 Varints编码原理
编程语言中数据的类型都是使用二进制的编码方式进行存储。比如普通的32位int数据类型,占用4个字节(byte),而每个字节是8位(bit),所以共需要32位,假设现使用int32类型存储数据是数字1,使用二进制表示就是0000 0000 0000 0000 0000 0000 0000 0001,可见前面的0都是无意义的。
对于普通的32位int数据类型,无论其值的大小是什么都会占用4个字节的存储空间,这使得值比较小的数字在存储上比较浪费。而在实际场景中小数字的使用率又远远多于大数字,所以在后来就开始有了变长整型编码的思想,意思就是占用字节存储空间会根据值的大小来决定。
Varint就是这样一种使用一个或多个字节进行序列化整数的紧凑的表示数字的方法,数值越小的数字使用越少的字节数。对于32位整型数据经过Varint编码后需要1~5个字节,小的数字(<128)使用1个字节,但是最大的数字使用了5个字节,比原来4个字节还多出1个字节。而64位整型数据编码后占用1~10个字节。因为往往小数字的使用率比大数字多,所以大多数情况下,采用 Varint 后可以用更少字节数来表示数字信息,从而达到很好的压缩效果。
其关键代码如下:
int computeLength(int val) {
if (val == 0) {
return 1;
}
int length = 0;
while (val != 0) {
val = val >>> 7;
length++;
}
return length;
}
byte[] writeVarint32(int val) {
byte[] data = new byte[computeLength(val)];
for (int i = 0; i < data.length - 1; i++) {
data[i] = (byte) ((val & 0x7F) | 0x80);
val = val >>> 7;
}
data[data.length - 1] = (byte) (val);
return data;
}
int decodeVarint(byte[] data) {
int val = 0;
int i = data.length - 1;
if (data.length == 5) {
val = val | (data[i] & 0x0F);
i -= 1;
}
while (i >= 0) {
val = val << 7;
val = val | (data[i] & 0x7F);
i--;
}
return val;
}
Varint编码是按照小端序进行多字节排序,每个字节的低7位用于以7位为一组存储数字的二进制补码表示,并且在每个字节都设置了最高有效位(Most Significant Bit,MSB)为1或者0,用于表示后面的字节是属于当前数据还是表示为最后一个字节数据。下面先来理解小端序和最高有效位是什么。
6.1.1 知识点补充——多字节序
多字节序分为大端序(Big-endian)和小端序(Little-endian)两种。对此存在两种阵营:IBM、Sun、Motorola系列的CPU采用Big-endian方式存储数据,而IA、x86系列CPU采用Little-endian方式存储数据,这就像有人吃鸡蛋喜欢从大端那头打破鸡蛋,而有人则喜欢从小端那头打破一样。目前市场需求关系Little-endian成为了主流。
Big-endian和Little-endian的区别:
Big-endian:将高序字节存储在起始地址,即内存顺序和数字的书写顺序是一致的,符合人类的思维习惯,方便阅读理解。
Little-endian:将低序字节存储在起始地址,在变量指针转换的时候地址保持不变,比如 int64* 转到 int32*不用考虑地址问题,对于机器而言较为友好。
我们来举个例子说明,假设现有一数据,其类型是int64,值用十六进制表示是0x000000001234abcd,现在要将其写入到以0x0000开始的内存中:
address | Big-endian | Little-endian |
0x0000 | 0x00 (0x0000 0000) | 0xcd(0x1100 1101) |
0x0001 | 0x00 (0x0000 0000) | 0xab(0x1010 1011) |
0x0002 | 0x00(0x0000 0000) | 0x34(0x0011 0100) |
0x0003 | 0x00(0x0000 0000) | 0x12(0x0001 0010) |
0x0004 | 0x12(0x0001 0010) | 0x00 (0x0000 0000) |
0x0005 | 0x34(0x0011 0100) | 0x00 (0x0000 0000) |
0x0006 | 0xab(0x1010 1011) | 0x00 (0x0000 0000) |
0x0007 | 0xcd(0x1100 1101) | 0x00 (0x0000 0000) |
说明:
因为int64是8个字节(Byte),所以内存地址是0x0000~0x0007,此时若要将int64转换成int32,值是:0x1234abcd。那么变成4个字节后,后面部分会被截断,内存地址会变成0x0000~0x0003,Big-endian原来的地址指向的值需要移位,而Little-endian中地址和值的位置不需要发生变化。
address | Big-endian | Little-endian |
0x0000 | 0x12(0x0001 0010) | 0xcd(0x1100 1101) |
0x0001 | 0x34(0x0011 0100) | 0xab(0x1010 1011) |
0x0002 | 0xab(0x1010 1011) | 0x34(0x0011 0100) |
0x0003 | 0xcd(0x1100 1101) | 0x12(0x0001 0010) |
在Java的字节序是Big-endian,而C/C++的字节序会根据编译平台所在的CPU来决定,而TCP/IP模型中各层协议将字节序定义为Big-Endian,所以我们一般也把Big-Endian称为网络字节序。在网络通信时两台采用不同字节序的主机在发送数据之前都必须经过字节序的转换成网络字节序后再进行传输。
6.1.2 知识点补充——MSB和LSB
MSB全称为Most Significant Bit,在二进制数中属于最高有效位,MSB是最高加权位,与十进制数字中最左边的一位类似。
LSB全称为Least Significant Bit,在二进制数中属于最低有效位。
一般来说MSB位于二进制数的最左侧,LSB位于二进制数的最右侧。
6.1.3 Varints编码示例
示例1
假设存在数据类型int32,值是127,用二进制表示是:
00000000 00000000 00000000 01111111
按照Varint编码原理提到,使用MSB+低7位作为一组存储,其表示应该变成这样:
01111111
再使用小端序进行排序,最后编码后的值应该是1个字节:
01111111
编码示例2
假设存在数据类型int32,值是128,用二进制表示是:
00000000 00000000 00000000 10000000
按照Varint编码原理提到,使用MSB+低7位作为一组存储,其表示应该变成这样:
00000001 10000000
再使用小端序进行排序,最后编码后的值应该是2个字节:
10000000 00000001
示例3
假设存在数据类型int32,值是305441741,用十六进制表示是:0x1234abcd,用二进制表示是:
00010010 00110100 10101011 11001101。
按照Varint编码原理提到,使用MSB+低7位作为一组存储,其表示应该变成这样:
00000001 10010001 11010010 11010111 11001101
再使用小端序进行排序,最后编码后的值应该是5个字节:
11001101 11010111 11010010 10010001 00000001
6.2 Zigzag编码原理
Zigzag编码是为了补充Varint编码在负数情况下的不足。因为负数在二进制中前面的所有位都是1,也就是意味着在32位整型数据经过Varints编码后恒定占用5个字节,64位是10个字节,所以就算其值是-1也会被视为一个很大的正数进行对待,从而比原来不转码前还要大,这显然Varints编码对于负数毫无优势可言。所以在这情况下Zigzag编码就是为了解决此不足而存在的预算法。
Zigzag编码会将准备进行Varints编码的数值先进行一次变换,如果是负数会将其映射为一个正数,得到正数后便可以利用Varint编码进行压缩,最终反序列化时再通过反Zigzag编码还原回原始的负数。
其关键代码如下:
int int_to_zigzag(int n) {
return (n << 1) ^ (n >> 31);
}
int zigzag_to_int(int n) {
return (n >> 1) ^ -(n & 1);
}
看似很简单的两个方法,int_to_zigzag方法是int32转Zigzag编码,使用了左边逻辑移位和右边算术移位,无论传入有符号还是无符号的整数,最后都可返回一个二进度的正数,在反序列化时再通过zigzag_to_int再将其还原成原来的int32值。
6.2.1 知识点补充——移位
逻辑移位:就是物理上按位进行的左右移动,两头用0进行补充,不关心数值的符号问题。
算术移位:也是物理上按位进行的左右移动,两头用0或1进行补充,但必须确保符号位不改变,1是负;0是正。
对于有符号和无符号整数,左移都是逻辑移位,而对于无符号整数右移是逻辑移位,有符号整数右移是算术移位。因为无符号一定是正数,符号一定也不会发生改变,所以也可将其当成算术移动。
从而总结成:逻辑左移,算术右移
6.2.2 Zigzag编码示例
假设调用int_to_zigzag方法传入数据类型int32,值是-5后,转换步骤如下:
11111111 11111111 11111111 11111011 -5转成二进度
11111111 11111111 11111111 11110110 << 1,左逻辑移位,右边补0
11111111 11111111 11111111 11111111 >>31,右算法移位,因为是负数,左边补1
00000000 00000000 00000000 00001001 求异^,最后结果是二进制的值就是等于十进制的9
再假设调用int_to_zigzag方法传入数据类型int32,值是5后,转换步骤如下:
00000000 00000000 00000000 00000101 5转成二进度
00000000 00000000 00000000 00001010 << 1,左逻辑移位,右边补0
00000000 00000000 00000000 00000000 >>31,右算法移位,因为是正数,左边补0
00000000 00000000 00000000 00001010 求异^,最后结果是二进制的值就是等于十进制的10
在反序列化时,调用zigzag_to_int,传入9后,转换步骤如下:
00000000 00000000 00000000 00001001 9的二进度,也是-5的Zigzag编码
00000000 00000000 00000000 00000100 >>1,右算法移位,左边补0
00000000 00000000 00000000 00000001 n & 1
11111111 11111111 11111111 11111111 取n & 1的负值,即取-1的二进度
11111111 11111111 11111111 11111011 求异^,最后结果是二进制的值就是等于十进制的-5
同理,调用zigzag_to_int,传入10后,最后结果是二进制的值就是等于十进制的5。
7 总结
Protocol Buffers是Google开发的一种跨语言跨平台的序列化机制,从空间方面考虑较较为有优势,因为序列化后数据体积小,非常适用于节省流量场景的数据传递,在即时通信、消息推送等场景应用比较多。内部实现原理是Varints编码+Zigzag编码。Varints编码按照小端序进行多字节排序,结合实际场景小数字使用的频率高,实现可根据数值的大小来决定占用字节存储空间,从而达到节省存储空间的效果,而Zigzag编码补充Varints编码在负数情况下的不足,因为负数的二进制前面都是1,意味着使用Varints编码会被误认为是一个很大的数值,在准备进行Varints编码的数值先进行一次变换,如果是负数会将其映射为一个正数,得到正数后便可以利用Varints编码进行压缩。