还在被数据类的序列化折磨?是时候丢弃 Gson 了

6,567 阅读8分钟
原文链接: discuss.kotliner.cn

今天我们来简单介绍下 kotlinx.serialization kotlinx.serialization 7

认识一下

看名字就知道这是 Kotlin 官方的序列化框架了,它支持 JSON/CBOR/Protobuf,下面我们主要以 JSON 为例介绍它的功能(因为后面那俩不是给人看的啊)。

它作为一套专门为 Kotlin 的类开发的序列化框架,自然要兼顾到 Kotlin 的类型的各种特性,你会发现用 Gson 来序列化 Kotlin 类时遇到的奇怪的问题在这里都没了。

最重要的是,跟其他 Kotlinx 家族的成员一样,它将来会以跨平台的身份活跃在 Kotlin 的所有应用场景,如果你想要构建可移植的程序,例如从 Android(Jvm)移植到 iOS(Native),用 Gson 那肯定是不行的了,但 kotlinx.serialization 就可以。尽管它现在在 Native 上的功能还有限制,不过,人家毕竟还是个宝宝嘛(0.6.1)。

开始用吧

闲话少说,咱们创建一个 Kotlin 的 Jvm 程序(毕竟它的功能最全,别的平台有的还不支持),创建好以后引入依赖,由于我用的是 Kotlin DSL 的 gradle,所以如果你用的仍然是 Groovy 的,请去参考 GitHub 仓库的介绍。

plugins {
    //注意 Kotlin 的版本要新,不要问旧版怎么用,因为人家官方说了旧版不能用
    kotlin("jvm") version "1.2.60" 
}

buildscript {
    repositories {
        jcenter()
        //这个库因为还是个宝宝,所以还在自己的仓库里面,gradle 插件从这儿找
        maven ("https://kotlin.bintray.com/kotlinx") 
    }
    dependencies {
        //序列化框架的重要部分:gradle 插件
        classpath("org.jetbrains.kotlinx:kotlinx-gradle-serialization-plugin:0.6.1")
    }
}

apply {
    //咦,怎么没有 apply kotlin 呢?不知道为啥的看代码的第一行
    plugin("kotlinx-serialization")
}

dependencies {
    compile(kotlin("stdlib", "1.2.60"))
    //加载自定义的 Serializer 有些情况下需要反射
    compile(kotlin("reflect", "1.2.60")) 
    //序列化框架的重要部分:运行时库
    compile("org.jetbrains.kotlinx:kotlinx-serialization-runtime:0.6.1")
}

repositories {
    jcenter()
    //运行时库从这儿找
    maven ("https://kotlin.bintray.com/kotlinx")
}

有了这些,你就可以写这么一段代码运行一下了:

import kotlinx.serialization.*
import kotlinx.serialization.json.JSON

@Serializable
data class Data(val a: Int, @Optional val b: String = "42")

fun main(args: Array<String>) {
    println(JSON.stringify(Data(42))) // {"a": 42, "b": "42"}
    val obj = JSON.parse<Data>("""{"a":42}""") // Data(a=42, b="42")
}

很棒啊,官方的例子。不过你如果直接使用 IntelliJ 的运行按钮,你就会发现一个编译错误,看起来就是什么版本不兼容啦之类的。别理它,这时候你只需要打开 Preference,找到 gradle->runner,把里面的 Delegate IDE build/run actions to gradle 勾上

再运行,很好,你就会看到运行成功了:

来个嵌套的类型

像数值类型、字符串这样的基本类型通常与 JSON 的类型都可以对应上,但如果是 JSON 中不存在的一个类型呢?

data class User(val name: String, val birthDate: Date)

然后:

println(JSON.stringify(User("bennyhuo", Calendar.getInstance().apply { set(2000, 3, 1, 10, 24,0) }.time)))

结果呢?

这日期我去,看了半天我才看懂,哪儿成啊。所以我要给 Date 自定义一个序列化的格式,怎么办?

我们需要定义一个 KSerializer 来实现自定义序列化:

@Serializer(forClass = Date::class)
object DateSerializer : KSerializer<Date> {
    private val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")

    override fun load(input: KInput) = simpleDateFormat.parse(input.readStringValue())

