【Hadoop】11-序列化

序列化(serialization)是指将结构化对象转化为字节流以便在网络上传输或写到磁盘进行永久存储的过程。反序列化(deserialiion)是指将字节流转回结构化对象的逆过程。
序列化用于分布式数据处理的两大领域:进程间通信和永久存储。在Hadoop中,系统中多个节点上进程间的通信是通过“远程过程调用”(RPC,remote procedure call)实现的。RPC协议将消息序列化成二进制流后发送到远程节点,远程节点接着将二进制流反序列化为原始消息。通常情况下,RPC序列化格式如下。
紧凑:紧凑格式能充分利用网络带宽(数据中心中最稀缺的资源)。
快速:进程间通信形成了分布式系统的骨架,所以需要尽量减少序列化和反序列化的性能开销,这是最基本的。
可扩展:为了满足新的需求,协议不断变化。所以在控制客户端和服务器的过程中,需要直接引进相应的协议。例如,需要能够在方法调用的过程中增添新的参数,并且新的服务器需要能够接受来自老客户端的老格式的消息(无新噌的参数)。

支持互操作:对于某些系统来说,希望能支持以不同语言写的客户端与服务器交互,所以需要设计需要一种特定的格式来满足这一需求。表面看来,序列化框架对选择用于数据持久存储的数据格式应该会有不同的要求。毕竟,RPC的存活时间不到1秒钟,持久存储的数据却可能在写到磁盘若干。

表面看来,序列化框架对选择用于数据持久存储的数据恪式应该会有不同的要求。毕竟,RPC的存活时间不到1秒钟,持久存储的数据却可能在写到磁盘若干年后才会被读取·但结果是,RPC序列化格式的四大理想属性对持久存储格式而言也很重要。我们希望存储格式比较紧凑(进而高效使用存储空间)、快速(读/写数据的额外开销比较小)、可扩展(可以透期地读取老格式的数据)且可以互操作(以可以使用不同的语言读/写永久存储的数曙)。

Hadoop使用的是自己的序列化格式writable,它绝对紧凑、速度快·但不太容易用Java以外的语言进行扩展或使用。因为Writable是Hadoop的核心(大多数MapReduce程序都会为键和值类型使用它),所以在接下来的三个小节中,我们要进行人探讨,然后再介绍Hadoop支持的其他序列化框架。Avro(—个克服了Writab1e部分不足的序列化系统)。

1.WritabIe接口

writable接口定义了两个方法:一个将其状态写人DataOutput二进制流,另一个从DataInput二进制流读取状态:
package org.apache.hadoop.io;
import java.io.DataInput;
import java.io.DataOutput;
import java.io.IOException;
import org.apache.hadoop.classification.InterfaceAudience.Public;
import org.apache.hadoop.classification.InterfaceStability.Stable;
@Public
@Stable
public interface Writable {
    void write(DataOutput var1) throws IOException;
    void readFields(DataInput var1) throws IOException;
}

让我们通过一个特殊的writable类来看看它的具体用途·我们将使用lntWritable来封装Java int类型。我们可以新建一个对象并使用set()方法来设置它的值:

IntWritable writable=new IntWritable();
writable.set(163);
也可以通过使用一个整数值作为输人参数的构造函数来新建一个对象:
IntWritable writable=new IntWritab1e(163);

为了检查IntWritable的序列化形式,我们在java.io.DataOutputStream(java.io.DataOutput的一个实现)中加人一个帮助函数来封装java.io.ByteArray0utputSteam,以便在序列化流中捕捉字节:

public static byte[] serialize(Writable writable)throws IOException{
    ByteArrayOutputStream out=new ByteArrayOutputStream();
    DataOutputStream dataOut=new DataOutputStream(out);
    writable.write(dataOut);
    dataOut.close();
    return out.toByteArray();
}

一个整数占用4个字节(因为我们使用〕JUnit4进行声明):

