在最近的项目中,我同时用到了 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
中含有以下依赖
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| plugins {
// ...
id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.10'
id "com.google.devtools.ksp" version "1.6.10-1.0.4"
}
dependencies {
// ...
ksp "com.squareup.moshi:moshi-kotlin-codegen:1.13.0"
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 两个实现类,如下所示。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| 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
的实例对象作为列表的成员,如下所示。
1
2
3
4
5
6
| 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 序列化之后,理论上应该生成如下的字符串。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| {
"games":
[
{
"title": "艾尔登法环",
"platforms": ["PlayStation", "Xbox", "PC"],
"releaseAt": "2022-02-25"
},
{
"title": "塞尔达传说:旷野之息",
"platform": "Nintendo Switch",
"releaseAt": 1488470400
}
]
}
|
然而 Moshi 与 kotlinx-serialization 中默认的 adapter/serializer 并不清楚代码中的多态关系,直接使用时会出现如下异常。
1
2
3
4
5
| 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)
|
1
2
3
4
| 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 中声明如下依赖。
1
| implementation "com.squareup.moshi:moshi-adapters:1.13.0"
|
然后新建一个 moshi 对象
1
2
3
4
5
6
7
8
| 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 之间的关系,Moshi 会在 JSON 的序列化过程中,为子类型添加 type
字段以标识当前JSON 对象的类型 ,我们可以使用以下代码进行验证
序列化#
1
2
3
4
5
6
7
8
9
| 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
相关,你可以为其制定任意键和值。但务必注意,每个子类型的值应该是唯一的,不可重复。
1
2
3
4
5
6
7
8
| val moshi = Moshi.Builder()
// 添加多态 JsonAdapter 工厂
.add(
PolymorphicJsonAdapterFactory.of(Game::class.java,"CUSTOME_KEY")// 基础类型:Game::class.java, 标签Key:
.withSubtype(Zelda::class.java,"CUSTOME_VALUE1") // 子类型:Zelda::class.java,标签Value:zelda
.withSubtype(EldenRing::class.java,"CUSTOME_VALUE2") // 子类型:EldenRing::class.java,标签Value: elden-ring
)
.build()
|
反序列化#
使用上一步生存的 jsonStr 进行反序列化,代码如下。
1
2
3
4
5
6
7
8
9
10
| 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 的官方文档相对更加友好,其对多态问题有单独的文档进行描述,通过查阅该文档,我们可以通过以下代码来声明多态关系。
声明多态对象之间的关系#
1
2
3
4
5
6
7
8
| val json = Json {
serializersModule = SerializersModule {
polymorphic(Game::class) { // 声明基础类型
subclass(Zelda::class) // 声明子类型
subclass(EldenRing::class) // 声明子类型
}
}
}
|
序列化#
1
2
3
4
5
6
7
8
| 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
注解为每个类指定特定的 type 值, 如下所示。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| +@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
值应该是唯一的,不可重复。再次进行序列化进行校验,代码如下。
1
2
3
4
5
6
7
8
| 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 进行反序列化,代码如下。
1
2
3
4
5
6
7
8
9
10
11
12
| 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 可以自动推导出多态类型之间的关系
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| @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>
)
|
此时便可以将之前显示声明的多态关系代码移除。
1
2
3
4
5
6
7
8
| - val json = Json {
- serializersModule = SerializersModule {
- polymorphic(Game::class) {
- subclass(Zelda::class)
- subclass(EldenRing::class)
- }
- }
- }
|
然后使用 kotlin-serialization 提供的默认Json
对象进行测试
序列化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| 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)
|
反序列化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| 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 在处理多态对象方面也很成熟,具体可以参考这个链接。如果你的业务中需要用到多态对象进行序列化,不妨把这个技巧推荐给你的后端小伙伴~