    override fun save(output: KOutput, obj: Date) {
        output.writeStringValue(simpleDateFormat.format(obj))
    }
}

然后在使用处注明要使用的 Serializer

@Serializable
data class User(val name: String,
                @Serializable(with = DateSerializer::class) val birthDate: Date)

这样输出的日期格式就是我指定的了:

日期当然是瞎写的。。。

更复杂一点儿的情况

假设我们有需求要讲一个 Date 序列化成一个数组,为了表达方便,我们先定义一个类:

@Serializable
class MyDate(var year: Int = 0, var month: Int = 0, var day: Int = 0, 
        var hour: Int = 0, var minute: Int = 0, var second: Int = 0){
    ... //省略 toString()        
}

我们希望下面的代码的序列化的结果按照数组的形式输出 MyDate 当中的参数:

MyDate(2000, 3, 1, 10, 24, 0)

这个对象序列化之后应该输出:[2000,3,1,10,24,0]

我们要怎么做呢?

@Serializer(forClass = MyDate::class)
object MyDateSerializer : KSerializer<MyDate> {
    private val jClassOfMyDate = MyDate::class.java
    
    override fun load(input: KInput): MyDate {
        val myDate = MyDate()
        val arrayInput = input.readBegin(ArrayClassDesc)
        for (i in 0 until serialClassDesc.associatedFieldsCount) {
            val index = arrayInput.readElement(ArrayClassDesc)
            val value = arrayInput.readIntElementValue(ArrayClassDesc, index)
            jClassOfMyDate.getDeclaredField(serialClassDesc.getElementName(i)).apply { isAccessible = true }.set(myDate, value)
        }
        arrayInput.readEnd(ArrayClassDesc)
        return myDate
    }

    override fun save(output: KOutput, obj: MyDate) {
        val arrayOutput = output.writeBegin(ArrayClassDesc, 0)
        for (i in 0 until serialClassDesc.associatedFieldsCount) {
            val value = jClassOfMyDate.getDeclaredField(serialClassDesc.getElementName(i)).apply { isAccessible = true }.get(obj) as Int
            arrayOutput.writeIntElementValue(ArrayClassDesc, i + 1, value)
        }
        arrayOutput.writeEnd(ArrayClassDesc)
    }
}

save 方法可以让我们在序列化 MyDate 的对象时按数组的形式输出,而 load 方法则用于反序列化。这段代码看上去有些古怪,不过不要感到害怕,一般情况下我们不会需要这样的代码。有了 MyDateSerializer 之后,我们需要注册它才可以使用,即:

val json = JSON(context = SerialContext().apply { registerSerializer(MyDate::class, MyDateSerializer) })
val result = json.stringify(MyDate(2000, 3, 1, 10, 24, 0)) //result = "[2000,3,1,10,24,0]"

这似乎与前面的 Date 的情况不同。通常如果作为一个类的成员,我们可以通过注解 @Serializable(with = MyDateSerializer::class) 来指定序列化工具类,就像我们前面为 Date 指定序列化工具类一样:

@Serializable
data class User(val name: String,
                @Serializable(with = DateSerializer::class) val birthDate: Date)

但如果我们针对类本身做序列化时,通过注解为一个类配置全局序列化工具则是徒劳的(也许是一个尚未实现的 feature,也许是一个 bug,也许是故意而为之呢),就像下面这种写法,实际上是没有意义的。

@Serializable(with = MyDateSerializer::class)
class MyDate(...){ ... }

当然你也可以通过自定义注解来为属性增加额外的信息,但这个使用场景比较少,就不介绍了。

Gson 做不到的事儿

看到这里 Gson 哥坐不住了,这事儿尼玛我也会啊,不就解析个 Json 串吗,有啥难的??

①构造方法默认值

这事儿还真不是说 Gson 的不是,Gson 作为 Java 生态中的重要一员,尽管它的速度不是最快的,但他的接口最好用啊,所以写 Java 的时候每次测试 Maven 库的时候我都会用引入 Gson 试试,嗯,它的 Maven id 是我认识 Kotlin 之前能背下来的唯一一个。

com.google.code.gson:gson:$version

