Kotlin data class 被 gson 解析遇到的坑

Kotlin data class 被 gson 解析遇到的坑

lucas Lv4

此篇文章来自于学习 Gson 和 Kotlin Data Class 的避坑指南

本文的所有代码和结论都经过实际验证和推敲,并且加入了我自己的理解和细节,更多算是对原文作者的补充,像是一个学习笔记,原文写的非常好,但我也希望你能看看我的这篇文章,

因为我对于我底层逻辑的思考很有信心,这里有简单的介绍 学习的本质在于摸透底层逻辑

从一个 NullPointerException 开始

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package org.czb

import com.google.gson.Gson

data class UserBean(val userName: String, val userAge: Int)

fun main() {
val json = """{"userName":null,"userAge":26}"""
val userBean = Gson().fromJson(json, UserBean::class.java) //第一步
println(userBean) //第二步
printMsg(userBean.userName) //第三步
}

fun printMsg(msg: String) {

}

这里例子输出的结果是

1
2
3
4
5
UserBean(userName=null, userAge=26)
Exception in thread "main" java.lang.NullPointerException: Parameter specified as non-null is null: method org.czb.MainKt.printMsg, parameter msg
at org.czb.MainKt.printMsg(Main.kt)
at org.czb.MainKt.main(Main.kt:11)
at org.czb.MainKt.main(Main.kt)

这里初级程序员也能看出来 "userName":nullusernameString 类型声明是不对应的,String 是非空的 String 类型

那么这次 gson 的反序列化,居然在调用 printMsg 的时候(第三步),报出了 NPE(NullPointerException)

我们看看这个 printMsg,把它反编译成 Java 方法

1
2
3
public static final void printMsg(@NotNull String msg) {
Intrinsics.checkNotNullParameter(msg, "msg");
}

可以看到,这里有 checkNotNullParameter 所以才会有 NPE

也就是说,gson 反序列化成功了

那么我们就需要再看 gson 到底是如何绕过非空类型的判断(nullsafe)的

gson 如何绕过 null check

我们在 fromJson 处打个断点,使用 IDEA 的 step into 功能,跳到源码去分析

可以发现,前面都是 fromJson 的重名函数,直到这个 typeAdapter.read(reader)

typeAdapter.read(reader).png

read-detail.png

提示

这里我使用的是 com.google.code.gson:gson:2.12.1 ,和原文章的作者应该是不一致的,所以源码也有所区别

我们可以看到 508~526 都是在遍历 JsonReader ,我们稍后再看

505 行是创建了一个累加器,我们看看这是干什么的,使用 step into 进去

createAccumulator.png

可以看到,createAccumulator 这里是调用了 ObjectConstructorconstruct() 函数

如果你直接从这里 step into 会直接跳转到 newUnsafeAllocator

ObjectConstructor-newUnsafeAllocator.png

而这个 lambda 就是 constuct(),所以这个 newUnsafeAllocator 会返回一个 ObjectConstructor

我们再看看 ObjectConstructor 除了这个实现,还有哪些实现

ObjectConstructor.png

newSpecialCollectionConstructor 使用特殊集合类的构造函数(EnumSet,EnumMap)。

newDefaultConstructor 使用无参构造函数。

newDefaultImplementationConstructor 回退到默认接口实现,支持一些 Gson 写好的集合类型

最后就是 newUnsafeAllocator 也就是我们确定的,这个 Gson 将这次我们使用的 data class 反序列化的构建对象的方式

注意上面的注释 // Consider usage of Unsafe as reflection, so don't use if BLOCK_ALL // Additionally, since it is not calling any constructor at all, don't use if BLOCK_INACCESSIBLE
考虑使用不安全的反射,因此请不要使用block_all,因为它根本不调用任何构造函数
所以这是一个兜底策略,或者说最终手段

UnsafeAllocator

类似 C 里面的 malloc 这里 Allocator 其实是一个含义————分配器。所以我们可以大概猜测,这里是和 C 类似的内存分配方式。这里其实也是对于中国程序员不友好的地方,英语的本身意思其实多多少少蕴含了一些代码的设计意图。

然后我们在继续推进,看看这里 newUnsafeAllocator

newUnsafeAllocator.png

可以看到这里使用 sun.misc.Unsafe 反射初始化了一个对象,然后又使用反射获取了 allocateInstance ,调用它生成了一个 UnsafeAllocator 也就是 ObjectConstructor<T>

ObjectConstructor<T> 里面只有一个返回 Tconstruct() 函数,所以 createAccumulator 最终就会返回这个被构造出来对象,并且没有使用 要被反序列化的那个类的任何构造函数,因此就跳过了 null check

使用默认值规避 null 风险???

如果我们给 userName 一个默认值,那么就可以规避掉风险了吗?我们看例子。

1
2
3
4
5
6
7
data class UserBean(val userName: String = "dark", val userAge: Int)

fun main() {
val json = """{"userAge":26}"""
val userBean = Gson().fromJson(json, UserBean::class.java)
println(userBean)
}

它的输出结果如下

1
UserBean(userName=null, userAge=26)

这里直接放结论,因为 Gson 使用了 UnsafeAllocator 没有使用任何构造函数,所以默认值并不会被加进去,而是会出现 null

解决 gson 反序列化中 null 的方法

为什么我们要在之前分析 gson 的生成 ObjectConstructor 的多种方式呢?

最重要的点就在于可以帮我们找到解决问题的思路,也就是看看如何不使用 newUnsafeAllocator

