iT邦幫忙

2021 iThome 鐵人賽

DAY 6
1
Modern Web

基於 Kotlin Ktor 建構支援模組化開發的 Web 框架系列 第 6

[Day 6] 使用 kotlinx.serialization 轉換 JSON

在 Java 的世界中,有很多種 json library 任君挑選,其中最多人使用的應該是 JacksonGson。我過往都是使用 Jackson,因為這是 Spring Boot 及 Play Framework 的預設偏好。因為我幾乎沒使用過其它的 library,所以也無法做詳盡的功能比較及推薦,不過我個人認為除非你需要處理數量或內容龐大的 json 資料,那麼可以參考 Github 上的 benchmark 結果做為挑選依據,要不然其實就用預設的就好,基本上你需要或想得到的功能都已經有實作了。

kotlinx.serialization

至於 Ktor ContentNegotiation Plugin 當然也有支援 Jackson 及 Gson,不過預設支援的還有自家 JetBrains 開發的 kotlinx.serialization,kotlinx.serialization 的特點在於

  • 支援跨平台 (JVM, JS, Native...)
  • 支援多種格式 (JSON, Protobuf, CBOR, Hocon...)
  • Compiler plugin 自動產生處理 serialize / deserialize 的程式碼,而不是只能在執行期使用 reflection 的方式處理而已。

雖然我目前的需求不需要跨平台、又只需要轉換 json 資料,但基於做 side project 就是要學習新技術的精神,而且未來跨平台開發會是一個趨勢,同時也想知道 Complier Plugin 的方式與 Reflection 的效能比較,所以決定使用 kotlinx.serialization !

使用 kotlinx.serialization 在開發上最大的差別就是它會要求或限制你的程式寫法,不像 Reflection 方式那樣無腦使用。例如只有 Backing fields 才會被序列化,所以如果你使用 delegated property 或是 getter function 那就不行。還有類別一定要加上 @Serializable annotation 或是已內建支援的型態才能轉換,所以如果要轉換的物件類別,存在任一個變數的型態不能轉換的話,那就無法「編譯」程式碼了。例如針對 java.util.UUID 要另外寫 CustomSerializer 實作 serialize / deserialize 函式,然後在變數宣告加上 @Serializable(with = UUIDSerializer::class) 才能編譯,否則 Compiler Plugin 根本不知道怎麼產生程式碼。如果是 Jackson 的話,幾乎任何物件都能夠在執行期透過反射轉換,除非產生的 json 不是你想要的,例如 UUID 會被序列化為 mostSigBits, leastSigBits 2個屬性值的 JsonObject,而非一個字串,此時我們要自己寫 CustomSerializer 再註冊到 Jackson。

上面的例子只是眾多限制之一,尤其處理繼承多型的類別更是要小心,建議如果你是初次使用 kotlinx.serialization,一定要先把官方說明文件大略看過一遍,至少有個印象,否則你會遇到一堆 Compiler Plugin 無法編譯的錯誤,或是發現怎麼有些屬性沒被 serialize,然後就想要放棄了…XD。另外還要關注一下 kotlinx.serialization 新版本,因為隨著版本的進化,過去的限制可能會被解除或有條件鬆綁,此時升級一下也是不錯的。

以上對 kotlinx.serialization 有所認識之後,接下來說明 Ktor 使用 kotlinx.serialization 處理 request json deserialization 及 response json serialization 的實作技巧。

request json deserialization

我在 ApplicationCall 類別加上 receiveAndValidateBody extension function,透過 kotlinx.serialization 的 serializer() 取得 reified type parameter T 類別的 serializer,藉此達到類似 reflection 的效果,這樣就可以處理每一種 T 型態的類別。但要小心 T 類別必須是 Serializable,否則會丟出 SerializationException,而且目前使用 serializer() 必須標註 InternalSerializationApi

@OptIn(InternalSerializationApi::class)
suspend inline fun <reified T : Form<*>> ApplicationCall.receiveAndValidateBody(location: Location? = null): T {
    val form = try {
        json.decodeFromString(T::class.serializer(), receiveUTF8Text())
    } catch (e: Throwable) {
        throw RequestException(InfraResponseCode.BAD_REQUEST_BODY, "can't deserialize from json: ${e.message}", e)
    }
    //... 以下省略
}

response json serialization

我在 ApplicationCall 類別加上 respond(responseDTO: ResponseDTO) extension function。ResponseDTO 設計上考慮多種回應格式,最簡單的格式 DataResponseDTO 就直接繼承 ResponseDTO,然後加上 @SerialName("data") 作為 classDiscriminator property 的值。

為了簡化建立 DataResponseDTO 物件的寫法,在這裡使用 invoke operator overloading 達到直接呼叫 constructor 一樣的寫法 call.respond(DataResponseDTO(loginResponse)) 這樣子就不必另外再定義 factory method,而且還可以拿到 refied type parameter T,然後再取得 T 的 serializer 了

suspend fun ApplicationCall.respond(responseDTO: ResponseDTO) {
    respond(responseDTO.code.httpStatusCode, responseDTO)
}

@Serializable
sealed class ResponseDTO {

    abstract val code: ResponseCode
    abstract val message: String?
    abstract val data: JsonElement?
}

@Serializable
@SerialName("data")
class DataResponseDTO(
    override val code: ResponseCode = InfraResponseCode.OK,
    override val message: String? = null,
    override val data: JsonElement
) : ResponseDTO() {

    companion object {

        inline operator fun <reified T : Any> invoke(data: T, message: String? = null): DataResponseDTO {
            return DataResponseDTO(data = json.encodeToJsonElement(T::class.serializer(), data), message = message)
        }

        inline operator fun <reified T : Any> invoke(data: List<T>, message: String? = null): DataResponseDTO {
            return DataResponseDTO(data = JsonArray(data.map { json.encodeToJsonElement(T::class.serializer(), it) }), message = message)
        }
    }
}

更多 kotlinx.serialization 的實作程式碼可參考Github repo,包含了許多常見資料型態的 CustomSerializer,例如 BigDecimal 及 java.time.* 的 ZonedDateTime…等。還有2個 json object 的 deepMerge 操作。


上一篇
[Day 5] Ktor 微框架就如同一間毛胚屋,先來列出想要整合的框架及實作的功能清單
下一篇
[Day 7] 實作 Request Data Validation 及 Global Exception Handler
系列文
基於 Kotlin Ktor 建構支援模組化開發的 Web 框架30

尚未有邦友留言

立即登入留言