ProtocolBuffer浅析
背景
ProtocolBuffer是google 定义的一种数据交换的格式,它独立于语言,独立于平台。google 提供了多种语言的实现:java、c#、c++、go 和 python,每一种实现都包含了相应语言的编译器以及库文件。ProtocolBuffer类似于xml、json,不过它更小、更快、也更简单。
与Json对比
目前使用最广泛的数据传输协议为JSON,JSON是一种轻量级的数据交换格式而且层次和结构比较简单和清晰,这里主要对比一下Protocol Buffer和JSON的对比,给出优势和劣势:
优势
传输数据更小
序列化和反序列化更快
由于传输的过程中使用的是二进制,没有结构描述文件,无法解析内容,安全性更高
劣势
- 由于传输过程使用的是二进制,自解释性较差,需要原有的结构描述文件才能解析
实际数据对比
序列化速度:比JSON快20-100倍
数据大小:序列化后体积小3倍
使用流程
Protocol Buffer的使用流程总体可以分为三步,如下图所示:
根据业务创建并定义proto文件
使用Google Protocol Buffer 提供的工具生成对应语言的源文件
将源文件拷贝到工程中,使用Protocol Buffer提供的库序列化或反序列化数据
Android中使用
-
在项目根目录的build.gradle中添加依赖:
dependencies { classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.8' }
-
在app的gradle中添加插件
apply plugin: 'com.google.protobuf'//添加插件
-
在app的build.gradle添加如下代码
protobuf { protoc { artifact = 'com.google.protobuf:protoc:3.7.1' // 也可以配置本地编译器路径 } generateProtoTasks { all().each { task -> task.builtins { java {}// 生产java源码 } } } }
-
添加proto文件的路径
sourceSets { main { proto { srcDir 'src/main/proto' } java { srcDir 'src/main/java' } } }
-
添加protobuf-java和protoc的依赖,其中protoc的依赖很重要,lite版中不需要添加
implementation 'com.google.protobuf:protobuf-java:3.7.1' implementation 'com.google.protobuf:protoc:3.7.1'
google推荐在Android项目中使用lite版,lite版本生成的java文件更加轻量,其配置如下:
protobuf {
//配置protoc编译器
protoc {
artifact = 'com.google.protobuf:protoc:3.8.0'
}
//这里配置生成目录,编译后会在build的目录下生成对应的java文件
generateProtoTasks {
all().each { task ->
task.builtins {
java {
option "lite"
}
}
}
}
}
dependencies {
implementation 'com.google.protobuf:protobuf-javalite:3.8.0'
}
语法解析
简单示例
首先创建一个.proto文件,并且在文件中声明如下内容:
syntax = "proto3"; //标明当前proto使用的版本为proto3
option java_package = "com.jon.protocol.protobuf"; //输出的java文件包名
option java_outer_classname = "Test"; //输出的java文件名称
message TestRequest{
string name = 1; //名称
int32 count = 2;
}
字段解析
在整个proto文件中数据类型分为基本类型和结构类型,其中结构类型主要为:
- message
- enum
- map
下面分别介绍一下不同结构的作用及规定:
message
message表示一个结构,类似于java中类,一个proto文件中可以声明多个message结构:
message SearchRequest {
string query = 1;
int32 page_number = 2;
int32 result_per_page = 3;
}
message SearchResponse {
...
}
import
message可以引用不同proto文件中的message,只要在proto文件中的最上面声明import即可,如下所示:
import "test.proto";
enum
enum使用很简单,直接在message中声明enum结构体并且将属性声明为对应的enum即可:
message EnumRequest {
Corpus corpus = 1;
enum Corpus {
UNIVERSAL = 0;
WEB = 1;
IMAGES = 2;
LOCAL = 3;
NEWS = 4;
PRODUCTS = 5;
VIDEO = 6;
}
}
在proto3中,enum第一个值必须为0,主要是为了和基础类型的默认值保持一致
map
map是proto3新加的,使用也很简单:
map map_field = N;
//示例如下
message MapRequest {
map map = 1;
}
基础类型
如下
.proto Type | Notes | C++ Type | Java Type | Python Type[2] | Go Type | Ruby Type | C# Type | PHP Type | Dart Type |
---|---|---|---|---|---|---|---|---|---|
double | double | double | float | float64 | Float | double | float | double | |
float | float | float | float | float32 | Float | float | float | double | |
int32 | Uses variable-length encoding. Inefficient for encoding negative numbers – if your field is likely to have negative values, use sint32 instead. | int32 | int | int | int32 | Fixnum or Bignum (as required) | int | integer | int |
int64 | Uses variable-length encoding. Inefficient for encoding negative numbers – if your field is likely to have negative values, use sint64 instead. | int64 | long | int/long[3] | int64 | Bignum | long | integer/string[5] | Int64 |
uint32 | Uses variable-length encoding. | uint32 | int[1] | int/long[3] | uint32 | Fixnum or Bignum (as required) | uint | integer | int |
uint64 | Uses variable-length encoding. | uint64 | long[1] | int/long[3] | uint64 | Bignum | ulong | integer/string[5] | Int64 |
sint32 | Uses variable-length encoding. Signed int value. These more efficiently encode negative numbers than regular int32s. | int32 | int | int | int32 | Fixnum or Bignum (as required) | int | integer | int |
sint64 | Uses variable-length encoding. Signed int value. These more efficiently encode negative numbers than regular int64s. | int64 | long | int/long[3] | int64 | Bignum | long | integer/string[5] | Int64 |
fixed32 | Always four bytes. More efficient than uint32 if values are often greater than 228. | uint32 | int[1] | int/long[3] | uint32 | Fixnum or Bignum (as required) | uint | integer | int |
fixed64 | Always eight bytes. More efficient than uint64 if values are often greater than 256. | uint64 | long[1] | int/long[3] | uint64 | Bignum | ulong | integer/string[5] | Int64 |
sfixed32 | Always four bytes. | int32 | int | int | int32 | Fixnum or Bignum (as required) | int | integer | int |
sfixed64 | Always eight bytes. | int64 | long | int/long[3] | int64 | Bignum | long | integer/string[5] | Int64 |
bool | bool | boolean | bool | bool | TrueClass/FalseClass | bool | boolean | bool | |
string | A string must always contain UTF-8 encoded or 7-bit ASCII text, and cannot be longer than 232. | string | String | str/unicode[4] | string | String (UTF-8) | string | string | String |
bytes | May contain any arbitrary sequence of bytes no longer than 232. | string | ByteString | str | []byte | String (ASCII-8BIT) | ByteString | strin |
默认值
- string:空串
- bytes:空字节
- bool:false
- 数字类型:0
- enum:默认值是第一个元素,且值必须为0
repeated
repeated修饰的属性类似于jsonArray,也类似于java中的List,该修饰符在格式正确的消息中可以重复任意次(包括0次)
message RepeatRequest {
repeated TestRequest requests = 1;
}
字段编号
- 字段编号从1开始,不可重复定义
- 字段编号1-15尽量保持经常访问的字段使用,因为1-15编号在传输的过程中只占用1个字节
字段扩充
日常开发过程中,由于需求的变更,往往需要增加字段,这就涉及到字段的扩充,字段扩充需要达到一个目的:兼容
所以Protocol Buffer在字段扩充中定义了如下规则:
不要修改已经存在的字段标号
-
不用的字段,可以删除,但是编号一定不可以再次使用,建议将字段标为废弃,如加前缀:"OBSOLETE_", 或者标记该字段为reserved
reserved 2;
只要记住上述规则,就能完成字段扩充且老版本也能兼容
原理简介
Protocol Buffer 更快更小的主要原因如下:
- 数据在序列化的时候不会传输字段名,只会传输字段标号,并且没有被设置值的字段是不会序列化和传输
- 采用可变长度编码,优化数据占用
message TestRequest{
string name = 1; //名称
int32 count = 2;
}
上面这个例子中,在序列化时,"name" 、"count"的key值不会参与,由编号1、2代替,这样在反序列化的时候直接通过编号找到对应的key就可以。需要注意的是编号一旦确定就不可以更改,服务端和客户端通过proto通信的时候需要提前定义号数据格式。
- 没有赋值的key,不参与序列化
序列化时只会对赋值的key进行序列化,没有赋值的不参与,在反序列化的时候直接给默认值即可 - 可变长度编码
可变长度编码,主要缩减整数占用字节实现,例如java中int占用4个字节,但是大多数情况下,我们使用的数字都比较小,使用1个字节就够了,这就是可变长度编码完成的事 - TLV
TLV全称为Tag-Length-Value,其中Tag表示后面数据的类型,Length不一定有,根据Tag的值确定,Value就是数据了,TLV表示数据时,减少分隔符的使用,更加紧凑
T-L-V的数据存储方式
其中Length不一定有,依据Tag确定,例如int类型的数据就只有Tag-Value,string类型的数据就必须是Tag-Length-Value。
数据类型
Protocol Buffer定义了如下的数据类型,其中部分数据类型已经不再使用:
类型 | 释义 | 备注 |
---|---|---|
0 | 可变长度编码 | int32 int64 uint32 uint64 sint32 sint64 bool enum |
1 | 64位长度 | fixed64 sfixed64 double |
2 | value 的长度 | string bytes message packed repeated fiels |
3 | Start Group | 废弃 |
4 | End Group | 废弃 |
5 | 32位长度 | fixed32 sfixed32 float |
Tag
上面已经介绍了Protocol Buffer的数据结构及Tag的类型,但是Tag块并不是只表示数据类型,其中数据编号也在Tag块中,Tag的生成规则如下:
(field_number << 3) | wire_type
其中Tag块的后3位表示数据类型,其他位表示数据编号
可变长度编码
Java中整数类型的长度都是确定的,如int类型的长度为4个字节,可表示的整数范围为-231——231-1,但是实际开发中用到的数字均比较小,会造成字节浪费,可变长度编码就能很好的解决这个问题,可变长度编码规则如下:
- 字节最高位表示数据是否结束,如果最高位为1,则表示后面的字节也是该数据的一部分
举个例子:
其中第一个字节由于最高位为1,则后面的字节也是前面的数据的一部分,第二个字节最高位为0,则表示数据计算终止,由于Protocol Buffer是低位在前,整体的转换过程如下:
10000001 00000011 ——> 00000110000001 表示的10进制数为:2^0 + 2^7 + 2^8 = 385 通过上面的例子可以知道一个字节表示的数的范围0-128,上面介绍的Tag生成算法中由于后3位表示数据类型,所以Tag中1-15编号只占用1个字节,所以确保编号中1-15为常用的,减少数据大小。
可变长度编码唯一的缺点就是当数很大的时候int32需要占用5个字节,但是从统计学角度来说,一般不会有这么大的数.
案例分析
上面介绍了Protocol Buffer的原理,现在通过实例来展示分析过程,我们定义的proto文件如下:
message TestRequest{
string name = 1; //名称
int32 count = 2;
}
其序列化后的字节数据如下:
前面介绍过Protocol Buffer的数据结构为TLV,其中L不是必须的,根据T的类型来确定 先看下第一个字节:
这里字节最高位为0,所以该Tag就用这一个字节表示,其中后3位表示类型,前面表示字段编号,所以:
这里字节最高位为0,所以该Tag就用这一个字节表示,其中后3位表示类型,前面表示字段编号,所以: file_num = 0001 = 1 type = 010 = 2 上面介绍过type=2,则后面有Length,按照可变长度编码规则,知道表示长度的字节为:
所以Length=4,则value的长度是4个字节,直接取出后面4个字节:
这4个字节对应的就是test 再看下一组:
由上面的Tag知道: file_num=2 type=0 前面介绍过type=0,后面没有Length,直接就是value,
value=1,通过上面的解析可以知道
file_num=1 value=test
file_num=2 value=1 这样解析就结束了
上面介绍了Protocol Buffer的原理,解释了为什么Protocol Buffer更快,更小,这里再总结一下:
序列化的时候,不序列化key的name,只序列化key的编号
序列化的时候,没有赋值的key,不参与序列化,反序列化的时候直接使用默认值填充
可变长度编码,减小字节占用
TLV编码,去除没有的符号,使数据更加紧凑
参考资料:
proto3官网指南:https://developers.google.com/protocol-buffers/docs/proto3
protobuf-gradle-plugin:https://github.com/google/protobuf-gradle-plugin
博客:https://juejin.im/post/5dcbf630e51d451bfe5bb21b