在 Java 的世界中,有很多種 json library 任君挑選,其中最多人使用的應該是 Jackson 及 Gson。我過往都是使用 Jackson,因為這是 Spring Boot 及 Play Framework 的預設偏好。因為我幾乎沒使用過其它的 library,所以也無法做詳盡的功能比較及推薦,不過我個人認為除非你需要處理數量或內容龐大的 json 資料,那麼可以參考 Github 上的 benchmark 結果做為挑選依據,要不然其實就用預設的就好,基本上你需要或想得到的功能都已經有實作了。
至於 Ktor ContentNegotiation Plugin 當然也有支援 Jackson 及 Gson,不過預設支援的還有自家 JetBrains 開發的 kotlinx.serialization,kotlinx.serialization 的特點在於
雖然我目前的需求不需要跨平台、又只需要轉換 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 的實作技巧。
我在 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)
}
//... 以下省略
}
我在 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 操作。