生成无参构造函数

  1. 添加无参构造函数

    1
    2
    3
    data class UserBean(val userName: String, val userAge: Int){
    constructor():this(userName = "dark", userAge = 0)
    }
  2. 使用 @JvmOverloads
    这个方法类似于第一种,也是相当于提供了一个无参构造函数

    1
    data class UserBean @JvmOverloads constructor(val userName: String = "dark", val userAge: Int = 0)

    注意

    @JvmOverloads 会将有默认值的参数依次忽略,生成重载方法,所以我们需要将所有参数都给上默认值,这样才能生成默认构造函数

  3. 不使用 data class 像 java 一样,写成成员变量(字段)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class UserBean {

    var userName = "dark"

    var userAge = 0

    override fun toString(): String {
    return "UserBean(userName=$userName, userAge=$userAge)"
    }
    }

这前三种都是通过生成默认构造函数
这里我们继续打断点 debug 看出,gson 调用的是 newDefaultConstructor

终极方案 放弃 Gson

比如我们可以改用 moshi

gson 目前对 kotlin 还不够友好,moshi 对 kotlin 的支持会更好,里面有专门针对 kotlin 的适配器
moshi 是 square 公司提供的一个开源库,就是那个开发了 okhttp 的公司

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory

data class UserBean(val userName: String = "dark", val userAge: Int)

fun main() {
val json = """{"userAge":26}"""
val moshi = Moshi.Builder()
.addLast(KotlinJsonAdapterFactory())
.build()
val jsonAdapter: JsonAdapter<UserBean> = moshi.adapter(UserBean::class.java)
val userBean = jsonAdapter.fromJson(json)
println(userBean)
}
1
UserBean(userName=dark, userAge=26)

另外,还有之前的问题,moshi 并不会在出现类型冲突的时候,成功反序列化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

// 空出 3 行对齐我 IDEA 里的行号,方便对应输出结果对应的行号

import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory

data class UserBean(val userName: String = "dark", val userAge: Int)

fun main() {
val json = """{"userName":null,"userAge":26}"""
val moshi = Moshi.Builder()
.addLast(KotlinJsonAdapterFactory())
.build()
val jsonAdapter: JsonAdapter<UserBean> = moshi.adapter(UserBean::class.java)
val userBean = jsonAdapter.fromJson(json)
println(userBean)
}
1
2
3
4
5
6
7
Exception in thread "main" com.squareup.moshi.JsonDataException: Non-null value 'userName' was null at $.userName
at com.squareup.moshi.internal.Util.unexpectedNull(Util.java:674)
at com.squareup.moshi.kotlin.reflect.KotlinJsonAdapter.fromJson(KotlinJsonAdapter.kt:89)
at com.squareup.moshi.internal.NullSafeJsonAdapter.fromJson(NullSafeJsonAdapter.java:41)
at com.squareup.moshi.JsonAdapter.fromJson(JsonAdapter.java:70)
at org.czb.MainKt.main(Main.kt:15)
at org.czb.MainKt.main(Main.kt)

四、扩展知识(平台类型)

再来看个扩展知识,和 Gson 无直接关联,但是在开发中也是蛮重要的一个知识点
json 为空字符串,此时 Gson 可以成功反序列化,且得到的 userBean 为 null

还是以 data class UserBean(val userName: String, val userAge: Int) 为例子

1
2
3
4
fun main() {
val json = ""
val userBean = Gson().fromJson(json, UserBean::class.java)
}

如果加上类型声明:UserBean?,那也可以成功反序列化

1
2
3
4
fun main() {
val json = ""
val userBean: UserBean? = Gson().fromJson(json, UserBean::class.java)
}

如果加上的类型声明是 UserBean 的话,那就比较好玩了,会直接抛出 NullPointerException

1
2
3
4
fun main() {
val json = ""
val userBean: UserBean = Gson().fromJson(json, UserBean::class.java)
}
1
2
3
Exception in thread "main" java.lang.NullPointerException: fromJson(...) must not be null
at org.czb.MainKt.main(Main.kt:12)
at org.czb.MainKt.main(Main.kt)

以上三个例子会有不同区别的原因是什么呢?

这是在 kotlin 调用 java 的时候会出现的,官方文档写着“Java 中的任何引用都可能是 null,这使得 Kotlin 对来自 Java 的对象要求严格空安全是不现实的。 Java 声明的类型在 Kotlin 中会以特殊方式对待并称为平台类型。”

就是 kotlin 在调用 java 代码的时候,会把 java 里的类型以平台类型(platform type)对待,例如 java 中的 String 会当做 String!

平台类型只是编译器的中间产物,并不能显式使用在代码里, val s:String! 是不允许出现在代码里的

所以对于写 kotlin 的程序员来说,在使用 kotlin 调用 java 代码的时候,稳妥的方式是自己进行判空处理,或者对于 java 程序员来说,写上 @NotNull 和 @Nullable 这类参数

回到我们的这个,当我们从 Kotlin 承接 Gson 这个 Java 类返回的变量时,既可以将其当做 UserBean 类型,也可以当做 UserBean? 类型。而如果我们直接显式声明为 UserBean 类型,就说明我们确信返回的是非空类型,当返回的是 null 时就会触发 Kotlinnull check,导致直接抛出 NullPointerException

  • Title: Kotlin data class 被 gson 解析遇到的坑
  • Author: lucas
  • Created at : 2025-02-27 15:20:51
  • Updated at : 2025-03-02 21:19:15
  • Link: https://darkflamemasterdev.github.io/2025/02/27/Kotlin-data-class-被-gson-解析遇到的坑/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments