Loading

【RPC】RPC的序列化方式

序列化

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

在一般的高级程序设计语言中,对象都是需要加载到内存中的。在没有进行持久化之前,是不能进行网络传输的。
序列化只是特殊的持久化的方式。遵循特定的序列化协议,接收方才能正确地还原发送方传递过来的对象。

image.png
在 RPC 的通信模型中,最关键的是要克服网络传输的问题。使得跨越网络也能达到调用的效果,所以在 RPC 框架中,我们一般遵循以下的 RPC 通信模型。
image.png

参考文档:深入理解RPC—序列化_xiaofang233的博客-CSDN博客_rpc 序列化

JDK 原生序列化方式

JDK 原生支持对象的序列化,主要依赖于 Serializable 序列化接口。

/**
* @author Real
* @since 2022-10-29 15:44
*/
public class Student implements Serializable{

    private static final long serialVersionUID = 1931653827252088076L;

    private String name;
    private Integer age;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Integer getAge() {
        return age;
    }

    public void setAge(Integer age) {
        this.age = age;
    }

    @Override
    public String toString() {
        return "Student{" +
            "name='" + name + '\'' +
            ", age=" + age +
            '}';
    }

    public static void main(String[] args) throws IOException, ClassNotFoundException {
        Student student = new Student();
        student.setName("Real");
        student.setAge(22);
        FileOutputStream studentFos = new FileOutputStream("student.dat");
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(studentFos);
        // 将对象写入持久化文件
        objectOutputStream.writeObject(student);
        objectOutputStream.flush();
        objectOutputStream.close();
        // 将对象从持久化文件中读取出来
        FileInputStream fis = new FileInputStream("student.dat");
        ObjectInputStream ois = new ObjectInputStream(fis);
        Student deStudent = (Student) ois.readObject();
        ois.close();
        System.out.println(deStudent);
    }

}

序列化具体的实现是由 ObjectOutputStream 完成的,而反序列化的具体实现是由 ObjectInputStream 完成的。
image.png

  • 头部数据用来声明序列化协议、序列化版本,用于高低版本向后兼容。
  • 对象数据主要包括类名、签名、属性名、属性类型及属性值,当然还有开头结尾等数据,除了属性值属于真正的对象值,其他都是为了反序列化用的元数据。
  • 存在对象引用、继承的情况下,就是递归遍历“写对象”逻辑。

在序列化对象生成的文件中,包含一些无法识别的分隔符。
image.png
正是这些分隔符,反序列才能根据既定的序列化协议,将序列化文件读取到内存中。
实际上任何一种序列化框架,核心思想就是设计一种序列化协议,将对象的类型、属性类型、属性值一一按照固定的格式写到二进制字节流中来完成序列化,再按照固定的格式一一读出对象的类型、属性类型、属性值,通过这些信息重新创建出一个新的对象,来完成反序列化。

JSON

**JSON **是一种广泛用于前后端交互的一种数据格式,全称为 JavaScript Object Notation 。因为 JSON 是一种纯文本格式,且具有可读性,所以是一种非常适合用来传递信息的方式。JSON 的格式如下:

{
  "timestamp": 1667032472746,
  "status": 404,
  "error": "Not Found",
  "message": "No message available",
  "path": "/service/business/xxx"
}

JSON 虽然广泛用于前后端交互中,但是其性能并不好。

  • JSON 进行序列化的额外空间开销比较大,对于大数据量服务这意味着需要巨大的内存和磁盘开销;
  • JSON 没有类型,但像 Java 这种强类型语言,需要通过反射统一解决,所以性能不会太好。在前后端交互中,Java 通常需要依赖一些现有的 JSON 转换框架,比如 Jackson 、fastjson 等。

XML

XML ,可扩展标记语言 (Extensible Markup Language, XML)。类似于 JSON ,同样是一种可读的文本协议,格式如下:

<application android:label="@string/app_name" android:icon="@drawable/osg">
  <activity android:name=".osgViewer"
    android:label="@string/app_name" android:screenOrientation="landscape"> <!--  Force screen to landscape -->
    <intent-filter>
      <action android:name="android.intent.action.MAIN" />
      <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
  </activity>
</application>

XML 是由 <> 标记成对构成的。对于 XML 文件,占用的空间比 JSON 更大,效率比 JSON 更低,所以在对性能有要求的 RPC 框架中已经完全不使用 XML 文件了。
但是因为 XML 标签含有 Attribution 属性,可以指定标签对应的类型,与 Java 语言的强类型含义比较契合,所以常见于配置文件。

Hessian

Hessian 是一种动态类型、二进制、紧凑的,并且可跨语言移植的序列化框架。Hessian 协议要比 JDK、JSON 、XML 更加紧凑,性能上要比 JDK、JSON 、XML 序列化高效很多,而且生成的字节数也更小。
使用 Hessian 需要先引入对应的 Java 版本的依赖,使用上与 JDK 序列化方式相差不大。