byte[] bytes=serialize(writable);
assertThat(bytes.length,is(4));
每个字节是按照大端顺序写人的(按照java.io.DataOutput接口中的声明,最重要的字节先写人流),并且通过Hadoop的StringUtils,我们可以看到这些字节的十六进制表示:
assertThat(StringUtils.byteToHexString(bytes),is("000000a3");
让我们试试反序列化。我们再次新建一个辅助方法,从一个字节数组中读取一个Writable对象:
public static byte[] deserialize(Writable writable,byte[] bytes)throws IOException{
    ByteArrayInputStream in=new ByteArrayInputStream(bytes);
    DataInputStream dataInt=new DataInputStream(in);
    writable.readFields(dataIn);
    dataIn.close();
    return bytes;
}

我们构建了一个新的、空值的lntwritable对象,然后调用deserialize()方法从我们刚写的输出数据中读取数据。最后,我们看到该值嗵过get()方法获取堤原始的数值163:

lntWritable newWritable=new lntWritable();
deserialize(newWritable,bytes);
assertThat(newWritable.get(),is(163));
WritableComparable接囗和comparator
IntWritable实现原始的WritableComparable接口,该接口继承自Writable和java.lang.Comparable接口:
package org.apache.hadoop.io;
import org.apache.hadoop.classification.InterfaceAudience.Public;
import org.apache.hadoop.classification.InterfaceStability.Stable;
@Public
@Stable
public interface WritableComparable<T> extends Writable, Comparable<T> {
}

对MapReduce来说,类型比较非常重要,因为中间有个基于键的排序阶段。Hadoop提供的一个优化接口是继承自Java Comparator的RawComparator接口。

package org.apache.hadoop.io;
import java.util.Comparator;
import org.apache.hadoop.classification.InterfaceAudience.Public;
import org.apache.hadoop.classification.InterfaceStability.Stable;
@Public
@Stable
public interface RawComparator<T> extends Comparator<T> {
    int compare(byte[] var1, int var2, int var3, byte[] var4, int var5, int var6);
}
该接口允许其实现直接比较数据流中的记录,无须先把数据流反序列化为对象,这样便避免了新建对象的额外开销·例如.我们根据lntWritable接口实现的comparator实现原始的compare()方法,该方法可以从每个字节数组b1和b2中读取给定起始位置(s1和s2)以及长度(l1和l2)的一个整数进而直接进行比较。
WritableComparator是对继承自WritableCmparable类的RawComparator类的一个通用实现。它提供两个主要功能·酊一,它提供了对原始compare()方法的一个默认实现,该方法能够反序列化将在流中进行比较的对象,并调用对象的compare()方法。第二,它充当的是RawComparator实例的工厂(已注册Writable的实现)例如,为了获得lntWritable的comparator,我们直接如下调用:
RawComparator<IntWritab1e> comparator=WritableComparator.get(IntWritab1e.class);

这个comparator可以用于比较两个lntWritable对象:

IntWritable w1=new IntWritable(163);
IntWritable w2=new IntWritable(67);
assertThat(comparator.compare(w1,w2),greaterThan(0));
或其序列化表示:
byte[] b1=serialize(w1);
byte[] b2=serialize(w2);
assertThat(comparator.compare(b1,0,b1.length,b2,0,b2.length),greaterThan(0));

2.Writable类

Hadoop自带的org.apache.hadoop.io包中有广泛的writable类可供选择。它们的层次结构如图所示。


1.Java基本类型的WntabIe封装器
writable类对所有Java基本类型参见下表)提供封装,char类型除外(可以存储在IntWritabIe中).所有的封装包含get()和set()两个方法用于读取或存储封装的值。
Java基类型的Writable类:

Java基本类型 Writable实现 序列化字节大小
boolean
BooleanWritable 1
byte
ByteWritable 1
short ShortWritable 2
int

IntWritable

VintWritable

4

1-5

float FloatWritable 4
long

