Java序列化Serializable的N种坑

业余草

共 6462字,需浏览 13分钟

 ·

2023-03-03 19:55

你知道的越多,不知道的就越多,业余的像一棵小草!

你来,我们一起精进!你不来,我和你的竞争对手一起精进!

编辑:业余草

来源:juejin.cn/post/7097515553497022472

推荐:https://www.xttblog.com/?p=5357

自律才能自由

本文档探讨了改进Java平台中的"序列化"的可能方向。这只是一份探索性文档,并不针对日后Java平台构建任何特定版本中任何特定功能的计划。

探讨的原因

Java的序列化有点自我矛盾:一方面,它可能对Java的成功至关重要(如果没有它,Java 可能不会占据主导地位,因为序列化采用了透明的远程处理,进而促成了Java EE的成功)。另一方面,Java 的序列化几乎犯下所有可以想象的错误,并为库维护者、语言开发人员和用户带来了持续的负担(以维护成本、安全风险和缓慢发展等形式)。

需要明确的是序列化概念本身并没有错;将对象转换为可以轻松跨JVM传输并在另一端还原的能力是一个完全合理的想法。问题在于Java中的序列化设计,以及ta是否适合当前的对象模型。

序列化有什么问题?

Java的序列化所犯的错误是多方面的。部分罪恶如下:

  • 「伪装成JDK库特性」:您通过实现Serializable接口来标记该类型允许被序列化,并使用ObjectOutputStream进行序列化。但实际上,序列化提取对象属性并通过特殊的、语言外的机制绕过构造方法并忽略类和字段的可访问性,创建新的对象。
  • 「假装是Serializable静态类型的特性」:实现Serializable接口实际上并不意味着对象是可序列化的,仅仅只能说明ta与序列化的操作并不敌对,因此即使实现了该接口,你并没有足够的信心来保证该对象可以被序列化成功。
  • 「编译器没法帮助你」:编写可序列化类时可能会犯各种错误,编译器无法帮助您识别它们,然后错误会在运行时被抛出。
/* 此处Comparator实现最好是可序列化的,但编辑器无法更好的提醒你 */
public TreeMap(Comparator<? super K> comparator) {
    this.comparator = comparator;
}
  • 「充斥大量的魔法」:有那么多影响序列化行为的“魔法”方法和字段(从某种意义上说,它们没有由任何基类或接口定义)。没有几个人可以准确无误的将ta们全部指出。又因为这些不存在于任何公共类型中,所以ta们很难被发现,并且无法轻松导航到它们的规范说明。ta们也很容易意外出错——如果您拼写错误,或者签名错误,或者在ta们应该是实例成员时将它们设为静态成员,没有人会告诉您。(我待会就偷偷告诉你😂😂😂)
{
    /* 列举Java中关于序列化的相关魔法 */
    
    /* 魔法字段 */
    private static final long serialVersionUID;
    
    private static final ObjectStreamField[] serialPersistentFields;
    
    /* 魔法方法 */
    void readObject(ObjectInputStream stream);
    
    void writeObject(ObjectOutputStream out);
    
    void readObjectNoData();
    
    Object readResolve();
    
    Object writeReplace();
}

写到这里笔者不禁想到一道面试八股文题目:ArrayList中的elementData明明被transient关键字修饰不参与序列化,为何序列化之后数据并没有丢失呢?原因就是ta重写了writeObject(ObjectOutputStream s)和readObject(ObjectInputStream s),Map同理。

  • 「无法回避的必要性」:如果你想要一个自定义的序列化形式,你可以实现方法readObject和 writeObject。但是人类无法轻易地阅读代码并推断出序列格式——它隐含在这些方法的主体中,您需要确保二者遵循同一约定,此外,如果您使用这些方法,则需要从一开始就构建版本控制机制(知易行难),否则当你想要作出改变并希望序列化可以保持兼容的时候,这将十分痛苦。
  • 「与代码紧密耦合」:序列化机制与其字节流的相关代码紧密耦合。这使得将序列化逻辑与其他编码格式(如JSON或XML)重用时变得不必要地困难;解构和重构对象的逻辑与读取和写入流的逻辑交织在一起。
  • 「被上帝抛弃的流格式」:我们被序列化流的格式所困扰,因为这种格式既不紧凑,也不高效,更不可读。

这些设计的恶果