public static void main(String[] args) throws IOException {
    Student student = new Student();
    student.setName("Real");
    student.setName("HESSIAN");
    // 将 student 对象转化为 byte 数组
    ByteArrayOutputStream bos = new ByteArrayOutputStream();
    Hessian2Output output = new Hessian2Output(bos);
    output.writeObject(student);
    output.flushBuffer();
    byte[] data = bos.toByteArray();
    bos.close();
    // 将刚才序列化出来的 byte 数组转化为 student 对象
    ByteArrayInputStream bis = new ByteArrayInputStream(data);
    Hessian2Input input = new Hessian2Input(bis);
    Student deStudent = (Student) input.readObject();
    input.close();
    System.out.println(deStudent);
}

Hessian 虽然具有不错的序列化性能,但是在使用时也有需要注意的地方:

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

Protobuf

参考文档:神奇的Google二进制编解码技术:Protobuf
浅谈protobuf

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

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

// 实例化
Msg msg;
msg.set_id(43);

编写了 IDL 之后,我们就能以比 Hessian 、JSON 更高效的编码方式完成序列化。那么在 Protobuf 中这是如何实现的呢?使用变长方式编码数据
不使用固定长度来表示数字,而需要使用变长方法来表示。
规定:对于每一个字节来说,第一个比特位如果是1那么表示接下来的一个比特依然要用来解释为一个数字,如果第一个比特为0,那么说明接下来的一个字节不是用来表示该数字的。
对两个 8bit 即两位的数据进行编解码,过程如下:

    1010110000000010  
->  10101100 | 00000010 // 解析得到两个字节
    _          _
 
->  0101100  |  0000010  // 各自去掉最高位 
->  0000010  |  0101100  // 两个字节翻转顺序

    0000010  +  0101100
->  100101100           // 拼接 

最后我们得到了 100101100,这一串二进制表示数字 300。

300 的二进制表示是 100101100。如果用 int32 变量来存储,需要 4 个字节:100101100。但显然只需要 2 个字节即可。
1、每个字节的第一位,叫做 msb(most significant bit),用于标识下一个字节是否还属于这个整数(1:属于;0:不属于)。
2、从右到左(从低位到高位),每7位一段(留1位给msb),高位不足用0补齐,得到:10 0101100。
3、反转字节序(因为要网络字节序),得到:0101100 10。如果只是借鉴思想,用于数据压缩,可以不要这步。
4、填充msb,得到:0101100 0000010。即300在protobuf中的存储,只用了2个字节。
参考文档:protobuf可变长编码的实现原理

这种数字的变长表示方法在 protobuf 中被称之为 varint。但是对于有符号的数字,还是无法进行表示。可以有的思路是:
既然无符号数字可以方便的进行变长编码,那么我们将有符号数字映射称为无符号数字不就可以了,这就是所谓的 ZigZag 编码。

原始信息      编码后
0            0 
-1           1 
1            2
-2           3
2            4
-3           5
3            6

...          ...

2147483647   4294967294
-2147483648  4294967295

有无符号数的表示问题解决之后,接下来需要解决的就是数据类型和数据名称的问题。在 Protobuf 中,数据类型是有限的。

参考文档:protobuf 数据类型 - 梯子教程网

我们可以将每一种数据类型做一个 Type 的映射,每一种数据类型用固定的 bit 值来表示,这样在数据类型本身就非常有限的情况下,占用的空间是非常低的。
接下来,数据字段名称该怎么表示呢?
int key_name = 100;
那么我们真的需要把 “key_name” 这么多字符通过网络传递给对端吗?
既然通信双方需要协议,那么“key_name”这字段其实是 client 和 server 都知道的,它们唯一不知道的就是“哪些值属于哪些字段”。

这里的理解为:如果 server 定义了需要编号为 2 的数据,那么 client 需要传递 Key 的编号为 2 的数据。这样在 IDL 语言的帮助下,Client 会将符合数据类型的数据编码为 2 ,server 接收到即可得知目标数据。
多参数的情况,我们可以给参数列表定义一个固定的顺序来表示参数名称的编号。参数名称的编号,我们可以依赖于参数列表的 IDL 定义的顺序。

为解决这个问题,我们给每个字段都进行编号,比如通信双方都知道“key_name”这个字段的编号是 2,那么对于 int key_name = 100; 这个信息我们只需要传递:

  • 字段名称:2 (2 对应字段“key_name”)
  • 字段类型:0 (0 表示 varint 类型)
  • 字段值:100

所以无论用多么复杂的字段名称也不会影响编码后占据的空间,字段名称根本就不会出现在编码后的信息中。
**Protobuf **对于嵌套格式的数据,一样存在对应的数据处理方式。
image.png
在这样的组织格式下, IDL 语言也有对应的实现。

message SubMsg {
optional int32 id = 1;
}
message Msg {
optional SubMsg msg = 1;
}

