上面的java,hessian和fastjson的序列化,说到底还是java内部之间的转换,也就是说序列化和反序列化都必须在java环境中,但是下面要说的apache thrift 和google protobuf和hadoop avro 则是不同语言之间的数据传递。
闲言少叙,具体看下:首先看下他们的哲学理念,也就是说他们为什么产生,是解决什么问题的。咱们公司的编程哲学是统一用java(部分算法以用c),这样可以做到最大程度的复用,但是在google和facebook ,他们的编程哲学是什么方便用什么,比如在后台用java方便,在前台用python方便,哪就后台用java,前台用python,又或者在某一个应用上用c比较好,那就用c,这样就会有一个问题,就是这些系统间的通信,也就是必须要解决这样一个场景,一个系统序列化的内容,其他系统必须能够反序列化出来,这就产生了google protobuf和apache thrift 顺便说一句,apache thrift 是facebook捐献出来的。他们为了解决这样的一个问题,定义了一个与语言无关的pojo描述文件,然后序列化的时候,根据描述文件,产生一个统一的文件,也就是这个最终的文件与语言无关,在反序列化的时候,根据这个描述文件和序列化文件,能够反序列化出来对应的pojo,hadoop avro和他们大致一样,但是一点小小的区别是avro 针对多条同一类型的数据,又做了一些优化,简单说,protobuf和thrift用来解决单个pojo的通信,而avro是用来解决pojo的list的通信
--------------------------------三种方式的比较
Protocol Buffers 是一种轻便高效的结构化数据存储格式,可以用于结构化数据串行化,或者说序列化。它很适合做数据存储或 RPC 数据交换格式。可用于通讯协议、数据存储等领域的语言无关、平台无关、可扩展的序列化结构数据格式。目前提供了 C++、Java、Python 三种语言的 API。
Apache Thrift 是 Facebook 实现的一种高效的、支持多种编程语言的远程服务调用的框架。
Avro是一个数据序列化的系统,它可以提供:1 丰富的数据结构类型2 快速可压缩的二进制数据形式3 存储持久数据的文件容器 4 远程过程调用RPC
5 简单的动态语言结合功能,Avro和动态语言结合后,读写数据文件和使用RPC协议都不需要生成代码,而代码生成作为一种可选的优化只值得在静态类型语言中实现
可以看到,Buffers和avro是一种序列化系统,而thrift是一个rpc框架,这个thrift中的序列化思路和Buffers差不多
首先来看下 Protocol Buffers,部署下载安装 http://code.google.com/p/protobuf/
从HelloWorld开始,在Protobuf 中,首先需要定义一个 proto 文件,定义我们程序中需要处理的结构化数据,在 protobuf 的术语中,结构化数据被称为 Message。proto 文件非常类似 java 或者 C 语言的数据定义。
package lm;
message helloworld
{
required int32 id = 1; // ID
required string str = 2; // str
optional int32 opt = 3; //optional field
}
在上例中,package 名字叫做 lm,定义了一个消息 helloworld,该消息有三个成员,类型为 int32 的 id,另一个为类型为 string 的成员 str。opt 是一个可选的成员,即消息中可以不包含该成员。
Required,optional是message定义的关键字,详细的可以参看https://developers.google.com/protocol-buffers/docs/javatutorial?hl=zh-CN
用我们之前下载的protoc.exe来生成相应的代
protoc -I=. --java_out=. HelloWorld.proto
执行完成后,就会在当前目录下生成一个包含HelloWorld.java的lm文件夹,可以看到文件很大,达到了18k
测试代码如下:
helloworld.Builder hello = helloworld.newBuilder();
hello.setId(1); hello.setStr("2"); hello.setOpt(123);
hello.build();
FileOutputStream output = new FileOutputStream("d:\\124.txt");
hello.build().writeTo(output);
将文件用16进制打开如下
0X8 0X1 0X12 0X1 0X32 0X18 0X7b
在分析这个代码之前,首先介绍几个概念,在http://www.iteye.com/topic/1113183 这篇文章中说,反序列化比较快的一个原因,对属性进行了排序,然后在反序列化的时候,有些token不再进行解析,在protobuf中,做的更加彻底,连toke都不写进去了,直接用数字替代,比如1,2,3等,代表第一个属性,第二个属性,这样文件大小就会更加小了。
1、消息流中的数据为一系列的 Key-Value 对,如下图所示
采用这种 Key-value 结构无需使用分隔符来分割不同的 Field。对于可选的 Field,如果消息中不存在该 field,那么在最终的 Message Buffer 中就没有该 field,这些特性都有助于节约消息本身的大小。在本例中id为第一个属性,str为第二个,opt为第三个,
Key 用来标识具体的 field,在解包的时候,Protocol Buffer 根据 Key 就可以知道相应的 Value 应该对应于消息中的哪一个 field。Key 的定义如下:
(field_number << 3) | wire_type
可以看到 Key 由两部分组成。第一部分是 field_number,比如消息 lm.helloworld 中 field id 的 field_number为 1。第二部分为 wire_type。表示 Value 的传输类型。
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 |
因此 id对应的key是1000 为 16进制中的8,str对应的key是10010 16机制中的18 ,而opt对应的key是110000 16进制中的24,当然在写字符串的时候,会有字符串长度
因此0X8 0X1 0X12 0X1 0X32 0X18 0X7b的分析如下:
0X8 0X1 后面的1是值,前面的8就是id的key
0X12 0X1 0X32 前面的12是key代表18,然后1代表字符串长度,0X32就是ascii中的字符串2
0X18 0X7b 18为key 7b就是123
2、 Varint :一种紧凑的表示数字的方法。它用一个或多个字节来表示一个数字,值越小的数字使用越少的字节数。这能减少用来表示数字的字节数。
比如对于 int32 类型的数字,一般需要 4 个 byte 来表示。但是采用 Varint,对于很小的 int32 类型的数字,则可以用 1 个 byte 来表示。当然凡事都有好的也有不好的一面,采用 Varint 表示法,大的数字则需要 5 个 byte 来表示。从统计的角度来说,一般不会所有的消息中的数字都是大数,因此大多数情况下,采用 Varint 后,可以用更少的字节数来表示数字信息。下面就详细介绍一下 Varint。
Varint 中的每个 byte 的最高位 bit 有特殊的含义,如果该位为 1,表示后续的 byte 也是该数字的一部分,如果该位为 0,则结束。其他的 7 个 bit 都用来表示数字。因此小于 128 的数字都可以用一个 byte 表示。大于 128 的数字,比如 300,会用两个字节来表示:1010 1100 0000 0010
下图演示了 Google Protocol Buffer 如何解析两个 bytes。注意到最终计算前将两个 byte 的位置相互交换过一次,这是因为 Google Protocol Buffer 字节序采用 little-endian 的方式。
由于上面我们设置的都是比较简单的1,或者123 因此只要用1个字节就可以完成,不需要正常的int中的4个字节,减小了数据量
你可能注意到Wire Type在 Type 0 所能表示的数据类型中有 int32 和 sint32 这两个非常类似的数据类型。Google Protocol Buffer 区别它们的主要意图也是为了减少 encoding 后的字节数。在计算机内,一个负数一般会被表示为一个很大的整数,因为计算机定义负数的符号位为数字的最高位。如果采用 Varint 表示一个负数,那么一定需要 5 个 byte。为此 Google Protocol Buffer 定义了 sint32 这种类型,采用 zigzag 编码。
Zigzag 编码用无符号数来表示有符号数字,正数和负数交错,这就是 zigzag 这个词的含义了。
使用 zigzag 编码,绝对值小的数字,无论正负都可以采用较少的 byte 来表示,充分利用了 Varint 这种技术。
其他的数据类型,比如字符串等则采用类似数据库中的 varchar 的表示方法,即用一个 varint 表示长度,然后将其余部分紧跟在这个长度部分之后即可。
其实上面的1和2就是protobuf中的Encoding 部分,你会看到这种方式,消息的内容小,适于网络传输,至于和其他的对比,咱们自己可以做的。
在序列化的过程中,还要再讲一下:
首先是设置属性的时候的内容,在上面的测试中,有这么一部分hello.setId(1); 具体的设置如下:
//这个值主要是用来判断id是否存在的,比如这个方法 public boolean hasId() {return ((bitField0_ & 0x00000001) == 0x00000001);}
bitField0_ |= 0x00000001;
//将value设置到id_这个字段上
id_ = value;
onChanged(); // 说明这个值已经改变了,通知用的
return this; //方便用的,返回的还是builder。
真正的序列化过程很简单,在生成的代码中,可以找到这样的代码:
if (((bitField0_ & 0x00000001) == 0x00000001)) { output.writeInt32(1, id_); }
if (((bitField0_ & 0x00000002) == 0x00000002)) { output.writeBytes(2, getStrBytes()); }
if (((bitField0_ & 0x00000004) == 0x00000004)) {output.writeInt32(3, opt_); }
由于所有的类型都已经定义好,不会出现不认识的pojo,因此序列化的时候是相当的快,那我们再来看下反序列化的内容,反序列化代码也很简单
helloworld hh = helloworld.parseFrom(new FileInputStream("d:\\124.txt"));
System.out.println(hh.getId());
而paseFrom里面的内容更加简单了
case 8: { bitField0_ |= 0x00000001; id_ = input.readInt32(); break; }
case 18: { bitField0_ |= 0x00000002; str_ = input.readBytes(); break; }
case 24: { bitField0_ |= 0x00000004; opt_ = input.readInt32(); break; }
需要说明的是上面的这三段代码都是通过描述文件,生成的java代码,这样的代码进行反序列化怎么会不快
这边有一个Benchmarking,比较各个序列化的http://code.google.com/p/thrift-protobuf-compare/wiki/Benchmarking
最后说一句,据说淘宝就在用这种序列化方式。
下面我们简单看下 apache thrift 站点http://thrift.apache.org/
和protobuf一样,这也是一个跨语言的序列化工具,但是这个更加强调的是rpc,rpc我们后面会讲到,此地我们只讲序列化
首先定义一个描述文件Hello.thrift ,其实后缀不一定是thrift,只要里面的内容满足要求即可
namespace java service.demo
service Hello{
string helloString(1:string para)
i32 helloInt(1:i32 para)
bool helloBoolean(1:bool para)
void helloVoid()
string helloNull()
}
Thrift的定义类型见http://thrift.apache.org/docs/types/
thrift --gen java Hello.thrift
序列化大小参看http://blog.csdn.net/xqy1522/article/details/6942344的比较
仔细的观察代码你就会发现,这个要比protobuf序列化大的原因是类型判断没有做好,而是作为一个或者多个字节进行处理了。那普通的吸入int32来说
oprot.writeFieldBegin(NUM2_FIELD_DESC);
oprot.writeI32(this.num2);
oprot.writeFieldEnd();
首先是写入field的开始,然后写内容,最后是结束
writeFieldBegin中会写两个byte
writeByte(field.type); writeI16(field.id);
而 writeI32的内容也没有压缩
i32out[0] = (byte)(0xff & (i32 >> 24));
i32out[1] = (byte)(0xff & (i32 >> 16));
i32out[2] = (byte)(0xff & (i32 >> 8));
i32out[3] = (byte)(0xff & (i32));
trans_.write(i32out, 0, 4);
仍然是四个字节,因此在序列化的时候就不详细介绍这个东东了,在rpc的时候,详细讲
---------------------hadoop avro--------------------------------
下面看下hadoop avro
Avro(读音类似于[ævrə])是Hadoop的一个子项目,由Hadoop的创始人Doug Cutting(也是Lucene,Nutch等项目的创始人)牵头开发。Avro是一个数据序列化系统,设计用于支持大批量数据交换的应用。它的主要特点有:支持二进制序列化方式,可以便捷,快速地处理大量数据;动态语言友好,Avro提供的机制使动态语言可以方便地处理 Avro数据。站点地址http://avro.apache.org/
这个工具一个比较好的点是将描述文件,也就是scheme写入到了序列化文件中,这样就达到了一个自省或者自举的功能,下载的内容就不说了。举例说明
首先是一个简单的scheme,具体参看http://avro.apache.org/docs/current/spec.html
{
"type" : "record",
"name" : "Pair",
"doc" : "A pair of strings",
"fields" : [{
"name" : "left",
"type" : "string"
}, {
"name" : "right",
"type" : "string"
}]
}
很简单的一个定义类型为记录形式,名字为Pair doc标示一个描述,属性有两个,1个name是left,类型为string,另一个name为right,类型为string
这个不需要生成文件了,下面看代码
Schema schema = Schema.parse(new File("Pair.json"));
FileOutputStream out = new FileOutputStream("c:\\data.1");
DatumWriter<GenericRecord> writer = new GenericDatumWriter<GenericRecord>(schema);
Encoder encoder = EncoderFactory.get().binaryEncoder(out, null);
GenericRecord datum = new GenericData.Record(schema);
datum.put("left", new Utf8("L0"));
datum.put("right", new Utf8("R0"));
writer.write(datum, encoder);
encoder.flush();
out.close();
得到结果如下:
其中04为L0的Byte 长度,4c 和30为 L和0 而下面的04为R0的byte长度,52和30,则是R0,从这里可以看出,相对于protobuf,这个连1,2,3这样的排序都没有了,直接写的是值。
将right的属性改为int,同时设置为18,可以得到这样的文件
也是没有属性的顺序和类型的。在这里有一点数据库表的意思,scheme就是表的定义。
再看一种具有自举类型的序列化方式
Schema schema = Schema.parse(new File("Pair.json"));
DatumWriter<GenericRecord> writer = new GenericDatumWriter<GenericRecord>( schema);
DataFileWriter<GenericRecord> fileWriter = new DataFileWriter<GenericRecord>(writer);
fileWriter.create(schema, new File("c:\\data.2"));
for(int i=0;i<100;i++){
GenericRecord datum = new GenericData.Record(schema);
datum.put("left", new Utf8("L"+i));
datum.put("right", new Utf8("R"+i));
fileWriter.append(datum);
}
fileWriter.close();
在这里,我们使用户了DataFileWriter,同时放入了50条数据,
可以看到这里面将schema写入到了文件中,这样在进行反序列化的时候即使没有没有这个schema文件,也能够成功的序列化出来。
下面做一个简单的分析:
4f 62 6a 是作为一个magic存在的,而1标示版本号,这四个值是固定的
下面的2标示2个field
16 61 76 72 6f 2e 73 63 68 65 6d 61 22是字符串长度的一个计算公式(n << 1) ^ (n >> 31),后面标示avro.schema
后面的内容自己看下,基本上和之前的内容一致,遇到int型的直接写,遇到字符串型的,要先写字符串长度。