LongWritable

VlongWritable

8

1-9

double

DoubleWritable 8
对整数进行编码时,有两种选择,即定长格式(lntWritable和LongWritable)和变长格式(VIntWritabIe和VLongWritabIe)。需要编码的数值如果相当小(在一127和127之间,包括一127和127),变长格式就是只用一个字节进行编码,否则,使用第一个字节来表示数值的正负和后跟多少个字节。例如,值163需要两个字节:
byte[] data=serialize(new VIntWritable(163));
assertThat(Stringutils.byteToHexString(data),is("c8fa3"));
如何在定长格式和变长格式之间进行选择呢?定长格式编码很适合数值在整个值域空间中分布非常均匀的情况,例如憷用精心设计的哈希函数·然而,大多数数值变量的分布都不均匀,一般而訁变长格式会更节省空间。变长编码的另一个优点是可以在VIntWritable和VLongWritable转換,因为它们的编码实际上是一致的·所以选择变长格式之后,便有增长的空间,不必一开始就用8字节的long 表示。
2.Text类型

Text是针对UTF-8序列的Writable类。一般可以认为它是java.lang.String的Writable等价。

类使用整型(通过边长编码的方式)来存储字符串编码中所需的字节数,因此该最大值为2GB.。另外text使用UTF-8编码,这使得能够简便与其他理解UTF-8的编码工具进行交互操作。
索引:由于着重使用标准的UTF-8编码,因此Text类和Java String类之间存在一定的差别。对Text类的索引是根据编码后字节序列中的位置实现的,并非字符串中的Unicode字符,也不是Java char的编码单元(如String)。对于ASCII字符串,这三个索引位置的概念是一致的。charAt()方法的用法如下例所示:

Text t=new Text("hadoop");
assertThat(t.getLength(),is(6));
assertThat(t.getBytes().length,is(6));
assertThat(t.charAt(2),is((int)'b'));
assertThat("Out of bound",t.charAt(100),is(-1));
注意:charAt()方法返回的是一个表示Unicode编码位置的int类型值,而String返回一个char类性值。Text还有一个find()方法,该方法类似于String的indexOf()方法。
Unicode一且开始使用需要多个字节来编码的字符时,Text和String之间的区别就昭然若揭了。考虑下表显示的Unicode字符。

所有字符(除了表中最后一个字符U+10400),都可以使用单个Java char类型来表示。U+10400是一个候补字符,并且需要两个Java char来表示,称为“字符代理对”(surrogate pair)。下面范例中的测试显示了处理一个字符串(表中的由4个字符组成的字符串)时String和Text之间的差别。

Unicode 字符:

Unicode编码点 U+0041 U+00DF U+6671 U+10400
名称 拉丁大写字母A l拉丁小写字母SHARP S 无(统一表示的汉字) DESERET CAPITAL LETTER LONG I

UTF-8编码单元

Java表示

41

\u0041

c39f

\u00DF

e69db1

\u6771

F0909080

\uuD81\uDC00

范例验证String类和Text类的差异性的试。

