什么是序列化?如何对序列化实现工具进行正确选型?

在现实中,我们通常会使用Dubbo、Spring Cloud等开源框架,以及TCP、HTTP等网络传输协议来发起远程调用。对于这些框架和协议而言,客户端发起请求的方式,以及服务端返回响应结果的处理过程都是不一样的。但是这里存在一个共同点,即无论采用何种开发框架和网络传输协议,都涉及到业务数据在网络中的传输,这就需要应用到序列化技术。和网络通信不同,序列化技术是直接面向开发人员的,我们可以对具体的序列化工具和框架进行选择,而不像网络通信过程那样只能依靠框架底层所封装的能力。

目前,序列化工具很多,据统计已经超过100种。我们显然无法对具体的实现工具进行一一罗列。因此,关于序列化技术的考查方式是比较灵活的,需要候选人有足够的知识面,了解目前业界主流的序列化技术。更为重要的,候选人还需要具备综合的抽象思维,能够将不同的工具按照一定的维度进行分类,从不同的功能特性角度出发进行分析。

从面试角度讲,关于序列化技术的常见考查方式包括:

  1. 你知道哪些序列化工具,它们各自有什么特性?
  2. 你在选择序列化工具时,重点会考虑哪些方面的要素?
  3. 为什么像Protobuf、Thrift这些序列化工具会采用中间语言技术?
  4. 如果只考虑性能,你会选择哪款序列化工具?
  5. Google的Protobuf为什么会那么快?

究竟什么是序列化?我们可以简单把它理解为是一种从内存对象到字节数据的转换过程。通过序列化,我们就可以把数据变成可以在网络上进行传输的一种媒介,如下图所示。


在上图中,我们注意到还有一个反序列化的概念。所谓反序列化,实际上就是序列化的逆向过程,把从网络上获取的字节数据再次转化为可以供内存使用的业务对象。

序列化的方式有很多,实现工具也非常丰富,常见的如下表所示。

序列化工具

简要描述

Java Serializable

JDK自带序列化工具

Hessian

Dubbo框架默认序列化工具

Protobuf

gRPC框架默认序列化工具

Thrift

Facebook跨语言序列化工具

Jackson

Spring框架默认序列化工具

FastJson

阿里巴巴高性能序列化工具

上表罗列的也只是一些最主流的序列化工具,其他可供开发人员使用的工具和框架还有很多。虽然这些工具的定位和作用是类似的,但所具备的特性却不尽相同。这就涉及到日常开发过程中开发人员经常要面对的一个问题,即技术选型问题。

关于技术选型,我们的思路首先是确定所需要考虑的技术维度。在序列化领域,我们可以抽象出三个技术维度,即:

  1. 功能:包括支持的序列化数据表现形式、数据结构等
  2. 性能:包括空间复杂度和时间复杂度等
  3. 兼容性:包括版本号机制等

基于上述三个技术维度,我们回答这类面试题的思路就有了。而从这三个技术维度的描述出发,我们也不难看出每一个技术维度还可以继续进行细分,接下来,让我们来对具体的技术体系展开讨论。

序列化技术

序列化工具和框架各有特色,但它们所采用的数据表现形式一般分成两大类,即具备可读性的文本类,以及不具备可读性的二进制类。对于前者,代表性的框架有Jackson和FastJson,它们都采用了JSON作为数据表现形式。而后者则包括Protobuf、Thrift等。开发人员往往对序列化工具的数据表现形式比较在意,因为这直接决定了我们是否可以直接人为对数据的正确性进行判断。显然,数据的表现形式是序列化工具的一大功能特性,但并不是最重要的功能特性。接下来,就让我们先从序列化的核心功能开始展开讨论。

序列化的功能

在选择序列化工具时,功能完整度是我们首先要考虑的一个技术维度,具体的关注点包括:

  1. 数据结构的丰富程度
  2. 开发的友好性
  3. 对异构平台的支持性

我们首先来看数据结构,这方面的功能差异性主要体现在是否对一些复杂数据结构的支持。常见的复杂数据结构包括泛型结构以及Map/List等集合结构。我们来看如下所示的一个具体的示例。

public class User{

public int id;

public String username;

public List<Link> links;

public Map result;

}

public class Link{

public String name;

public String phone;

}

可以看到,这里定义了一个User对象,而这个User对象中又分别包含了一个List结构和一个Map结构,其中List结构所指向的还是一组自定义对象Link。针对这种复杂数据结构,如果我们使用FastJson来进行序列化,那么如下所示的代码是可以正常运作的。

User user=new User();

//省略对user对象进行赋值

//将对象转化为JSON字符串

String str=JSON.toJSONString(user);

//将JSON字符串转成对象

User myuser=JSON.parseObject(str,User.class);

