分布式系统(四)

借助于分布式服务框架,我们可以实现不同服务之间跨网络的交互和协作。网络通信涉及到数据的有效传输,这就需要引入另一个技术组件,即序列化。而目前关于如何实现序列化和反序列化,业界也诞生了一大批工具和框架。

那么,序列化是一种什么样的技术组件?我们又应该如何对种类繁多的序列化实现工具进行正确选型呢

问题背景

无论采用何种开发框架和网络传输协议,都涉及到业务数据在网络中的传输,这就需要应用到序列化技术。序列化技术是直接面向开发人员的,我们可以对具体的序列化工具和框架进行选择,而不像网络通信过程那样只能依靠框架底层所封装的能力。

更为重要的,候选人还需要具备综合的抽象思维,能够将不同的工具按照一定的维度进行分类,从不同的功能特性角度出发进行分析

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

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

问题分析

究竟什么是序列化?我们可以简单把它理解为是一种从内存对象到字节数据的转换过程

所谓反序列化,实际上就是序列化的逆向过程,把从网络上获取的字节数据再次转化为可以供内存使用的业务对象。

序列化工具 简要描述
Java Serializable JDK 自带序列化工具
Hessian Dubbo 框架默认序列化工具
Protobuf gRPC 框架默认序列化工具
Thrift Facebook 跨语言序列化工具
Jackson Spring 框架默认序列化工具
FastJson 阿里巴巴高性能序列化工具

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

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

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

技术体系

  • Jackson 和 FastJson 可读
  • Protobuf、Thrift 不可读

功能

功能完整度是我们首先要考虑的一个技术维度,具体的关注点包括:

  • 数据结构的丰富程度;
  • 开发的友好性;
  • 对异构平台的支持性;

而有些工具则不一定,以 Protobuf 为例,在使用该工具时,我们首先要做的是定义一种中间语言,示例如下:

1
2
3
4
5
6
7
8
9
10
syntax = "proto3";

message Student
{
int32 id= 1;
string name = 2;
int32 sex = 3;
string hobby = 4;
string skill = 5;
}

为什么 Protobuf 和 Thrift 要使用中间语言呢?原因就在于它们基于中间语言提供了一项重要的技术特性,即跨语言的异构性

性能

在日常开发过程中,我们在选择序列化工具时往往会把性能作为一项重要的指标进行考虑

对于序列化的性能而言,我们关注两个指标,即:

  • 时间复杂度:表示序列化/反序列化执行过程的速度。
  • 空间复杂度:表示序列化数据所占有的字节大小。
常用 时间复杂度(序列化) 时间复杂度(反序列化) 空间复杂度
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 则具备较大的优势。

兼容性

关于序列化技术最后需要讨论的一个话题是兼容性

我们知道随着业务系统的不断演进,服务中所定义的接口以及数据结构也不可避免会发生变化。通常,在分布式服务开发过程中,我们会引入版本概念来应对接口和数据结构的调整。在序列化工具中,我们同样需要考虑版本。

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

源码解析

事实上,Dubbo 提供了 Serialization 接口(位于 dubbo-common 代码工程中)作为对序列化的抽象。

而对应的序列化和反序列化操作的返回值分别是 ObjectOutputObjectInput

  • 其中 ObjectInput 扩展自 DataInput,用于读取对象;
  • ObjectOutput 扩展自 DataOutput,用于写入对象,

这两个接口的定义如下所示:

1
2
3
4
5
6
7
8
9
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 类定义如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 完成具体的反序列化操作,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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 中相应方法的封装。

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

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

1
2
3
4
5
6
7
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);
}
}

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

1
2
3
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 机制,这里只需要明白 Dubbo 采用这种配置方式来动态加载运行时的类对象。在这里,可以看到 Dubbo 针对 exchangetransport 都提供了Codec支持。

解题要点

从解题思路上讲,序列化是一个相对比较容易把握的面试题。基于本讲关于序列化技术组件的讨论,我们发现有很多列表式的内容需要记忆。这部分内容需要大家平时多看一些资料,尽量扩展自己的知识面,这是针对这一主题的第一个解题要点。

关于序列化相关工具之间的对比也有一个非常好的汇总资料,这里推荐给大家:https://github.com/eishay/jvm-serializers/wiki

但也正是因为序列化本身是一个内容比较固化的主题,所以在解题上就不能完全照本宣科。

  • 只讲概念,而不给出自己的一些思考和总结,往往体现不出你和其他候选人之间的差别,这也是日常面试过程中需要注意的一个点。
  • 因此,针对这类题的第二个解题要点在于要事先用自己的语言来梳理回答问题的内容体系,重点展示自己对于这一技术主题的抽象和分析能力。

针对技术选型类面试题,更加需要明确给出自己的判断。