iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
Software Development

Kotlin Ktor 實戰 101系列 第 12 篇

Kotlin Ktor 實戰 101 Day 12 ContentNegotiation 與 kotlinx.serialization

  • 分享至 

  • xImage
  •  

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

respondText 很一開始使用到現在,todo-api 回的一直是純文字,todo 在程式裡也只是一個 List<String>,這篇進入新的段落,內容協商與序列化,裝上 ContentNegotiation,把 todo 改造成 data class,讓 API 回真正的 JSON,順便補上系列第 1 個 POST 端點,第 1 次收 request body

day 08 看過的 kotlinx.serialization 編譯器外掛,這次正式來介紹,day 10 說 onCallReceive 這個 hook「等後面幾篇加上 JSON body 之後再讓它上場」,這篇就是它上場的時候

這篇要完成什麼

  • 加上 ContentNegotiation 與 kotlinx.serialization 的 dependencies
  • 把 todo 從 List<String> 改造成 @Serializable 的 data class,先改既有測試,再把 2 條 GET 路由改成回 JSON
  • 一樣測試先寫,加第 1 個 POST 端點,用 call.receive<CreateTodoRequest>() 收 JSON body 建立新 todo
  • 實測 3 種失敗,Content-Type 不對的 415、JSON 壞掉的 400、Accept 談不攏的 406
  • day 06 用測試固定下來的 POST 回 405 這次正式讓位給 PUT
  • 對照 Relix day 19 到 21 手刻的那條路

內容協商在協商什麼

一個 API 的請求和回應各有一個「格式」問題,client 送來的 body 是什麼格式,server 回去的 body 該用什麼格式,HTTP 用 2 個 header 表達

  • 請求的 Content-Type 說「我送的 body 長這樣」,server 依它決定怎麼解析
  • 請求的 Accept 說「我想收到這種格式」,server 依它決定怎麼序列化回應

ContentNegotiation 這個 plugin 做的就是兩邊的翻譯,裝的時候註冊一組 converter (這篇只註冊 JSON),收 body 時拿 Content-Type 去比對誰認得這個格式,回應時拿 Accept 去挑誰能產出對方要的格式,比對得上就交給那個 converter 做,比對不上會發生什麼,後面實測

converter 只是翻譯的入口,真正把 JSON 字串和 Kotlin 物件互轉的是 kotlinx.serialization,它靠編譯器外掛在編譯期替每個標了 @Serializable 的 class 產生序列化程式碼,執行期不用反射

加 dependencies,編譯器外掛正式常駐

打開 build.gradle.kts,在 plugins 區塊,如果 day 08 之後有拿掉的話,就加回來

kotlin("plugin.serialization") version "2.2.20"

dependencies 加 2 行,第 1 行是 plugin 本體,第 2 行是它跟 kotlinx.serialization 之間的橋

implementation(ktorLibs.server.contentNegotiation)
implementation(ktorLibs.serialization.kotlinx.json)

注意第 2 行的 accessor 不在 server 底下,在 serialization 底下,因為這個 artifact 是 server 跟 client 共用的,Ktor 的 HttpClient 講 JSON 用的也是它

3 個零件各司其職,編譯器外掛負責編譯期產碼,contentNegotiation 負責協商,serialization.kotlinx.json 負責把兩邊接起來

把 todo 變成 data class

todo 一直是 List<String> 裡的 3 個字串,要回 JSON,先讓它有個形狀。開一個新檔案 src/main/kotlin/com/cashwu/todo/Todo.kt

package com.cashwu.todo

import kotlinx.serialization.Serializable

@Serializable
data class Todo(val id: Int, val title: String, val done: Boolean)

@Serializable
data class CreateTodoRequest(val title: String)

val defaultTodos = listOf(
    Todo(id = 1, title = "買牛奶", done = true),
    Todo(id = 2, title = "繳電費", done = false),
    Todo(id = 3, title = "寫 day 05 的文章", done = false),
)

Todo 是完整的一筆資料,CreateTodoRequest 是建立時 client 要送的形狀,只有 title,id 由 server 發,剛建立的 todo 一律視為還沒做完,done 不需要讓 client 給,2 個都標 @Serializable,編譯器外掛看到這個 annotation 才會替它們產生序列化程式碼,漏標的話編譯會過,執行期找不到 serializer 才爆炸