public class StringTextComparisonTest {
@Test
public void string() throws UnsupportedEncodingException {
String s = "\u0041\u00DF\u6771\uD801\uDC00";
assertThat(s.length(), is(5));
assertThat(s.getBytes("UTF-8").length, is(10));
assertThat(s.indexOf("\u0041"), is(0));
assertThat(s.indexOf("\u00DF"), is(1));
assertThat(s.indexOf("\u6771"), is(2));
assertThat(s.indexOf("\uD801\uDC00"), is(3));
assertThat(s.charAt(0), is('\u0041'));
assertThat(s.charAt(1), is('\u00DF'));
assertThat(s.charAt(2), is('\u6771'));
assertThat(s.charAt(3), is('\uD801'));
assertThat(s.charAt(4), is('\uDC00'));
assertThat(s.codePointAt(0), is(0x0041));
assertThat(s.codePointAt(1), is(0x00DF));
assertThat(s.codePointAt(2), is(0x6771));
assertThat(s.codePointAt(3), is(0x10400));
}
@Test
public void test() {
Text t = new Text("\u0041\u00DF\u6771\uD801\uDC00");
assertThat(t.getLength(), is(10));
assertThat(t.find("\u0041"), is(0));
assertThat(t.find("\u00DF"), is(1));
assertThat(t.find("\u6771"), is(3));
assertThat(t.find("\uD801\uDC00"), is(6));
assertThat(t.charAt(0), is(0x0041));
assertThat(t.charAt(1), is(0x00DF));
assertThat(t.charAt(3), is(0x6771));
assertThat(t.charAt(6), is(0x10400));
}
}
        这个测试证实String的长度是其所含char编码单元的个数(5,由该字符串的前三个字符和最后的一个代理对组成),但Text对象的长度却是其UTF-8编码的字节数(10=1+2+3+4)。相似的,String类的indexOf()方法返回char编码单元中的索引位置,Text类的find()方法则返回字节偏移量。
        当代理对不能代表整个Unicode字符时,String类中的charAt()方法会根据指定的索引位置返回char编码单元。根据char编码单元索引位置,需要codePointAt()方法来获取表示成int类型的单个Unicode字符。事实上,Text类中的charAt()方法与String中codePointAt()更加相似(相较名称而言)。唯一的区别是通过字节的偏移量进行索引。

迭代:利用字节偏移量实现的位置索引,对Text类中的Unicode字符进行迭代是非常复杂的,因为无法通过简单地增加索引值来实现该迭代。同时迭代的语法有写模糊(参见下面范例代码):将Text对象转换为java.nio.ByteBuffer对象,然后利用缓冲区对Text对象反复调用bytesToCodePoint()静态方法。该方法能够获取下一代码的位置,并返回相应的int值,最后更新缓冲区中的位置。当bytesToCodePoint()返回-1时,则检测到字符串的末尾。

遍历Text对象中的字符。
public class TextIterator {
    public static void main(String[] args) {
        Text t = new Text("\u0041\u00DF\u6771\uD801\uDC00");
        ByteBuffer buf = ByteBuffer.wrap(t.getBytes(), 0, t.getLength());
        int cp;
        while(buf.hasRemaining() && (cp = Text.bytesToCodePoint(buf)) != -1) {
            System.out.println(Integer.toHexString(cp));
        }
    }
}

 运行这个程序,打印出字符串中四个字符的编码点(code point):

% hadoop TextIterator
41
df
6771
10400
可变性:与String相比,Text的另一个区别在于他是可变的(与所有Hadoop的Writable接口实现相似,NullWritable除外,他是单实例对象)。可以通过调用其中一个set()方法来重用Text实例,例如:
Text t = new Text("hadoop");
t.set("pig");
assertThat(t.getLength(), is(3));
assertThat(t.getBytes().length, is(3));
注意:在某些情况下,getBytes()方法返回的字节数组可能比getLength()函数返回的长度更长:
Text t = new Text("hadoop");
t.set(new Text("pig"));
assertThat(t.getLength(), is(3));
assertThat("Byte length not shortened", t.getBytes90.length, is(6));

以上代码说明了在调用getBytes()之前为什么始终都要调用getLength()方法,因为可以由此知道字节数组中多少字符是有效地。

对String重新排序:Text类并不像java.lang.String类那样有丰富的字符串操作API。所以,在多数情况下需要将Text对象转换成String对象。这一转换通常通过调用toString()方法实现:

assertThat(new Text("hadoop").toString(), is("hadoop"));
3.BytesWritable
        BytesWritable是对二进制数据数组的封装。他的序列化格式为一个指定所含数据字节数的整数域(4字节),后跟数据内容本身。例如,长度为2 的字节数组包含数据值3和5,序列化形式为一个4字节的整数(00000002)和该数组中的两个字节(03和05):
