iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
Software Development

Kotlin Ktor 實戰 101系列 第 13 篇

Kotlin Ktor 實戰 101 Day 13 Serialization 細節

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260909/20121948LfQo3aVCtG.jpg

day 12 的 Todo 3 個欄位都必填,也都沒有預設值,JSON 長什麼樣 data class 就長什麼樣,這種一一對應在測試時很乾淨,接上真實的 client 就會開始有問題,這篇把 kotlinx.serialization 的規則一條一條拆開,@SerialName 怎麼改名、預設值真正決定的是什麼、ignoreUnknownKeys 沒開會發生什麼事,然後替 Todo 加上 created_at

這篇要完成什麼

  • 開一個獨立的 test class,把 kotlinx.serialization 的規則驗過一輪,@SerialName、預設值、ignoreUnknownKeys、nullable 的 4 種組合
  • 翻 3.5.2 原始碼看 json() 的預設 Json 是什麼,弄清楚自訂設定是疊加還是取代
  • 寫一個 KSerializer<Instant>,讓 java.time.Instant 進得了 JSON
  • 替 Todo 加上 created_at,CreateTodoRequest 加上有預設值的 done
  • 決定 ContentNegotiation 的 Json 設定,2 個開、1 個刻意不開
  • 處理回應帶了 Instant.now() 之後,斷言整串 JSON 的測試不再穩定的問題

規則放哪裡驗

這篇要講的規則幾乎都屬於 kotlinx.serialization 本身,跟 Ktor 沒有直接關係,也跟 HTTP 沒有關係,拿 Json.encodeToString 和 Json.decodeFromString 直接呼叫就驗得完,不用起 server

所以開一個獨立的測試檔案 src/test/kotlin/com/cashwu/todo/SerializationRulesTest.kt,用到的 data class 只服務這些測試,宣告成 private 跟測試放同一個檔案,整個檔案的骨架長這樣

package com.cashwu.todo

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith

@Serializable
private data class Renamed(@SerialName("created_at") val createdAt: String)

@Serializable
private data class WithDefault(val title: String, val done: Boolean = false)

@Serializable
private data class NullableRequired(val title: String, val note: String?)

@Serializable
private data class NullableOptional(val title: String, val note: String? = null)

class SerializationRulesTest {
    private val json = Json

    // 底下每一節的測試都放進這個 class
}

4 個 data class 放在 class 外面的 top level,private 讓它們只在這個檔案裡看得到,不會跟 Todo.kt 那些真的 model 混在一起。class 裡的 private val json = Json 一個設定都沒改,這件事後面會變得很重要,因為 Ktor 交給我們的預設值跟原本的 Json 不一樣,後面撞到的那個坑就埋在這個差別裡

@SerialName 只改 JSON 那一邊

第 1 條規則最單純,JSON 世界習慣 snake_case,Kotlin 世界習慣 camelCase,@SerialName 就是兩邊的對照表,標在屬性上,只影響 JSON 的鍵名,Kotlin 這邊的屬性名不動

@Test
fun `serial name renames field in both directions`() {
    assertEquals(
        """{"created_at":"2026-10-05"}""",
        json.encodeToString(Renamed("2026-10-05")),
    )
    assertEquals(
        Renamed("2026-10-05"),
        json.decodeFromString<Renamed>("""{"created_at":"2026-10-05"}"""),
    )
}

寫出去跟讀進來都認 created_at,程式裡拿到的仍然是 createdAt,要注意的是這個改名是取代不是新增別名,改完之後原本的名字就不通了,original property name is not accepted after renaming 這個測試送 {"createdAt":"2026-10-05"} 進去,拿到的是 SerializationException,舊名字會被當成不認識的鍵,所以真的要替線上 API 的欄位改名時得先講清楚,不然舊 client 會在你改名的那天集體壞掉,想同時吃 2 種名字,kotlinx.serialization 有 @JsonNames 可以掛別名,不過這篇沒用到

預設值決定的是必填還是選填

第 2 條規則常被誤解,在 kotlinx.serialization 裡,一個欄位有沒有預設值,決定的是反序列化的時候 client 能不能省略它

