深入了解 Google Protocol Buffer

移动应用客户端与服务器之间的通信协议,目前比较主流的有Facebook的Thrift,腾讯的JCE,以及Google的Protocol Buffer(以下简称protobuf),本文主要介绍protobuf基本概念,协议解析,以及在Android中的应用实践。


一、Protocol Buffer 基本概念

Protobuf是一种灵活高效的,用于序列化结构化数据的机制,类似于XML,但比XML更小,更快,更简单。Protobuf序列化为二进制数据,不依赖于平台和语言,同时具备很好的兼容性。Protobuf基本使用方式如下:

1 . 编写proto文件(applog.proto),定义数据结构

syntax = "proto3";

package com.sohu.proto.rawlog;
option java_multiple_files = true;

message LogTime{
    optional int32 submit = 1;   
    optional int32 create = 2;  
    optional int32 test = 16; 
}

其中,optional代表该字段是否为可选的,int32代表数据类型,不同的类型对应不同的编码方式(wire_type),submit为字段名,1表示field_number。

2 . 使用代码生成工具protoc,生成指定语言代码

  --cpp_out=OUT_DIR           Generate C++ header and source.
  --csharp_out=OUT_DIR        Generate C# source file.
  --java_out=OUT_DIR          Generate Java source file.
  --javanano_out=OUT_DIR      Generate Java Nano source file.
  --js_out=OUT_DIR            Generate JavaScript source.
  --objc_out=OUT_DIR          Generate Objective C header and source.
  --python_out=OUT_DIR        Generate Python source file.
  --ruby_out=OUT_DIR          Generate Ruby source file.
protoc -I=$SRC_DIR --java_out=$DST_DIR $SRC_DIR/applog.proto

3 . 调用Protobuf API,序列化及反序列化

ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); 
log.writeDelimitedTo(byteArrayOutputStream);
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(byteArrayOutputStream.toByteArray());  
Log log = Log.parseDelimitedFrom(byteArrayInputStream);

二、Protocol Buffer 编码机制

1. Base 128 Varints

所谓序列化,即是将结构化数据按一定的编码规范转换为指定格式的过程,protobuf使用的是Base 128 Varints的编码方式。Varints是一种使用可变字节序列化整型的方法,越小的整数占用越小的字节数,它的基本规则如下:

1 . 每个Byte的最高位(msb)是标志位,如果该位为1,表示该Byte后面还有其它Byte,如果该位为0,表示该Byte是最后一个Byte
2 . 每个Byte的低7位是用来存数值的位
3 . Varints方法用Litte-Endian(小端)字节序

一个多位整数按照其存储地址的最低或最高字节进行排列,如果最低有效位在最高有效位的前面,则称小端序;反之则称大端序。

假设变量x类型为int,位于地址0x1001处,它的十六进制为0x01234567,地址范围为0x1001~0x1004字节,其内部排列顺序依赖于机器的类型。

大端序:0x1001: 01,0x1002: 23,0x1003: 45,0x1004: 67

小端序:0x1001: 67,0x1002: 45,0x1003: 23,0x1004: 01

比如,300使用Varints序列化后的结果为1010 1100 0000 0010,其运算过程如下:

  • 300转化为二进制 : 1 0010 1100
  • Byte低7位存储数值 :000 0010 010 1100
  • Litte-Endian字节序 : 010 1100 000 0010
  • 添加MSB标志位 :1010 1100 0000 0010
2. 消息结构

protobuf每条消息都是由一系列的key-value键值对组成的,key和value分别采用不同的编码方式。

key的具体值为(field_number << 3) | wire_type,也就是说,Byte第一位作为标志位,最后三位用于存储wire type(编码数据类型),其他位用于存储field_number值。

可用的wire types如下 :

Type Meaning Used For
0 Varint int32, int64, uint32, uint64, sint32, sint64, bool, enum
1 64-bit fixed64, sfixed64, double
2 Length-delimited string, bytes, embedded messages, packed repeated fields
3 Start group groups (deprecated)
4 End group groups (deprecated)
5 32-bit fixed32, sfixed32, float

这六种wire type,其中Start group 和 End group 已经弃用,下面重点介绍Varint,64-bit,Length-delimited和32-bit。


Varint

