別再說 Kotlin Serialization 難用了!

GeekCat發表於2024-12-04

我不止一次見到有開發者吐槽 Kotlin Serialization 難用。尤其是 Java 開發者將它與 Jackson \ Gson 來對比。這種印象主要源於對其工作原理的誤解,Kotlin Serialization 並不依賴執行時反射機制來完成序列化/反序列化操作。

這個設計選擇是經過深思熟慮的:Kotlin 是一個多平臺語言,意味著同一份程式碼可以編譯到 JVMAndroidNativeJavaScript 等不同平臺。而反射機制在各個平臺的實現和效能特徵差異很大,有些平臺甚至完全不支援反射。因此,Kotlin Serialization 選擇了一個更優雅的解決方案:透過編譯器外掛在編譯期生成序列化程式碼。

這種方案帶來了幾個顯著優勢:

  1. 跨平臺相容性:生成的程式碼可以在所有支援的平臺上執行
  2. 更好的效能:避免了執行時反射帶來的效能開銷
  3. 編譯期型別安全:序列化錯誤在編譯期就能被發現

接下來讓我們看看在 Compose Multiplatform 專案中如何使用 Kotlin Serialization

初始化 Compose Multiplatform 專案可以檢視我之前的文章。所有原始碼基於我開源專案 crosspaste-desktop

快速開始

1. Gradle 配置

首先需要在專案中新增 Kotlin Serialization 外掛和依賴:

gradle/libs.versions.toml

[versions]
kotlin = "2.0.21"

[libraries]
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }

[plugins]
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

composeApp/build.gradle.kts

plugins {
    ...
    alias(libs.plugins.kotlinSerialization)
    ...
}

kotlin {

    sourceSets {
        commonMain.dependencies {
            implementation(libs.kotlinx.serialization.json)
        }
    }
}

2. 基礎使用

讓我們透過一個包含單元測試的示例來詳細瞭解 Kotlin Serialization 的基礎功能。這個示例不僅展示了基本用法,還透過測試用例確保了序列化和反序列化的正確性:

import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals

// @Serializable 註解告訴編譯器需要為這個類生成序列化程式碼
@Serializable
data class User(
    // @SerialName 註解允許我們自定義序列化後的欄位名
    // 這在與後端 API 對接時特別有用,比如 MongoDB 預設使用 _id 作為主鍵
    @SerialName("_id")
    val id: Int,
    val name: String,
    val email: String
)

class JsonTest {
    
    @Test
    fun testJson() {
        // 建立測試資料
        val user = User(1, "張三", "zhangsan@example.com")

        // 序列化為 JSON 字串
        // 注意這裡直接使用 Json.encodeToString(user),不需要顯式傳入序列化器
        // 這是因為 @Serializable 註解會在編譯期自動生成所需的序列化器
        val jsonString = Json.encodeToString(user)
        
        // 驗證序列化結果
        // 可以看到 id 欄位被序列化為 _id,這是因為我們使用了 @SerialName 註解
        assertEquals(
            "{\"_id\":1,\"name\":\"張三\",\"email\":\"zhangsan@example.com\"}", 
            jsonString
        )

        // 從 JSON 字串反序列化
        // 使用泛型函式 decodeFromString<User> 來指定目標型別
        // Kotlin 的型別推斷能夠自動處理大部分情況
        val decodedUser = Json.decodeFromString<User>(jsonString)
        
        // 驗證反序列化結果
        // 透過比較原始物件和反序列化後的物件,確保整個序列化過程的正確性
        assertEquals(user, decodedUser)
    }
}

3. 自定義序列化

import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.encodeToString
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals

@Serializable
data class Post(
    val id: Int,
    val title: String,
    // 使用 @Serializable 註解指定該欄位使用自定義序列化器
    @Serializable(with = LocalDateTimeIso8601Serializer::class)
    val createTime: LocalDateTime
)

// 自定義序列化器需要實現 KSerializer 介面
object LocalDateTimeIso8601Serializer : KSerializer<LocalDateTime> {
    // descriptor 定義了這個型別在序列化時的基本資訊
    // 這裡我們將 LocalDateTime 序列化為字串型別
    override val descriptor = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)

    // 實現序列化邏輯:如何將物件轉換為基本型別
    override fun serialize(encoder: Encoder, value: LocalDateTime) {
        encoder.encodeString(value.toString())
    }

    // 實現反序列化邏輯:如何從基本型別恢復物件
    override fun deserialize(decoder: Decoder): LocalDateTime {
        return LocalDateTime.parse(decoder.decodeString())
    }
}

class CustomJsonTest {
    @Test
    fun testCustomSerializer() {
        val post = Post(1, "Hello", LocalDateTime(2021, 1, 1, 12, 0))

        val jsonString = Json.encodeToString(post)
        assertEquals(
            "{\"id\":1,\"title\":\"Hello\",\"createTime\":\"2021-01-01T12:00\"}", 
            jsonString
        )

        val decodedPost = Json.decodeFromString<Post>(jsonString)
        assertEquals(post, decodedPost)
    }
}

