阅读 1435

「一探究竟」迷之序列化

⚠️本文为掘金社区首发签约文章,未获授权禁止转载


事件起因

今天,我需要上线一个非常小但是又非常重要的系统改动,即给核心接口的RPC接口出参增加序列化接口(由下图可见,原实体类未实现序列化)。

image-20210907025636984.png


编码、测试、代码审核一气呵成,然后收到驳回通知,架构师说实现序列化接口时注意不要忘记配置serialversionUID,还非常贴心的跟我说,IDEA 有一个插件可以自动生成UID,推荐我下载使用(IDEA serialversionUID 插件地址),按照要求调整之后,提测、编译、发布一气呵成,进入今天的午觉模式 (😎)


梦中惊魂

我突然梦见企业微信以每毫秒弹出一个窗口的速度不停的闪烁,周围的人熙熙攘攘,面露忧色,不知道在说些什么...

线上出问题了?和我有什么关系呢(🤪)肯定不是我的问题,不过为了保险起见,还是回忆一下今天都做了什么事吧。

**做了什么?**中台系统上线。**改了什么?**对部分类增加了序列化接口,并增加了serialversionUID... 会导致什么? 接口调用失败...COE...

蹭的一下,我立即从梦中醒来,开始看企业微信,看监控,看接口可用率,看了一切数据正常无误后才逐渐心安。


纳尼?我们不用Java序列化?

回顾自己所了解的关于序列化的知识,打开了各种关于序列化的文章,都给我指向了一个答案:我这种改动铁定会影响序列化,就像下面这样程序会报错。

Exception in thread "main" java.io.InvalidClassException: ser.demo.StuDemo; local class incompatible: stream classdesc serialVersionUID = 6395135316924936201, local class serialVersionUID = 1
	at java.io.ObjectStreamClass.initNonProxy(ObjectStreamClass.java:616)
	at java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:1843)
	at java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1713)
	at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2000)
	at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1535)
	at java.io.ObjectInputStream.readObject(ObjectInputStream.java:422)
	at ser.demo.App.main(App.java:27)
复制代码

现在线上没报错,只有一种可能,即:我们的RPC框架并没有使用原生的序列化方式。遇事不决架构师,咨询完毕之后果然和我猜测的一样,还从架构师的口中知晓了另外几种序列化方式,比如:MessagePack、Hessian等等。


常见序列化方式

Java序列化

Java序列化就是将一个对象转化为一个二进制表示的字节数组,通过保存或则转移这些二进制数组达到持久化的目的。

要实现序列化,需要实现java.io.Serializable接口,反序列化是和序列化相反的过程,就是把二进制数组转化为对象的过程,在反序列化的时候,必须有原始类的模板才能将对象还原,其核心方法在于以下两个方法,其中Serializable接口起到的作用是标识是否实现序列化、以及前后对象是否一致等作用。

序列化:java.io.ObjectOutputStream#writeObject0

反序列化:java.io.ObjectInputStream#readObject0

以测试类(StuDemo)为例,序列化后的结果如下:

// 序列化
FileOutputStream fos = new FileOutputStream("C:\\Users\\Kerwin\\Desktop\\log\\object.out");
ObjectOutputStream oos = new ObjectOutputStream(fos);
StuDemo demo = new StuDemo("Kerwin");
oos.writeObject(demo);
oos.flush();
oos.close();
   
// 结果如下
//  sr ser.demo.StuDemoX??莅	 L namet Ljava/lang/String;xpt Kerwin
复制代码

一堆乱码,但还是能看出来文件内容大致是指向某一个类,有什么字段、对应的值等信息。


MessagePack 序列化

MessagePack(简写Msgpack)是一个高效的二进制序列化格式,它让你像JSON一样可以在各种语言之间交换数据,但是它比JSON更快、更小。

更快更小就代表着性能更高,它是如何实现的?

Msgpack序列化的时候,字段不会标明Key,仅会按照字段的先后顺序存储,类似数组一样,它的编码方式是类型 + 长度 + 内容,如下所示:

image-20210907041011662.png

这种高效的编码方式就带来一些限制,例如:

  • 服务端不可随意在任意位置增加字段,因为客户端不升级的话会导致反序列化失败
  • 不能使用第三方包提供的集合类工具包作为返回值

使用方式如下:

// 其中 StuDemo 类需要增加 @Message 注解标识需要被MessagePack序列化
// MessagePack 序列化方式不需要依赖 Serializable
public static void main(String[] args) throws IOException {
    StuDemo demo = new StuDemo("Kerwin");
    MessagePack pack = new MessagePack();

    // 序列化
    byte[] bytes = pack.write(demo);

    // 反序列化
    StuDemo res = pack.read(bytes, StuDemo.class);
    System.out.println(res.getName());
}
复制代码