@Test
fun `missing field with default value falls back to default`() {
    assertEquals(
        WithDefault("買牛奶", done = false),
        json.decodeFromString<WithDefault>("""{"title":"買牛奶"}"""),
    )
}

@Test
fun `missing field without default value fails`() {
    assertFailsWith<SerializationException> {
        json.decodeFromString<WithDefault>("""{"done":true}""")
    }
}

done 有預設值,省略沒事,title 沒有預設值,省略就丟 SerializationException,所以「必填」這件事在這裡不是靠什麼 annotation 宣告的,是 Kotlin 的預設值語法順便決定的,你在 data class 上為了方便加的那個 = false,同時也是在對外宣告「這個欄位 client 可以不給」

反過來的方向就不一樣了。序列化的時候,預設值預設會被省略

@Test
fun `default values are omitted from output by default`() {
    assertEquals(
        """{"title":"買牛奶"}""",
        json.encodeToString(WithDefault("買牛奶", done = false)),
    )
    assertEquals(
        """{"title":"買牛奶","done":true}""",
        json.encodeToString(WithDefault("買牛奶", done = true)),
    )
}

done 是 false (剛好等於預設值) 的時候,整個欄位從輸出裡消失了,done 是 true 才寫出來,這個行為的出發點是省流量,同一份資料反正對方解回來也會補上預設值,所以不需要傳,問題是 API 的 client 不一定是 kotlinx.serialization,可能是 JavaScript、可能是 curl 加人眼,對他們來說「沒有 done 這個欄位」跟「done 是 false」不是同一件事,第 1 種要去猜,想關掉這個省略,可以開 encodeDefaults = true,"done":false 就會回到輸出裡,encodeDefaults true writes default values 這個測試看著這件事

json() 到底替我們設了什麼

day 12 裝 ContentNegotiation 的時候寫的是 json(),沒帶參數,那時候沒追究它給了什麼設定,既然這篇要動 Json,先把 3.5.2 的 JsonSupport.kt 翻開對答案

public val DefaultJson: Json =
    Json {
        encodeDefaults = true
        isLenient = true
        allowSpecialFloatingPointValues = true
        allowStructuredMapKeys = true
        prettyPrint = false
        useArrayPolymorphism = false
    }

public fun Configuration.json(
    json: Json = DefaultJson,
    contentType: ContentType = ContentType.Application.Json
) {
    serialization(contentType, json)
}

2 件事,第 1,json() 沒帶參數用的是 DefaultJson,而 DefaultJson 已經替我們開了 encodeDefaults = true,所以上一節那個「預設值會從輸出消失」的坑,day 12 的 API 根本沒踩到,Ktor 先擋掉了。它也順手開了 isLenient,允許 JSON 的鍵和值不加引號

第 2 件事更要注意,json 是一個參數,帶了自訂的 Json 進去就是整個取代掉 DefaultJson,不是在它上面疊,也就是說只要你為了開某一個設定而寫 json(Json { ... }),DefaultJson 那 6 行全部還原成 kotlinx.serialization 的預設值,6 行裡面 prettyPrint 和 useArrayPolymorphism 設的本來就跟預設值一樣,還原了也沒有差別,真正會變的是另外 4 個,encodeDefaults、isLenient、allowSpecialFloatingPointValues、allowStructuredMapKeys,後 2 個 todo-api 感覺不到,因為它沒有 NaN 也沒有非字串的 map key,但前 2 個馬上就有事,而且沒有任何警告,編譯照過、server 照跑,只有回應的 JSON 悄悄少了幾個欄位,等一下就會實際撞到

ignoreUnknownKeys,多帶的欄位預設會爆

第 3 條規則是 client 多送欄位。kotlinx.serialization 的預設態度很嚴格

@Test
fun `unknown key fails by default`() {
    assertFailsWith<SerializationException> {
        json.decodeFromString<WithDefault>("""{"title":"買牛奶","priority":1}""")
    }
}

多一個 priority 就整個解不開,換成 Json { ignoreUnknownKeys = true } 同一份 JSON 就解得開,priority 被安靜丟掉,這是 ignoreUnknownKeys drops unknown key 那個測試

DefaultJson 沒開 ignoreUnknownKeys,所以 day 12 那支 API 現在拿到任何多餘欄位都會回 400。嚴格的價值在於它會把問題講出來,client 想送 done 卻打成 dnoe,嚴格會直接回 400,放寬則是回 201 而那個欄位安靜消失,對方以為自己設定生效了,這種安靜的失敗在對外的 API 上更貴,因為你沒辦法叫對面改程式,所以原則上對外反而應該更嚴才對

放寬的理由不是對 client 比較友善,是相容性,client 常常把 GET 拿到的整包物件改一改就送回來,你哪天在回應裡拿掉或改名一個欄位,那些 client 就會開始收到 400,中間某層 proxy 或 SDK 自己加了追蹤欄位也是同一種狀況,所以這裡真正的分界不是內部還是對外,是你能不能跟 client 一起改版,兩邊一起部署的內部服務其實最適合嚴格,打錯欄位名當下就會被抓到,反過來你控制不了的 client 才是放寬的理由,代價是接受打錯的欄位名會被安靜吃掉

nullable 跟必填是兩件事

第 4 條規則最容易搞混,因為 Kotlin 的 ? 跟 JSON 的 null 看起來很像同一件事,實際上「能不能省略」由預設值決定,「能不能是 null」由型別決定,2 個維度互相獨立

@Test
fun `nullable without default is still required`() {
    assertFailsWith<SerializationException> {
        json.decodeFromString<NullableRequired>("""{"title":"買牛奶"}""")
    }
    assertEquals(
        NullableRequired("買牛奶", note = null),
        json.decodeFromString<NullableRequired>("""{"title":"買牛奶","note":null}"""),
    )
}

note: String? 沒有預設值,client 可以明確送 "note":null,但不能整個不送,想讓它真的變成選填,得補上 = null

4 種組合在預設設定的 Json 底下整理起來是這樣,每一格都有一個測試看著

宣告 JSON 沒有這個鍵 JSON 給 null
val note: String 失敗 失敗
val note: String? 失敗 null
val note: String = "x" "x" 失敗
val note: String? = null null null

val note: String = "x" 那一列的右邊那格最容易誤判,拿前面那個 WithDefault.done 來看就是這一格,它有預設值 false,但型別是 Boolean 不是 Boolean?,送 null 進來會失敗,預設值管的是「沒給」不是「給了 null」

想讓 null 自動落回預設值,開 coerceInputValues,SerializationRulesTest 裡的 coerceInputValues turns null into default value 就是驗這個

輸出方向另外有個 explicitNulls,它的歸因很容易搞錯。預設設定的 Json 序列化 NullableOptional("買牛奶", note = null) 得到 {"title":"買牛奶"},note 整個不見,但這是 encodeDefaults 造成的 (null 剛好就是它的預設值),跟 explicitNulls 無關,把 encodeDefaults 打開 "note":null 就寫出來了,真正管 null 要不要出現在輸出的是 explicitNulls,關掉它連沒有預設值的 nullable 欄位都會被省略,這 2 個設定會互相影響,實際專案要調的時候建議各寫一個測試分清楚,光看名字猜不準

讓 Instant 進得了 JSON

Todo 要加的 created_at 是時間,kotlinx.serialization 內建認得的型別裡沒有 java.time.Instant,畢竟它是 JVM 專屬的東西,而 kotlinx.serialization 是跨平台的函式庫,解法是自己寫一個 serializer,開新檔案 src/main/kotlin/com/cashwu/todo/InstantSerializer.kt

package com.cashwu.todo

import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.time.Instant

object InstantSerializer : KSerializer<Instant> {
    override val descriptor: SerialDescriptor =
        PrimitiveSerialDescriptor("java.time.Instant", PrimitiveKind.STRING)

    override fun serialize(encoder: Encoder, value: Instant) {
        encoder.encodeString(value.toString())
    }

    override fun deserialize(decoder: Decoder): Instant =
        Instant.parse(decoder.decodeString())
}

整個檔案就這些,6 行 import 分散在 3 個地方,KSerializer 在 kotlinx.serialization 底下,PrimitiveKind、PrimitiveSerialDescriptor、SerialDescriptor 在 descriptors 這個子套件,Decoder、Encoder 在 encoding,最後一行是 JDK 的 java.time.Instant

3 個成員各有分工,descriptor 是這個型別在序列化世界裡的形狀說明,PrimitiveKind.STRING 等於宣告「我在 JSON 裡是一個字串」,那個字串名字只要全域唯一就好,慣例是用型別的完整名稱,serialize 和 deserialize 是實際的轉換,Instant.toString() 產出的就是 ISO-8601 格式,Instant.parse() 讀得回來,這一組剛好互為反向,不用自己組格式字串

寫成 object 而不是 class,是因為它沒有任何狀態,一個實體就夠全程式共用

為什麼是 java.time 不是 kotlin.time

Kotlin 現在有 3 個 Instant 可以選,這一段的十幾行 serializer 其實是選擇的結果,換另一個選項就不用寫

kotlin.time.Instant 是 stdlib 從 2.1.20 開始提供的跨平台型別,kotlinx.serialization 內建就認得它,serializer 一行都不用寫,第 3 個是 kotlinx-datetime 那個套件裡的 Instant,它從 0.7 起把自己的型別讓位給 stdlib 那個,等於同一條路

寫出去的 JSON 沒有差別,實測 kotlin.time.Instant 和 java.time.Instant 送同一個時間點,字串都是 2026-08-27T08:00:00Z,帶到奈秒的 2026-10-10T12:00:00.123456789Z 也一樣,兩邊都是 ISO-8601、都存得下奈秒。所以這個選擇對 API 的使用者是隱形的,差別全在專案這一側

第 1 個差別是穩定度,這個專案的 Kotlin 是 2.2.20,kotlin.time.Instant 在這個版本還標著 experimental,直接拿來當欄位型別編譯就過不了

e: This declaration needs opt-in. Its usage must be marked with '@kotlin.time.ExperimentalTime' or '@OptIn(kotlin.time.ExperimentalTime::class)'

補上 @OptIn(kotlin.time.ExperimentalTime::class) 就編得過,但那個 annotation 會跟著這個型別散到用得到的每個檔案,而 experimental 的意思是它保留改 API 的權利

第 2 個差別更關鍵,在資料庫那一端,這個系列 day 20 會接上 Exposed,它的時間欄位分成 exposed-java-time 和 exposed-kotlin-datetime 2 個模組,JDBC 本身講的也是 java.time,Todo.createdAt 選 java.time.Instant,day 20 就直接用 exposed-java-time,中間不用轉換,反過來選 stdlib 那個,序列化這一層省下十幾行 serializer,資料庫那一層就得補回一層轉換

所以這是看專案性質的選擇,純 JVM 的 server、後面要接 JDBC,java.time 是阻力最小的路,如果是 Kotlin Multiplatform、model 要跟 iOS 或 web 共用,java.time 根本帶不過去,只能選 stdlib 那個

把規則落到 todo-api

規則都驗過了,改 src/main/kotlin/com/cashwu/todo/Todo.kt

@Serializable
data class Todo(
    val id: Int,
    val title: String,
    val done: Boolean = false,
    @SerialName("created_at")
    @Serializable(with = InstantSerializer::class)
    val createdAt: Instant,
)

@Serializable
data class CreateTodoRequest(
    val title: String,
    val done: Boolean = false,
)

createdAt 一次用上 2 個 annotation,@SerialName 讓 JSON 那邊叫 created_at,@Serializable(with = ...) 指定這個屬性用剛剛寫的 serializer,注意 @Serializable 標在屬性上跟標在 class 上是不同的用法,標在屬性上時它的意思是「這一個欄位改用指定的 serializer」

done 兩邊都補上了 = false,在 CreateTodoRequest 這邊,意思是 client 可以只送 title,也可以在建立時就標記已完成,在 Todo 這邊沒有 client 會反序列化它,加預設值純粹是為了讓 data class 好建,這個看似無害的決定等一下會出事

同一個檔案裡的 defaultTodos 3 筆各補上一個固定時間