3 個欄位都是必填,都沒有預設值,這是故意的,kotlinx.serialization 對「欄位改名」「預設值」「client 多送一個不認識的欄位」都有自己的規則,那是下一篇的主題,這篇先讓欄位跟 JSON 一一對應

這一步只新增 Todo.kt 這個檔案,Application.kt 和 TodoRoutes.kt 都先不動,val todos = mutableListOf("買牛奶", ...) 還是原本那份字串清單,defaultTodos 暫時沒人用。型別先存在、還不接上去,是為了下一節,測試要能編譯得過才跑得起來

特地把初始資料抽成 defaultTodos 這個不可變的 list,而不是直接寫在 mutableListOf 裡,是為了後面的測試,POST 會改動這個全域的 list,測試之間需要一個乾淨的重設點

先改既有測試

todo 的形狀變了,2 條 GET 的回應格式跟著要變,這時候動作的順序有差,先改路由再回頭修測試,中間會有一段時間測試是失敗的,而且失敗的理由分不出來,是「格式如預期換過去了、斷言還沒跟上」還是「實作真的做壞了」,反過來先改測試,跑一次,看它失敗在哪一行,再動實作,同一批測試從失敗變成通過,就是格式真的換過去的證據

TodoRoutesTest 裡有 3 個測試斷言的是純文字 body,todos path responds all todos、todo by id responds single todo、todos with limit query responds limited todos,買牛奶\n繳電費 這種字串現在要換成 JSON,前 2 個順便在測試名字加上 as json,limit 那個測的重點還是 limit,名字不動,全部列表那個改完長這樣,多驗一個回應的 Content-Type

@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},""" +
            """{"id":2,"title":"繳電費","done":false},""" +
            """{"id":3,"title":"寫 day 05 的文章","done":false}]""",
        response.bodyAsText(),
    )
}

單筆那個只有 body 的斷言要換

@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}""", response.bodyAsText())
}

limit 那個換成只有前 2 筆的 JSON 陣列

