Android Protobuf应用及原理

前言

之前一直忙于移动端日志SDK Trojan的开源工作,已十分稳定地运行在饿了么团队App中,集成了日志加密和解密功能。哎呀,允许我卖个狗皮膏药,不用不知道,用了就知道,从此爱不释手,Trojan其实是一个很好用的膏药,甚至是一剂不可或缺的良药,能帮助我们跟踪在线用户,解决疑难杂症。

闲话少说,进入今天的正题,Protobuf,可能大家对此很陌生,还未接触过,不过不要紧,看完这篇博客,相信你一定有所感触。起初为了节约流量,在我们千里眼后端接口率先使用Protobuf替代Json,支持Java、C++、Python等语言,就尝到甜头了,简单好用还节省内存流量,基于这个特性,英雄岂无用户之地。后面,我们推广到Sqlite、SharedPerference等领域,利用Protobuf进行改造,替换原有的Json或者XML存储方式!

Protobuf

说了这么久,Protobuf到底是什么呢,借花献佛,引用Protobuf官网的解释:

Protocol buffers are a flexible, efficient, automated mechanism for serializing structured data – think XML, but smaller, faster, and simpler. You define how you want your data to be structured once, then you can use special generated source code to easily write and read your structured data to and from a variety of data streams and using a variety of languages. You can even update your data structure without breaking deployed programs that are compiled against the "old" format.

本人英语水平有限,就在此简单翻译一下,大意是:

Protobuf是一种灵活高效可序列化的数据协议,相于XML,具有更快、更简单、更轻量级等特性。支持多种语言,只需定义好数据结构,利用Protobuf框架生成源代码,就可很轻松地实现数据结构的序列化和反序列化。一旦需求有变,可以更新数据结构,而不会影响已部署程序。

从上面我们可以总结出,Protobuf具有以下优点:

  1. 代码生成机制
syntax = "proto3";
package me.ele.demo.protobuf;
option java_outer_classname = "LoginInfo";
message Login {
    string account = 1;
    string password = 2;
}

这是一个用户登录信息的数据结构,通过Protobuf提供的Gradle Plugin就可以在me.ele.demo.protobuf目录下编译自动生成LoginInfo类,并有序列化和反序列化等Api。

  1. 高效性

用千里眼项目中跑出来的数据进行对比,更具说服力。

序列化时间效率对比:

数据格式 1000条数据 5000条数据
Protobuf 195ms 647ms
Json 515ms 2293ms

序列化空间效率对比:

数据格式 5000条数据
Protobuf 22MB
Json 29MB

从上面的数据可以看出来,Protobuf序列化时,和Json对比,不管在时间和空间上都是更加高效。由于篇幅的原因就不展示反序列化的数据对比了。

  1. 支持向后兼容和向前兼容

当客户端和服务器同事使用一块协议的时候, 当客户端在协议中增加一个字节,并不会影响客户端的使用

  1. 支持多种编程语言

在Google官方发布的源代码中包含了c++、java、Python三种语言

至于缺点,Protobuf采用了二进制格式进行编码,这直接导致了可读性差;缺乏自描述,Protobuf是二进制格式的协议内容,要是不配合proto结构体根本看不出来什么来。

接入

在项目的根gradle配置如下

dependencies {
        classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.0'
}

在gradle中配置如下:

apply plugin: 'com.google.protobuf'

android {
    sourceSets {
        main {
            // 定义proto文件目录
            proto {
                srcDir 'src/main/proto'
                include '**/*.proto'
            }
        }
    }
}

dependencies {
    // 定义protobuf依赖,使用精简版
    compile "com.google.protobuf:protobuf-lite:3.0.0"
    compile ('com.squareup.retrofit2:converter-protobuf:2.2.0') {
        exclude group: 'com.google.protobuf', module: 'protobuf-java'
    }
}

protobuf {
    protoc {
        artifact = 'com.google.protobuf:protoc:3.0.0'
    }
    plugins {
        javalite {
            artifact = 'com.google.protobuf:protoc-gen-javalite:3.0.0'
        }
    }
    generateProtoTasks {
        all().each { task ->
            task.plugins {
                javalite {}
            }
        }
    }
}

apply plugin: 'com.google.protobuf'是Protobuf的Gradle插件,帮助我们在编译时通过语义分析自动生成源码,提供数据结构的初始化、序列化以及反序列等接口。