val defaultTodos = listOf(
    Todo(id = 1, title = "買牛奶", done = true, createdAt = Instant.parse("2026-08-27T08:00:00Z")),
    Todo(id = 2, title = "繳電費", done = false, createdAt = Instant.parse("2026-08-28T09:30:00Z")),
    Todo(id = 3, title = "寫 day 05 的文章", done = false, createdAt = Instant.parse("2026-08-29T21:15:00Z")),
)

Todo.kt 的 import 這時候要補上 kotlinx.serialization.SerialName 和 java.time.Instant,時間寫死是刻意的,測試要拿它們斷言整串 JSON,換成 Instant.now() 那些斷言就不可能成立

再改 src/main/kotlin/com/cashwu/todo/TodoRoutes.kt 的 POST handler,done 改成聽 client 的,createdAt 由 server 發

post {
    val request = call.receive<CreateTodoRequest>()
    val todo = Todo(
        id = (todos.maxOfOrNull { it.id } ?: 0) + 1,
        title = request.title,
        done = request.done,
        createdAt = Instant.now(),
    )
    todos.add(todo)
    call.respond(HttpStatusCode.Created, todo)
}

Instant.now() 直接寫在 handler 裡,跟 day 12 那個全域的 todos 一樣,是個等著被換掉的東西,時間來源寫死就沒辦法在測試裡控制它,正式的做法是把時鐘抽成可以從外面注入的相依物件,day 18 做 DI 的時候會一起處理,現在先讓它跑起來

Todo 多了一個欄位,回應的 JSON 就跟著變,先跑一次測試看有多少東西要動,./gradlew test --tests 'com.cashwu.todo.TodoRoutesTest',13 個測試 4 個失敗,2 個列表的訊息很長,中間的欄位省略掉

TodoRoutesTest > todos with limit query responds limited todos() FAILED
    org.opentest4j.AssertionFailedError: expected: <[{"id":1,...},{"id":2,"title":"繳電費","done":false}]> but was: <[{"id":1,...},{"id":2,"title":"繳電費","done":false,"created_at":"2026-08-28T09:30:00Z"}]>

TodoRoutesTest > post todos creates todo and responds created() FAILED
    org.opentest4j.AssertionFailedError: expected: <{"id":4,"title":"倒垃圾","done":false}> but was: <{"id":4,"title":"倒垃圾","done":false,"created_at":"2026-09-02T03:14:30.545151Z"}>

TodoRoutesTest > todo by id responds single todo as json() FAILED
    org.opentest4j.AssertionFailedError: expected: <{"id":1,"title":"買牛奶","done":true}> but was: <{"id":1,"title":"買牛奶","done":true,"created_at":"2026-08-27T08:00:00Z"}>

TodoRoutesTest > todos path responds all todos as json() FAILED
    org.opentest4j.AssertionFailedError: expected: <[...,{"id":3,"title":"寫 day 05 的文章","done":false}]> but was: <[...,{"id":3,"title":"寫 day 05 的文章","done":false,"created_at":"2026-08-29T21:15:00Z"}]>

13 tests completed, 4 failed

4 個都是斷言整串 JSON 的測試,多出來的 created_at 讓字串對不上,前 3 個好處理,POST 那個沒這麼單純

測試跟著改

4 個測試分成 2 種改法,3 個 GET 的資料來自固定的 defaultTodos,時間是寫死的,所以照樣可以斷言整串 JSON,只要把 created_at 補進去。全部列表那個 3 筆都要補

@Test
fun `todos path responds all todos as json`() = testApplication {
    application {
        module()
    }

    val response = client.get("/todos")

    assertEquals(HttpStatusCode.OK, response.status)
    assertEquals(
        ContentType.Application.Json,
        response.contentType()?.withoutParameters(),
    )
    assertEquals(
        """[{"id":1,"title":"買牛奶","done":true,"created_at":"2026-08-27T08:00:00Z"},""" +
            """{"id":2,"title":"繳電費","done":false,"created_at":"2026-08-28T09:30:00Z"},""" +
            """{"id":3,"title":"寫 day 05 的文章","done":false,"created_at":"2026-08-29T21:15:00Z"}]""",
        response.bodyAsText(),
    )
}

查單筆那個補 id 1 的時間

