从零实现一个轻量级 RPC 框架-系列文章
Github: https://github.com/DongZhouGu/XRpc

前言

RPC 需要将对象序列化成二进制数据,写入本地 Socket 中,然后被网卡发送到网络设备中进行网络传输,序列化的速度以及序列化后的数据大小非常影响网络通信的效率,这里,我们实现了多中序列化的方法,并通过 SPI 实现自定义拓展。

对象是不能直接在网络中传输,我们需要提前把它转成可传输的二进制,并要求转换算法是可逆的,这个过程我们一般叫做“序列化”。
服务提供方就可以正确的从二进制数据中分割出不同的请求,同时根据请求类型和序列化类型,把二进制消息逆向还原成请求对象,称之为”反序列化“。

序列化要素

  • 解析效率:序列化协议应该首要考虑的因素,像 xml/json 解析起来比较耗时,需要解析 doom 树,二进制自定义协议解析起来效率要快很多。
  • 压缩率:同样一个对象,xml/json 传输起来有大量的标签冗余信息,信息有效性低,二进制自定义协议占用的空间相对来说会小很多。
  • 扩展性与兼容性:是否能够利于信息的扩展,并且增加字段后旧版客户端是否需要强制升级,这都是需要考虑的问题,在自定义二进制协议时候,要做好充分考虑设计。
  • 可读性与可调试性:xml/json 的可读性会比二进制协议好很多,并且通过网络抓包是可以直接读取,二进制则需要反序列化才能查看其内容。
  • 跨语言:有些序列化协议是与开发语言紧密相关的,例如 dubbo 的 Hessian 序列化协议就只能支持 Java 的 RPC 调用。
  • 通用性:xml/json 非常通用,都有很好的第三方解析库,各个语言解析起来都十分方便,二进制数据的处理方面也有 Protobuf 和 Hessian 等插件,在做设计的时候尽量做到较好的通用性。

序列化算法

最简单的一种就是直接实现 JDK 自带的序列化接口 Serializable 就可以了,但是这种方式不支持跨语言调用,而且性能比较低。现在常用的序列化协议有 hessian,kyro,protostuff。另外 JSON 和 XML 这种文本类序列化方式,可读性比较好,但是性能也比较差。

JDK 序列化

1
2
3
4
5
6
public class RpcRequest implements Serializable{
private static final long serialVersionUID = 1L;
private String requestId;
private String interfaceName;
private String methodName;
}

这里的 serialVersionUID 是我们指定的序列化数据的版本,当对这个类的对象进行序列化操作的时候,serialVersionUID 会被写入到二进制序列中,当反序列化的时候会检查这个二进制序列的 serialVersionUID 是否和当前类的 serialVersionUID 相同,如果相同才会正常进行,否则就会抛出 InvalidClassException 异常。一般我们会手动指定 serialVersionUID,如果没有手动指定,编译器会自动生成默认的 serialVersionUID。如果想把一个 Java 对象变为 byte[]数组,需要使用 ObjectOutputStream。它负责把一个 Java 对象写入一个字节流
缺点:

  1. 不支持跨语言调用,其他语言无法使用
  2. 相比其他序列化框架封装的序列化功能性能较低,主要原因是序列化后的字节数组体积较大,传输成本高。

Kryo

Kryo 是一个高性能的序列化/反序列化工具,由于其变长存储的特性,并且使用了字节码生成机制(底部使用了 ASM 库),拥有较高的运行速度和较小的字节码体积。Kryo 作为一个成熟的序列化工具,在 Twitter,Groupon,Yahoo 等多个著名开源项目中都有广泛使用. 号称 Java 中最快的序列化框架
优点:接口易用、解析快、体积小
缺点:只支持 Java、增删字段会异常

Hessian2

Hessian 是动态类型、二进制、紧凑的,并且可跨语言移植的一种序列化框架。Hessian 协议要比 JDK、JSON 更加紧凑,性能上要币 JDK、JSON 序列化高很多,而且序列化的字节数也要更小。有非常好的兼容性和稳定性,所以 Hessian 更加适合作为 RPC 框架远程通信的序列化协议。
但是 Hessian 本身也有问题,比如:

  • Linked 系列,LinkedHashMap、LinkedHashSet 等,但是可以通过扩展 CollectionDeserializer 类修复
  • Locale 类,可以通过扩展 ContextSerializerFactory 类修复
  • Byte/Short 反序列化的时候编程 Integer

Protobuf

Protobuf 是 Google 内部的混合语言数据标准,是一种轻便、高效的结构化数据存储格式,可以用于结构化数据序列化,支持 Java、Python、C ++、Go 等语言。 Protobuf 使用时需要定义 IDL,使用不同语言的 IDL 编译器,生成序列化工具类
优点:

  • 序列化后体积相比 JSON 、Hessian 之类的小很多
  • IDL 能清晰地描述语义,保证应用程序之间的类型不会丢失,无需类似 XML 解析器
  • 序列化反序列化速度很快,不需要通过反射获取类型
  • 消息格式升级和兼容性不错,可以做到向后兼容