BytesWritable b = new BytesWritable(new byte[]{3, 5});
byte[] bytes = serialize(b);
assertThat(StringUtils.byteToHexString(bytes), is("000000020305"));
         BytesWritable是可变的,其值可以通过set()方法进行修改。和Text相似,BytesWritable类的getBytes()方法返回的字节数组长度(容量)可能无法体现BytesWritable所存储数据的实际大小。可以通过getLength()方法来确定BytesWritable的大小。示例如下:
b.setCapacity(11);
assertThat(b.getLength(), is(2));
assertThat(b.getBytes().length, is(11));
4.NullWritable
        NullWritable是writable的特殊类型,他的序列化长度为0。他并不从数据流中读取数据,也不写入数据。他充当占位符;例如,在MapReduce中,如果不需要使用键或值的序列化地址,就可以将键或值声明为NullWritable,这样可以高效存储常量空值。如果希望存储一系列数值,与键-值对相对,NullWritable也可以用作在SequenceFile中的键。他是一个不可变的单实例类型,通过调用NullWritable.get()方法可以获取这个实例。
5.ObjectWritable和GenericWritable
        ObjectWritable是对Java基本类型(String,enum,Writable,null或这些类型组成的数组)的一个通用封装。他在Hadoop RPC中用于对方法的参数和返回类型进行封装和解封装。
        当一个字段中包含多个类型时,ObjectWritable非常有用:例如,如果SequenceFile中的值包含多个类型,就可以将值类型声明为ObjectWritable,并将每个类型封装在一个ObjectWritable中。作为一个通用的机制,每次序列化都写封装类型的名称,这非常浪费空间。如果封装的类型数量比较少并且能够提前知道,那么可以通过使用静态类型的数组,并使用对序列化后的类型的引用加入位置索引来提高性能。GenericWritable类采取的就是这种方式,所以你得在继承的子类中指定支持什么类型。
6.Writable集合类
        org.apache.hadoop.io软件包中共有6个Writable集合类,分别是ArrayWritable、ArrayPrimitiveWritable、TwoDArrayWritable、MapWritable、SortedMapWritable以及EnumMapWritable。
        ArrayWritable和TwoDArrayWritable是对Writable的数组和二维数组(数组的数组)的实现。ArrayWritable或TwoDArrayWritable中所有元素必须是同一类的实例(在构造函数中指定),如下所示:
ArrayWritable writable = new ArrayWritable(Text.class);
        如果Writable根据类型来定义,例如SequenceFile的键或值,或更多时候作为MapReduce的输入,则需要继承ArrayWritable(或相应的TwoDArrayWritable类)并设置静态类型。示例如下:
public class TextArrayWritable extends ArrayWritable {
    public TextArrayWritable() {
        super(text.class);
    }
}
        ArrayWritable和TwoDArrayWritable都有get()、set()和toArray()方法。toArray()方法用于新建该数组(或二维数组)的一个“浅拷贝”(shallow copy)。
        ArrayPrimitiveWritable是对Java基本数组类型的一个封装。调用set()方法时,可以识别相应组件类型,因此无需通过继承该类来设置类型。
 MapWritable和SortedMapWritable分别实现了java.util.Map<Writable, Writable>和java.util.SortedMap<WritableComparable, Writable>。每个键/值字段使用的类型是相应字段序列化形式的一部分。类型存储为单个字节(充当类型数组的索引)。在org.apache.hadoop.io包中,数组经常与标准类型结合使用,而定制的Writable类型也通常结合使用,但对于非标准类型,则需要在包头指明所使用的数组类型。根据实现,MapWritable类和SortedMapWritable类通过正byte值来指定定制的类型,所以在MapWritable和SortedMapWritable实例中最多可以使用127个不同的非标准的Writable类。下面显示使用了不同键值类型的MapWritable实例:
MapWritable src = new MapWritable();
src.put(new IntWritable(1), new Text("cat"));
src.put(new VIntWritable(2), new LongWritable(163));
MapWritable dest = new MapWritable();
WritableUtils.cloneInto(dest, src);
assertThat((Text) dest.get(new IntWritable(1)), is(new Text("cat")));
assertThat((LongWritable) dest.get(new VIntWritable(2)));
is(new LongWritable(163));

        显然,可以通过Writable集合类来实现集合和列表。可以使用MapWritable类型(或针对排序集合,使用SortedMapWritable类型)来枚举集合中的元素,用NullWritable类型枚举值。对集合的枚举类型可采用EnumSetWritable。对于单类型的Writable列表,使用ArrayWritable就足够了,但如果现需要把不同的Writable类型存储在单个列表中,可以用GenericWritable将元素封装在一个ArrayWritable中。另一个可选方案是,可以借鉴MapWritable的思路写一个通用的ListWritable。

4.序列化框架

尽管大多数MapReduce程序使用的都是Writable类型的键和值,但这并不是MapReduce API强制要求使用的。事实上,可以使用任何类型,只要能有一种机制对每个类型进行类型与二进制表示的来回转换就可以。
        为了支持这一机制,Hadoop有一个针对可替换序列化框架(serialization framework)的API。序列化框架用一个Serialization实现(包含在org.apache.hadoop.io.serializer包中)来表示。例如,WritableSerialization类是对Writable类型的Serialization实现。Serialization对象定义了从类型到Serializer实例(将对象转换为字节流)和Deserializer实例(将字节流转换为对象)的映射方式。
        为了注册Serialization实现,需要将io.serizalizations属性设置为一个由逗号分隔的类名列表。他的默认值包括org.apache.hadoop.io.serializer.WritableSerialization和Avro指定(Specific)序列化及Reflect(自反)序列化类,这意味着只有Writable对象和Avro对象才可以在外部序列化和反序列化。
        Hadoop包含一个名为JavaSerialization的类,该类使用Java Object Serialization。尽管他方便了我们在MapReduce程序中使用标准的Java类型,如Integer或String,但不如Writable高效,所以不建议使用。
序列化IDL
        还有许多其他序列化框架从不同的角度来解决该问题:不通过代码来定义类型,而是使用“接口定义语言”(IDL,Interface Description Language)以不依赖于具体语言的方式进行声明。由此,系统能够为其他语言生成类型,这种形式能有效提高互操作能力。他们一般还会定义版本控制方案(使类型的演化直观易懂)。
        两个比较流行的序列化框架Apache Thrift和Goodgle的Protocol Buffers,常常用做二进制数据的永久存储格式。MapReduce格式对该类的支持有限,但在Hadoop内部,部分组件仍使用上述两个序列化框架来实现RPC和数据交换。

        Avro是一个基于IDL的序列化框架,非常适用于Hadoop的大规模数据处理。

为什么不用Java Object Serialization?

Java有自己的序列化机制,称为“Java Object Serialization”(通常简称为“Java Serialization”),该机制与编程语言紧密相关,所以我们很自然会问为什么不在Hadoop中使用该机制,针对这个问题,Doug Cutting是这样解释的:“为什么开始设计Hadoop的时候我不用Java Serialization?因为它看起来太复杂,而我认为需要有一个至精至简的机制,可以用于精确制对象的读和写,这个机制将是Hadoop的核心.使用JavaSerialization虽然可以获得一些控制权,但用起来非常纠结。不用RMI(Remote Method lnvocation远程方法调用)也出于类似的考虑、高效、高性能的进程间通信是Hadoop的关健·我觉得我们需虔精确控制连接、延迟媛冲的处理方式,RMI对此圮能为力。”问题在于Java Serialization不满足先前列出的序列化恪式标准:精简、快速、可扩展、支持互操作。

猜你喜欢

转载自blog.csdn.net/shenchaohao12321/article/details/80299079