实践中的记录序列化

简介 了解序列化框架如何支持记录类。

DJ Duke scratching on vynil records


记录类和序列化

序列化是提取对象状态并将其转换为持久格式的过程,可以从该格式构造等效对象。记录类(现在在 Java 16 中已最终确定)是语义约束类,其设计自然符合序列化的需求。

对于普通的 Java 类,由于它们可以自由地对可扩展行为和可变状态进行建模,因此序列化很快就会变得非常复杂。相比之下,记录使事情变得简单:它们是声明不可变状态并提供用于初始化和访问该状态的 API 的简单数据载体。例如,以下是 Point 记录类的声明

record Point (int x, int y) { }

如您所见,该语言提供了一种简洁的语法来声明记录类,其中记录组件在记录标头中声明。记录标头中声明的记录组件列表构成记录描述符。记录描述符描述状态并驱动简洁的 API。记录类有一个规范构造函数,其参数列表与记录描述符的参数列表匹配 - 这用于初始化状态。可以通过其组件值检索记录(记录类的实例)的状态,并且可以通过同名访问器访问每个组件。

从这个设计中流出了 Java 对象序列化 使用的简单记录序列化协议,该协议基于两个属性

  1. 记录的序列化仅基于其状态组件,并且
  2. 记录的反序列化仅使用规范构造函数。

记录仅包含其状态,其序列化形式是在没有任何自定义的情况下根据其状态建模的。在序列化期间,访问器用于在状态转换为序列化形式之前读取该状态。在反序列化期间,将调用规范构造函数(其参数是已知的,因为它们与状态描述相同),这是创建记录的唯一方法。

对于普通的 Java 类,事情要复杂得多,序列化框架喜欢使用某些后门技术来处理这种复杂性。例如,序列化期间的一种常见做法是抓取私有字段;这仅在抑制 Java 语言访问控制检查时才有效。在反序列化期间,通常使用核心反射来设置新反序列化对象的私有状态。从 JDK 15 开始,这对于记录对象不再可能,即使使用具有足够权限的代码也是如此(有关更多详细信息,请参阅此 JIRA 问题)。相反,实例化记录类的唯一方法是调用其规范构造函数。

无论这种新颖的强制执行如何,实际上都不需要侵入式反射访问和变异。记录类通过公开其状态和通过明确指定的方法进行重建的方法,提供了序列化所需的所有 API 点 - 不使用它们将是一种耻辱。采用特定于记录的序列化协议是一个机会,可以使序列化框架更安全、更易于维护且更易于使用。因此,我们努力帮助现有框架充分支持记录。


支持记录的序列化框架

我们与三个流行的基于 Java 的序列化框架(Jackson、Kryo 和 XStream)进行了合作,以促进记录序列化。

框架 Jackson Kryo XStream
序列化格式 JSON 二进制 XML
构建 JDK 8 8, 11 8

Jackson 是我们参与的第一个项目,事实上,当我们在 2020 年 6 月联系时,一位贡献者已经在致力于记录支持。在代码在两个月后集成之前,我们提供了一些审查意见和建议。大约在同一时间,Kryo 的一位用户提出了一个问题,这是我们参与的起点,最终导致了一个拉取请求 (PR)。对于 XStream,我们创建了一个问题,并在不久之后创建了一个 PR。这两个 PR 均于 2021 年 3 月成功集成。

Jackson
https://github.com/FasterXML/jackson
https://github.com/FasterXML/jackson-future-ideas/issues/46
https://github.com/FasterXML/jackson-databind/pull/2714

Kryo
https://github.com/EsotericSoftware/kryo
https://github.com/EsotericSoftware/kryo/issues/735
https://github.com/EsotericSoftware/kryo/pull/766

XStream
https://github.com/x-stream/xstream
https://github.com/x-stream/xstream/issues/210
https://github.com/x-stream/xstream/pull/220

虽然每个项目都有自己的架构和约定,但我们想分享一些支持记录的常见要素。


通用方法

支持记录的基本思想在这三个框架中是相同的:实现特定于记录的序列化器/反序列化器并将其集成到现有项目中。借助您可以在 此处 找到的一些实用方法,可以相当轻松地完成实现。