但是使用 Protobuf 对于具有反射和动态能力的语言来说使用起来很费劲,可以考虑使用 Protostuff。
Protostuff 不需要依赖 IDL 文件,可以直接对 Java 领域对象进行反/序列化操作,在效率上根 Protobuf 差不多,生成的二进制格式和 Protobuf 是完全相同的,可以说是一个 Java 版本的 Protobuf 序列化框架。
缺点:

  • 不支持 null
  • Protostuff 不支持单纯的 Map、List 集合对象,需要包在对象里面

JSON

JSON 是典型的 key-value 方式,没有数据类型,是一种文本型序列化框架。
JSON 序列化的两大问题:

  • JSON 进行序列化的额外空间开销比较大,对于数据量大的服务这意味着需要巨大的内存和磁盘开销。
  • JSON 没有类型,但像 Java 这种强类型语言,需要通过反射同一解决,性能不太好。

所以如果 RPC 框架选用 JSON 序列化,服务提供者与服务调用者之间传输的数据量要相对较小,否则将严重影响性能。一般来说 JSON 用在 HTTP 中多一些,因为具有较好的可读性。

  • FastJson 是阿里开源的 JSON 解析库。正如其名,“快”是其主要卖点。从官方的测试结果来看,FastJson 确实是最快的,比 Jackson 快 20% 左右,但是近几年 FastJson 的安全漏洞比较多,而且版本升级可能会存在较大的兼容问题,所以在选择的时候,还是需要谨慎一些。
  • Jackson 相对 FastJson 的功能比较多,安全漏洞也比较少,社区活跃。虽然性能相对于 Jackson 稍差,但是用着安心。但是其序列化结果的体积比较大,对 RPC 框架来说,还是不大适合的。

性能对比

摘自美团技术团队文章https://tech.meituan.com/2015/02/26/serialization-vs-deserialization.html

解析时间

image.png

解析空间

image.png

代码实现

定义序列化接口

1
2
3
4
5
6
7
8
9
@SPI
public interface Serializer {

SerializerTypeEnum getSerializerAlgorithm();

byte[] serialize(Object object);

<T> T deserialize(Class<T> clazz, byte[] bytes);
}

Kyro 序列化

Kryo 不是线程安全的。每个线程都应该有自己的 Kryo 对象、输入和输出实例。
因此在多线程环境中,可以考虑使用 ThreadLocal 或者对象池来保证线程安全性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class KryoSerializer implements Serializer {
/**
* Because Kryo is not thread safe. So, use ThreadLocal to store Kryo objects
*/
private final ThreadLocal<Kryo> kryoThreadLocal = ThreadLocal.withInitial(() -> {
Kryo kryo = new Kryo();
kryo.register(RpcResponse.class);
kryo.register(RpcRequest.class);
return kryo;
});

@Override
public SerializerTypeEnum getSerializerAlgorithm() {
return SerializerTypeEnum.KRYO;
}

@Override
public byte[] serialize(Object obj) {
try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
Output output = new Output(byteArrayOutputStream)) {
Kryo kryo = kryoThreadLocal.get();
kryo.writeObject(output, obj);
kryoThreadLocal.remove();
return output.toBytes();
} catch (Exception e) {
throw new SerializationException("Kryo Serialization failed");
}
}

@Override
public <T> T deserialize(Class<T> clazz, byte[] bytes) {
try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
Input input = new Input(byteArrayInputStream)) {
Kryo kryo = kryoThreadLocal.get();
T res = kryo.readObject(input, clazz);
kryoThreadLocal.remove();
return res;
} catch (Exception e) {
throw new SerializationException("Kyro Deserialization failed");
}
}
}

Hessian 序列化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class HessianSerializer implements Serializer {
@Override
public SerializerTypeEnum getSerializerAlgorithm() {
return SerializerTypeEnum.HESSIAN;
}

@Override
public byte[] serialize(Object object) {
try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
HessianOutput hessianOutput = new HessianOutput(byteArrayOutputStream);
hessianOutput.writeObject(object);
return byteArrayOutputStream.toByteArray();
} catch (Exception e) {
throw new SerializationException("Hessian Serialization failed:", e.getMessage());
}

}

@Override
public <T> T deserialize(Class<T> clazz, byte[] bytes) {
try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes)) {
HessianInput hessianInput = new HessianInput(byteArrayInputStream);
Object o = hessianInput.readObject();
return clazz.cast(o);
} catch (Exception e) {
throw new SerializationException("Hessian Deserialization failed:", e.getMessage());
}
}
}

Protostuff 序列化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class ProtostuffSerializer implements Serializer {
/**
* Avoid re applying buffer space every time serialization
*/
private static final LinkedBuffer BUFFER = LinkedBuffer.allocate(LinkedBuffer.DEFAULT_BUFFER_SIZE);

@Override
public SerializerTypeEnum getSerializerAlgorithm() {
return SerializerTypeEnum.PROTOSTUFF;
}

@Override
public byte[] serialize(Object obj) {
Class<?> clazz = obj.getClass();
Schema schema = RuntimeSchema.getSchema(clazz);
byte[] bytes;
try {
bytes = ProtostuffIOUtil.toByteArray(obj, schema, BUFFER);
} finally {
BUFFER.clear();
}

return bytes;
}

@Override
public <T> T deserialize(Class<T> clazz, byte[] bytes) {
Schema<T> schema = RuntimeSchema.getSchema(clazz);
T obj = schema.newMessage();
ProtostuffIOUtil.mergeFrom(bytes, obj, schema);
return obj;
}
}