前言

之前一直忙于移动端日志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类,类结构如下:

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

原理分析

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

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

Key 定义如下:

(field_number << 3) | wire_type
复制代码

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

string account = 1;
复制代码

在序列化时,并不会把字段account写进二进制流中,而是把field_number=1通过上述Key的定义计算后写进二进制流中,这就是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

作者:饿了么物流技术团队
链接:https://juejin.im/post/5ab85c92518825556a7265e3
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

 

Android Protobuf应用及原理的更多相关文章

  1. (转)Android 系统 root 破解原理分析

    现在Android系统的root破解基本上成为大家的必备技能!网上也有很多中一键破解的软件,使root破解越来越容易.但是你思考过root破解的 原理吗?root破解的本质是什么呢?难道是利用了Lin ...

  2. Android系统Recovery工作原理

    Android系统Recovery工作原理之使用update.zip升级过程分析(一)---update.zip包的制作 http://blog.csdn.net/mu0206mu/article/d ...

  3. Android启动篇 — init原理(二)

    ========================================================          ================================== ...

  4. Android启动篇 — init原理(一)

    ========================================================          ================================== ...

  5. Android热修复技术原理详解(最新最全版本)

    本文框架 什么是热修复? 热修复框架分类 技术原理及特点 Tinker框架解析 各框架对比图 总结   通过阅读本文,你会对热修复技术有更深的认知,本文会列出各类框架的优缺点以及技术原理,文章末尾简单 ...

  6. Android FoldingLayout 折叠布局 原理及实现(二)

    转载请标明出处:http://blog.csdn.net/lmj623565791/article/details/44283093,本文出自:[张鸿洋的博客] 1.概述 在上一篇Android Fo ...

  7. Android 长截屏原理

    https://android-notes.github.io/2016/12/03/android%E9%95%BF%E6%88%AA%E5%B1%8F%E5%8E%9F%E7%90%86/   a ...

  8. Android 系统 root 破解原理分析 (续)

    上文<Android系统root破解原理分析>介绍了Android系统root破解之后,应用程序获得root权限的原理.有一些网友提出对于root破解过程比较感兴趣,也提出了疑问.本文将会 ...

  9. Android自复制传播APP原理学习(翻译)

     Android自复制传播APP原理学习(翻译) 1 背景介绍 论文链接:http://arxiv.org/abs/1511.00444 项目地址:https://github.com/Tribler ...

随机推荐

  1. 阿里云修改CentOS Linux服务器的主机名

    阿里云主机的默认主机名是为AY开头的随机名称,如何修改为易于区分的友好名称呢?请看下面的操作步骤: 1. vi /etc/hosts i键,修改主机名,esc键,:wq键保存退出 2. vi /etc ...

  2. Expression表达式树 案例

    1,Expression.Invoke //运用委托或Lambda表达式 System.Linq.Expressions.Expression<Func<; System.Linq.Exp ...

  3. iOS 11开发教程(十)iOS11无线连接手机真机测试

    iOS 11开发教程(十)iOS11无线连接手机真机测试 在Xcode 9.0中,已经可以通过无线连接手机进行真机测试了.具体的操作步骤如下: (1)首先需要使用数据线将手机连接到苹果电脑上. (2) ...

  4. [ 原创 ]学习笔记-做一个Android音乐播放器是遇到的一些困难

    最近再做一个安卓的音乐播放器,是实验室里学长派的任务,我是在eclipse上进行开发的,由于没有android的基础,所以做起来困难重重. 首先是布局上的困难 1.layout里的控件属性不熟悉 2. ...

  5. XShell通过中转服务器直接连接目标服务器

    最近由于公司生产环境的变化,使得我们不能使用自己的机器连接到生产环境去,而是要通过跳板机中转才可以连接.于是今天尝试使用 XShell 通过跳板机直接转接到生产环境. 一.使用代理方式 首先填写连接信 ...

  6. 【Vijos 1998】【SDOI 2016】平凡的骰子

    https://vijos.org/p/1998 三维计算几何. 需要混合积求四面体体积: 四面体剖分后合并带权重心求总重心: 四面体重心的横纵坐标是四个顶点的横纵坐标的平均数: 三维差积求平面的法向 ...

  7. codevs 2292 图灵机游戏

    2292 图灵机游戏  时间限制: 1 s  空间限制: 64000 KB  题目等级 : 黄金 Gold   题目描述 Description [Shadow 1]第二题 Shadow最近知道了图灵 ...

  8. [luogu4459][BJOI2018]双人猜数游戏(DP)

    https://zhaotiensn.blog.luogu.org/solution-p4459 从上面的题解中可以找到样例解释,并了解两个人的思维方式. A和B能从“不知道”到“知道”的唯一情况,就 ...

  9. 【平面图最小割】BZOJ2007-[NOI2010]海拔

    [题目大意] 城市被东西向和南北向的主干道划分为n×n个区域,包括(n+1)×(n+1)个交叉路口和2n×(n+1)条双向道路.现得到了每天每条道路两个方向的人流量.每一个交叉路口都有海拔,每向上爬h ...

  10. kali下利用weeman进行网页钓鱼

    工具下载链接:https://files.cnblogs.com/files/wh4am1/weeman-master.zip 利用wget命令下载zip压缩包 利用unzip命令解压 接着直接cd进 ...