为啥 Kotlin 数据类可以在 Gson 的不可空字段中包含空值?

Posted

技术标签:

【中文标题】为啥 Kotlin 数据类可以在 Gson 的不可空字段中包含空值?【英文标题】:Why Kotlin data classes can have nulls in non-nullable fields with Gson?为什么 Kotlin 数据类可以在 Gson 的不可空字段中包含空值? 【发布时间】:2019-03-21 02:48:19 【问题描述】:

在 Kotlin 中,您可以创建 data class:

data class CountriesResponse(
    val count: Int,
    val countries: List<Country>,
    val error: String)

然后您可以使用它来解析 JSON,例如“n: 10”。在这种情况下,您将有一个对象val countries: CountriesResponse,从RetrofitFuelGson 接收,其中包含以下值:count = 0, countries = null, error = null

在Kotlin + Gson - How to get an emptyList when null for data class 你可以看到另一个例子。

当您稍后尝试使用countries 时,您将在此处收到异常:val size = countries.countries.size:“kotlin.TypeCastException: null 不能转换为非 null 类型 kotlin.Int”。如果您编写代码并在访问这些字段时使用?android Studio 将突出显示?. 并警告:Unnecessary safe call on a non-null receiver of type List&lt;Country&gt;

那么,我们应该在数据类中使用? 吗?为什么应用程序可以在运行时将null 设置为不可为空的变量?

【问题讨论】:

【参考方案1】:

这是因为 Gson 使用不安全(如java.misc.Unsafe)实例构造机制来创建类的实例,绕过它们的构造函数,然后直接设置它们的字段。

有关一些研究,请参阅此问答:Gson Deserialization with Kotlin, Initializer block not called。

因此,Gson 忽略了构造逻辑和类状态不变量,因此不建议将其用于可能受此影响的复杂类。它也忽略了 setter 中的值检查。

考虑一个支持 Kotlin 的序列化解决方案,例如 Jackson(在上面链接的问答中提到)或 kotlinx.serialization

【讨论】:

目前我在所有可疑字段的类型之后设置?,可以是null。崩溃消失了。 在使用 Gson 时,我也在为不可为空的字段中的空值而苦苦挣扎。因此,我为 Gson 编写了一个包装器,用于检查此类无效的空值。看看:github.com/taskbase/arson 我使用了 MoshiConverterFactory 并且遇到了与 Gson 给我的问题完全相同的问题...【参考方案2】:

JSON 解析器在两个本质上不兼容的世界之间进行转换 - 一个是 Java/Kotlin,具有静态类型和 null 正确性,另一个是 JSON/javascript,其中一切都可以是一切,包括 null 甚至不存在,并且“强制”的概念属于你的设计,而不是语言。

因此,差距必然会发生,并且必须以某种方式加以处理。一种方法是在最轻微的问题上抛出异常(这会让很多人当场生气),另一种方法是即时捏造值(这也会让很多人生气,稍后再说)。

Gson 采用第二种方法。它默默地吞噬着缺席的田野;将 Objects 设置为 null,将原语设置为 0false,从而完全掩盖 API 错误并导致下游的神秘错误。

因此,我推荐 2-stage 解析:

package com.example.transport
//this class is passed to Gson (or any other parser)
data class CountriesResponseTransport(
   val count: Int?,
   val countries: List<CountryTransport>?,
   val error: String?)
   
   fun toDomain() = CountriesResponse(
           count ?: throw MandatoryIsNullException("count"),
           countries?.mapit.toDomain() ?: throw MandatoryIsNullException("countries"),
           error ?: throw MandatoryIsNullException("error")
       )


package com.example.domain
//this one is actually used in the app
data class CountriesResponse(
   val count: Int,
   val countries: Collection<Country>,
   val error: String)

是的,它的工作量是原来的两倍 - 但它会立即查明 API 错误,并在您无法修复这些错误时为您提供处理这些错误的地方,例如:

   fun toDomain() = CountriesResponse(
           count ?: countries?.count ?: -1, //just to brag we can default to non-zero
           countries?.mapit.toDomain() ?: ArrayList()
           error ?: MyApplication.INSTANCE.getDeafultErrorMessage()
       )

是的,您可以使用具有更多选项的更好的解析器 - 但您不应该这样做。您应该做的是将解析器抽象出来,以便您可以使用任何解析器。因为无论您今天发现多么高级和可配置的解析器,最终您都需要一个它不支持的功能。这就是为什么我将 Gson 视为最小公分母。

There's an article 解释了这个概念在更大的存储库模式上下文中使用(和扩展)。

【讨论】:

谢谢!一个有趣的解决方案。如果在error 字段php 编码器发送null[] 如果它是空的怎么办?例如,在修复了一个错误后,他们从[] 更改为null。我们更新了一个 Android 应用程序,但旧版本不知道它。 @CoolMind 您无法真正追溯地处理错误,您需要 API 版本控制来支持较旧的应用程序。另一种方法是创建一个“应用程序最小版本”端点并强制用户更新应用程序。在开发应用程序时,您有两个选择:要么与 API 开发人员签订严格的合同,当他们违反合同时,这是他们的错,他们会修复它(我的第一个版本抛出异常),或者您将 API 开发人员视为孩子并处理每个错误在他们完成之前就可能(我的第二个版本)。 同意你的看法。在这种情况下,API 版本控制会更好,因为后端开发人员可以再次更改某些内容,而当我们更新 Android 应用程序时,我们可能会浪费几天时间。因此,使用 API 版本控制用户不会看到异常,但在其他情况下他们可以。在此处查看我的问题解决方案:***.com/a/54709501/2914140. @CoolMind 似乎我们想要相反的东西。我的示例是用空数组替换空值,因此您可以在没有 NPE 的情况下迭代它们。如果你想用 null 替换空数组,你可以做countries?.let if(it.count&gt;0) it else null 。我的观点是,一旦你有了你的代码,你就可以做任何你想做的事情。 @CoolMind 我找到并链接了一篇深入解释这个概念的文章。

以上是关于为啥 Kotlin 数据类可以在 Gson 的不可空字段中包含空值?的主要内容,如果未能解决你的问题,请参考以下文章

如何使用 Gson @SerializedName 注释在 Kotlin 中反序列化嵌套的 Json API 响应

带有注释的 kotlin 数据类,为啥 @DateTimeFormat 注释可以在没有定位的情况下工作

如何在 Kotlin 中将 TypeToken + 泛型与 Gson 一起使用

使用Gson解析Json

Kotlin基础从入门到进阶系列讲解(入门篇)Android之GSON的使用

Gson 无法解析 Kotlin 中的字符串 json 格式数据