而如果我们使用Protobuf,那么这种复杂数据结构是无法支持的。如果想要使用Protobuf,需要对这个数据结构进行调整,将List换成更为通用的数据结构。另一方面,针对Protobuf框架,我们也可以引出下一个我们要讨论的功能点,即开发的友好性。

开发友好性用来衡量工具本身对于开发人员实现序列化的开发难易程度。就像前面示例所展示的,我们在使用FastJson时,通过几行代码就能实现对象的序列化和反序列化。而有些工具则不一定,以Protobuf为例,在使用该工具时,我们首先要做的是定义一种中间语言,示例如下。

syntax = "proto3";

message Student

{

    int32 id= 1;

    string name = 2;

    int32 sex = 3;

    string hobby = 4;

    string skill = 5;

}

这里的syntax=“proto3”表示运用proto3版本的语法,而message类似于Java中的Class。在开发过程中,我们需要将这段中间语言保存为一个student.proto文件,然后再通过Protobuf的protoc命令将它转化为Java文件才能使用,如下所示。

protoc --java_out=.  student.proto

可以看到,中间语言所带来的开发复杂度是显而易见的。使用中间语言的典型工具还包括Thrift,它需要事先提供一个.thrift文件。

讲到这里,你可能会问,为什么Protobuf和Thrift要使用中间语言呢?原因就在于它们基于中间语言提供了一项重要的技术特性,即跨语言的异构性。

异构性的需求来自分布式系统中技术架构和实现方式的多样性。原则上,每个服务都可以基于不同的技术体系进行实现,这些技术体系所采用的开发语言可能都是不一样的,这时候就需要引入支持多语言的序列化工具,如下图所示。


实际上,很多场景下我们之所以不选择JDK自带的序列化机制,很重要的一个原因就在于它只能支持Java语言。而基于Protobuf等工具所提供的中间语言机制,我们可以生成面向不同语言的序列化数据,包括C++、JAVA、Python、Objective C、C#、Ruby、PHP、JavaScript等,我们也可以找到几乎涵盖所有其他语言的第三方拓展包。

另一方面,无论是数据结构的丰富程度,还是开发友好性,这些功能特性与跨语言支持之间往往是存在一定矛盾的。举个例子,要想支持多语言,那么就必须采用那些非常通用的数据结构,确保所有语言都内置了对这些数据结构的支持。这样的话,诸如前面提到的Map/List等复杂数据结构显然就不合适了。

序列化的性能

在日常开发过程中,我们在选择序列化工具时往往会把性能作为一项重要的指标进行考虑。对于序列化的性能而言,我们关注两个指标,即:

  1. 时间复杂度:表示序列化/反序列化执行过程的速度
  2. 空间复杂度:表示序列化数据所占有的字节大小

对于日常开发过程中经常使用的一些序列化工具,我们可以列举了它们在性能上的一些量化数据,如下表所示。

时间复杂度(序列化)

时间复杂度(反序列化)

空间复杂度

Java

8654

43787

889

Hessian

6725

10460

501

Protobuf

2964

1745

239

Thrift

3177

1949

349

Jackson

3052

4161

503

Fastjson

2595

1472

468

通过对比,我们注意到在时间复杂度上可以优先选择阿里巴巴的FastJson,而在空间复杂度上Google的Protobuf则具备较大的优势。

序列化的兼容性

关于序列化技术最后需要讨论的一个话题是兼容性。我们知道随着业务系统的不断演进,服务中所定义的接口以及数据结构也不可避免会发生变化。通常,在分布式服务开发过程中,我们会引入版本概念来应对接口和数据结构的调整。在序列化工具中,我们同样需要考虑版本。这方面比较典型的例子就是JDK自带的序列化版本Id。一旦我们在类定义中实现了Serializable接口,JDK就提示你需要指定一个序列化版本Id,如下所示。

public class MyObject implements Serializable {

//唯一的序列化版本号

private static final long serialVersionUID = -1127800498182345096L;

}

基于这种显式的版本号机制,在序列化时,如果对象之间的版本Id不一致,那么JVM就会抛出一个InvalidCastException的异常;反之则可以正常进行转换。

有些序列化工具虽然没有明确指定版本号的概念,但也能实现前向兼容性,比较典型的就是Protobuf。

Dubbo中的序列化技术

介绍完序列化的相关技术体系,让我们再次回到具体的分布式开源框架,来看看业界主流的框架是如何实现序列化过程的。这里还是以阿里巴巴的Dubbo框架为例展开讨论。Dubbo框架中的Remoting模块如下图所示。