varint是可变字节编码类型,例如:logTime.submit = 4; logTime.create = 5; logTime.test = 6,则序列化后的数据有7个字节,如下:

wire_type总共只有六种类型,因此用3bit足以表示,在一个Byte里,去掉mbs,以及3bit的wire_type,只剩下4bit来表示field_number,因此,一个Byte里,field_number只能表达0-15,如果超过15,则需要两个或多个Byte来表示。


64-bit / 32-bit

varint适用于表达较小的整数(0-127),因为mbs的存在,每个字节实际能用于表达数据的,只有7bit,当数字很大的时候,protobuf定义了64-bit和32-bit两种定长编码类型,比如2的28次方,两种方式表达如下:

varint编码还有个问题在于不利于表达负数,我们知道,计算机中的负数是以其补码(原码取反+1)形式存在的,比如-1,二进制表示为:11111111 11111111 11111111 11111111,如果我们采用varint表示,则需要5个字节,因为protbuf在编码格式上兼容int64,所以实际上表示-1需要用到10个字节。

为了解决这个问题,Protobuf提供了sint32和sint64两种数据类型。如果某个消息的某个字段出现负数值的可能性比较大,那么应该使用sint32或sint64。这两种数据类型在编码时,会先使用ZigZig编码将负数映射成正数,然后再使用Varint编码。

Zigzig 算法如下:

(n << 1) ^ (n >> 31)    // sint32
(n << 1) ^ (n >> 63)    // sint64
Signed Original Encoded As
0 0
-1 1
1 2
-2 3
2147483647 4294967294
-2147483648 4294967295

Zigzag 编码用无符号数来表示有符号数,正数和负数交错,无论正负都可以采用较少的 Byte 来表示。


Length-delimited

Length-delimited编码格式则会将数据的length也编码进最终数据,使用Length-delimited编码格式的数据类型包括string,bytes和自定义消息。

message Test2 {
  required string b = 2;
}

设置b的值为"testing",则编码之后为:

12 07 74 65 73 74 69 6e 67

repeated / packed

optional类型是序列化单个字段,而repeated类型是把每个字段依次进行序列化,key相同,value不同,但如果repeated的字段较多,每次都带上相同的key则会浪费空间,因此,protobuf提供了packed选项,当repeated字段设置了packed选项,则会使用Length-delimited格式来编码字段值。


三、Protocol Buffer 应用实践

protobuf主要用于客户端与服务器之间的数据传递,比如日志上报,长连接等,在实际使用过程中,主要遇到以下三个问题:

1. 65535 method limit

protobuf的标准实现会生成大量的方法数,其依赖库以及自动生成的Java代码,所包含的方法数在一万以上,因此很容易导致Android客户端达到65535的方法数限制。

为了解决这个问题,我们在生成java代码的时候,可以选择生成protobuf 3.0 精简版(Nano):

protoc -I=$SRC_DIR --javanano_out=$DST_DIR $SRC_DIR/applog.proto

精简版使用常量代替枚举,使用public代替private,省略了get/set方法,以及builder模式,很大程度上减少了自动生成的Java代码的方法数。当然,方法数的大头还是集中在protobuf的依赖库文件,当我们生成nano版本的java代码时,还需要找到其对应的库文件,很遗憾的是,Google官方并没有提供protobuf 3.0 nano 版本的依赖库,以下是jcenter查询到的文件:

protobuf-java-3.0.0-javadoc.jar
protobuf-java-3.0.0-javadoc.jar.asc
protobuf-java-3.0.0-sources.jar
protobuf-java-3.0.0-sources.jar.asc
protobuf-java-3.0.0.jar
protobuf-java-3.0.0.jar.asc
protobuf-java-3.0.0.pom
protobuf-java-3.0.0.pom.asc

为了解决依赖库的问题,查看protobuf源码,找到了javanano的源文件,然后编译成jar包,其依赖文件如下:


至此,解决了protobuf方法数问题,使其能够很好的用于Android客户端与服务器数据交换。


2. Message Delimited

protobuf转换成的二进制数据可以直接用于网络传输,当传输一条消息的时候,没有任何问题,但当一次传输多条消息的时候,该如何界定数据的边界呢?

为了解决多条数据传输的问题,我们在每条消息的头部加上数据长度,那么在解析的时候,就可以通过这个长度来解析对应的二进制数据。具体实现如下:

