1. 关于 Protobuf
1.1 简介
Protocol Buffer,简称 Protobuf,是 Google 开发的一种数据描述语言。它是一种轻便高效的结构化数据存储格式,适合做数据存储或 RPC 数据交换格式,可用于数据传输量较大的即时通讯协议、数据存储等场景。Protobuf 与语言无关、平台无关,目前提供了多种语言的 API。
官方文档:https://developers.google.com/protocol-buffers
开源地址:https://github.com/protocolbuffers/protobuf
1.2 优势
- 体积小速度快。像 XML 这种报文是基于文本格式的,存在大量的描述信息,虽然对于人来说可读性更好,但增加了序列化时间、网络传输时间等。导致系统的整体性能下降。而 PB 则将信息序列化为二进制的格式,安全性提高的同时,序列化后的数据大小缩小了3倍,序列化速度比 Json 快了20-100倍,也必然会减小网络传输时间。
- 跨平台跨语言。接收端和发送端只需要维护同一份 proto 文件即可。proto 编译器会根据不同的语言,生成对应的代码文件。
- 向后兼容性。不必破坏旧的数据格式,可以直接对数据结构进行更新。
2. 原理分析
通过本质探究,了解 Protobuf 为何如此高效。
2.1 编码背景
- 信源编码:信源编码是一种以提高通信有效性为目的而对信源符号进行的变换,或者说为了减少或消除信源利余度而进行的信源符号变换。具体说,就是针对信源输出符号序列的统计特性来寻找某种方法,把信源输出符号序列变换为最短的码字序列,使后者的各码元所载荷的平均信息量最大,同时又能保证无失真地恢复原来的符号序列。信源编码的作用之一是,即通常所说的数据压缩;作用之二是将信源的模拟信号转化成数字信号,以实现模拟信号的数字化传输。现代通信应用中常见的信源编码方式有:Huffman编码、算术编码、L-Z编码,这三种都是无损编码;另外还有一些采用压缩方式的有损编码。同时无损编码也根据"是否把一个传输单位编码为固定长度"区分为定长编码和变长编码。定长编码就是一个符号变换后的码字的比特长度是固定的,比如 ASCII、Unicode 都是定长编码,码字是8比特,16比特。变长编码则是将信源符号映射为不同的码字长度,比如 Huffman 编码,PB 编码。
- 信道编码:信道编码是为了对抗信道中的噪音和衰减,通过增加冗余来提高抗干扰能力以及纠错能力。信道编码的本质是降低误码率、增加通信的可靠性。数字信号在传输中往往由于各种原因,使得在传送的数据流中产生误码,所以通过信道编码这一环节来避免码流传输中误码的发生。常用的处理技术有奇偶校验码、纠错码、信道交织编码等。
- 简单来说,信源编码就是将信源产生的消息变换为数字序列的过程,主要目的是降低数据率,提高信息量效率,一般用来对视频、音频、数据进行处理。而信道编码的主要目的是提高系统的抗干扰能力,比如纠错码啊,卷积码这类,可以检测出信息是否有被传错。
- 从通信角度来看,Protobuf 是一种变长的无损的信源编码。
2.2 整数的编码优化
varint 编码:一般情况下,一个 int 值看作4字节,也就是所谓的定长编码。PB 考虑到现实情况中,数值较大的数比数值较小的数更少地被使用这一事实,采用了变长编码。如果一个数能够用1个字节来表示,那就用一个字节来表示。如数值1就会被编码为0000 0001,而不是把它编码为0000 0000 0000 0000 0000 0000 0000 0001。但是也由此产生一个问题,每个整数的编码长度可能不一样,如何区分边界呢?PB 将每个字节拿出1比特最高位的那个比特 MSB(Most Significant Bit)来作为边界的标记(编码是否为最后一个字节),1表示还没有到最后一个字节,0表示到了最后一个字节。
规则如下 ↓
- 0xxx xxxx表示某个整数编码后的结果是单个字节,因为MSB=0;
- 1xxx xxxx 0xxx xxxx表示某个整数编码后的结果是2个字节,因为前一个字节的MSB=1(编码结果未结束),后一个字节的MSB=0;
- 同理,三个字节、四个字节都用这种方法来表示边界。
代码如下 ↓
final void bufferUInt32NoTag(int value) {
if (HAS_UNSAFE_ARRAY_OPERATIONS) {
final long originalPos = position;
while (true) {
if ((value & ~0x7F) == 0) {
//最后一次取出最高位补0
UnsafeUtil.putByte(buffer, position++, (byte) value);
break;
} else {
UnsafeUtil.putByte(buffer, position++, (byte) ((value & 0x7F) | 0x80));
//取出后面7位,最高位补1
value >>>= 7;
}
}
int delta = (int) (position - originalPos);
totalBytesWritten += delta;
} else {
while (true) {
if ((value & ~0x7F) == 0) {
buffer[position++] = (byte) value;
totalBytesWritten++;
return;
} else {
buffer[position++] = (byte) ((value & 0x7F) | 0x80);
totalBytesWritten++;
value >>>= 7;
}
}
}
}
示例如下 ↓
- 0000 0001表示整数1;
- 1010 1100 0000 0010表示两个字节的结果。将两字节的MSB去掉为:0101100 0000010。由于 PB 对于多个字节的情况采用低字节优先,即后面的字节要放在高位,于是拼在一起的结果为:00000100101100,表示300这个整数值。(其实就是将数字的二进制补码的每7位分为一组, 低7位先输出,编码在前面,在输出下一组,依次类推)
可以看到以上的变长编码方式,在数据压缩上能节省很多空间,不过它也存在以下几个小缺点 ↓
- 造成了比特的1/8的浪费,一个很大的数将可能使用5个字节来表示。
- 负数需要10个字节显示(因为负数最高位是1,会被当作很大的整数处理)
2.3 对象的编码优化
2.3.1 Protobuf 对 key-value 中 key 的优化:使用序号 key 代替变量名
对于一个对象,里面包含多个变量,如何编码呢?假设一个类的定义如下 ↓
Class Student {
String name;
String sex;
int age;
}
如果使用 XML,那么传输的格式如下 ↓
<?xml version="1.0" encoding="UTF-8" ?>
<name>Bob</name>
<sex>male</sex>
<age>18</age>
如果使用 Json,那么传输的格式如下 ↓
{
"name": "Bob",
"sex": "male",
"age": "18"
}
而 Protobuf 认为 "name"、"sex"、"age" 这些变量名不应该包含在传输消息中,因为编解码、传输这些信息也需要资源。Protobuf 为了节省空间,在通信双方都保持一份文档,记录了变量名的编号,比如上述三个变量名字分别编号为1、2、3。于是在序列化的时候,只需要传输下面的信息 ↓
1:"Bob", 2:"male", 3:"18"
由于对方也保留了一份编号文档,于是就可以反序列化了。这些编号本身也可以用上面对整数的编码优化方式进行编码。??
2.3.2 Protobuf 对 key-value 中 value 的优化
如果 value 为整数,那么直接使用前面提到的对整数的编码优化即可。即大多数整数只占一两个字节。
如果 value 为字符串,这时候每个字节都拿出1个 bit 来区分边界就太浪费空间了,而且字符串本身就是一个一个字节的,被打乱后也会影响解码效率。因此,Protobuf 将 value 长度信息的指示可以放在 key 和 value 之间(长度本身也是一个整数,也能编码优化)。在解码 value 时,解析长度就可以知道 value 值到哪里结束。不过也因此产生一个问题,比如整数这种情况,value 中已经自带了结束标识符,那就不需要 value 的长度指示信息了。因此 Protobuf 引入了 Type 类型,即提前告诉接收端 value 的类型。Protobuf 将这个 Type 信息放在了 key 中的最后 3 个 bit 中。根据这个 Type,即可让接受端注意或者忽略 value 的长度字段。value 的类型在 Protobuf 中称为 wire_type。主要有以下几种 ↓
wire type = 0
// 0 表示这个Value是一个变长整数,比如int32, int64, uint32, uint64, sint32, sint64, bool, enum
wire type = 1
// 1 表示这个Value是一个64位的定长数,比如fixed64, sfixed64, double
wire type = 2
// 2 表示string, bytes等,这些Value的长度需要置于Key后面
wire type = 3
// 3 表示groups中的Start Group,就是有一组,3表示接下来的Value是第一组
wire type = 4
// 4 表示groups中的End Group
wire type = 5
// 5 表示32位固定长度的fixed32, sfixed32, float等
2.4 示例
- 例子1:08 ac 02 这三个字节,分析如下 ↓
- 08 二进制为 0000 1000,最高位 0 表示这是最后一个字节,去除最高位为 0001 000。
- 最后 3 个 bit 为 Type 类型,000 表示 wire type = 0,前面的 0001 表示这是编号为1的变量。
- 后面的 ac 02,写成二进制为 10101100 00000010,去掉最高位分隔符为 0101100 0000010,因为低字节优先,于是串起来为 0000010 0101100 = 300。
- 最终,08 ac 02 这三个字节解码为编号为 1 的变量值为整数 300。
- 例子2:12 07 74 65 73 74 69 6e 67 这九个字节,分析如下 ↓
- 12 的二进制为 0001 0010,最高位 0 表示这是最后一个字节,去除最高位为 0010 010。
- 最后 3 个 bit 010 表示 wire type = 2,前四位 0010 表示这是编号为 2 的变量。
- 因为wire type = 2,表示 value 是 String、bytes 等变长流。接下来要解码 value 的长度。
- 07 的二进制为 0000 0111,最高位为 0,表示这是最后一个字节,去除最高位后是 000 0111,表示Value的长度为 7,也就是后面的 7 个字节:74 65 73 74 69 6e 67。
- 这 7 个字节如果是 String,那么根据 ASCII 码可解码为:"testing"。
- 最终,12 07 74 65 73 74 69 6e 67 这几个字节解码为编号为 2 的变量值为字符串"testing"。
2.5 总结
2.5.1 体积压缩优势
由 2.4 的第二个例子,08 ac 02 12 07 74 65 73 74 69 6e 67 这九个字节等价于 Json 中的 ↓
{"testInt":"300", "testString":"testing"}
可看出,Json 使用了40个左右的字节,而 Protocol 只使用了12个字节,这也就解释了为什么 Protobuf 将信息序列化为二进制后,体积缩小了3倍,也因此减少了数据网络传输的时间。
2.5.2 序列化速度优势
以 XML 的解包过程为例,XML 首先需要将得到的字符串转换为 XML 文档对象的结构模型,再从结构模型中读取指定节点的字符串,最后再将这个字符串指定为某个对象的变量值。这个过程非常复杂,其中转换文档对象结构模型的过程,通常需要完成词法文法分析等大量消耗 CPU 的复杂计算。而 Protobuf 只需要简单地将一个二进制序列,按照指定的格式读取赋值到某个对象的变量值即可。因此它的的序列化速度非常快。
3. 在 Android 中的简单使用
分析完 Protobuf 的原理之后,便是开始学习如何使用。前面说到它可用于多种语言,且与平台无关。不过我只稍微学习了如何在 Android studio 中使用 Protobuf。
3.1 Gradle 配置
在根目录的 build.gradle 中添加如下代码 ↓
buildscript {
repositories {
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.2.1'
classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.6' // 添加这行
}
}
在 module 的 build.gradle 中首先添加如下代码 ↓
apply plugin: 'com.google.protobuf' // 添加插件
接着添加 protobuf 块(与android同级)↓
protobuf {
// 配置protoc编译器
protoc {
artifact = 'com.google.protobuf:protoc:3.0.0-alpha-3'
}
plugins {
javalite {
artifact = 'com.google.protobuf:protoc-gen-javalite:3.0.0'
}
}
// 这里配置生成目录,编译后会在build的目录下生成对应的java文件
generateProtoTasks {
all().each { task ->
task.plugins {
javalite {}
}
}
}
}
再添加依赖 ↓
implementation 'com.google.protobuf:protobuf-lite:3.0.0'
此时可以编译项目,会生成 proto java class,这个类就是我们后面所要使用到的。
3.2 定义 proto 文件
一般是在 java/res 同级目录下建立 proto 文件夹,然后再创建 .proto 文件。
我们在 .proto 文件里定义数据结构。这里有份官网的示例代码 ↓
package tutorial;
option java_package = "com.example.tutorial";
option java_outer_classname = "AddressBookProtos";
message Person {
required string name = 1;
required int32 id = 2;
optional string email = 3;
enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}
message PhoneNumber {
required string number = 1;
optional PhoneType type = 2 [default = HOME];
}
repeated PhoneNumber phones = 4;
}
message AddressBook {
repeated Person people = 1;
}
关键字说明 ↓ (更多内容可以参考官网 Protocol Buffer Language Guide)
syntax | 声明版本。例如上面代码的syntax="proto3",如果没有声明,则默认是proto2 |
package | 声明包名 |
import | 导入包 |
java_package | 指定生成的类应该放在什么Java包名下。如果你没有显式地指定这个值,则它简单地匹配由package 声明给出的Java包名,但这些名字通常都不是合适的Java包名 (由于它们通常不以一个域名打头) |
java_outer_classname | 定义应该包含这个文件中所有类的类名。如果你没有显式地给定java_outer_classname ,则将通过把文件名转换为首字母大写来生成。例如上面例子编译生成的文件名和类名是AddressBookProtos |
message | 类似于java中的class关键字 |
repeated | 用于修饰属性,表示对应的属性是个array |
optional | 可选字段,可以不传入数据,或者设置默认值 |
required | 必填字段,如果创建数据对象时不传入参数,编码时就会抛出exception。使用required时要注意,如果你升级协议时把这个字段改为optional,接收方没有升级,你发送的数据对方将无法解释。因此不建议使用它,一般只使用optional和repeated |
这里我跟着教程,定义了一个简单的数据结构 ↓
syntax = "proto3";
package tutorial;
message Person {
string name = 1;
int32 id = 2;
string email = 3;
string phone = 4;
}
编译后,在以下的目录中会生成对应的 java 文件 ↓
Dataformat.java 即是 dataformat.proto 生成的对应 java 文件,里面代码行数有点多。
3.3 获取数据
通过网络获取数据流,然后解析成 proto 文件定义的格式 ↓
Observable.just("http://elyeproject.x10host.com/experiment/protobuf")
.map(new Function<String, Dataformat.Person>() {
@Override
public Dataformat.Person apply(String url) throws Exception {
OkHttpClient okHttpClient = new OkHttpClient();
Request request = new Request.Builder().url(url).build();
Call call = okHttpClient.newCall(request);
Response response = call.execute();
if (response.isSuccessful()) {
ResponseBody responseBody = response.body();
if (responseBody != null) {
return Dataformat.Person.parseFrom(responseBody.byteStream());
}
}
return null;
}
}).subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(new Consumer<Dataformat.Person>() {
@Override
public void accept(Dataformat.Person person) throws Exception {
Log.i(TAG, person.getName());
}
}, new Consumer<Throwable>() {
@Override
public void accept(Throwable throwable) throws Exception {
Log.i(TAG, throwable.getMessage());
}
});
该网站返回的数据如下 ↓
Android 获取的数据如下 ↓
PS:doc-android-client 项目的 bitable module 主要实现 native UI,它所引入的 bitable_bridge 包则负责业务逻辑。bitable_bridge 是个 React Native 工程,逻辑是用 JS 写的,数据格式则为 Protobuf 。
4. 数据交互格式比较
4.1 XML、Json、Protobuf
Json | 一般的 web 项目中,最流行的主要还是 Json。因为浏览器对于 Json 数据支持非常友好,有很多内建的函数支持。 Json 使用了键值对的方式,不仅压缩了一定的数据空间,同时也具有可读性。 |
XML | 在 webservice 中应用最为广泛,但是相比于 Json,它的数据更加冗余,因为需要成对的闭合标签。 |
Protobuf | 后起之秀,适合高性能,对响应速度有要求的数据传输场景。因为是二进制数据格式,需要编码和解码。数据本身不具有可读性。因此只能反序列化之后得到真正可读的数据。 |
相对于其他两种语言,Protobuf 具有的优势如下 ↓
- 序列化后体积相比 Json 和 XML 很小,适合网络传输;
- 支持跨平台多语言;
- 消息格式升级和兼容性还不错;
- 序列化和反序列化速度很快,快于 Json 的处理速度。
PS:虽然 Protobuf 并非像 Json 那样直接明文显示,不过我们只需定义对象结构,然后由 Protbuf 库去把对象自动转换成二进制,用的时候再自动反解码过来。传输过程于我们而言是透明的。我们只负责传输的对象就可以了,所以用起来很方便。
结论:在一个需要大量的数据传输的场景中,如果数据量很大,那么选择 Protobuf 可以明显地减少数据量,减少网络 IO,从而减少传输所消耗的时间。
4.2 Protobuf 与 Json 速度比较
测试平台 | Android studio 3.2 |
所引用的库 | google.protobuf(proto3)、google.gson.Gson |
目的 | 比较 Protobuf 与 Json 的序列化/反序列化速度 |
方法 | 控制变量法 |
过程简述 ↓
- 为 Protobuf 和 Json 创建一样的数据结构(.proto 文件 和 .class 文件),然后存进以下的数据,当然这些数据可以多次赋值、多次测试。
String name = "王小明";
int id = 15331016;
String email = "chen@bytedance.com";
String phone = "12345678910";
- Protobuf 操作
Dataformat.Person.Builder builder = Dataformat.Person.newBuilder();
// 存进数据
builder.setName(name);
builder.setId(id);
builder.setEmail(email);
builder.setPhone(phone);
// 序列化
Dataformat.Person person_write = builder.build();
byte[] result = person_write.toByteArray();
// 反序列化
Dataformat.Person person_read = Dataformat.Person.parseFrom(result);
可以看到,protocol 序列化后得到的编码结果为 49 个字节。
- Json 操作
Gson gson = new Gson();
// 存进数据
Person person1 = new Person(name, id, email, phone);
// 序列化
String person1_write = gson.toJson(person1);
// 反序列化
Person person1_read = gson.fromJson(person1_write, Person.class);
序列化后的大小为 82 个字节。
- Log输出每个节点的时间,为了效果明显,每个(反)序列化操作重复进行1000000次
结果分析 ↓ (单位:ms)
结论 ↓
Protobuf 的体积比 Json 小,(反)序列化速度比 Json 快,在数据传输上更具优势。