實現自定義序列化器主要需要以下步驟:

  1. 定義序列化器類:
  • 實現 KSerializer<T> 介面,其中 T 是要序列化的型別
  • 通常定義為 object,因為序列化器通常是無狀態的
  1. 提供序列化描述符:
  • 實現 descriptor 屬性
  • 描述符定義了序列化後的資料型別(如字串、數字等)
  • 使用 PrimitiveSerialDescriptor 表示基本型別
  1. 實現序列化/反序列化方法:
  • serialize:將物件轉換為基本型別
  • deserialize:將基本型別轉換回物件
  • 使用 encoder/decoder 提供的方法進行基本型別的編解碼
  1. 應用序列化器:
  • 使用 @Serializable(with = ...) 註解指定序列化器
  • 可以針對特定欄位使用不同的序列化策略

這種方式讓我們能夠:

  • 完全控制序列化和反序列化的過程
  • 將複雜型別轉換為可序列化的基本型別
  • 保持型別安全和編譯時檢查

4. 多型序列化

多型序列化是處理繼承關係時的一個關鍵功能。當我們需要序列化一個可能包含多個子型別的基類或介面時,就需要用到多型序列化。這在處理外掛系統、資料儲存、網路傳輸等場景下特別有用。

composeApp/src/desktopMain/kotlin/com/crosspaste/utils/JsonUtils.desktop.kt

object DesktopJsonUtils : JsonUtils {

    override val JSON: Json =
        Json {
            encodeDefaults = true
            ignoreUnknownKeys = true
            serializersModule =
                SerializersModule {

                    polymorphic(PasteItem::class) {
                        subclass(ColorPasteItem::class)
                        subclass(FilesPasteItem::class)
                        subclass(HtmlPasteItem::class)
                        subclass(ImagesPasteItem::class)
                        subclass(RtfPasteItem::class)
                        subclass(TextPasteItem::class)
                        subclass(UrlPasteItem::class)
                    }
                }
        }
}

composeApp/src/commonMain/kotlin/com/crosspaste/dto/paste/SyncPasteCollection.kt

@Serializable
data class SyncPasteCollection(
    val pasteItems: List<PasteItem>,
)

在這個例子中:

  1. 型別體系設計:
  • PasteItem 作為基類,統一抽象了不同型別的貼上板內容
  • 各種具體實現(如 ColorPasteItemFilesPasteItem 等)處理不同的資料型別
  • @Serializable 註解和多型配置讓這個型別體系可以被序列化和反序列化
  1. 應用場景:
  • 網路同步:當使用者在裝置 A 複製內容時,可以將 PasteItem 序列化後傳送到裝置 B
  • 本地儲存:可以將不同型別的貼上板內容統一儲存到本地資料庫
  1. 實現優勢:
  • 型別安全:完整保留了型別資訊,接收方可以安全地還原出正確的型別
  • 擴充套件性好:新增新的貼上板型別只需建立新的 PasteItem 子類並註冊到 SerializersModule
  • 程式碼簡潔:使用 List<PasteItem> 這樣簡單的資料結構就能處理所有型別的貼上板內容

這種設計讓 CrossPaste 能夠優雅地處理各種型別的貼上板內容,無論是文字、圖片、檔案還是富文字,都能在不同裝置間可靠地傳輸和還原。這充分展示了 Kotlin Serialization 在實際專案中的應用價值。

總結

Kotlin Serialization 的設計選擇反映了它的核心目標:為 Kotlin 多平臺專案提供統一的序列化解決方案。它透過編譯期程式碼生成而不是執行時反射來實現序列化,這帶來了跨平臺相容性、型別安全性和優秀的效能表現。
然而,選擇序列化庫時需要根據具體場景來權衡:

  • 如果你的專案是純 JVM 環境:

    • Jackson/Gson 可能是更好的選擇
    • 它們擁有更成熟的生態系統
    • 使用起來更簡單直觀
    • 有更豐富的功能支援和社群資源
  • 如果你的專案涉及多平臺開發:

    • Kotlin Serialization 是理想選擇
    • 一套程式碼可以執行在所有平臺
    • 編譯期保證型別安全
    • Kotlin 語言特性深度整合
    • 更適合現代的 Kotlin-first 架構

歸根結底,Kotlin Serialization 不是為了取代 Jackson/Gson,而是為了解決多平臺序列化的問題。理解這一點,我們就不會把它簡單地與 Java 序列化庫進行比較,而是應該在合適的場景下使用合適的工具。選擇技術棧時,專案的具體需求永遠是最重要的考慮因素。

相關文章