public static final int DEFAULT_BUFFER_SIZE = 4096;

static int computePreferredBufferSize(int dataLength) {
    if (dataLength > DEFAULT_BUFFER_SIZE) {
        return DEFAULT_BUFFER_SIZE;
    }
    return dataLength;
}

public static int computeUInt32SizeNoTag(final int value) {
    if ((value & (~0 <<  7)) == 0) {
        return 1;
    }
    if ((value & (~0 << 14)) == 0) {
        return 2;
    }
    if ((value & (~0 << 21)) == 0) {
        return 3;
    }
    if ((value & (~0 << 28)) == 0) {
        return 4;
    }
    return 5;
}

public static byte[] writeDelimitedTo(MessageNano log) throws IOException {
    final int serialized = log.getSerializedSize();
    final int bufferSize = computePreferredBufferSize(
                computeUInt32SizeNoTag(serialized) + serialized);
    byte[] data = new byte[bufferSize];
    com.google.protobuf.nano.CodedOutputByteBufferNano codedOutputByteBufferNano = CodedOutputByteBufferNano.newInstance(data);
    codedOutputByteBufferNano.writeRawVarint32(serialized);
    log.writeTo(codedOutputByteBufferNano);
    return data;
}

其核心思想在于,通过消息内容每个字段的tag,type以及value,计算出整个内容所占的字节数,然后再计算出为了表示内容长度所需要用到的字节数,这样就得到了整体需要的bufferSize,最后在写入数据的时候,先写入消息内容的长度,再写入消息本身的内容。

基于此,我们在每条消息的头部写入该消息长度,实现了一次传输多条消息。

3. 长连接protobuf编码解码器

目前比较主流的长连接框架有Netty和mina,为了解决数据传递过程中粘包和拆包的问题,Netty提供了很多Encoder和Decoder,其中也包括了对protobuf的支持,比如一个典型的通信管道如下:

ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup, workerGroup); 
            b.channel(NioServerSocketChannel.class);
            b.childHandler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel ch) throws Exception {
                    // Decoder
                    ch.pipeline().addLast(new ProtobufVarint32FrameDecoder());
                    ch.pipeline().addLast(new ProtobufDecoder(Log.newBuilder().getDefaultInstanceForType()));
                    // Encoder
                    ch.pipeline().addLast(new ProtobufVarint32LengthFieldPrepender());
                    ch.pipeline().addLast(new ProtobufEncoder());
                    // Register handler
                    ch.pipeline().addLast(new ServerHandler());
                }
            });

遗憾的是,Netty只提供了标准支持,对于Android客户端所使用的Nano版本并没有支持,因此在客户端,得自己实现一套基于Nano的编码解码器。为了解决这个问题,我写了以下几个类:

ProtobufNanoDecoder
ProtobufNanoEncoder
ProtobufNanoUtil
ProtobufNanoVarint32FrameDecoder
ProtobufNanoVarint32LengthFieldPrepender

所幸的是,深入理解了protobuf的编码机制,自己写编码解码器也并不困难,以ProtobufNanoVarint32LengthFieldPrepender为例:

@Sharable
public class ProtobufNanoVarint32LengthFieldPrepender extends MessageToByteEncoder<ByteBuf> {

    @Override
    protected void encode(ChannelHandlerContext ctx, ByteBuf msg, ByteBuf out) throws Exception {

        int bodyLen = msg.readableBytes();
        int bufferSize = ProtobufNanoUtil.computePreferredBufferSize(
                ProtobufNanoUtil.computeUInt32SizeNoTag(bodyLen) + bodyLen);
        out.ensureWritable(bufferSize);

        byte[] data = new byte[bufferSize];
        CodedOutputByteBufferNano codedOutputByteBufferNano = CodedOutputByteBufferNano.newInstance(data);
        codedOutputByteBufferNano.writeRawVarint32(bodyLen);
        codedOutputByteBufferNano.writeRawBytes(msg.array(), 0, bodyLen);

        out.writeBytes(data);
        out.writeBytes(msg, msg.readerIndex(), bodyLen);
    }
}

写到这里,关于protobuf也就闲聊完了,用一个比较霸气的词结尾:以上

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念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

推荐阅读更多精彩内容