compile "com.google.protobuf:protobuf-lite:3.0.0"是Protobuf支持库的精简版本,在原有的基础上,用public替换set、get方法,减少Protobuf生成代码的方法数目。

定义数据结构

还是以上面的例子来展开:

syntax = "proto3";
package me.ele.demo.protobuf;
option java_outer_classname = "LoginInfo";
message Login {
    string account = 1;
    string password = 2;
}

在这里定义了一个LoginInfo,我们只是简单的定义了accountpassword两个字段。这里注意,在上例中, syntax = "proto3";声明proto协议版本,proto2和proto3在定义数据结构时有些差别,option java_outer_classname = "LoginInfo";定义了Protobuf自动生成类的类名,package me.ele.demo.protobuf;定义了Protobuf自动生成类的包名。

通过Android Studio clean,Protobuf插件会帮助我们自动生成LoginInfo类,类结构如下:

LoginInfo类结构

Protobuf帮我们自动生成LoginOrBuilder接口,主要声明各个字段的set和get方法;并且生成Login类,核心逻辑这个类中,通过writeTo(CodedOutputStream)接口序列化到CodedOutputStream,通过ParseFrom(InputStream)接口从InputStream中反序列化。类图如下:

Login类图

原理分析

上文提到,Protobuf不管在时间和空间上更高效,是怎么做到的呢?

消息经过Protobuf序列化后会成为一个二进制数据流,通过Key-Value组成方式写入到二进制数据流,如图所示:

二进制数据流

Key 定义如下:

(field_number << 3) | wire_type

以上面的例子来说,如字段account定义:

string account = 1;

在序列化时,并不会把字段account写进二进制流中,而是把field_number=1通过上述Key的定义计算后写进二进制流中,这就是Protobuf可读性差的原因,也是其高效的主要原因。

数据类型

Protobuf数据类型

在Java种对不同类型的选择,其他的类型区别很明显,主要在与int32、uint32、sint32、fixed32中以及对应的64位版本的选择,因为在Java中这些类型都用int(long)来表达,但是protobuf内部使用ZigZag编码方式来处理多余的符号问题,但是在编译生成的代码中并没有验证逻辑,比如uint的字段不能传入负数之类的。而从编码效率上,对fixed32类型,如果字段值大于2^28,它的编码效率比int32更加有效;而在负数编码上sint32的效率比int32要高;uint32则用于字段值永远是正整数的情况。

编码原理

在实现上,Protobuf使用CodedOutputStream实现序列化、CodedInputStream实现反序列化,他们包含write/read基本类型和Message类型的方法,write方法中同时包含fieldNumbervalue参数,在写入时先写入由fieldNumberWireType组成的tag值(添加这个WireType类型信息是为了在对无法识别的字段编码时可以通过这个类型信息判断使用那种方式解析这个未知字段,所以这几种类型值即可),这个tag值是一个可变长int类型,所谓的可变长类型就是一个字节的最高位(msb,most significant bit)用1表示后一个字节属于当前字段,而最高位0表示当前字段编码结束。在写入tag值后,再写入字段值value,对不同的字段类型采用不同的编码方式:

  1. 对int32/int64类型,如果值大于等于0,直接采用可变长编码,否则,采用64位的可变长编码,因而其编码结果永远是10个字节,所有说int32/int64类型在编码负数效率很低。

  2. 对uint32/uint64类型,也采用变长编码,不对负数做验证。

  3. 对sint32/sint64类型,首先对该值做ZigZag编码,以保留,然后将编码后的值采用变长编码。所谓ZigZag编码即将负数转换成正数,而所有正数都乘2,如0编码成0,-1编码成1,1编码成2,-2编码成3,以此类推,因而它对负数的编码依然保持比较高的效率。

  4. 对fixed32/sfixed32/fixed64/sfixed64类型,直接将该值以小端模式的固定长度编码。

  5. 对double类型,先将double转换成long类型,然后以8个字节固定长度小端模式写入。

  6. 对float类型,先将float类型转换成int类型,然后以4个字节固定长度小端模式写入。

  7. 对bool类型,写0或1的一个字节。

  8. 对String类型,使用UTF-8编码获取字节数组,然后先用变长编码写入字节数组长度,然后写入所有的字节数组。

  9. 对bytes类型(ByteString),先用变长编码写入长度,然后写入整个字节数组。

  10. 对枚举类型(类型值WIRETYPE_VARINT),用int32编码方式写入定义枚举项时给定的值(因而在给枚举类型项赋值时不推荐使用负数,因为int32编码方式对负数编码效率太低)。

  11. 对内嵌Message类型(类型值WIRETYPE_LENGTH_DELIMITED),先写入整个Message序列化后字节长度,然后写入整个Message