更详细地查看实现,一个重要方面是 JDK 版本兼容性。所讨论的框架通常使用它们支持的最旧 JDK 进行编译,以便在运行时可以使用等于或高于编译版本的任何 JDK。记录最初是作为 Java 14 中的 预览功能 引入的,因此为了避免对 Java 14+ 的静态依赖,必须在运行时确定记录的存在。为此,Java 14 添加了一个特定方法 Class::isRecord,如果该类是记录类,则返回 true,否则返回 false。如果该方法不存在,则 Java 运行时不支持记录。此外,还引入了另一个方法和一个新类型:Class::getRecordComponents,它返回一个 java.lang.reflect.RecordComponent 对象数组,该数组表示此记录类的记录组件。RecordComponent 提供有关记录组件的信息以及对其的动态访问,特别是其名称和类型(RecordComponent::getNameRecordComponent::getType)。

这几个原语是实现记录序列化的关键要素。例如,反序列化期间记录类的实例化如下所示

Class<?>[] paramTypes = Arrays.stream(cls.getRecordComponents())
                                .map(RecordComponent::getType)
                                .toArray(Class<?>[]::new);
MethodHandle MH_canonicalConstructor =
        LOOKUP.findConstructor(cls, methodType(void.class, paramTypes))
                .asType(methodType(Object.class, paramTypes));
MH_canonicalConstructor.invokeWithArguments(args);

此代码示例使用 MethodHandles 进行反射调用(请参阅示例代码中的 invoke 包)。这是一个实现细节,使用核心反射也可以实现相同的结果(请参阅示例代码中的 reflect 包)。话虽如此,java.lang.invoke 中的方法句柄 API 提供了一组有趣的低级操作,用于查找、调整、组合和调用方法或设置字段,如果使用得当,可以提高效率。

有了这些工具,序列化的实际机制就很简单了。在序列化期间,获取记录组件并将其转换为序列化形式。在反序列化期间,在将值传递给记录类的规范构造函数以创建对象之前读取这些值。序列化形式取决于序列化格式和框架的约定。在 Kryo 的情况下,序列化数据的使用者假设数据的形状,这意味着序列化形式可以简化为仅包含组件值。这导致了非常紧凑的序列化形式。在 XStream 的情况下,序列化形式包含组件的名称和值以及相应的类名。数据的形状不太简化,它不必反映类的形状,因为可以通过名称匹配值。通常,类的形状在序列化形式中捕获得越多,反序列化过程就越灵活。另一方面,更紧凑的序列化形式依赖于反序列化期间的特定假设,这些假设可以提高存储和内存效率。

另一个有趣的方面是序列化形式中记录组件表示的顺序。一种方法是应用特定的顺序,一个明显的选择是记录声明中组件的顺序。Class::getRecordComponents 返回的数组遵循此顺序,规范构造函数的参数列表也是如此。按照此顺序,可以将值顺序写入流并从流中读取,并直接传递给规范构造函数。

但是,如果记录类随着时间的推移而演变,例如,如果我们更改了其组件的顺序,该怎么办?在序列化期间,我们现在无法再确定组件顺序,并且上述相当静态的方法将无法令人满意。为了支持这种类型的记录类演变,我们需要允许序列化形式更加灵活。更确切地说,实现必须提供某种匹配或排序算法,以便将流值正确映射到规范构造函数的参数列表。这里的一种选择是按名称对序列化形式中的记录组件进行字典排序(如 Kryo 的解决方案中所做的那样)。对序列化形式中的记录组件进行排序不仅支持记录类型版本控制,而且也符合普通类的做法。这样做的好处是,可以促进将来从普通类到记录类的转换。

从实现转向测试,这里也需要处理对 Java 14+ 的依赖关系。测试代码必须与依赖于旧 JDK 版本的测试分开存储,并且只有在存在 Java 14+ 的情况下才能编译和执行。Maven 被所有三个框架用作构建和依赖管理工具,其构建配置文件可用于根据检测到的 JDK 版本有条件地编译测试。根据现有的构建过程,此配置可能会有些棘手,但某些插件可以帮助设置源代码和测试代码的编译和执行。使用 Java 14 和 15 时,需要某些标志来启用预览功能,特别是 --enable-preview。对于 Java 16,记录是最终版本,因此不再需要此标志。


结论

Java 记录可以为序列化框架增加价值。我们展示了三个成功添加记录支持的框架,并概述了它们的通用方法。有了这些,支持记录就变得轻而易举了——无论您是否是框架开发人员——我们都希望您尝试一下。