如上文所述Java的这些序列化设计将直接或间接的带来很多严重的问题:

  • 「增加开源库的维护者负担」:Library设计者在发布可序列化的类之前必须仔细考虑——只有这样做才可能会使您保持与所有已序列化的实例的兼容性。
  • 「这是对封装的嘲弄」:对象的序列化实际上是其成员属性的序列化,并且通过一种绕过用户编写的构造方法的非语言机制的方式进行对象的还原。所以选择可序列化意味着放弃封装的好处。序列化构成了一个不可见但公共的构造方法,以及一组不可见但公共的内部状态访问器。这意味着很容易将坏数据注入到可序列化的类中,除非您痛苦地在构造方法和readObject()之间重复检查参数,这种情况下,您就背离了 Java序列化机制的初衷:它应该是不需要额外耗费心力的。
  • 「不能仅仅通过阅读代码来验证正确性」:在面向对象的系统中,构造函数的作用是初始化一个对象并建立其不变量(如果成员变量恰好经过final修饰)。这允许系统的其余部分假定一个基本的对象完整性程度。理论上,我们应该能够通过阅读其构造方法和任何改变属性的方法来推断对象可能处于的状态。但是由于序列化构成了一个隐藏的公共构造器,您还必须根据以前版本的代码(其源代码甚至可能不再存在,更不用说恶意构造的字节流)来推断对象可能处于的状态。通过绕过构造方法,序列化彻底颠覆了对象模型的完整性。
  • 「难以确定其安全性」:对序列化的安全漏洞的多样性和微妙性令人印象深刻。没有一个普通的开发人员可以一次将它们全部放在脑海中。即使是安全专家代码中的漏洞也可能从他眼皮底下溜走。确保代码序列化值得信任太难了——因为序列化操作大多是不可见的,并且由神秘的的底层机制控制。
  • 「阻碍语言进化」:编程语言的复杂性来自特性之间的意外交互,而序列化几乎与一切交互。可能添加到语言中的每个特性都必须以某种方式与序列化相结合。Java内存模型的一些细节是被序列化需要在对象构造后写入final字段所驱使的。(想一想:内存模型应该描述语言与硬件的底层交互,但是我们却需要扭曲ta以适应序列化!)Lambdas设计工作的一个重要部分涉及到与序列化的交互——我们所能做到最好的实现是一个没有人会喜欢的妥协的让步的结果。语言发展被迫长久背负序列化这个历史包袱。

错误的源头

上面列举的诸多设计错误都源于一个共同的来源——通过“魔术”来实现序列化,而不是在对象模型本身中将解构和重建放在首位。手机对象的字段是魔术;通过语言外的后门重建对象则更神奇。使用这些语言外机制意味着我们在对象模型之外,因此我们只得放弃对象模型为我们提供的许多好处。

更糟糕的是,魔法会尽可能的让工作者无感。假如魔法的附近有警告,那将会是另一回事——至少我们需要停下思考一下是不是会有预期之外的代码逻辑被执行。但是由于魔法是在无形之中损害程序,所以我们仍然认为我们的主要工作是设计健壮的API和实现我们的业务逻辑,而实际上我们已经打开了后门,没有任何防备。

魔法的诱惑是显而易见的;只要在您的「Class」上撒上一些序列化粉末,瞧:即时透明远程处理!但累积起来的成本是沉重的。在「Goto Considered Harmful」一文中,E•W•Dijkstra提供了一个合理依据,解释了为什么带有「goto」的语言会给开发人员带来不合理的认知负担。相同的论点同样适用于当前的序列化。

我们的智力能力倾向于掌握静态关系,而将随时间演变的过程形象化的能力则相对落后。因此,我们应该最大限度地缩短静态程序与动态过程之间的概念鸿沟:使程序(在文本空间维度展开)与执行过程(在时间维度展开)之间的对应关系尽可能地细小入微。(笔者注:平铺直叙的代码总是更加可读,但是用了goto关键字,代码执行顺序,就与代码文本从上至下的顺序有些出入,我们需要想象一下执行的动态过程,就好像递归怎么也没有迭代好理解)

序列化,正如它目前实现的那样,代码文本和计算效果之间如此悬殊。

为什么不写一个新的序列化库

市面上现存大量的第三方类库,它们要么旨在成为序列化“替代品”,要么是某些特定场景下的序列化的有效”替代品“。(包含但不仅限于:Arrow、Avro、Bert、Blixter、Bond、Capn Proto、CBOR、Colfer , Elsa, Externalizor, FlatBuffers, FST, GemFire PDX, Gson, Hessian, Ion, Jackson, JBoss Marshaling, JSON.simple, Kryo, Kudu, Lightning, MessagePack, Okapi, ORC, Paranoid, Parcelable, Parquet, POF, Portable, Protocol Buffers、Protostuff、Quickser、ReflecT、Seren、Serial、Simple、Simple Binary Encoding、SnakeYAML、Stephenerialization、Thrift、TinySerializer、travny、Verjson、Wobly、Xson、XStream、YamlBeans 等等)。

