学习笔记:Java Protocol Buffer的使用和编码原理学习

一、protocolbuffer简介:

  protocol buffer 是 google 的一种数据交换的格式,它独立于语言,独立于平台。google 提供了三种语言的实现:java、c++ 和 python,每一种实现都包含了相应语言的编译器以及库文件。可以把它用于分布式应用之间的数据通信或者异构环境下的数据交换,还有用于数据存储。

与json、xml的比较:

  在我个人看来,与其说PB是像json、xml那样的一种数据传输格式,还不如说它是一种规范,一种像Gson数据与javaBean对应规则那样的规范。通过protoc格式写的java文件,包含一个用来装数据的类和一个特殊的Builder类(用来创建消息类接口),传输类数据时,并不是通过传输protoc文件来传输,而是把装数据的类转为二进制字节流传输,PB的作用通过编写类格式就是提供一种高效的编码方式,让类里面装的数据传输得更高效,解析得更快。
  简单来说,就是通过protoc写一种规范好的类,用这种类装数据,在数据传输(化为二进制编码形式传输)的时候可以用更小的传输量传输更多的信息,提高传输效率。

优缺点:

  优点:传输数据量比xml、json等更小,解析更快,保密性好。而且PB提供了很好的向后兼容,即旧版本的程序可以正常处理新版本的数据,新版本的程序也能正常处理旧版本的数据。
  缺点:它传输的数据是编码之后的,可读性很差,不能和web浏览器等直接互动。

二、protocolbuffer在Windows环境下的安装和使用:

  1.我采用的是maven的方法安装,所以要首先下载安装maven,地址: http://maven.apache.org/download.cgi
  Maven 3.1 要求 JDK 1.5 或以上,没有jdk的要先装jdk,解压之后要配置系统环境变量,新加一个系统变量:变量名:M2_HOME;值是maven解压之后的路径:D:\tools\maven\apache-maven-3.1.0,然后在path的值后面加上%M2_HOME%\bin
配置完毕之后可以进cmd输入 mvn –v 是否成功安装,如果出现maven的版本信息则说明成功。

  2.protocol下载:地址: http://code.google.com/p/protobuf/downloads/list 。下载protobuf-2.6.1.zip 和 protoc-2.6.1-win32.zip两个包。解压完成之后将protoc-2.6.1-win32中的protoc.exe拷贝到c:\windows\system32目录下和解压后的protobuf-2.4.1\src目录中。

  3.生成jar包:用cmd操作进入解压后的protobuf-2.6.1\java 目录 执行mvn package命令编辑该包,系统将会在target目录中生成protobuf-java-2.4.1.jar文件(需要联网,时间比较长)。运行成功后会产生一个protobuf-java-2.6.1.jar包。(注意,dos不支持ntfs的磁盘格式,如果你电脑c盘之外的盘是ntfs格式的话就只能读c盘了)

  4.测试:用cmd进入解压后的protobuf-2.6.1\examples文件夹,里面有一个样本例子addressbook.proto文件,把protobuf-java-2.4.1.jar文件拷贝到这里,然后cmd执行protoc –java_out=. addressbook.proto命令(注意空格),如果生成com文件夹并在里面最终生成AddressBookProtos.java类则说明安装成功。

  5.使用:和测试一样,使用protoc生成java类一定要把它的jar包和你编写的proto文件放在一起,然后用CMD进入它们的所在目录,运行protoc –java_out=. 文件的名字.proto就可以生成对应的java文件。

三、protocolbuffer语法

  我用一个potoc文件生成一个java文件,用编辑器打开看到的第一行就是一个注释:

// Generated by the protocol buffer compiler.  DO NOT EDIT!

  所以说想用PB传输数据的话必须学他的语法,学会用protoc编写所需要的类。

1.定义一个简单的Protocol Buffer消息。

假如我们想建立一个类来存放用户信息,类属性里面有用户id,用户名name,用户email,那么:

package scut.newprotocol;
message UserInfo { 
  required int32 id = 1;
  required string name = 2;
  optional string email = 3;
}

