Ohhnews

分类导航

$ cd ..
Baeldung原文

Gson反序列化中的InaccessibleObjectException问题与解决方案

#gson#java模块系统#反射#json反序列化#record类型

[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.xmlproperties 部分进行设置:

$ xml
<maven.compiler.release>17</maven.compiler.release>

接下来,我们检查 Gson 版本。应使用最新版本

$ xml
<dependency>
    <groupId>com.google.code.gson</groupId>
    <artifactId>gson</artifactId>
    <version>2.14.0</version>
</dependency>

4. Gson 与 Java 模块

让我们创建一个名为 gson-module 的简单 Java 项目。我们在 module-info.java 文件中声明 gson.exception 模块:

$ java
module gson.exception {
    requires com.google.gson;
    requires org.slf4j;
    exports gson.exception;
}

通过 requires 语句,我们声明自己的模块需要 Gson(以及用于日志记录的 SLF4J)。然后,通过 exports 语句,我们将自己的包 gson.exception 提供给其他模块使用。

现在,让我们添加一些用于描述会议(conference)的类。首先是一个经典的 POJO:

$ java
public class ConferencePojo {
    private String name;
    private int numberOfParticipants;
    // standard setters and getters
}

该对象存储会议的 name 和 numberOfParticipants。它拥有私有字段以及公开的 getter 和 setter。

接下来,我们准备一个等价的 Java record:

$ java
public record ConferenceRecord(String name, int numberOfParticipants) {
}

使用 record 时,我们无需声明字段,也无需编写 getter 和 setter。

4.1. POJO 情况下 Gson 的失败

类路径项目中,Gson 可以轻松反序列化 ConferencePojo 这样的 POJO,但在模块路径项目中却不行。为了证明这一点,我们将在模块化项目内运行 GsonModuleMain 应用。来看看它的 main 方法:

$ java
public static void main(String[] args) {
    String moduleName = GsonModuleMain.class.getModule()
      .getName();
    if (moduleName == null) {
        log.info("Mode: [ Class Path ] (Class in the Unnamed Module)");
    } else {
        log.info("Mode: [ Module Path ] - Module name: " + moduleName);
    }
    Gson gson = new Gson();
    String json = "{\"name\":\"Java Conference\"}";
    try {
        ConferencePojo pojo = gson.fromJson(json, ConferencePojo.class);
        log.info("Deserialization successful! Object " + pojo);
    } catch (Exception e) {
        log.error("Expected exception caught!", e);
    }
}

开头处,我们记录 JVM 是否确实从模块路径加载了我们的类。随后尝试反序列化该 POJO。让我们看看结果:

$ plaintext
10:40:06.766 [main] INFO gson.exception.GsonModuleMain -- Mode: [ Module Path ] - Module name: gson.exception
10:40:06.868 [main] ERROR gson.exception.GsonModuleMain -- Expected exception caught!
com.google.gson.JsonIOException: Failed making field 'gson.exception.ConferencePojo#name' accessible; either increase its visibility or write a custom TypeAdapter for its declaring type.
See https://github.com/google/gson/blob/main/Troubleshooting.md#reflection-inaccessible-to-module-gson
...
Caused by: java.lang.reflect.InaccessibleObjectException: Unable to make field private java.lang.String gson.exception.ConferencePojo.name accessible: module gson.exception does not "opens gson.exception" to module com.google.gson
...

Gson 抛出了一个由 InaccessibleObjectException 引发的 JsonIOException 从这两条消息中,我们可以了解发生了什么,并获得解决问题的提示。简而言之,在模块化项目中,除非我们允许,否则 Gson 无法访问我们类的私有字段。

4.2. 使用 record 成功

使用 record 时,我们会得到完全不同的结果。让我们用一个 JUnit 5 测试来演示:

$ java
@Test
void givenModularAndExportedPackage_whenDeserializingRecord_thenSuccess() {
    String json = """
        {
            "name": "Java Conference",
            "numberOfParticipants": 150
        }
        """;
    Gson gson = new Gson();
    ConferenceRecord result = assertDoesNotThrow(() -> {
        return gson.fromJson(json, ConferenceRecord.class);
    });
    assertNotNull(result);
    assertEquals("Java Conference", result.name());
}

这一次,Gson 成功反序列化了 JSON 并创建了 record。从 2.10 版本开始,Gson 在实例化对象时会使用 record 的规范构造器(canonical constructor),而不像普通类那样需要访问私有字段。

5. 如何反序列化 POJO

如果我们仍想使用 POJO,那就按照异常消息中的提示来做。我们有四种修复方式。

5.1. 将字段设为 public

让我们创建一个 ConferencePojo 的副本,使用 public 字段:

$ java
public class ConferencePojoPublic {
    public String name;
    public int numberOfParticipants;
}

现在 Java 允许 Gson 通过反射访问这些字段,反序列化就能成功。然而,这样做我们就失去了封装性。

5.2. 使用 opens 暴露私有字段

为了让 Gson 能够操作我们的私有字段,我们可以向它开放我们的包。让我们创建一个新项目 gson-module-opens,看看它的 module-info.java 文件:

$ java
module gson.exception {
    requires com.google.gson;
    requires org.slf4j;
    opens gson.exception to com.google.gson;
    exports gson.exception;
}

通过语句 opens gson.exception to com.google.gson,我们允许 Gson 访问 gson.exception 包中的私有字段。 让我们再次运行 GsonModuleMain 应用:

$ plaintext
19:08:55.369 [main] INFO gson.exception.GsonModuleMain -- Mode: [ Module Path ] - Module name: gson.exception
19:08:55.410 [main] INFO gson.exception.GsonModuleMain -- Deserialization successful! Object gson.exception.ConferencePojo@675d3402

这一次,程序没有抛出异常,即使在模块化项目中也能成功反序列化 JSON。

5.3. 使用 TypeAdapter 抽象类

如果我们不能使用 opens,可以为 POJO 扩展 Gson 的 TypeAdapter 抽象类。我们只需实现其 writeread 方法。由于我们关注反序列化,这里只看 read 方法:

$ java
@Override
public ConferencePojo read(JsonReader in) throws IOException {
    String name = null;
    int numberOfParticipants = 0;
    in.beginObject();
    while (in.hasNext()) {
        String key = in.nextName();
        if ("name".equals(key)) {
            name = in.nextString();
        } else if ("numberOfParticipants".equals(key)) {
            numberOfParticipants = in.nextInt();
        } else {
            in.skipValue();
        }
    }
    in.endObject();
    ConferencePojo result = new ConferencePojo();
    result.setName(name);
    result.setNumberOfParticipants(numberOfParticipants);
    return result;
}

read 方法返回一个 ConferencePojo 实例。它遍历 JsonReader 的所有条目,并根据 JSON 名称挑出相应字段。

注意,我们使用默认构造器创建 ConferencePojo 对象,再用 setter 填充它。这是关键点:要使用 TypeAdapter,我们需要一个用于创建对象的公共 API。 在本例中就是 setter,不过构造器、public 字段、工厂或构建器也同样可行。

现在让我们测试这个适配器:

$ java
@Test
void whenAdapterForPojo_thenSuccess() {
    Gson gson = new GsonBuilder()
      .registerTypeAdapter(ConferencePojo.class, new ConferencePojoAdapter())
      .create();
    String json = """
        {
            "name": "Java Conference",
            "numberOfParticipants": 100
        }
        """;
    ConferencePojo result = gson.fromJson(json, ConferencePojo.class);
    assertNotNull(result);
    assertEquals("Java Conference", result.getName());
}

注意,我们需要通过 GsonBuilder 注册该适配器。

5.4. 作为最后手段的 --add-opens 参数

当我们无法修改模块的源代码时,仍然还有一个选择。我们可以在程序启动时通过向 JVM 传递 --add-opens 参数来开放包。 让我们看看针对 gson-module 项目(它没有开放自己的包)所需传递的参数:

$ plaintext
--add-opens gson.exception/gson.exception=com.google.gson

从左到右依次是:在 module-info.java 文件中声明的模块名 gson.exception;接着是我们要开放的包 gson.exception;最后是我们授予访问权限的模块 com.google.gson

这是在启动时等价于 opens 语句的做法,有了它,gson-module 中的 GsonModuleMain 就能成功反序列化该 POJO。不过,我们应将其视为最后手段,因为该参数存在于代码之外,而且我们必须在每个启动配置中重复它。我们关于非法反射访问的文章更详细地介绍了相关背景。

6. Gson 与时间

除了模块化项目中的问题之外,在将 JSON 反序列化为 java.time API 中的日期时间类(例如 LocalDateInstantDuration)时,我们也可能遇到同样的异常。 在强封装加上较旧 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 项目中进行,因为其他字段都是私有的:

$ java
public class ConferencePojoWithDate {
    private String name;
    private int numberOfParticipants;
    private LocalDate conferenceStart;
    // standard setters and getters
}

现在让我们准备 JSON。对于 conferenceStart,我们需要使用嵌套结构:

$ cat
{
    "name": "Java Conference",
    "numberOfParticipants": 500,
    "conferenceStart": {
        "year": 2026,
        "month": 8,
        "day": 17
    }
}

注意,键 yearmonthdayLocalDate 的私有字段名完全一致。 其他时间类也是如此。例如,对于 Duration,嵌套的 JSON 提供秒和纳秒,如下所示:

$ cat
"duration": {
    "seconds": 7200,
    "nanos": 0
}

Gson 出于向后兼容性保留了这种命名约定。它不再(也不被允许)访问 Java 内部类的私有字段。取而代之的是,作者实现了内置的 TypeAdapter,使用公共的 java.time API 来传输数据。

我们尤其要小心使用正确的字段名。如果 Gson 在 JSON 中找不到匹配的键,它会静默回退到默认值零。对于 LocalDate 而言,月份为零会触发一个完全不同的异常,即来自 java.time 日期校验的 DateTimeException

其次,我们无法开箱即用地反序列化 ISO 格式的日期。 因此,像下面这样的纯字符串值会抛出 JsonSyntaxException

$ cat
"conferenceStart":"2026-08-17"

要处理以字符串编码的日期,我们需要实现自定义的 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