本文已参与「新人创作礼」活动,一起开启掘金创作之路。
前言
在最近的项目中,我同时用到了 Moshi 和 kotlinx-serialization 这两个 JSON 库,两者的 API 都很简洁且实用。
与 Gson 的反射机制不同,Moshi 和 kotlinx-serialization 都提供了预编译机制,可以在编译期间分别生成 Adapter 和 Serializer,从而能够以类型安全、且更高效的方式完成 JSON 的序列化和反序列化。
- Moshi
- 源于 Square ,与 Retrofit 的集成度较高,对 Android 平台的开发者比较友好
- 可以借助 kapt/ksp 在编译期生成 XxxJsonAdapter.kt 文件
- kotlinx-serialization
- 源于 JetBrains,属于官方推出的扩展包,能够很方便的集成到 Ktor 中
- 基于 kotlin compiler plugin,在编译期生成字节码文件(Xxx$$serializer.class)
- 支持 KMP,能够跨平台使用。
- 例如:定义一套 DTO,同时在 Android端、iOS端、前端、桌面端、服务端复用。
- 官方支持 JSON、Protobuf、CBOR、Hocon、Properties 等格式
- 有大量的三方扩展,支持 TOML、XML、YAML、BSON、NBT、SharePreference、Bundle 等格式
如果你在使用 kotlin 进行日常开发工作,非常推荐你去体验和使用这两个 JSON 库。
书归正传,在今天这篇文章中,主要想和大家聊一聊多态对象的序列化问题。
代码中的多态对象
在 Java 中,我们定义的任一接口都可以有多个不同的实现类。每个实现类可以声明自己特有的字段和方法。 Kotlin 中的 sealed class 的语法糖则进一步简化了这种代码组织行为。
开始之前,请确保
gradle.build中含有以下依赖plugins { // ... id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.10' // kotlinx-serializaton 插件 id "com.google.devtools.ksp" version "1.6.10-1.0.4" // ksp 插件 } dependencies { // ... ksp "com.squareup.moshi:moshi-kotlin-codegen:1.13.0" // 编译期生成 Adapter,非反射 implementation "com.squareup.moshi:moshi:1.13.0" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2" testImplementation 'junit:junit:4.13.2' // ... }
假设有一个名为 Game 接口和一个含有 games 列表的 GamingRoom 类,以及 Zelda 和 EldenRing 两个 Game 接口的实现类,如下所示。
package com.devwu.dto
interface Game
-----
package com.devwu.dto
@Serializable // kotlinx-serialization: 自动生成 GamingRoom$$serializer.class
@JsonClass(generateAdapter = true) // Moshi: 自动生成 GamingRoomJsonAdapter.kt
data class GamingRoom(
val games: List<Game>
)
-----
package com.devwu.dto
@Serializable // kotlinx-serialization: 自动生成 Zelda$$seriliazer.class
@JsonClass(generateAdapter = true) // Moshi: 自动生成 ZeldaJsonAdapter.kt
data class Zelda(
val title: String,
val platform: String,
val releaseAt: Long
) : Game
-----
package com.devwu.dto
@Serializable // kotlinx-serialization: 自动生成 EldenRing$$seriliazer.class
@JsonClass(generateAdapter = true) // Moshi: 自动生成 EldenRingJsonAdapter.kt
data class EldenRing(
val title: String,
val platforms: List<String>,
val releaseAt: String
) : Game
Zelda 和 EldenRing 在以下字段上有略微区别,
- platform/platforms: 在 Zelda 中是
String类型,在 EldenRing 中是List<String>类型,且有s后缀。 - releaseAt: 在 Zelda 中是
Long类型,在 EldenRing 中是String类型
GamingRoom 的 games 列表字段使用 Game 类型,可以同时接收 Zelda 和 EldenRing的实例对象作为列表的成员,如下所示。
val room = GamingRoom(
games = listof(
EldenRing(title = "艾尔登法环", platforms = listof("PlayStation", "Xbox", "PC"), releaseAt = "2022-02-25"),
Zelda(title = "塞尔达传说:旷野之息", platform = "Nintendo Switch", releaseAt = 1488470400)
)
)
多态对象的 JSON 序列化
针对上述 room 对象,将其进行 JSON 序列化之后,理论上应该生成如下的字符串。
{
"games":
[
{
"title": "艾尔登法环",
"platforms": ["PlayStation", "Xbox", "PC"],
"releaseAt": "2022-02-25"
},
{
"title": "塞尔达传说:旷野之息",
"platform": "Nintendo Switch",
"releaseAt": 1488470400
}
]
}
然而 Moshi 与 kotlinx-serialization 中默认提供的 adapter/srilizazer 并不清楚代码中的多态关系,直接使用时会出现如下异常。
- Moshi
No JsonAdapter for interface com.devwu.dto.Game (with no annotations)
for interface com.devwu.dto.Game
for java.util.List<com.devwu.dto.Game> games
for class com.devwu.dto.GamingRoom
java.lang.IllegalArgumentException: No JsonAdapter for interface com.devwu.dto.Game (with no annotations)
- kotlinx-serialization
Class 'EldenRing' is not registered for polymorphic serialization in the scope of 'Game'.
Mark the base class as 'sealed' or register the serializer explicitly.
kotlinx.serialization.SerializationException: Class 'EldenRing' is not registered for polymorphic serialization in the scope of 'Game'.
Mark the base class as 'sealed' or register the serializer explicitly.
针对上述异常,我们需要在代码中为 Moshi 或 kotlinx-serialization 声明代码的多态关系。
Moshi 中多态对象的序列化
尽管 Moshi 的官方文档中对多态对象没有太多介绍,但其 moshi-adapter 库中提供了一个 PolymorphicJsonAdapterFactory, 可以很方便的为多态对象生成 JsonAdapter 工厂。在使用时,需要在 DSL 中声明如下依赖。
implementation "com.squareup.moshi:moshi-adapters:1.13.0"
然后新建一个 moshi 对象
val moshi = Moshi.Builder()
// 添加多态 JsonAdapter 工厂
.add(
PolymorphicJsonAdapterFactory.of(Game::class.java,"type")// 基础类型:Game::class.java, 标签Key:type
.withSubtype(Zelda::class.java,"zelda") // 子类型:Zelda::class.java,标签Value:zelda
.withSubtype(EldenRing::class.java,"elden-ring") // 子类型:EldenRing::class.java,标签Value: elden-ring
)
.build()
此时 moshi 便知晓了 Game, Zelda, EldenRing 之间的关系,我们可以使用以下代码进行验证
序列化
val room = GamingRoom(
games = listof(
EldenRing(title = "艾尔登法环", platforms = listof("PlayStation", "Xbox", "PC"), releaseAt = "2022-02-25"),
Zelda(title = "塞尔达传说:旷野之息", platform = "Nintendo Switch", releaseAt = 1488470400)
)
)
val adapter = moshi.adapter(GamingRoom::class.java)
val jsonStr = adapter.toJson(room)
println(jsonStr)
可以看到 Moshi 为 room 对象生成了预期的 JSON 字符串,图中红色标记处的 type 字段为额外生成的类型标识符,这个标记符与我们构造 moshi 时添加的 PolymorphicJsonAdapterFactory相关,你可以为其制定任意键和值。但务必注意,每个子类型的值应该是唯一的,不可重复。
val moshi = Moshi.Builder()
// 添加多态 JsonAdapter 工厂
.add(
PolymorphicJsonAdapterFactory.of(Game::class.java,"CUSTOME_KEY") // 基础类型:Game::class.java, 标签 Key:CUSTOME_KEY
.withSubtype(Zelda::class.java,"CUSTOME_VALUE1") // 子类型:Zelda::class.java,标签 Value:CUSTOME_VALUE1
.withSubtype(EldenRing::class.java,"CUSTOME_VALUE2") // 子类型:EldenRing::class.java,标签 Value: CUSTOME_VALUE2
)
.build()
反序列化
使用上一步生存的 jsonStr 进行反序列化,代码如下。
val adapter = moshi.adapter(GamingRoom::class.java)
val jsonStr = """{"games":[{"type":"elden-ring","title":"艾尔登法环","platforms":["PlayStation","Xbox","PC"],"releaseAt":"2022-02-25"},{"type":"zelda","title":"塞尔达传说:旷野之息","platform":"Nintendo Switch","releaseAt":1488470400}]}"""
val dto = adapter.fromJson(jsonStr)!!
dto.games.forEach{
when(it){
is Zelda -> { assert(it.platform == "Nintendo Switch")}
is EldenRing -> { assert(it.platforms.contains("Xbox"))}
else -> {}
}
}
kotlinx-serialization 中多态对象的序列化
kotlinx-serialization 的官方文档相对更加友好,其对多态问题有单独的文档进行描述,通过查阅该文档,我们可以通过以下代码来声明多态关系。
声明多态对象之间的关系
val json = Json {
serializersModule = SerializersModule {
polymorphic(Game::class) { // 声明基础类型
subclass(Zelda::class) // 声明子类型
subclass(EldenRing::class) // 声明子类型
}
}
}
序列化
val room = GamingRoom(
games = listof(
EldenRing(title = "艾尔登法环", platforms = listof("PlayStation", "Xbox", "PC"), releaseAt = "2022-02-25"),
Zelda(title = "塞尔达传说:旷野之息", platform = "Nintendo Switch", releaseAt = 1488470400)
)
)
val jsonStr = json.encodeToJson(room)
println(jsonStr)
kotlinx-serialization 在进行 JSON 序列化时,额外添加了 type 字段来标识当前 JSON 对象的实际类型。type字段的默认值是该对象的全限定类名。
我们可以通过 @SerialName 注解来修改这个值, 代码如下。
+@SerialName("zelda") // kotlinx-serialization: 设置类型键名
@Serializable // kotlinx-serialization: 自动生成 Zelda$$seriliazer.class
@JsonClass(generateAdapter = true) // Moshi: 自动生成 ZeldaJsonAdapter.kt
data class Zelda(
val title: String,
val platform: String,
val releaseAt: Long
) : Game
========
+@SerialName("elden-ring") // kotlinx-serialization: 设置类型键名
@Serializable // kotlinx-serialization: 自动生成 EldenRing$$seriliazer.class
@JsonClass(generateAdapter = true) // Moshi: 自动生成 EldenRingJsonAdapter.kt
data class EldenRing(
val title: String,
val platforms: List<String>,
val releaseAt: String
) : Game
同样需要注意的是,每个类型的type值应该是唯一的,不可重复。再次进行序列化进行校验,代码如下。
val room = GamingRoom(
games = listof(
EldenRing(title = "艾尔登法环", platforms = listof("PlayStation", "Xbox", "PC"), releaseAt = "2022-02-25"),
Zelda(title = "塞尔达传说:旷野之息", platform = "Nintendo Switch", releaseAt = 1488470400)
)
)
val jsonStr = json.encodeToJson(room)
println(jsonStr)
可见
type 字段的值已经从全限定类名变成了自定义的名称。
反序列化
使用上一步生成的 jsonStr 进行反序列化,代码如下。
val jsonStr = """{"games":[{"type":"elden-ring","title":"艾尔登法环","platforms":["PlayStation","Xbox","PC"],"releaseAt":"2022-02-25"},{"type":"zelda","title":"塞尔达传说:旷野之息","platform":"Nintendo Switch","releaseAt":1488470400}]}"""
val gamingRoom = json.decodeFromString<GamingRoom>(jsonStr).games.forEach {
when (it) {
is Zelda -> {
assert(it.platform == "Nintendo Switch")
}
is EldenRing -> {
assert(it.platforms.contains("Xbox"))
}
else -> {}
}
}
利用 sealed class 自动推导多态关系
如果你的项目中使用了闭合的sealed class ,那么 kotlinx-serialization 可以自动推导出多态类型之间的关系,无需提前显式声明。
@Serializable
sealed class Game2 {
@Serializable
@SerialName("zelda")
data class Zelda2(
val title: String,
val platform: String,
val releaseAt: Long
) : Game2()
@Serializable
@SerialName("elden-ring")
data class EldenRing2(
val title: String,
val platforms: List<String>,
val releaseAt: String
) : Game2()
}
-----
@Serializable
data class GamingRoom2(
val games: List<Game2>
)
此时便可以将之前代码中显示声明的多态关系移除。
- val json = Json {
- serializersModule = SerializersModule {
- polymorphic(Game::class) {
- subclass(Zelda::class)
- subclass(EldenRing::class)
- }
- }
- }
然后使用 kotlin-serialization 提供的默认Json 对象进行测试
序列化
val game1 = Game2.EldenRing2(
title = "艾尔登法环",
platforms = listOf("PlayStation", "Xbox", "PC"),
releaseAt = "2022-02-25"
)
val game2 = Game2.Zelda2(
title = "塞尔达传说:旷野之息",
platform = "Nintendo Switch",
releaseAt = 1488470400
)
val gamingRoom = GamingRoom2(listOf(game1, game2))
val jsonStr = Json.encodeToString(gamingRoom)
assert(jsonStr == """{"games":[{"type":"elden-ring","title":"艾尔登法环","platforms":["PlayStation","Xbox","PC"],"releaseAt":"2022-02-25"},{"type":"zelda","title":"塞尔达传说:旷野之息","platform":"Nintendo Switch","releaseAt":1488470400}]}""")
println(jsonStr)
反序列化
val jsonStr =
"""{"games":[{"type":"elden-ring","title":"艾尔登法环","platforms":["PlayStation","Xbox","PC"],"releaseAt":"2022-02-25"},{"type":"zelda","title":"塞尔达传说:旷野之息","platform":"Nintendo Switch","releaseAt":1488470400}]}"""
val gamingRoom: GamingRoom2 = Json.decodeFromString(jsonStr)
gamingRoom.games.forEach {
when (it) {
is Game2.Zelda2 -> {
assert(it.platform == "Nintendo Switch")
}
is Game2.EldenRing2 -> {
assert(it.platforms.contains("Xbox"))
}
else -> {}
}
}
println(gamingRoom)
小结
由于此前的 JSON 库在多态问题上存在一定的短板,以至于在实际的开发中,JSON 的多态通常被有意规避,客户端开发者少有机会接触这样的数据。但随着三方库的不断完善,JSON 在处理多态问题上有了长足进步。希望通过这篇文章能够抛砖引玉,扩展大家对 JSON 多态序列化的认识。
本文涉及的代码已上传至 Github,代码主要在 dto 模块的单元测试中,您可以点击这个链接进行查看。
后记
Java 服务端常用的 Jackson 在处理多态对象方面也很成熟,具体可以参考这个链接。如果你的业务中需要用到多态对象进行序列化,不妨把这个技巧推荐给你的后端小伙伴,然后就可以一起愉快的玩耍了~