解说:
1.package 是包名。
2.message 是用户定义的关键字,等同Java中的class。
3.UserInfo 是用户类的名字,等同Java中的类名
4.required前缀表示该字段为必要字段,即在序列化和反序列化之前该字段(类属性)必须已经被赋值。optional前缀表示该字段为可选字段,即在序列化和反序列化之前该字段(类属性)可以不被赋值。
5.int64和string分别表示长整型和字符串型的消息字段。
6.分别表示消息字段名,等同于Java中的类属性变量。
7.标签数字1和2则表示不同的字段在序列化后的二进制数据中的布局位置,注意不能在同一个message重复。另外,对于Protocol Buffer而言,标签值为1到15的字段在编码时可以得到优化,既标签值和类型信息仅占有一个byte,标签范围是16到2047的将占有两个bytes,而Protocol Buffer可以支持的字段数量则为2的29次方减一。所以,我们在设计消息结构时,可以尽可能考虑让repeated类型的字段标签位于1到15之间,这样便可以有效的节省编码后的字节数量。

2.定义一个含有嵌套消息的Protocol Buffer消息。

            enum UserStatus {
                OFFLINE = 0;
                ONLINE = 1;
            }
            message UserInfo {
             required int32 id = 1;
             required string name = 2;
             optional string email = 3;
             required UserStatus status = 4;
            }

解说:
1.enum是枚举类型定义的关键字,等同于Java中的enum。
2.UserStatus为枚举的名字。
3.和Java中的枚举不同的是,枚举值之间的分隔符是分号,而不是逗号。
4.OFFLINE/ONLINE为枚举值。
5.0和1表示枚举值所对应的实际整型值,和C/C++一样,可以为枚举值指定任意整型值,而无需总是从0开始定义。
6.枚举类型UserStatus是嵌套在UserInfo消息类型里面的,对应类的内部类。

3.限定符(required/optional/repeated)的基本规则。

  • required:一个格式良好的消息一定要含有1个这种字段。表示该值是必须要设置的
  • optional:每个消息中可以包含**0个或多个**optional类型的字段。
  • repeated表示的字段可以包含0个或多个数据。重复数据的顺序将会保存在protocol buffer中,将这个字段想象成一个可以自动设置size的数组就可以了。由于一些历史原因,数字类型的repeated字段性能有些不尽人意,但是,PB已经做了改进,但是需要再添加一点改动,即在声明后添加[packed=true]例如:
repeated int32 samples = 4 [packed=true];

4.类型对照表。

proto Type Notes Java Type
double . double
float . float
int32 使用可变长编码. 对于负数比较低效,如果负数较多,请使用sint32 int
int64 使用可变长编码. 对于负数比较低效,如果负数较多,请使用sint64 long
uint32 使用可变长编码 int
uint64 使用可变长编码 long
sint32 使用可变长编码. Signed int value. 编码负数比int32更高效 int
sint64 使用可变长编码. Signed int value. 编码负数比int64更高效 long
fixed32 恒定四个字节。如果数值几乎总是大于2的28次方,该类型比unit32更高效。 int
fixed64 恒定四个字节。如果数值几乎总是大于2的56次方,该类型比unit64更高效。 long
sfixed32 恒定四个字节 int
sfixed64 恒定八个字节 long
bool . boolean
string A string must always contain UTF-8 encoded or 7-bit ASCII text. String
bytes 包含任意数量顺序的字节 ByteString

5.Protocol Buffer消息升级原则

  在实际的开发中会存在这样一种应用场景,既消息格式因为某些需求的变化而不得不进行必要的升级,但是有些使用原有消息格式的应用程序暂时又不能被立刻升级,这便要求我们在升级消息格式时要遵守一定的规则,从而可以保证基于新老消息格式的新老程序同时运行。规则如下:
1. 不要修改已经存在字段的标签号。
2. 任何新添加的字段必须是optional和repeated限定符,否则无法保证新老程序在互相传递消息时的消息兼容性。
3. 在原有的消息中,不能移除已经存在的required字段,optional和repeated类型的字段可以被移除,但是他们之前使用的标签号必须被保留,不能被新的字段重用。
4. int32、uint32、int64、uint64和bool等类型之间是兼容的,sint32和sint64是兼容的,string和bytes是兼容的,fixed32和sfixed32,以及fixed64和sfixed64之间是兼容的,这意味着如果想修改原有字段的类型时,为了保证兼容性,只能将其修改为与其原有类型兼容的类型,否则就将打破新老消息格式的兼容性。
5. optional和repeated限定符也是相互兼容的。

6.Options