PS:我司的RPC框架目前就使用的MessagePack序列化方式,也是因为此,所以上述调整 serialVersionUID 时没有发生任何问题 同理,受制于底层序列化的限制,我们的新人文档中也明确提到了上述的限制,比如必须在最末尾增加字段等等。


Hessian2 序列化

Hessian是动态类型、二进制、紧凑的,并且可跨语言移植的一种序列化框架,在Hessian的基础之上,Hessian2的性能和压缩率大大提升。

Hessian会把复杂的对象所有属性存储在一个类似Map的结构中进行序列化,所以在父类、子类中存在同名成员变量的情况下,它先序列化子类,然后序列化父类,因此会导致子类同名成员变量的值被父类覆盖等情况。

它有八大核心设计目标,官网

  • 必须自我描述序列化类型,即不需要外部模式或接口定义
  • 必须与语言无关,包括支持脚本语言
  • 必须在一次传递中可读或可写
  • 必须尽可能紧凑(压缩)
  • 必须简单
  • 必须尽可能快
  • 必须支持Unicode字符串
  • 必须支持8位二进制数据
  • 必须支持加密

使用方式如下:

public class StuHessianDemo implements Serializable {

    private static final long serialVersionUID = -640696903073930546L;

    private String name;

    public StuHessianDemo(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}
复制代码
public static void main(String[] args) throws IOException {
    StuHessianDemo hessianDemo = new StuHessianDemo("Kerwin");

    ByteArrayOutputStream stream = new ByteArrayOutputStream();
    HessianOutput hessianOutput = new HessianOutput(stream);
    hessianOutput.writeObject(hessianDemo);

    ByteArrayInputStream inputStream = new ByteArrayInputStream(stream.toByteArray());

    // Hessian的反序列化读取对象
    HessianInput hessianInput = new HessianInput(inputStream);
    System.out.println(((StuHessianDemo) hessianInput.readObject()).getName());
}

// 结果:Kerwin
复制代码

选择的依据

由上文我们得知了几种常用的序列化方式,及其优劣,比如MessagePack就是极致的压缩和快,Hessian2则依赖Serializable接口,在保证安全性、自身描述性的基础上,尽可能的追求空间利用率,效率等,而Java序列化方式则一直被诟病,难等大雅之堂,因此在RPC框架选择底层序列化方式时,需要根据自身所需,有所侧重的选择某一项序列化方式。

选择的依据如下,优先级从高到低:

image-20210907051517893.png


一点思考

JSON序列化的地位

其实JSON序列化才是我们最熟知的序列化方式,它本身也不需要实现Serializable接口,为什么大多数RPC框架没有选择用它作为默认的序列化方式呢?

在了解完上文的内容后,我们知道关键还是在性能,效率、空间开销上,因为JSON是一种文本类型序列化框架,采用KEY-VALUE的方式存储数据,它在进行序列化的额外空间开销相对就更大,在反序列化时更不必说,需要依赖反射,因此性能进一步缩水。

然而JSON本身又具备极强的可读性、因此被作为Web中HTTP协议的事实标准。


为什么还要自定义 serialVersionUID

在《Effect Java》中有一句提到:

不管你选择了哪种序列化方式,都要为自己编写的每个可序列化的类声明一个显式的序列版本UID。

为什么架构师会提醒我实现它?为什么书中也会这么说?

serialVersionUID分解下来全称为:serial Version UID,序列版本UID,每一个可序列化的类都有一个long域中显式地指定该编号,如果编码者未定义的话,系统就会对这个类的结构运用一个加密的散列函数(SHA-1),从而在运行时自动产生该标识号,该编号会受类名称、接口名称、公有及受保护的成员变量所影响,一旦有相关改动例如增加一个不重要的公有方法即会影响UID,导致异常发生。

因此这是一个习惯问题,也是为了避免潜在风险。


总结

截止到这里,我们了解了原来之前学习到的Java序列化是那么的不实用(甚至到了被吐槽的地步),也知晓了一些框架使用注意事项底层的秘密(比如MsgPack增加字段),下面是关于序列化的一些小建议:

  1. 无论是否依赖Serializable,接口出参都建议实现序列化接口。
  2. 如果实现了序列化接口,务必自行实现serialVersionUID。
  3. 接口出参对象不宜使用特殊的数据类型(如MsgPack第三方集合等)、过于复杂的结构(继承等),不然会导致很多莫名其妙的问题发生。
  4. 当发生服务端/客户端数据不一致时,第一时间想到是序列化问题,并针对当前序列化方式的特点,仔细排查。

如果觉得这篇内容对你有帮助的话:

  1. 当然要点赞支持一下啦~
  2. 另外,可以搜索并关注公众号「是Kerwin啊」,一起在技术的路上走下去吧~ 😋

参考资料

  1. MsgPack官方网站
  2. 《Effect Java》
文章分类
后端