那么的问题是啥?问题就是,它不是为 Kotlin 专门定制的。大家都知道,如果你想要在你的项目中做出成绩来,你必须要针对你的业务场景做优化,市面上所有的轮子都倾向于解决通用的问题,我们这些 GitHub 的搬运工的水平级别主要是看上轮子的时候谁的螺丝和润滑油上的更好。

我们还是看官方的那个例子:

@Serializable
data class Data(val a: Int, @Optional val b: String = "42")
val obj = JSON.parse<Data>("""{"a":42}""") // Data(a=42, b="42")
val objGson = gson.fromJson("""{"a":42}""", Data::class.java) //Data(a=42, b="?")

不同的是,我们这回用 Gson 去反序列化同样的字符串,结果呢?

为什么会这样?因为 Gson 在反序列化的时候,构造对象实例时没有默认无参构造方法,同时又没有设置 TypeAdapter 的话,它就不知道该怎么实例化这个对象,于是用到了一个千年黑魔法 Unsafe 。尽管我们在 Data 的构造器里面给出了默认值,但 Gson 听了之后会说:啥玩意?啥默认值?

②属性的初始化值

@Serializable
data class Data(val a: Int, @Optional val b: String = "42"){
    @Optional
    private val c: Long = 9

    override fun toString(): String {
        return "Data(a=$a, b='$b', c=$c)"
    }
}

好的,我们现在给 Data 添加了一个成语,注意它不在构造方法中,所以后面的 9 不是默认值,而是构造的时候的初始化值。同时由于默认的 toString 方法只有构造器中的属性,所以我们需要自己来一个,带上 c

还是前面的程序,这次猜猜两个框架是如何初始化 c 的值的?

val obj = JSON.parse<Data>("""{"a":42}""") // Data(a=42, b="42", c=?)
val objGson = gson.fromJson("""{"a":42}""", Data::class.java) //Data(a=42, b="null", c=?)

结果嘛,当然就是 Gson 没有对 c 做任何初始化的操作。

你当然可以骂 Gson “你瞎啊,那么明显的构造都不会执行?”,Gson 回复你的估计仍然是:

前面说过了,Gson 实例化的时候根本不会调用我们定义的构造器啊,这个初始化的值本身就是构造的一部分。

③属性代理

如果你在数据类(不是 data class 但也被当数据结构用的类也算)里面用到了属性代理,就像这样:

@Serializable
data class Data(val a: Int, @Optional val b: String = "42"){
    @Optional
    private val c: Long = 9

    @Transient val d by lazy { b.length }

    override fun toString(): String {
        return "Data(a=$a, b='$b', c=$c, d=$d)"
    }
}

我们定义了一个 d,它自己没有 backing field,我们用属性代理来让它代理 b 的长度,这样的用法本身也是经常见的。由于这个值本身自己只是一个代理,所以我们需要把它标记为 Transient,意思就是不参与序列化过程。

那么这时候同样,我们还是运行前面的那段代码:

val obj = JSON.parse<Data>("""{"a":42}""") // Data(a=42, b="42", c=9, d=?)
val objGson = gson.fromJson("""{"a":42}""", Data::class.java) //Data(a=42, b=null, c=0, d=?)

其实猜结果的时候,我们能想到的差异就是,KS 能够正常的执行 Data 的初始化流程,因此可以覆盖到默认值、初始化值等等,而 Gson 不能,所以 Gson 一定不会处理 d。不过这次的结果可能就不是一个简单的 null 了,而是:

用 Gson 解析之后,如果我们想要访问 d,直接抛出空指针。这是为什么呢?因为属性代理会产生一个内部的代理属性,反编译之后我们就会看到是

private final Lazy d$delegate;

我们访问 d 的时候实际上就是去访问这个属性的 getValue 方法,而这个属性并没有被正常初始化,所以就有了空指针的结果了。

小结

序列化 Kotlin 数据类型的时候,以后可以考虑使用 kotlinx.serialization 这个框架了,它不仅 API 简单,还解决了我们经常遇到用别的 Java 框架带来的问题。


对啦,我的 Kotlin 新课 “基于 GitHub App 业务深度讲解 Kotlin1.2高级特性与框架设计” 上线之后,大家普遍反映有难度,有深度,如果哪位朋友想要吊打 Kotlin,不妨来看看哦!

coding.imooc.com/class/232.h…


转载请注明出处:微信公众号 Kotlin