代码改变世界

google protobuf的原理和思路提炼

2021-06-27 20:37  tera  阅读(2797)  评论(0编辑  收藏  举报

之前其实已经用了5篇文章完整地分析了protobuf的原理。回过头去看,感觉一方面篇幅过大,另一方面过于追求细节和源码,对protobuf的初学者并不十分友好,因此这篇文章将会站在“了解、使用、特性、原理、改进”的角度重新整理protobuf的相关知识,希望对大家有所帮助。

1.什么是protobuf以及为何要使用protobuf

protocol buffer是由google推出一种数据编码格式,不依赖平台和语言。

和json或者xml相比,protocol buffer的解析速度更快,编码后的字节数更少。

2.如何使用protobuf

首先我们需要下载一个google提供的编译器,下载地址:

https://github.com/protocolbuffers/protobuf/releases/tag/v3.12.1

选择自己的系统下载相应的zip包.

解压后就能看到看到一个protoc的执行文件,即是我们所需要的编译器。

接着我们需要定义一份BasicUsage.proto的描述文件,其结构和我们定义普通的类十分类似。特别需要注意的是,字段后跟着的=号不代表字段的值,而是字段的序号,后面会详细解释

syntax = "proto3";

option java_package = "cn.tera.protobuf.model";
option java_outer_classname = "BasicUsage";

message Person {
  string name = 1;
  int32 id = 2;
  string email = 3;
}

第一行表示所使用的的语法版本,这里选择的是最新的proto3版本。

syntax = "proto3";

第三、四行表示最终生成的java的package名和class的类名

option java_package = "cn.tera.protobuf.model";
option java_outer_classname = "BasicUsage";

有了编译器和.poto描述文件,我们就可以生成java模型文件了

-I :表示工作目录,如果不指定,则就是当前目录

--java_out:表示输出.java文件的目录

protoc -I=/protocol_buffer/protobuf/proto --java_out=/protocol_buffer/protobuf/src/main/java/ /protocol_buffer/protobuf/proto/BasicUsage.proto

以上都是准备工作,接着我们就要进入代码相关部分

引入maven依赖

<!--这部分是protobuf的基本库-->
<dependency>
    <groupId>com.google.protobuf</groupId>
    <artifactId>protobuf-java</artifactId>
    <version>3.9.1</version>
</dependency>
<!--这部分是protobuf和json相关的库,这里一并导入,后面会用到-->
<dependency>
    <groupId>com.google.protobuf</groupId>
    <artifactId>protobuf-java-util</artifactId>
    <version>3.9.1</version>
</dependency>

接下去创建一个简单的测试用例

/**
 * protobuf的基础使用
 */
@Test
void basicUse() {
    //创建一个Person对象
    BasicUsage.Person person = BasicUsage.Person.newBuilder()
            .setId(5)
            .setName("tera")
            .setEmail("tera@google.com")
            .build();
    System.out.println("Person's name is " + person.getName());

    //编码
    //此时我们就可以通过我们想要的方式传递该byte数组了
    byte[] bytes = person.toByteArray();

    //将编码重新转换回Person对象
    BasicUsage.Person clone = null;
    try {
        //解码
        clone = BasicUsage.Person.parseFrom(bytes);
        System.out.println("The clone's name is " + clone.getName());
    } catch (InvalidProtocolBufferException e) {
    }
    //引用是不同的
    System.out.println("==:" + (person == clone));
    //equals方法经过了重写,所以equals是相同的
    System.out.println("equals:" + person.equals(clone));

    //修改clone中的值
    clone = clone.toBuilder().setName("clone").build();
    System.out.println("The clone's new name is " + clone.getName());
}

3.protobuf的特性

A.之前有提到protobuf编码后的字节大小要小于json格式,因此做一个简单的对比,json结果60字节,而protobuf是37个字节。

/**
 * json和protobuf的编码数据大小
 */
@Test
void codeSizeJsonVsProtobuf() throws Exception {
    //构造简单的java模型
    PersonJson model = new PersonJson();
    model.email = "personJson@google.com";
    model.id = 1;
    model.name = "personJson";
    String json = JSON.toJSONString(model);
    System.out.println("原始json");
    System.out.println("------------------------");
    System.out.println(json);
    System.out.println("json编码后的字节数:" + json.getBytes("utf-8").length + "\n");

    //parser
    JsonFormat.Parser parser = JsonFormat.parser();
    //需要build才能转换
    BasicUsage.Person.Builder personBuilder = BasicUsage.Person.newBuilder();
    //将json字符串转换成protobuf模型,并打印
    parser.merge(json, personBuilder);
    BasicUsage.Person person = personBuilder.build();
    //需要注意的是,protobuf的toString方法并不会自动转换成json,而是以更简单的方式呈现,所以一般没法直接用
    System.out.println("protobuf内容");
    System.out.println("------------------------");
    System.out.println(person.toString());
    System.out.println("protobuf编码后的字节数:" + person.toByteArray().length);
}

B.protobuf是一个不可完全自解析的编码格式,也就是说,如果没有任何其他资料的支持(例如模型的定义文件、.proto文件等)仅仅拿到编码后的结果字节,是无法还原出原始的数据的,也很难人工阅读。

C.通过google官网提供的编译器生成的.java类文件的大小十分惊人,例如本文之前的一个简单的类定义,就会生成32kb的.java文件。在实际项目中,遇到过1mb左右的.java文件。

D.protobuf的编码和解码速度都优于json

4.protobuf的编码原理

针对上面4个主要的特性,我们来了解一下protobuf的基本编码原理,从而理解导致上述4个特性的原因。

A.数字编码Varint、数据长度与数据编号

平时我们对数字的编码其实并不会有特别的约定,一个字节本身就能表示0~255的数字(这里假定是无符号的)。然而当我们在网络上传递编码成字节的数据时,由于网络传输半包、粘包等等各种因素的存在,如何确定整个数据的长度就是最首要的任务。

因为数据大小的不确定性,我们无法约定固定的字节表示数据长度。例如有时候数据量小于255个字节,那么一个字节就能表示长度,若超过了65535,那么就需要3个字节才能表示数据长度了。

为了解决这个问题,采用了varint编码方式。它约定,每个表示数字字节的最高位若为1,则说明该数字还需要读取下一个字节。若字节的最高位位0,则表示数字的字节读取完毕。而剩下的7位则记录具体的数字。

例如:

0 0001000
最高位为0,因此这个字节就表示了完整的数字,后7位 0001000 就表示数字8。

1 0001000  0 0001000
此时第一个字节最高位是1,说明还需要读取下一个字节。第二个字节的最高位是0,表示读取结束。
去掉2个最高位后,0001000 0001000 表示数字1032

真正的varint编码其实还需要按照小端排序,不过这里就不细究了,了解最高位的作用即可。

了解了varint编码方式后,我们回到protobuf,看看是如何应用的。

在前面定义模型的.proto文件中,有看到每个字段后面会跟着一个数字。这不表示这个字段的默认值,而是表示这个字段的编号。当编码完成后的字节中,每一个字段的数据之前都会有几个字节表示该数据的编号。

例如:

对于简单的数字

原始的json数据:{age:15},若age的编号为1,那么就会得到如下的编码结果
0 0001 000  00001111
这里第一个字节最高位为0,即第一个字节就表示了完整的数字
第一个字节的后3位为000,protobuf用于区别字段的类型
中间的4位即为编号的具体值,表示1
第二个字节就表示数据的具体值,即15

综上所述,protobuf编码的结果和json编码的结果相比较

原始的json数据:{"name":"personJson","id":15,"email":"personJson@google.com"}
按照之前定义的.proto文件,name编号为1,id编号为2,email编号为3,编码后的结果若翻译成可读的文字来说如下
1personJson2153personJson@google.com
即1号字段的值是personJson,2号字段的值是15,3号字段的值是personJson@google.com

和json编码比较而言,protobuf首先就去除了{}"",等标点符号,其次完全没有字段名,而是仅保留了字段的编号。因此protobuf的编码结果会比json的编码结果更小

B.protobuf的不完全自解析

假如我们拿到一个json格式的数据,那么其中是包含了该数据的所有完整信息,完全不需要依赖任何外部信息,我称其为完全自解析性。

而在了解了protobuf的基本编码原理后,我们就会发现一个问题。编码的结果中完全没有字段的名字了!因此当我们拿到一段protobuf的编码数据后,是没有办法将其完整还原为原始数据的,我称其为不完全自解析性。

C.protobuf编码与解码的定制性

为了解决数据的不完全自解析性,google采用的办法就是在编译.proto文件生成.java文件时,将会把很多解析数据的代码直接硬编码到.java文件中,从而补足了编码结果中缺少的信息。因此,生成的.java文件的大小会大得惊人。

D.protobuf编码与解码的速度型

通过特性C,我们其实很容易就能推断,一般的json文件的编码与解码类库或多或少会涉及到反射的调用,而protobuf采用的是硬编码的方式,完全不需要使用反射,那么速度自然会快上一大截。

在为何要使用protobuf中,有提到是因为编码结果更小,编码速度更快。而通过上述4个特性的分析,我们就能了充分了解其背后的根本原因了。

综上所述,protobuf的设计思想是等价交换:即用编码方和解码方的硬盘存储空间换取编码结果的减小和编码速度的加快

5.对protobuf的思路的进一步改进

在移动互联网的使用场景下,单次请求耗时对于用户来说是一个非常敏感的数据指标,而影响单次请求耗时的因素有很多,其中最重要的自然是服务端的数据处理能力与网络信号的状态。服务端的处理数据处理能力是完全在我们自己的掌控之中,可以有很多方法提高响应速度。然而用户的网络信号状态是我们无法控制的,也许是3G信号,也许是4G信号,也许正在经过一个隧道,也许正在地下商场等等。如果我们能降低每一次网络请求的数据量,那么也算是在我们所能掌控的范围内去优化请求响应时长的问题了。

A.类库大小的缩减

对于android和ios用户来说,app的大小其实是非常重要的,因此google编译生成后的.java文件过大就是一个致命问题,而ios的类库也有10mb所有。因此为了在该场景下应用protobuf,需要解决的首要问题就是类库的大小

在前面的编码过程中,我们了解到.java文件的巨大是由于编码结果的不可完全自解析性,从而需要硬编码来弥补。然而无论是java还是swfit,都是强类型的语言,因此首先便考虑牺牲编码的速度,使用反射来代替硬编码

B.字段默认值的优化

在移动互联网场景下,由于APP发布后是无法修改的,即使发布了新版本,用户也可以选择不更新。因此大部分的页面数据都是通过服务端返回的。然而很多时候,一些描述性的文字可能会保持很长时间不修改,若每次请求都返回相同的内容,其实很浪费传输数据的大小

a).对于固定的默认值,对于java可以采用annotation的方式,直接存放在客户端的模型文件中。而在传输过程中,只需要一个位的0和1来标记是否采用默认值即可。若为0则表示采用默认值,此时就不需要将该值放入编码结果中,而客户端读取到0后就直接采用annotation中的默认值即可。

b).除了单一默认值的情况,还有可能是多种默认值。例如订单的状态,可能包括预订成功、预订失败、预订取消等等,如果我们采用一个int的枚举值表示,让客户端根据int枚举值判断之后展示相应文案,那么当将来需要新增一个预订确认中的状态时已经发布的老版本客户端将无法处理,所以这些文案也应当是从服务端返回。

顺着之前的思路,我们在定义默认值的时候可以定义多个,而在编码的时候,除了用标记位表示是否使用默认值,再需要一个Int表示默认值的索引。

c).还有一种情况,一个字符串中的大部分都是不变的,只有其中的几个字符会根据不同的情况改变,例如“亲爱的XXX用户您好,欢迎回来”,在这种情况下,只有XXX需要根据实际情况进行替换,而其余的字符都是可以不变的,因此在传输该数据的过程中,我们只需要传递会变化的部分,而不变的部分就存放到模型中

通过上述改进,可以使得传输数据的大小缩减20%~80%

6.总结

通过上面5个部分的分析,我们着重需要整理请以下思路

1.protobuf为了降低编码结果的大小,牺牲了数据的自解析性

2.为了弥补不可自解析,进行了很多硬编码,牺牲了数据的存储空间

3.因为硬编码,不需要反射了,因此加快了数据的编码、解码速度

综上:protobuf的思想抽象而言,就是通过牺牲存储空间,换来了编码大小的减少和编码速度的提高

而我们可以进一步发扬这种思路,牺牲编码速度和一些些的存储空间,进一步减小了编码的结果