Protocol Buffers 入门(Android)

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 这三个字节,分析如下 ↓
  1. 08 二进制为 0000 1000,最高位 0 表示这是最后一个字节,去除最高位为 0001 000。
  2. 最后 3 个 bit 为 Type 类型,000 表示 wire type = 0,前面的 0001 表示这是编号为1的变量。
  3. 后面的 ac 02,写成二进制为 10101100 00000010,去掉最高位分隔符为 0101100 0000010,因为低字节优先,于是串起来为 0000010 0101100 = 300。
  4. 最终,08 ac 02 这三个字节解码为编号为 1 的变量值为整数 300。
  • 例子2:12 07 74 65 73 74 69 6e 67 这九个字节,分析如下 ↓
  1. 12 的二进制为 0001 0010,最高位 0 表示这是最后一个字节,去除最高位为 0010 010。
  2. 最后 3 个 bit 010 表示 wire type = 2,前四位 0010 表示这是编号为 2 的变量。
  3. 因为wire type = 2,表示 value 是 String、bytes 等变长流。接下来要解码 value 的长度。
  4. 07 的二进制为 0000 0111,最高位为 0,表示这是最后一个字节,去除最高位后是 000 0111,表示Value的长度为 7,也就是后面的 7 个字节:74 65 73 74 69 6e 67。
  5. 这 7 个字节如果是 String,那么根据 ASCII 码可解码为:"testing"。
  6. 最终,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 具有的优势如下 ↓

  1. 序列化后体积相比 Json 和 XML 很小,适合网络传输;
  2. 支持跨平台多语言;
  3. 消息格式升级和兼容性还不错;
  4. 序列化和反序列化速度很快,快于 Json 的处理速度。

PS:虽然 Protobuf 并非像 Json 那样直接明文显示,不过我们只需定义对象结构,然后由 Protbuf 库去把对象自动转换成二进制,用的时候再自动反解码过来。传输过程于我们而言是透明的。我们只负责传输的对象就可以了,所以用起来很方便。

结论:在一个需要大量的数据传输的场景中,如果数据量很大,那么选择 Protobuf 可以明显地减少数据量,减少网络 IO,从而减少传输所消耗的时间。

4.2 Protobuf 与 Json 速度比较
测试平台 Android studio 3.2
所引用的库 google.protobuf(proto3)、google.gson.Gson
目的 比较 Protobuf 与 Json 的序列化/反序列化速度
方法 控制变量法

过程简述 ↓

  1. 为 Protobuf 和 Json 创建一样的数据结构(.proto 文件 和 .class 文件),然后存进以下的数据,当然这些数据可以多次赋值、多次测试。
String name = "王小明";
int id = 15331016;
String email = "chen@bytedance.com";
String phone = "12345678910";
  1. 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 个字节。



  1. 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 个字节。


  1. Log输出每个节点的时间,为了效果明显,每个(反)序列化操作重复进行1000000次

    结果分析 ↓ (单位:ms)

    结论 ↓
    Protobuf 的体积比 Json 小,(反)序列化速度比 Json 快,在数据传输上更具优势。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容