@Test
fun `todo by id responds single todo as json`() = testApplication {
    application {
        module()
    }

    val response = client.get("/todos/1")

    assertEquals(HttpStatusCode.OK, response.status)
    assertEquals(
        """{"id":1,"title":"買牛奶","done":true,"created_at":"2026-08-27T08:00:00Z"}""",
        response.bodyAsText(),
    )
}

limit 那個只回前 2 筆,補前 2 個時間

@Test
fun `todos with limit query responds limited todos`() = testApplication {
    application {
        module()
    }

    val response = client.get("/todos?limit=2")

    assertEquals(HttpStatusCode.OK, response.status)
    assertEquals(
        """[{"id":1,"title":"買牛奶","done":true,"created_at":"2026-08-27T08:00:00Z"},""" +
            """{"id":2,"title":"繳電費","done":false,"created_at":"2026-08-28T09:30:00Z"}]""",
        response.bodyAsText(),
    )
}

3 個都只動 body 的斷言,狀態碼和 Content-Type 那幾行不變

麻煩的是 POST,回應裡的 created_at 來自 Instant.now(),每次跑都不一樣,字串斷言直接失去意義,既然回應是 JSON,那就解析回來再看

@Test
fun `post todos creates todo and responds created`() = testApplication {
    application {
        module()
    }

    val before = Instant.now()
    val response = client.post("/todos") {
        header(HttpHeaders.ContentType, ContentType.Application.Json)
        setBody("""{"title":"倒垃圾"}""")
    }
    val after = Instant.now()

    assertEquals(HttpStatusCode.Created, response.status)
    val created = Json.decodeFromString<Todo>(response.bodyAsText())
    assertEquals(4, created.id)
    assertEquals("倒垃圾", created.title)
    assertEquals(false, created.done)
    assertTrue(created.createdAt in before..after)

    val list = Json.decodeFromString<List<Todo>>(client.get("/todos").bodyAsText())
    assertEquals(4, list.size)
    assertEquals(created, list.last())
}

Json.decodeFromString<Todo> 把回應解回物件,能確定的欄位逐個斷言,時間則改成斷言區間,發請求前後各記一個時間,回來的 createdAt 必須落在 before 到 after 之間,這樣既沒有放過這個欄位,也不依賴任何猜測的時間值,這裡順便驗到了 InstantSerializer 2 個方向都通,server 用它寫出字串,測試用它讀回 Instant

最後那 2 行有個小巧思,建完再查一次列表,直接拿 created 跟列表最後一筆比,data class 的 equals 會逐欄位比對,包含那個測不準的時間,所以「回應裡的那筆」跟「真的存進去的那筆」是同一份資料這件事,一行就驗完了

這次測試的 import 多 3 行,kotlinx.serialization.json.Json、java.time.Instant、kotlin.test.assertTrue,再跑一次,TodoRoutesTest 測試應該全部通過

那個設定被弄丟的瞬間

接著決定 ContentNegotiation 的 Json,開 ignoreUnknownKeys,讓 client 多帶欄位不要整個請求失敗,於是很自然地把 Application.kt 裡的 json() 換成 json(Json { ignoreUnknownKeys = true })。./gradlew test,2 個測試失敗,斷言的差異是整份 JSON 的比對,很長,這裡把不相干的欄位省略掉,節錄一段

TodoRoutesTest > todos path responds all todos as json() FAILED
expected: <[...{"id":2,"title":"繳電費","done":false,"created_at":"2026-08-28T09:30:00Z"}...]>
but was:  <[...{"id":2,"title":"繳電費","created_at":"2026-08-28T09:30:00Z"}...]>

剛剛才全部通過的測試,只改一行設定就掛了 2 個,3 個斷言整串 JSON 的 GET 測試裡,查單筆那個躲過了,因為它查的 id 1 的 done 是 true,不等於預設值,本來就會寫進輸出。"done":false 從回應裡消失了,前面 2 節的伏筆在這裡,我替 Todo.done 加了 = false,而自訂的 Json 取代掉 DefaultJson 之後 encodeDefaults 回到預設的 false,於是所有還沒完成的 todo 都不再輸出 done 這個欄位,client 那邊這就是實實在在的破壞性變更,查列表拿到一筆沒有 done 的資料,要嘛猜、要嘛當成錯誤