可以看到,在Remoting模块中还剩下一个Serialize组件没有介绍,从命名上我们不难看出该组件与序列化相关。事实上,Dubbo提供了Serialization接口(位于dubbo-common代码工程中)作为对序列化的抽象。而对应的序列化和反序列化操作的返回值分别是ObjectOutput和ObjectInput,其中ObjectInput扩展自DataInput,用于读取对象;而ObjectOutput扩展自DataOutput,用于写入对象,这两个接口的定义如下所示。

public interface ObjectInput extends DataInput {

    Object readObject() throws IOException, ClassNotFoundException;

    <T> T readObject(Class<T> cls) throws IOException, ClassNotFoundException;

    <T> T readObject(Class<T> cls, Type type) throws IOException, ClassNotFoundException;

}

public interface ObjectOutput extends DataOutput {

    void writeObject(Object obj) throws IOException;

}

在Serialization接口的定义上,可以看到Dubbo中默认使用的序列化实现方案基于hessian2。Hessian是一款优秀的序列化工具。在功能上,它支持基于二级制的数据表示形式,从而能够提供跨语言支持;在性能上,无论时间复杂度还是空间复杂度也比Java序列化高效很多,如下图所示。


在Dubbo中,Hessian2Serialization类实现了Serialization接口,我们就以该类为例介绍Dubbo中具体的序列化/反序列化实现方法。Hessian2Serialization类定义如下所示。

public class Hessian2Serialization implements Serialization {

    public static final byte ID = 2;

    public byte getContentTypeId() {

        return ID;

    }

    public String getContentType() {

        return "x-application/hessian2";

    }

    public ObjectOutput serialize(URL url, OutputStream out) throws IOException {

        return new Hessian2ObjectOutput(out);

    }

    public ObjectInput deserialize(URL url, InputStream is) throws IOException {

        return new Hessian2ObjectInput(is);

    }

}

Hessian2Serialization中的serialize和deserialize方法分别创建了Hessian2ObjectOutput和Hessian2ObjectInput类。以Hessian2ObjectInput为例,该类使用Hessian2Input完成具体的反序列化操作,如下所示。

public class Hessian2ObjectInput implements ObjectInput {

     private final Hessian2Input mH2i;

     public Hessian2ObjectInput(InputStream is) {

         mH2i = new Hessian2Input(is);

      mH2i.setSerializerFactory(Hessian2SerializerFactory.SERIALIZER_FACTORY);

    }

//省略各种读取具体数据类型的工具方法

   

  public Object readObject() throws IOException {

        return mH2i.readObject();

    }

    public <T> T readObject(Class<T> cls) throws IOException,

            ClassNotFoundException {

        return (T) mH2i.readObject(cls);

    }

    public <T> T readObject(Class<T> cls, Type type) throws IOException, ClassNotFoundException {

        return readObject(cls);

    }

}

Hessian2Input是Hessian2的实现库com.caucho.hessian中的工具类,初始化时需要设置一个SerializerFactory,所以我们在这里还看到存在一个Hessian2SerializerFactory工厂类,专门用于设置SerializerFactory。而在Hessian2ObjectInput中,各种以read为前缀的方法实际上都是对Hessian2Input中相应方法的封装。

用于执行反序列化的Hessian2ObjectOutput与Hessian2ObjectInput类也比较简单,这里不再展开。

关于Dubbo序列化的另一条代码支线是Codec2接口,该接口位于dubbo-remoting-api代码工程中,提供了对网络编解码的抽象,而编解码过程显然需要依赖Serialization接口作为其数据序列化的手段。我们可以通过如下所示的代码片段来回顾这一点,这段代码来自DubboCodec中的decodeBody方法。

public class DubboCodec extends ExchangeCodec implements Codec2 {

protected Object decodeBody(Channel channel, InputStream is, byte[] header) throws IOException {

// 获取序列化对象

Serialization s = CodecSupport.getSerialization(channel.getUrl(), proto);

}

}

那么,这里的Codec和Serialization如何与Exchange和Transport结合起来构成一个完整的链路呢?我们可以明确一点,序列化和编解码过程在网络传输层和信息交换层中都应该存在。因此,我们快速来到dubbo-remoting-api代码工程的META-INF/dubbo/internal文件夹,发现存在一个org.apache.dubbo.remoting.Codec2配置文件,内容如下所示。

transport=org.apache.dubbo.remoting.transport.codec.TransportCodec

telnet=org.apache.dubbo.remoting.telnet.codec.TelnetCodec

exchange=org.apache.dubbo.remoting.exchange.codec.ExchangeCodec

org.apache.dubbo.remoting.Codec2配置文件用来执行SPI机制,我们会在第X讲中对这个主题进行专项介绍,这里只需要明白Dubbo采用这种配置方式来动态加载运行时的类对象。在这里,可以看到Dubbo针对exchange和transport都提供了Codec支持。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值