其中不乏一些流行的跨语言的序列化解决方案(例如 CBOR、Protocol Buffers)。种种迹象表明大多数人期许“更好的”的序列化。值得发问:怎么才算“更好“呢?现有的环境孵化了如此之多的序列化类库说明诸多方案总是不能完美满足开发者需求(例如:考虑人类可读性或互通性,JSON是一个相对优秀的选择,但是有的人认为ta低效且易出错)。同时我们发现,这些类库往往只是关注编码格式、效率、灵活性,却很少有人试图解决基本的编程模型或安全隐患。

怎样科学的做

正如我们所说,将对象序列化为字节流的概念根本没有错。想避免到目前为止所描述的问题,那就不得不调整我们的目标和各种需求的优先级。我们先约定一些术语并尝试陈述一些更好的实践路线。

对于本文其余部分:

  1. 序列化将指将对象转换为字节流并重构它们的抽象概念
  2. 序列化框架是指实现某种形式的序列化的库或工具
  3. Java序列化将指的是内置在平台中并由《Java对象序列化规范》https://docs.oracle.com/en/java/javase/12/docs/specs/serialization/index.html定义的特定序列化框架

减少期望

我们在上面已经注意到,Java序列化的一个问题是它试图做太多的事情。现实场景中对序列化机制的期望通常并没有那么苛刻;应用程序使用序列化来持久化数据,或与其他应用程序交换数据。关注的重心在于数据而非对象。

更加显式的设计

Java序列化是透明的;这被认为是它的主要优点之一。但是这种透明性也是一个弱点,我们很容易忘记我们处理的是一个可序列化的类。Java为开发者构建健壮、安全的Api提供了很好的帮助。开发人员知道如何编写构造方法来验证其参数、对可变数据进行防御性拷贝,以及如何使用非公共成员将某些操作排除在对外公开API之外。但是Java序列化构成了一个隐式的公开API,而且由于它通常是不可见的,所以很容易忘记保护它。

我们设计一个「class」时,通常考虑它的更典型的情景——面向他方访问的公开Api以及拓展对象自身能力的私有api。我们称其为“Front Door Api”或“User-Facing Api”。但是仍有一些特殊情景,例如序列化、mock或依赖注入等框架。我们通常会公开一些Api,这些Api是供框架使用的,可能并不想要公开给普通开发人员。我们称呼ta们为“Back Door Apis”。问题不在于我们有“Back Door Apis”;而是ta们是隐式的,因此对于「class」作者来说很难保护。保护“Back Door Apis”应该和保护“Front Door Api”一样容易,理想情况下,我们可以使用相同的办法来实现这一点。至少让开发者不能彻底忘记、无视ta们。

对象模型中引进序列化

如果序列化的主要问题与它的语言外特性有关,那么解决方法是将序列化带回到语言和对象模型中,以便开发人员可以对“Front Door Api”和“Back Door Apis”一视同仁。这意味着不仅提供对类是否可序列化的显式控制,而且还提供对如何进行序列化和反序列化以及由谁序列化和反序列化的控制。一些基本要求包括:

  1. 可序列化的类应该是为序列化而设计的;开发者应该提供解构和重构对象的类成员。阅读源代码或文档时应该可以清楚地看出它是为序列化而设计的。
  2. 开发者应该控制他们的类的序列化形式。序列形式应该在代码中体现出来,以便读者可以阅读、推理。
  3. 反序列化的对象应该通过普通的构造方法或工厂创建,以获得有效性检查和对可变数据进行防御性拷贝的全部好处。用于反序列化的构造函数可以但不必与“Front Door Api”共享。(笔者注:支持反序列化的类,针对创造对象、和反序列化还原出新对象的两种情景,最好能够分离Api)

后续

虽然笔者不想承认但是不得不诚实的说:这是目前耗费我最多心血的一篇博客,可惜的是还并非原创,笔者只是在翻译(或者说自以为已经理解了部分真意的转述)他人的智慧结晶。

在行文过程中加入了一些自己的思考与理解,原作的内容要更加丰富多彩,论述更加睿智精确,可惜笔者能力有限,在穷尽心力之后也没有交出满意答卷。完成一半内容以后,我决定暂时搁浅继续下去的想法。待有了更深入的理解,方敢续接上文。

本文翻译自:《Towards Better Serialization》https://openjdk.java.net/projects/amber/design-notes/towards-better-serialization

浏览 40
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报