ZigZag编码实现:(n << 1) ^ (n >> 31) / (n << 1) ^ (n >> 63);CodedOutputStream中还存在一些用于计算某个字段可能占用的字节数的compute静态方法,这里不再详述。

在Protobuf的序列化中,所有的类型最终都会转换成一个可变长int/long类型、固定长度的int/long类型、byte类型以及byte数组。对byte类型的写只是简单的对内部buffer的赋值:

public void writeRawByte(final byte value) throws IOException {
  if (position == limit) {
    refreshBuffer();
  }
  buffer[position++] = value;
}

对32位可变长整形实现为:

public void writeRawVarint32(int value) throws IOException {
  while (true) {
    if ((value & ~0x7F) == 0) {
      writeRawByte(value);
      return;
    } else {
      writeRawByte((value & 0x7F) | 0x80);
      value >>>= 7;
    }
  }
}

对于定长,Protobuf采用小端模式,如对32位定长整形的实现:

public void writeRawLittleEndian32(final int value) throws IOException {
    writeRawByte((value      ) & 0xFF);
    writeRawByte((value >>  8) & 0xFF);
    writeRawByte((value >> 16) & 0xFF);
    writeRawByte((value >> 24) & 0xFF);
}

对byte数组,可以简单理解为依次调用writeRawByte()方法,只是CodedOutputStream在实现时做了部分性能优化。这里不详细介绍。对CodedInputStream则是根据CodedOutputStream的编码方式进行解码,因而也不详述,其中关于ZigZag的解码:

(n >>> 1) ^ -(n & 1)

repeated字段编码

对于repeated字段,一般有两种编码方式:

  1. 每个项都先写入tag,然后写入具体数据。

  2. 先写入tag,后count,再写入count个项,每个项包含length|data数据。

从编码效率的角度来看,个人感觉第二中情况更加有效,然而不知道处于什么原因考虑,Protobuf采用了第一种方式来编码,个人能想到的一个理由是第一种情况下,每个消息项都是相对独立的,因而在传输过程中接收端每接收到一个消息项就可以进行解析,而不需要等待整个repeated字段的消息包。对于基本类型,Protobuf也采用了第一种编码方式,后来发现这种编码方式效率太低,因而可以添加[packed = true]的描述将其转换成第三种编码方式(第二种方式的变种,对基本数据类型,比第二种方式更加有效)

  1. 先写入tag,后写入字段的总字节数,再写入每个项数据。

目前Protobuf只支持基本类型的packed修饰,因而如果将packed添加到非repeated字段或非基本类型的repeated字段,编译器在编译proto文件时会报错。

结束

以上是Protobuf的详细介绍,基于源码的分析这里并未展开,请大家多多指教!最后,非常感谢大家对本篇博客的关注!

参考文献

https://developers.google.com/protocol-buffers/docs/overview
http://www.blogjava.net/DLevin/archive/2015/04/01/424011.html

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

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,599评论 18 139
  • 国家电网公司企业标准(Q/GDW)- 面向对象的用电信息数据交换协议 - 报批稿:20170802 前言: 排版 ...
    庭说阅读 10,868评论 6 13
  • Spark SQL, DataFrames and Datasets Guide Overview SQL Dat...
    草里有只羊阅读 18,291评论 0 85
  • 早晨 我跟妈妈要了一个拥抱 窗外 麻雀抢食你撒下的小米 今天 我要为你做一顿可口的饭菜 或许 明天我会快乐的与你说...
    王一达阅读 310评论 0 3
  • 集体备课结束时,我们组的快乐生活金牌策划师洪老师提议去他家DIY聚餐,久在“樊笼”里的我们一呼即应,一边收拾书书本...
    玲珑简书阅读 400评论 1 1