只改一行設定就好,弄壞的是完全沒提到的另一個行為,這就是「自訂 Json 是取代不是疊加」的實際代價,修法是把要的設定明確寫出來

install(ContentNegotiation) {
    json(Json {
        encodeDefaults = true
        ignoreUnknownKeys = true
    })
}

搭配的 import 是 kotlinx.serialization.json.Json,DefaultJson 開的另一個 isLenient 我沒有補回來,這是刻意的取捨,isLenient 會讓 {"title":倒垃圾} 這種沒加引號的內容也解得開,寬鬆解析吞掉的是 client 的錯誤,而這支 API 寧可回 400 讓對方早點發現,這個判斷有前提,如果你的 API 已經上線、已經有 client 靠著 isLenient 在送不合規的 JSON,改掉它就是破壞性變更,要照相容性的流程走

至於為什麼開 ignoreUnknownKeys 而不是維持嚴格,這支 API 站在前面那個取捨的放寬那一邊,理由是這 2 種失敗的代價不對稱,多帶欄位就整個請求 400,壞的是一次真實的建立操作,而忽略它的代價只是 client 的某個欄位沒生效,等 day 14 的 RequestValidation 進來,該擋的值會在那一層擋,這一層先放行

新增 3 個測試盯住這篇的新行為,post todos without done uses default value 只送 title 斷言 done 是 false,post todos with done true keeps the given value 明確送 true 確認沒被預設值蓋掉,post todos with unknown field is accepted 送 priority 斷言 201,最後這個要等剛剛的 ignoreUnknownKeys 開了才會過。跑 ./gradlew test,SerializationRulesTest 19 個、TodoRoutesTest 16 個,加上其他 class 的 15 個,50 個測試全部通過

curl 實測

./gradlew run,先看列表

curl -i localhost:8080/todos
HTTP/1.1 200 OK
X-Response-Time: 7ms
Content-Length: 245
Content-Type: application/json

[{"id":1,"title":"買牛奶","done":true,"created_at":"2026-08-27T08:00:00Z"},{"id":2,"title":"繳電費","done":false,"created_at":"2026-08-28T09:30:00Z"},{"id":3,"title":"寫 day 05 的文章","done":false,"created_at":"2026-08-29T21:15:00Z"}]

created_at 是 snake_case,"done":false 在該出現的地方都出現了,@SerialName 跟 encodeDefaults 2 件事在同一行輸出裡驗完。接著只送 title

curl -i -X POST -H "Content-Type: application/json" \
  -d '{"title":"倒垃圾"}' localhost:8080/todos
HTTP/1.1 201 Created
X-Response-Time: 7ms
Content-Length: 84
Content-Type: application/json

{"id":4,"title":"倒垃圾","done":false,"created_at":"2026-09-02T04:52:44.131792Z"}

done 用了預設值,created_at 是 server 發的,這次執行在這台機器上輸出到微秒,沒有特別裁掉,Instant.now() 的實際解析度取決於作業系統與系統時鐘,不保證每個環境都一樣,反過來明確把 done 送進去

curl -i -X POST -H "Content-Type: application/json" \
  -d '{"title":"補牙","done":true}' localhost:8080/todos
HTTP/1.1 201 Created
X-Response-Time: 0ms
Content-Length: 80
Content-Type: application/json

{"id":5,"title":"補牙","done":true,"created_at":"2026-09-02T04:52:44.142180Z"}

回的是 "done":true,明確給的值不會被預設值蓋掉,然後是 ignoreUnknownKeys,塞一個 API 完全不認識的 priority

curl -i -X POST -H "Content-Type: application/json" \
  -d '{"title":"買書","priority":1}' localhost:8080/todos
HTTP/1.1 201 Created
X-Response-Time: 1ms
Content-Length: 81
Content-Type: application/json

{"id":6,"title":"買書","done":false,"created_at":"2026-09-02T04:52:44.152108Z"}

201,priority 被安靜丟掉,同一個機制也讓改名之後的舊欄位名變成無害,送 createdAt 這個 camelCase 的鍵一樣會被忽略,就算把 created_at 送對了也沒有用,POST 收的是 CreateTodoRequest,它根本沒有時間欄位,少送必填欄位則是另一回事