Protocol Buffer允许我们在.proto文件中定义一些常用的选项,这样可以指示Protocol Buffer编译器帮助我们生成更为匹配的目标语言代码。Protocol Buffer内置的选项被分为以下三个级别:
1. 文件级别,将影响当前文件中定义的所有消息和枚举。
2. 消息级别,仅影响某个消息及其包含的所有字段。
3. 字段级别,仅仅响应与其相关的字段。

  • o**ption java_package = “com.companyname.projectname”;**
    java_package是文件级别的选项,通过指定该选项可以让生成Java代码的包名为该选项值,如上例中的Java代码包名为com.companyname.projectname。与此同时,生成的Java文件也将会自动存放到指定输出目录下的com/companyname/projectname子目录中。如果没有指定该选项,Java的包名则为package关键字指定的名称。
  • option java_outer_classname = “LYPhoneMessage”;
    java_outer_classname是文件级别的选项,主要功能是显示的指定生成Java代码的外部类名称。
  • option optimize_for = LITE_RUNTIME;
    optimize_for是文件级别的选项,Protocol Buffer定义三种优化级别SPEED/CODE_SIZE/LITE_RUNTIME。缺省情况下是SPEED。

SPEED: 表示生成的代码运行效率高,但是由此生成的代码编译后会占用更多的空间。

CODE_SIZE: 和SPEED恰恰相反,代码运行效率较低,但是由此生成的代码编译后会占用更少的空间,通常用于资源有限的平台,如Mobile。

LITE_RUNTIME: 生成的代码执行效率高,同时生成代码编译后的所占用的空间也是非常少。这是以牺牲Protocol Buffer提供的反射功能为代价的。因此我们在C++中链接Protocol Buffer库时仅需链接libprotobuf-lite,而非libprotobuf。在Java中仅需包含protobuf-java-2.4.1-lite.jar,而非protobuf-java-2.4.1.jar。

四、代码测试

使用PB写一个简单的类

package scut.newprotocol;
message UserInfo { 
  required int32 id = 1;
  required string name = 2;
  optional string email = 3;
}

用protoc编译器编译

protoc --java_out=. user.proto

输出的user.java放到工程里

package scut.newprotocol;

import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        //建立一个新的类
        User.UserInfo.Builder builder = User.UserInfo.newBuilder();
        //设置数据
        builder.setId(1);
        builder.setName("Yan");
        builder.setEmail("yanzhiakai_yjk@qq,com");
        User.UserInfo YJK = builder.build();


        // 将数据写到输出流
        ByteArrayOutputStream output = new ByteArrayOutputStream();
        try {
            YJK.writeTo(output);
        } catch (IOException e) {
            e.printStackTrace();
        }
        byte[] byteArray = output.toByteArray();

        // 接收到流并读取
        ByteArrayInputStream input = new ByteArrayInputStream(byteArray);

        try {
            User.UserInfo YJKreceive = User.UserInfo.parseFrom(input);
            System.out.println(YJKreceive.getId() + "," + YJKreceive.getName() + "," + YJKreceive.getEmail());
        } catch (IOException e) {
            e.printStackTrace();
        }


    }

}

输出结果:

System.out: 1,Yan,yanzhiakai_yjk@qq,com

五、Protocol Buffer 编码方式

  前面说过,Protocol Buffer是通过把类数据编码为二进制数据来传输的,传输数效率高,解析快,保密性好。这些优点是怎么实现的呢?下面我就学习了一下它的编码原理。

1.数字编码格式-Varint

  Varint 是一种紧凑的表示数字的方法。它用一个或多个字节来表示一个数字,值越小的数字使用越少的字节数。这能减少用来表示数字的字节数。Varint 中的每个 byte 的最高位有特殊的含义,如果该位为 1,表示后续的 byte 也是该数字的一部分,如果该位为 0,则结束。其他的 7 个位都用来表示数字。因此小于 128 的数字都可以用一个 byte 表示。大于 128 的数字,会用两个字节。
  对于很小的(2的7次方-1以下的) int32 类型的数字,Varint可以用 1 个 byte 来表示,但是,大的数字(2的28次方到2的32次方-1)则需要 5 个 byte 来表示。从统计的角度来说,一般不会所有的消息中的数字都是大数,因此大多数情况下,采用 Varint 后,可以用更少的字节数来表示数字信息。
  下图演示了 Google Protocol Buffer 如何解析两个 bytes。注意到最终计算前将两个 byte 的位置相互交换过一次,这是因为 Google Protocol Buffer 字节采用 little-endian 的方式排序(最低位字节存储在最低的内存地址处,后面字节依次存在后面的地址处,即先放高位,再放低位)。

