Gson反序列化中的InaccessibleObjectException问题与解决方案
[LOADING...]
1. 概述
从版本 9 开始,Java 高度重视封装。其背后的主要特性是 Java 平台模块系统(JPMS)。它既控制我们自己的包如何对外暴露,同时也限制对 Java 内部类的访问。然而,许多库严重依赖反射来访问私有成员。因此,我们需要以受控的方式放宽这些限制。
在本教程中,我们将学习在使用 Gson 反序列化 JSON 时如何避免 InaccessibleObjectException。我们会重点讨论模块化项目以及日期处理。
2. Gson 现状
Gson 提供了便捷的工具,用于将对象序列化为 JSON 以及反向反序列化,它依赖反射来访问私有字段。在强封装机制启用后,这种方式不再能无缝工作,无论是对我们自己的对象,还是对 Java 的内部类都是如此。最新的 Gson 版本已经朝公共 API 迈出了一小步,特别是针对 record 和 日期。
Gson 目前处于维护模式。因此,我们不应期待任何新功能,只会有缺陷修复和安全修复。
3. 项目搭建
让我们看看将要使用的 pom.xml。我们需要 Java 17 来演示强封装,并使用其对 record 的完整支持。因此,我们在 pom.xml 的 properties 部分进行设置:
接下来,我们检查 Gson 版本。应使用最新版本:
4. Gson 与 Java 模块
让我们创建一个名为 gson-module 的简单 Java 项目。我们在 module-info.java 文件中声明 gson.exception 模块:
通过 requires 语句,我们声明自己的模块需要 Gson(以及用于日志记录的 SLF4J)。然后,通过 exports 语句,我们将自己的包 gson.exception 提供给其他模块使用。
现在,让我们添加一些用于描述会议(conference)的类。首先是一个经典的 POJO:
该对象存储会议的 name 和 numberOfParticipants。它拥有私有字段以及公开的 getter 和 setter。
接下来,我们准备一个等价的 Java record:
使用 record 时,我们无需声明字段,也无需编写 getter 和 setter。
4.1. POJO 情况下 Gson 的失败
在类路径项目中,Gson 可以轻松反序列化 ConferencePojo 这样的 POJO,但在模块路径项目中却不行。为了证明这一点,我们将在模块化项目内运行 GsonModuleMain 应用。来看看它的 main 方法:
开头处,我们记录 JVM 是否确实从模块路径加载了我们的类。随后尝试反序列化该 POJO。让我们看看结果:
Gson 抛出了一个由 InaccessibleObjectException 引发的 JsonIOException。 从这两条消息中,我们可以了解发生了什么,并获得解决问题的提示。简而言之,在模块化项目中,除非我们允许,否则 Gson 无法访问我们类的私有字段。
4.2. 使用 record 成功
使用 record 时,我们会得到完全不同的结果。让我们用一个 JUnit 5 测试来演示:
这一次,Gson 成功反序列化了 JSON 并创建了 record。从 2.10 版本开始,Gson 在实例化对象时会使用 record 的规范构造器(canonical constructor),而不像普通类那样需要访问私有字段。
5. 如何反序列化 POJO
如果我们仍想使用 POJO,那就按照异常消息中的提示来做。我们有四种修复方式。
5.1. 将字段设为 public
让我们创建一个 ConferencePojo 的副本,使用 public 字段:
现在 Java 允许 Gson 通过反射访问这些字段,反序列化就能成功。然而,这样做我们就失去了封装性。
5.2. 使用 opens 暴露私有字段
为了让 Gson 能够操作我们的私有字段,我们可以向它开放我们的包。让我们创建一个新项目 gson-module-opens,看看它的 module-info.java 文件:
通过语句 opens gson.exception to com.google.gson,我们允许 Gson 访问 gson.exception 包中的私有字段。 让我们再次运行 GsonModuleMain 应用:
这一次,程序没有抛出异常,即使在模块化项目中也能成功反序列化 JSON。
5.3. 使用 TypeAdapter 抽象类
如果我们不能使用 opens,可以为 POJO 扩展 Gson 的 TypeAdapter 抽象类。我们只需实现其 write 和 read 方法。由于我们关注反序列化,这里只看 read 方法:
read 方法返回一个 ConferencePojo 实例。它遍历 JsonReader 的所有条目,并根据 JSON 名称挑出相应字段。
注意,我们使用默认构造器创建 ConferencePojo 对象,再用 setter 填充它。这是关键点:要使用 TypeAdapter,我们需要一个用于创建对象的公共 API。 在本例中就是 setter,不过构造器、public 字段、工厂或构建器也同样可行。
现在让我们测试这个适配器:
注意,我们需要通过 GsonBuilder 注册该适配器。
5.4. 作为最后手段的 --add-opens 参数
当我们无法修改模块的源代码时,仍然还有一个选择。我们可以在程序启动时通过向 JVM 传递 --add-opens 参数来开放包。 让我们看看针对 gson-module 项目(它没有开放自己的包)所需传递的参数:
从左到右依次是:在 module-info.java 文件中声明的模块名 gson.exception;接着是我们要开放的包 gson.exception;最后是我们授予访问权限的模块 com.google.gson。
这是在启动时等价于 opens 语句的做法,有了它,gson-module 中的 GsonModuleMain 就能成功反序列化该 POJO。不过,我们应将其视为最后手段,因为该参数存在于代码之外,而且我们必须在每个启动配置中重复它。我们关于非法反射访问的文章更详细地介绍了相关背景。
6. Gson 与时间
除了模块化项目中的问题之外,在将 JSON 反序列化为 java.time API 中的日期时间类(例如 LocalDate、Instant 或 Duration)时,我们也可能遇到同样的异常。 在强封装加上较旧 Gson 版本的情况下,我们会得到一条 JsonIOException,消息为 "Failed making field 'java.time.LocalDate#year' accessible",同样由 InaccessibleObjectException 引发。这一次,Gson 试图访问的是 Java 内部类的私有字段。此外,这个问题无法通过 opens 语句解决,因为它只能开放我们自己的包,而不能开放 Java 的内部结构。不过,Gson 2.14.0 已恢复对这些类的支持。
6.1. LocalDate 与结构化 JSON
让我们在一个新的 ConferencePojoWithDate 类中添加 conferenceStart 字段。我们继续在 gson-module-opens 项目中进行,因为其他字段都是私有的:
现在让我们准备 JSON。对于 conferenceStart,我们需要使用嵌套结构:
注意,键 year、month 和 day 与 LocalDate 的私有字段名完全一致。 其他时间类也是如此。例如,对于 Duration,嵌套的 JSON 提供秒和纳秒,如下所示:
Gson 出于向后兼容性保留了这种命名约定。它不再(也不被允许)访问 Java 内部类的私有字段。取而代之的是,作者实现了内置的 TypeAdapter,使用公共的 java.time API 来传输数据。
我们尤其要小心使用正确的字段名。如果 Gson 在 JSON 中找不到匹配的键,它会静默回退到默认值零。对于 LocalDate 而言,月份为零会触发一个完全不同的异常,即来自 java.time 日期校验的 DateTimeException。
其次,我们无法开箱即用地反序列化 ISO 格式的日期。 因此,像下面这样的纯字符串值会抛出 JsonSyntaxException:
要处理以字符串编码的日期,我们需要实现自定义的 TypeAdapter。
最后要强调的是,同样的规则也适用于 record:我们可以开箱即用地反序列化嵌套的时间结构,否则就需要一个 TypeAdapter。## 7. 结论
在本文中,我们探讨了在现代模块化 Java 中使用 Gson。我们发现,它不再像在 Java 8 和类路径(classpath)下那样易于使用。
我们研究了模块化项目中的这个问题,并考察了几种让 Gson 符合 JPMS 规则的方法。第一步,我们将类字段设为 public。接着,我们通过 opens 语句,或使用等效的 --add-opens JVM 参数,让 Gson 能够访问私有字段。
然后,我们实现了一个 TypeAdapter,通过对象的公共 API 来创建对象。我们还发现,Gson 可以开箱即用地支持 record 类,并使用其规范构造器。
最后,我们检查了 Gson 如何处理 java.time 包中的类。我们注意到,最新版本再次能够按预期反序列化嵌套的时间对象。
总之,避免 InaccessibleObjectException 主要有两种方式。 第一种是向 Gson 的反射开放我们的代码;通过 opens 语句,我们可以仅向 Gson 授予该访问权限。第二种是改用 record 类,并遵守 JPMS 的规则。此外,如果我们有用于创建对象的公共 API,就可以实现一个 TypeAdapter。