protobuf 不只是一种序列化协议,也是一门语言。它可以将可读性较好的消息编码为二进制从而可以在网络中进行传播,而对端也可以将其解码回来。
在这里 protobuf 中定义的消息就好比 C 语言,编码后的二进制消息就好比机器指令。也可以类比于 Java 语言,编码之后就变成二进制的 .class 文件交由 JVM 执行。
总结
Protobuf 二进制编码之所以快,是因为它对编译原理以及信息的编解码有着十分优秀的实践使用方法。主要归纳为以下几点:

  • 使用变长编码 varint 表示数字。
    • 第一个比特位如果是 1 那么表示接下来的一个比特依然要用来解释为一个数字,如果第一个比特为0,那么说明接下来的一个字节不是用来表示该数字的。
  • 使用 ZigZag 编码将有符号数字映射为无符号数字。
    • 将有符号数字映射称为无符号数字解决数字的符号问题,这就是所谓的 ZigZag 编码。
  • 使用给字段名编号的方式解决字段名的传输问题。
    • 给每个字段都进行编号,避免在 C/S 双方单纯传递字段名,改为只传递字段名编号,加快编码效率。
  • 使用字段类型映射编码的方式解决字段的类型传输问题。
    • Protobuf 支持的数据类型是有限的,给每种数据类型加上编号,以后每次的调用中只需要传输编号就能知道字段类型。

其他的序列化协议

序列化协议有非常多,除了以上提到的常见的序列化协议,还有 Message pack、kryo 、flatbuf 等等。

flatbuf 的性能其实比 protobuf 的性能更高,但是因为复杂程度、社区维护等原因,业界内的使用程度和知名度并不如 protobuf 。
参考文档:浅谈protobuf

在选择序列化协议的时候,除了性能、通用性,还会有更多维度的考量。选择的时候遵循如下的权重是一个比较好的选择。
image.png
在做技术选型的时候,如果没有特殊要求,通常也会根据这个逻辑进行。

其他问题

  • 对象构造得过于复杂:属性很多,并且存在多层的嵌套。序列化框架在序列化与反序列化对象时,对象越复杂就越浪费性能,消耗 CPU,这会严重影响 RPC 框架整体的性能;另外,对象越复杂,在序列化与反序列化的过程中,出现问题的概率就越高。
  • 对象过于庞大:比如为一个大 List 或者大 Map,序列化之后字节长度达到了上兆字节。这种情况同样会严重地浪费了性能、CPU,并且序列化一个如此大的对象是很耗费时间的,这肯定会直接影响到请求的耗时。
  • 使用序列化框架不支持的类作为入参类:比如 Hessian 框架,天然是不支持LinkHashMap、LinkedHashSet 的,而且大多数情况下最好不要使用第三方集合类,如 Guava 中的集合类,很多开源的序列化框架都是优先支持编程语言原生的对象。因此如果入参是集合类,应尽量选用原生的、最为常用的集合类,如 HashMap、ArrayList。
  • 对象有复杂的继承关系:大多数序列化框架在序列化对象时都会将对象的属性一一进行序列化,当有继承关系时,会不停地寻找父类,遍历属性。就像问题 1 一样,对象关系越复杂,就越浪费性能,同时又很容易出现序列化上的问题。

在 RPC 框架的使用过程中,我们要尽量构建简单的对象作为入参和返回值对象,避免上述问题。

总结

这归根结底还是因为服务调用的稳定性与可靠性,要比服务的性能与响应耗时更加重要。另外对于 RPC 调用来说,整体调用上,最为耗时、最消耗性能的操作大多都是服务提供者执行业务逻辑的操作,这时序列化的开销对于服务整体的开销来说影响相对较小。
在使用 RPC 框架的过程中,我们构造入参、返回值对象,主要记住以下几点:

  • 对象要尽量简单,没有太多的依赖关系,属性不要太多,尽量高内聚;
  • 入参对象与返回值对象体积不要太大,更不要传太大的集合;
  • 尽量使用简单的、常用的、开发语言原生的对象,尤其是集合类;
  • 对象不要有复杂的继承关系,最好不要有父子类的情况。

实际上,虽然 RPC 框架可以让我们发起全程调用就像调用本地,但在 RPC 框架的传输过程中,入参与返回值的根本作用就是用来传递信息的,为了提高 RPC 调用整体的性能和稳定性,我们的入参与返回值对象要构造得尽量简单,这很重要。

远程过程调用,终归是远程调用,需要经过网络 I/O 的过程。
本地调用,才是在统一的语言环境内,使用语言内存体系构建的调用栈。
两者区别的是一个网络 I/O 的过程,所以 RPC 的关键就是在网络 I/O 的过程中发生的链路问题。

posted @ 2022-10-30 23:58  雨下一整晚Real  阅读(67)  评论(0编辑  收藏  举报  来源