@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},""" +
            """{"id":2,"title":"繳電費","done":false}]""",
        response.bodyAsText(),
    )
}

再加一個新的測試,client 指定一個我們不打算支援的格式

@Test
fun `todo with unsupported accept responds not acceptable`() = testApplication {
    application {
        module()
    }

    val response = client.get("/todos/1") {
        header(HttpHeaders.Accept, ContentType.Text.Xml)
    }

    assertEquals(HttpStatusCode.NotAcceptable, response.status)
}

Accept: text/xml 送進來,期望 406 Not Acceptable,這是裝上 ContentNegotiation 之後才會有的行為,現在還沒裝,Ktor 不管 Accept 寫什麼,respondText 照回不誤,測試的 import 這次多 4 行,io.ktor.client.request.header、io.ktor.http.ContentType、io.ktor.http.HttpHeaders、io.ktor.http.contentType

跑 ./gradlew test --tests 'com.cashwu.todo.TodoRoutesTest',10 個測試 4 個失敗

TodoRoutesTest > todos with limit query responds limited todos() FAILED
    org.opentest4j.AssertionFailedError: expected: <[{"id":1,"title":"買牛奶","done":true},{"id":2,"title":"繳電費","done":false}]> but was: <買牛奶
    繳電費>

TodoRoutesTest > todo with unsupported accept responds not acceptable() FAILED
    org.opentest4j.AssertionFailedError: expected: <406 Not Acceptable> but was: <200 OK>

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

TodoRoutesTest > todos path responds all todos as json() FAILED
    org.opentest4j.AssertionFailedError: expected: <application/json> but was: <text/plain>

4 個失敗的理由都對得上,3 個是回應還是純文字,列表那個先卡在 Content-Type,還沒比到 body 就先失敗,第 4 個是 Accept 沒人理,post todos responds method not allowed 這時候還是通過的,POST 等後面再處理

裝上 plugin,路由改成回 JSON

Application.kt 的 module 裡裝上 ContentNegotiation,json() 就是註冊 JSON converter 的入口,day 11 留下的 RequestTiming 還在

fun Application.module() {
    install(RequestTiming)

    install(ContentNegotiation) {
        json()
    }

    routing {
        get("/") {
            call.respondText("Hello, Ktor!")
        }
        todoRoutes()
    }
}

搭配的 import 是 io.ktor.server.plugins.contentnegotiation.ContentNegotiation 和 io.ktor.serialization.kotlinx.json.json,後者就是剛剛那個橋的套件,json() 是它提供的 extension function

同一個檔案裡的 todos 這時候才換成從 defaultTodos 建

val todos = defaultTodos.toMutableList()

MutableList 不保證並行安全,等一下 POST 會用到的 maxOfOrNull() + 1 和後面的 add 也不是一個不可分割的動作,2 個 POST 同時進來可能拿到相同 id,甚至互相干擾,day 18、19 換成 DI 和 repository 時會把這份全域可變狀態拿掉

再改 src/main/kotlin/com/cashwu/todo/TodoRoutes.kt,2 條 GET 從 respondText 換成 call.respond,把物件直接交出去

get {
    val limitText = call.request.queryParameters["limit"]
    val limit = if (limitText == null) todos.size else limitText.toIntOrNull()?.takeIf { it >= 0 }
    if (limit == null) {
        call.respondText("limit 要是 0 以上的整數", status = HttpStatusCode.BadRequest)
        return@get
    }
    call.respond(todos.take(limit))
}
get("{id}") {
    val id = call.parameters["id"]?.toIntOrNull()
    if (id == null) {
        call.respondText("id 要是數字", status = HttpStatusCode.BadRequest)
        return@get
    }
    val todo = todos.find { it.id == id }
    if (todo == null) {
        call.respondText("找不到 id $id 的待辦", status = HttpStatusCode.NotFound)
        return@get
    }
    call.respond(todo)
}

call.respond(todos.take(limit)) 回的是 List<Todo>,call.respond(todo) 回單一物件,序列化全部交給 plugin,查單筆的邏輯也修正了,之前 todos.getOrNull(id - 1) 是拿 id 當 list 的索引用,todo 只是字串的時代只能這樣,現在資料自己帶著 id,改成 todos.find { it.id == id },id 跟資料綁在一起,之後就算刪掉中間某筆也不會整個錯位

再跑一次測試,剛剛那 4 個全部通過,TodoRoutesTest 10 個測試都過,格式換過去這件事有證據了

錯誤回應維持 respondText,所以這支 API 目前成功回 JSON、失敗回純文字,格式不一致,day 15 的 StatusPages 會把錯誤回應統一成結構化的格式

第一個 POST 端點

前面的文章全是 GET,todo-api 還沒有收過任何 request body,一樣測試先寫,3 個新的測試,成功建立一筆、Content-Type 帶錯、body 不是合法 JSON

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

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

    assertEquals(HttpStatusCode.Created, response.status)
    assertEquals("""{"id":4,"title":"倒垃圾","done":false}""", response.bodyAsText())

    val list = client.get("/todos")
    assertEquals(
        """[{"id":1,"title":"買牛奶","done":true},""" +
            """{"id":2,"title":"繳電費","done":false},""" +
            """{"id":3,"title":"寫 day 05 的文章","done":false},""" +
            """{"id":4,"title":"倒垃圾","done":false}]""",
        list.bodyAsText(),
    )
}

第 1 個建完再查一次列表,斷言資料真的進去了,id 是 server 發的 4,另外 2 個是失敗路徑,第 2 個帶 ContentType.Text.Plain 送 JSON 內容期望 415

@Test
fun `post todos with non json content type responds unsupported media type`() = testApplication {
    application {
        module()
    }

    val response = client.post("/todos") {
        header(HttpHeaders.ContentType, ContentType.Text.Plain)
        setBody("""{"title":"倒垃圾"}""")
    }

    assertEquals(HttpStatusCode.UnsupportedMediaType, response.status)
}

第 3 個帶正確的 header 送 not json 期望 400

@Test
fun `post todos with malformed json responds bad request`() = testApplication {
    application {
        module()
    }

    val response = client.post("/todos") {
        header(HttpHeaders.ContentType, ContentType.Application.Json)
        setBody("not json")
    }

    assertEquals(HttpStatusCode.BadRequest, response.status)
}

2 個狀態碼各自被一個測試看住,這裡有個測試 client 的細節,setBody 給字串時 client 會自動補上 text/plain 的 Content-Type,所以測 415 的那個其實不用刻意帶錯 header 也會過,但測試要說清楚自己在測什麼,明確把 text/plain 寫出來

還有一個既有測試要動,post todos responds method not allowed,day 06 用它固定「路徑對、方法錯回 405」的語意,POST 一旦變成真端點,這個測試的前提就消失了,405 的語意本身還想看住,所以不是刪掉而是換一個還沒實作的方法,對 /todos 發 PUT 斷言 405,等哪天 PUT 也變成真端點再讓位一次

@Test
fun `put todos responds method not allowed`() = testApplication {
    application {
        module()
    }

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

    assertEquals(HttpStatusCode.MethodNotAllowed, response.status)
}

最後一件事不是某個測試的修改,是整個 test class 的重構調整

todos 是全域的 mutable list,之前每個測試只讀不寫,順序怎麼跑都沒差,現在 POST 測試會塞一筆進去,而 testApplication 每次起的雖然是全新的 application,todos 這個 top-level 變數卻是整個 JVM 共用的,POST 測試跑完,後面的 GET 測試就會看到 4 筆,所以補一個 @BeforeTest,每個測試開跑前把資料重設回 defaultTodos

@BeforeTest
fun resetTodos() {
    todos.clear()
    todos.addAll(defaultTodos)
}

這也是前面把初始資料抽成 defaultTodos 的原因,在 day 18、19 做 DI 和 repository 時會把它處理掉,這次測試的 import 多 3 行,io.ktor.client.request.put、io.ktor.client.request.setBody、kotlin.test.BeforeTest

這裡會有個問題,收尾要不要也補一個 @AfterTest 把資料清掉 ? 基本上只有 @AfterTest 也一樣測試會全部通過,2 個都不寫才會有問題,這不是對錯,是可靠度的取捨,@AfterTest 保證的是「我離開時有清乾淨」,前提是我真的跑完了,@BeforeTest 保證的是「我開始時是乾淨的」,不管上一個測試怎麼死的

teardown 只要有一次沒執行,下一個測試就帶著髒資料跑,而且失敗的會是那個無辜的測試,這個專案裡也沒有「留垃圾給別的 test class」的問題,CorsTest 和 RateLimitTest 都自己註冊 get("/todos"),沒碰到這個全域 list

2 個一起用沒有問題,只是重複做同一件事,如果要比較嚴緊的話,可以 2 個都加上,這裡只用 @BeforeTest 就好

跑測試,會有 3 個失敗,理由一模一樣

TodoRoutesTest > post todos creates todo and responds created() FAILED
    org.opentest4j.AssertionFailedError: expected: <201 Created> but was: <405 Method Not Allowed>

TodoRoutesTest > post todos with malformed json responds bad request() FAILED
    org.opentest4j.AssertionFailedError: expected: <400 Bad Request> but was: <405 Method Not Allowed>

TodoRoutesTest > post todos with non json content type responds unsupported media type() FAILED
    org.opentest4j.AssertionFailedError: expected: <415 Unsupported Media Type> but was: <405 Method Not Allowed>

13 tests completed, 3 failed

在 TodoRoutes.kt 的 route("/todos") 區塊裡,2 條 get 後面加上

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

call.receive<CreateTodoRequest>() 一行做完 3 件事,讀 body、看 Content-Type 挑 converter、反序列化成指定的型別,handler 裡沒有任何 JSON 的痕跡,拿到的直接是型別安全的 CreateTodoRequest,id 用現有最大值加 1 來使用,回應帶 HttpStatusCode.Created,建立成功回 201 而不是 200,body 是建好的那筆完整資料,client 馬上知道 server 發了什麼 id

post 和 receive 的 import 分別是 io.ktor.server.routing.post 和 io.ktor.server.request.receive

curl 實測,先走順的路

./gradlew run 起 server,先看列表

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

[{"id":1,"title":"買牛奶","done":true},{"id":2,"title":"繳電費","done":false},{"id":3,"title":"寫 day 05 的文章","done":false}]

Content-Type 從 11 篇以來的 text/plain; charset=UTF-8 變成了 application/json,注意它沒帶 charset,這不是漏掉,JSON 的規格 (RFC 8259) 規定編碼就是 UTF-8,規格上不需要 charset 參數,Ktor 也只在 client 帶了 Accept-Charset 時才會附上。單筆的 /todos/1 也一樣

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

{"id":1,"title":"買牛奶","done":true}

回的是單一物件不是陣列,call.respond(todo) 交出去的是什麼形狀,序列化出來就是什麼形狀。然後是這個系列的第 1 個 POST

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

{"id":4,"title":"倒垃圾","done":false}

201,server 發了 id 4,done 是 false,再打一次 GET /todos,列表尾巴多了 {"id":4,"title":"倒垃圾","done":false},資料真的進去了

三種失敗,三個狀態碼

正常的路走完,換走有問題的,測試只斷言狀態碼,curl 看得到完整的回應

第 1 種,Content-Type 帶錯,用 text/plain 送 JSON 內容

curl -i -X POST -H "Content-Type: text/plain" \
  -d '{"title":"倒垃圾"}' localhost:8080/todos
HTTP/1.1 415 Unsupported Media Type
X-Response-Time: 2ms
Content-Length: 76
Content-Type: text/plain; charset=UTF-8

Cannot transform this request's content to com.cashwu.todo.CreateTodoRequest

415 Unsupported Media Type,「這個格式我不認得」,body 內容明明是合法的 JSON 也一樣,協商看的是 header 不是內容,宣告 text/plain 就沒有 converter 認領,完全不帶 Content-Type 也是同一個 415,用 -H "Content-Type:" 把 header 清空可以做出這個情境,curl 的 -d 預設會自己補一個 application/x-www-form-urlencoded,不清掉的話測不到「沒帶」

第 2 種,格式對了,內容壞掉

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

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

400 Bad Request,「格式我認得,但內容有問題」,415 跟 400 的分界對 client 很重要,收到 415 該去改 header,收到 400 該去改 body。不過送 {} 這種少了 title 的合法 JSON,拿到的也是這個一模一樣的 400 訊息,client 看不出自己是少帶欄位還是 JSON 壞掉,錯誤訊息的品質這裡先不處理

第 3 種,回應方向的協商失敗,client 指定要一個我們沒註冊的格式

curl -i -H "Accept: text/xml" localhost:8080/todos/1
HTTP/1.1 406 Not Acceptable
X-Response-Time: 0ms
Content-Length: 0

406 Not Acceptable,「你要的格式我做不出來」,平常 curl 不帶 Accept 或帶 */* 都沒事,client 沒有堅持,server 就用註冊的 converter 直接回,指定了一個做不到的格式才會談崩

它掛在 pipeline 的哪裡

day 10 寫 RequestTiming 時說過,onCallReceive 和 onCallRespond 這 2 個 hook 分別掛在收 body 和送 response 的 2 條小 pipeline 上,當時 onCallReceive 沒上場,因為全是 GET,翻 3.5.2 的 ContentNegotiation.kt,它的 plugin body 做協商的就 2 行

public val ContentNegotiation: RouteScopedPlugin<ContentNegotiationConfig> = createRouteScopedPlugin(
    "ContentNegotiation",
    ::ContentNegotiationConfig
) {
    convertRequestBody()
    convertResponseBody()

    // register default content types for application metadata, used in OpenAPI
    application.attributes[DefaultContentTypesAttribute] = pluginConfig.registrations.map { it.contentType }.distinct()
}

最後那行是替 OpenAPI 登記預設 content type 的 metadata,跟協商本身無關,convertRequestBody() 打開來就是一個 onCallReceive,convertResponseBody() 就是一個 onCallRespond,官方 plugin 用的正是 day 10 我們自己寫 plugin 時用的那組 hook

再翻 PluginBuilder.kt 對 phase,onCallReceive 掛的是 ApplicationReceivePipeline.Transform,onCallRespond 掛的是 ApplicationSendPipeline.Transform,2 條 pipeline 各自的 Transform phase,名字取得很誠實,這個 plugin 做的事就是轉換,把 ByteReadChannel 轉成物件、把物件轉成可以送出去的內容

還有一個小發現,它是 createRouteScopedPlugin 不是 createApplicationPlugin,所以它可以只裝在某段路由上,某條路由講 JSON,另一條講別的格式是做得到的,我們裝在 application 層級等於全站生效

然後確認 3 個狀態碼的來源,415 和 400 都是 receive 方向,但回的人不是 ContentNegotiation

  • 400 的來源最直接,RequestConverter.kt 裡 converter 反序列化失敗會丟 BadRequestException,訊息就是 curl 看到的 Failed to convert request body to ...
  • 415 是沒有 converter 認領時,plugin 把原始的 ByteReadChannel 原封不動放回去,receive 走到最後拿到的還是一條 channel 而不是 CreateTodoRequest,丟出 CannotTransformContentToTypeException
  • 2 個 exception 都往外穿,接住的是 engine 的 handleFailure,DefaultEnginePipeline.kt 裡有一張 defaultExceptionStatusCode 的對照表,BadRequestException 對 400、CannotTransformContentToTypeException 對 415,錯誤訊息用純文字回,這就是為什麼錯誤回應的 Content-Type 是 text/plain

406 更有意思,ContentNegotiationConfig 裡有個 checkAcceptHeaderCompliance 預設是 false,也就是說 plugin 預設根本不主動回 406,Accept: text/xml 進來時,plugin 拿它去過濾註冊的 converter,一個都不剩,於是放棄轉換,把 Todo 物件原樣留在 send pipeline 上,回 406 的是 engine,BaseApplicationEngine.kt 在 Render phase 後面插了一個叫 BodyTransformationCheckPostRender 的 phase,檢查走到這裡的東西是不是 OutgoingContent,不是就代表沒有任何人成功把它變成可以送出去的內容,回 406

plugin 負責盡力轉換,engine 負責在沒人轉換成功時表態,跟 day 09 看過的 Fallback 預設 404 是同一種設計,框架的預設行為住在 pipeline 的最後一站

跟 Relix 的對照

這篇走完的路,Relix 用了 3 篇手刻,day 19 把 request body 讀進框架,處理 charset 解析和讀取上限,day 20 抽出 converter 介面、做 registry 和 ContentNegotiation plugin,讓 ok(user) 自動序列化,day 21 做出 receive<T>() 收 JSON body

有一件事要先說清楚,Relix 沒有手刻 JSON parser,day 20 接的同樣是 kotlinx.serialization,手刻的是協商那一層,converter 介面怎麼抽、registry 怎麼選、receive<T>() 怎麼把 Content-Type 比對和反序列化串起來,所以這篇的對照不是「手工對機器」,是同一層機制的 2 種實作

Relix 的 receive<T>() 把錯誤分成 2 層,Content-Type 沒有 converter 認領回 415,反序列化丟 SerializationException 回 400,Ktor 這篇實測出來的分界一模一樣,連「格式我不認得」跟「格式認得但內容有問題」的語意切法都相同,這不是巧合,HTTP 語意推到底就是這樣分

差異在細節的位置,第 1 個是 406 的來源,Relix 的 ok(value) 發現 Accept 談不攏,當場自己回 406,plugin 一手包辦,Ktor 的 ContentNegotiation 談不攏只是安靜放手,406 由 engine 在 pipeline 尾端的檢查點回,職責切得更開,plugin 管轉換,engine 管「都沒人轉換成功怎麼辦」,第 2 個是 body 的形狀,Relix day 19 用 readAllBytes() 把 body 整包讀成 ByteArray 再處理,Ktor 的 converter 拿到的是 ByteReadChannel,大 body 不用先整包進記憶體,第 3 個是型別資訊怎麼傳,兩邊同樣用 inline + reified 保留泛型型別,Relix 傳 KType,Ktor 包成 TypeInfo,機制是同一個

415 跟 400 為什麼分 2 個、Content-Type 為什麼要去掉 charset 參數再比對、Accept 沒帶時該當作什麼,這些問題 Relix 每一個都自己回答過,所以 Ktor 的行為每一項都對得上出處,包括那個藏在 engine 裡的 406,找它的時候第 1 個念頭就是「plugin 沒回,那一定有個像 Fallback 404 那樣的預設在後面」


小結

ContentNegotiation 加 kotlinx.serialization,todo-api 從純文字換成 JSON,todo 變成 @Serializable 的 data class,call.respond(todo) 出、call.receive<CreateTodoRequest>() 進,第 1 個 POST 端點上線回 201

3 種失敗各有歸屬,Content-Type 沒人認領回 415、反序列化失敗回 400,這 2 個是 exception 穿到 engine 的對照表換來的,Accept 談不攏的 406 則是 engine 在 Render phase 後面的檢查點回的,plugin 本身預設不管 Accept 合不合規

ContentNegotiation 用的就是 day 10 介紹的 onCallReceive 和 onCallRespond,掛在 receive 和 send 2 條 pipeline 的 Transform phase


下一篇

data class 跟 JSON 現在是一一對應的理想狀態,現實的 API 沒這麼乾淨,JSON 欄位叫 created_at 但 Kotlin 屬性想叫 createdAt、client 少帶的欄位想給預設值、多帶的欄位想直接忽略

下一篇拆 kotlinx.serialization 的這些規則,@SerialName、預設值的行為、ignoreUnknownKeys,把序列化的細節整理清楚


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Ktor 實戰 101 Day 11 CORS 與 RateLimit
下一篇
Kotlin Ktor 實戰 101 Day 13 Serialization 細節
系列文
Kotlin Ktor 實戰 101 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言