curl -i -X POST -H "Content-Type: application/json" \
  -d '{"done":true}' localhost:8080/todos
HTTP/1.1 400 Bad Request
X-Response-Time: 2ms
Content-Length: 73
Content-Type: text/plain; charset=UTF-8

Failed to convert request body to class com.cashwu.todo.CreateTodoRequest

title 沒有預設值,所以它是必填,少了就 400,這正是 day 12 留下的那個伏筆,這個 400 的訊息跟送 not json 拿到的一模一樣,client 分不出自己是 JSON 壞掉還是少帶欄位,serialization 這一層能講的到此為止,它只知道解不開,說不出哪裡解不開,day 14 會補上值的驗證,統一錯誤訊息要到 day 15

跟 Relix 的對照

Relix 的 day 20 手刻協商那一層時,json() 的簽名長這樣

fun json(jsonInstance: Json = Json { prettyPrint = false }) {
    registry.register(RelixSerializationConverter(jsonInstance))
}

跟 Ktor 的 json(json: Json = DefaultJson) 是同一個形狀,設定入口都開在同一個位置,帶進去的 Json 也同樣是整個取代預設那一份,當時會這樣設計,是因為框架不該替使用者決定序列化細節,把 Json 開成參數就是把決定權交出去

差別在預設值選了什麼,Relix 給的 Json { prettyPrint = false } 基本上就是 kotlinx.serialization 的預設設定,encodeDefaults 是關的、ignoreUnknownKeys 是關的、isLenient 是關的,嚴格而且輸出會省略預設值,Ktor 的 DefaultJson 則是一份有意見的設定,encodeDefaults、isLenient 這幾個都替你開好了,2 種都說得通,Relix 那份對使用者比較誠實,你看到什麼就是什麼,Ktor 那份對新手比較友善,先幫你避開輸出少欄位的坑,代價是 Ktor 這種友善只在你不自訂的時候有效,一旦自訂就整包還你,而這件事在 API 文件上是看不出來的,得翻原始碼

還有一個對得上的點,Relix day 20 寫 converter 測試時就注意到 nickname 是 null 會輸出 "nickname":null,欄位不會消失,要消失得自己開 explicitNulls = false,當時那句話是對的,但少了一半,因為那個測試用的 nickname 沒有預設值,這篇補上的另一半是,同樣是 nullable 欄位,加了 = null 之後 encodeDefaults 就會接手,2 個設定管的是不同的省略路徑


小結

kotlinx.serialization 對欄位值的核心規則有 2 個維度,「能不能省略」看有沒有預設值,「能不能是 null」看型別有沒有 ?,encodeDefaults、explicitNulls、coerceInputValues 會調整這些邊界,@SerialName 換的是 JSON 那一側的名字,ignoreUnknownKeys 則另外決定多出的鍵要產生錯誤還是忽略

真正有問題的不是規則本身,是 Ktor 的 json() 預設塞了一份有意見的 DefaultJson,而自訂 Json 是整個取代,我們為了開 ignoreUnknownKeys 順手弄丟了 encodeDefaults,靠 2 個斷言整串 JSON 的測試才發現回應少了 done 欄位,todo-api 這一輪拿到 created_at、一個手寫的 InstantSerializer、以及一組寫明理由的 Json 設定,也留了一個新問題,Instant.now() 寫死在 handler 裡讓回應不再可預測,測試只好改成解析回物件再斷言,真正的解法要等 day 18 的 DI


下一篇

送 {"done":true} 少了 title,跟送 not json,拿到的是一模一樣的 400 訊息,client 看不出問題出在哪裡,目前的驗證只到「解得開解不開」,解開之後 title 是不是空字串、長度合不合理,serialization 完全管不著,下一篇裝 RequestValidation,把值的驗證從 handler 裡拉出來,順便處理錯誤訊息說不清楚的問題


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Ktor 實戰 101 Day 12 ContentNegotiation 與 kotlinx.serialization
下一篇
Kotlin Ktor 實戰 101 Day 14 RequestValidation 輸入驗證
系列文
Kotlin Ktor 實戰 101 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言