2.Message 格式

  类里面装的消息经过序列化后会成为一个二进制数据流,该流中的数据为一系列的 Key-Value 对。


  采用这种结构无需使用分隔符来分割不同的 Field。对于可选的 Field,如果消息中不存在该 field,那么在最终的 Message Buffer 中就没有该 field,这些特性都有助于节约消息本身的大小。

  二进制格式的message使用数字标签作为key,key占一个字节,在Message编码的首位,然后后面的就是Value。key 用来标识具体的 field和Value的数据类型,在解包的时候,Protocol Buffer 根据 Key 就可以知道相应的 Value 应该对应于消息中的哪一个 field和数据类型。
  此处的数字标签Key,并非单纯的数字标签,而是数字标签与传输数据类型的组合,根据传输类型能够确定出值的数据类型。

key = (field_number << 3) | wire_type //field_number左移3位,然后后面的3位放入wire_type

Key 由两部分组成。第一部分是 field_number,表示所在field的区域。第二部分为 wire_type,表示 Value 的传输类型。也就是说,key中的后三位,是值的传输类型。

Wire Type 可能的类型如下表

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

.proto文件定义一条简单的message:

message Test1 {
  required int32 a = 1;
}

使用该.proto生成相应类并写入一条message到一个文件中,写入test.txt文件:

public static void main(String[] args) throws IOException {
    Simple simple = Simple.newBuilder().setId(150).build();
    FileOutputStream output = new FileOutputStream("abc.txt");
    simple.writeTo(output);
    output.close();
}

用二进制格式查看,只占用了三个字节:08 96 01
08 为key部分:0000 1000
由上述的key表达式规则可知
field_number = 0000 0001,wire_type = 0
则value类型为Varint,按上面规则解码:

96 01 = 1001 0110  0000 0001000 0001  ++  001 0110 (drop the msb and reverse the groups of 7 bits)
       → 100101102 + 4 + 16 + 128 = 150

3.其他value类型

3.1有符号整数

  在 Type 0 所能表示的数据类型中有 int32 和 sint32 这两个非常类似的数据类型。Google Protocol Buffer 区别它们的主要意图也是为了减少 encoding 后的字节数。这部分,主要是针对负数来设计的。

  在计 算机内,一个负数一般会用补码表示,在Varint看来是一个很大的整数,而且计算机定义负数的符号位为数字的最高位与Varint所需要的最高位重合了。为此 Google Protocol Buffer 定义了 sint32 这种类型,采用 zigzag 编码。将所有整数映射成无符号整数,然后再采用varint编码方式编码,这样,绝对值小的整数,编码后也会有一个较小的varint编码值。

Zigzag(n) = (n << 1) ^ (n >> 31), n为sint32时
Zigzag(n) = (n << 1) ^ (n >> 63), n为sint64时
//对应输出为:
 0          ->     0
-1          ->     1
 1          ->     2
-2          ->     3
 2          ->     4
-3          ->     5->       2147483647 ->      4294967294
-2147483648 ->   4294967295
//即编码偶数就除以二是原来的数,奇数就加一除以负二是原来的数

3.2 Non-varint 数字

  Non-varint数字比较简单,double 、fixed64 的wire_type为 1,在解析式告诉解析器,该类型的数据需要一个64位大小的数据块即可。同理,float和fixed32的wire_type为5,给其32位数据块即可。两种情况下,都是高位在后,低位在前。

3.3 String类型

  wire_type为2的数据,是一种指定长度的编码方式:key+length+content,key的编码方式是统一的和前面一样,length采用varint编码方式,content就是由length指定长度的Bytes。
例如:

message Test2 {

required string b = 2;

}

  设置该值为”testing”,二进制格式查看输出为:12 07 74 65 73 74 69 6e 67
**key为 12 = 0001 0010 ->field_number= 2 ,wire type = 2,**length为07,代表后面byte[]长度为7,后面的7个就是”testing”的utf-8代码。

3.4嵌套message

定义如下嵌套消息:

message Test3 {
  required Test1 c = 3;
}

  同前面一样,设置字段为整数150,编码后的字节为:1a 03 08 96 01
  key为1a = 0001 1010->field_number= 3 ,wire type = 2,后三个字节跟第一个例子中的一模一样(08 96 01),这就代表了Message Test1,他们前边有一个长度限制03,可见嵌套消息跟string是一摸一样的,其wire type 也为2。

3.5总结

  由前表可以看出wire_type字段3和4的都被弃用,0、1和5都是用key+Varint编码,2则是用key+length+content编码,通过这样的编码方式来减少类数据的二进制编码的冗余,达到提高传输效率的效果。

你可能感兴趣的:(Android笔记)