iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
Software Development

Kotlin 手刻 Ktor 從零開始系列 第 20

Kotlin 手刻 Ktor 從零開始 Day 20 Content Negotiation,統一的內容協商與 Response 序列化

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260822/20121948NIqCtgaIUJ.png

第 19 篇把 request body 讀進框架,但到目前為止,handler 要回 JSON 還是得自己拼字串、自己設 Content-Type,第 16 篇的錯誤回應也因為序列化還沒接上,只能先用純文字,這篇開始做框架該做的事,讓 handler 不需要手動處理 JSON

目標是寫出這樣的 handler

get("/users/{id}") {
    val user = findUser(pathParam("id"))
    ok(user)  // 自動轉 JSON
}

handler 不 import 任何 JSON 函式庫,不呼叫 toJson(),不設定 Content-Type,讓框架根據 Accept header 選 converter、序列化、設 header,全部自動處理

這篇主要聚焦 Response 方向,而 Request 方向的 receive<T>() 留給第 21 篇

什麼是 Content Negotiation

HTTP 的內容協商很單純

  • Client 用 Accept header 告訴 Server,我想要什麼格式
  • Server 選一個它支援的格式回應,用 Content-Type header 標記
Request:  Accept: application/json
Response: Content-Type: application/json; charset=utf-8

如果 Server 不支援 Client 要的格式,回 406 Not Acceptable

現實中的 Accept header 可以包含多個 MIME type、quality factor 與 wildcard,我們這裡處理明確指定的 MIME type、逗號分隔的清單與 */*,quality factor 會被拿掉但不做排序,目的是先說清楚選擇流程,不宣稱涵蓋所有 client

JSON 交給 kotlinx.serialization

為什麼不自己刻一套 JSON 序列化 ? 因為這件事連 Ktor 自己都不做,字元跳脫、巢狀結構、數字精度、泛型的 type erasure,每一個都是可以單獨寫好幾篇的坑,而且跟 Web 框架的核心責任無關,Ktor 把序列化交給 kotlinx.serialization 或 Jackson 這類專門的函式庫,框架只負責「根據 header 選格式、把物件交給 converter」這一段

先在 Kotlin Toolchain 的 module.yaml 啟用 serialization compiler plugin

settings:
  kotlin:
    serialization: json

這個設定會同時帶入 kotlinx-serialization-json 的 runtime,不用另外宣告 dependency

kotlinx.serialization 的做法是編譯期程式碼產生,你在 data class 上加 @Serializable,compiler plugin 會在編譯時自動產生對應的 serializer,不靠執行期反射去推導結構

import kotlinx.serialization.Serializable

@Serializable
data class User(val name: String, val age: Int)

因為 serializer 是編譯期產生的,沒有 Reflection 開銷,錯誤訊息可以精確定位到欄位,支援的型別也廣 (enum、sealed class、nullable、Map、自訂 serializer)

先抽一個 RelixConverter 介面

框架不應該把 JSON 的細節散落到每個 handler 裡,也不應該把自己綁死在特定的序列化函式庫上。先抽一層 converter 介面

import kotlin.reflect.KType

interface RelixConverter {
    val contentType: String
    fun encode(value: Any, type: KType): ByteArray
    fun decode(bytes: ByteArray, type: KType): Any
}

contentType 標記這個 converter 處理什麼 MIME type,encode 把物件轉成 byte array (可以直接塞進 response body),decode 把 byte array 轉回物件,Request 方向的 receive<T>() 會在第 21 篇用到

框架的 Content Negotiation 只認 RelixConverter 介面,不管底層用什麼實作,以後想換 Jackson 或 Moshi,寫一個新的 converter 就好,框架層不用改

TDD 先寫 converter 的測試

介面定好了,接下來要寫用 kotlinx.serialization 實作的版本,一樣先從測試開始

converter 接收一個物件跟一個 KType、回一個 byte array,不碰 RelixApplication 也不用起 server,跟第 19 篇的 parseCharset() 一樣是可以直接呼叫的單元,測試檔案放 RelixSerializationConverterTest.kt

import kotlinx.serialization.Serializable
import kotlin.reflect.typeOf
import kotlin.test.Test
import kotlin.test.assertEquals

@Serializable
data class TestUser(val name: String, val age: Int)

@Serializable
enum class Role { ADMIN, MEMBER }

@Serializable
data class Team(val title: String, val members: List<TestUser>)

@Serializable
data class Account(val id: Int, val nickname: String?, val role: Role)

class RelixSerializationConverterTest {

    private val converter = RelixSerializationConverter()

    @Test
    fun `contentType is application json`() {
        assertEquals("application/json", converter.contentType)
    }

    @Test
    fun `encode writes compact json without whitespace`() {
        val bytes = converter.encode(TestUser("Relix", 1), typeOf<TestUser>())

        assertEquals("""{"name":"Relix","age":1}""", bytes.toString(Charsets.UTF_8))
    }

    @Test
    fun `encode writes UTF-8 bytes for non-ASCII text`() {
        val bytes = converter.encode(TestUser("小明", 1), typeOf<TestUser>())

        assertEquals("""{"name":"小明","age":1}""", bytes.toString(Charsets.UTF_8))
    }

    @Test
    fun `encode handles nested objects and enums`() {
        val team = Team("Core", listOf(TestUser("Relix", 1)))
        val teamBytes = converter.encode(team, typeOf<Team>())
        assertEquals(
            """{"title":"Core","members":[{"name":"Relix","age":1}]}""",
            teamBytes.toString(Charsets.UTF_8),
        )

        val account = Account(1, null, Role.ADMIN)
        val accountBytes = converter.encode(account, typeOf<Account>())
        assertEquals(
            """{"id":1,"nickname":null,"role":"ADMIN"}""",
            accountBytes.toString(Charsets.UTF_8),
        )
    }

    @Test
    fun `decode reads json back into an object`() {
        val bytes = """{"name":"Relix","age":1}""".toByteArray(Charsets.UTF_8)

        val decoded = converter.decode(bytes, typeOf<TestUser>())

        assertEquals(TestUser("Relix", 1), decoded)
    }

    @Test
    fun `encode and decode round-trip keeps the value`() {
        val original = Account(7, null, Role.MEMBER)

        val bytes = converter.encode(original, typeOf<Account>())
        val decoded = converter.decode(bytes, typeOf<Account>())

        assertEquals(original, decoded)
    }
}

第一個測試很短,確認 contentTypeapplication/json 就好,後面的 registry 要拿這個字串跟 Accept header 做匹配

第二個測試驗的是 compact 輸出,{"name":"Relix","age":1} 這串裡面一個空白都沒有,第三個測試管 UTF-8,中文字要能原樣進出,這在 encode() 裡是靠 toByteArray(Charsets.UTF_8) 決定的

前面提過 kotlinx.serialization 支援的型別很廣,所以第四個測試就拿巢狀結構、enum 跟 nullable 各驗一次,Team 裡面包一個 List<TestUser>Account 同時有 nullable 的 nickname 跟 enum 的 role,這裡順便確認一個容易誤會的預設行為,nicknamenull 的時候會輸出 "nickname":null,欄位不會整個消失,要讓它消失得自己設 Json { explicitNulls = false },跟 Jackson 那邊要加 @JsonInclude(NON_NULL) 是同一件事

第五個測試把 JSON 讀回物件,第六個是 round-trip,同一個物件 encode 完再 decode 回來要相等,這兩個在第 21 篇接上 receive<T>() 之後才會真正發揮作用,先寫起來放著

那幾個 @Serializable 的 fixture 宣告在這個測試檔裡,我們沒有宣告 package,所有檔案都在同一個 root package 底下,其他測試檔可以直接用,後面的整合測試就不用再宣告一次 TestUser

實作 RelixSerializationConverter

kotlinx.serialization 實作這個介面

import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
import kotlin.reflect.KType

class RelixSerializationConverter(
    private val json: Json = Json { prettyPrint = false },
) : RelixConverter {

    override val contentType = "application/json"

    override fun encode(value: Any, type: KType): ByteArray {
        val serializer = serializer(type)
        val jsonString = json.encodeToString(serializer, value)
        return jsonString.toByteArray(Charsets.UTF_8)
    }

    override fun decode(bytes: ByteArray, type: KType): Any {
        val serializer = serializer(type)
        val jsonString = bytes.toString(Charsets.UTF_8)
        return json.decodeFromString(serializer, jsonString)
            ?: throw SerializationException("decode returned null")
    }
}

serializer(type)KType 在執行期找到對應的 serializer,serializer 本身是編譯期產生的,執行期只是把它找出來,不是用 Reflection 現場建一個

Json { prettyPrint = false } 用 compact 輸出,HTTP response 不需要 pretty print,節省 body 大小

整個實作只有十幾行,前一節那些巢狀、enum、nullable 的測試能過,靠的全是 kotlinx.serialization,基本上複雜度都是由它處理,框架只留介面

TDD 先定住 select() 的選擇規則

框架需要一個地方管理所有註冊的 converter,並根據 Accept header 選出合適的,這個類別叫 ConverterRegistry,選擇的規則有四條

  1. 沒有 Accept header → 回第一個 converter (通常是 JSON)
  2. Accept*/* → 回第一個 converter
  3. Accept 明確指定 MIME type → 找到對應的 converter
  4. 都找不到 → 回 null (呼叫端回 406)

select() 接收一個字串、回傳一個 converter 或 null,一樣不需要 TestKit,要測「明確指定選到對的那個」就得有兩個 converter 可以挑,但這裡不需要真的序列化,所以用一個只有 contentType 有意義的假實作就夠了,測試檔案放 ConverterRegistryTest.kt

import kotlin.reflect.KType
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertSame

private class FakeConverter(override val contentType: String) : RelixConverter {
    override fun encode(value: Any, type: KType): ByteArray = ByteArray(0)
    override fun decode(bytes: ByteArray, type: KType): Any = Unit
}

class ConverterRegistryTest {

    private val jsonConverter = FakeConverter("application/json")
    private val xmlConverter = FakeConverter("application/xml")

    private fun registry(): ConverterRegistry {
        val registry = ConverterRegistry()
        registry.register(jsonConverter)
        registry.register(xmlConverter)
        return registry
    }

    @Test
    fun `no Accept header selects the first converter`() {
        assertSame(jsonConverter, registry().select(null))
    }

    @Test
    fun `blank Accept header selects the first converter`() {
        assertSame(jsonConverter, registry().select("   "))
    }

    @Test
    fun `wildcard selects the first converter`() {
        assertSame(jsonConverter, registry().select("*/*"))
    }

    @Test
    fun `explicit media type selects the matching converter`() {
        assertSame(xmlConverter, registry().select("application/xml"))
    }

    @Test
    fun `unsupported media type returns null`() {
        assertNull(registry().select("text/html"))
    }

    @Test
    fun `multiple media types pick the first supported one`() {
        assertSame(xmlConverter, registry().select("text/html, application/xml"))
    }

    @Test
    fun `quality factor is stripped before matching`() {
        assertSame(jsonConverter, registry().select("application/json;q=0.9"))
    }

    @Test
    fun `empty registry returns null`() {
        assertNull(ConverterRegistry().select("*/*"))
    }

    @Test
    fun `registration order decides the default converter`() {
        val registry = ConverterRegistry()
        registry.register(xmlConverter)
        registry.register(jsonConverter)

        assertSame(xmlConverter, registry.select("*/*"))
        assertEquals("application/xml", registry.select(null)?.contentType)
    }
}

前五個測試對應上面那四條規則,select(" ") 那個是把空白字串當成沒有 Accept 處理,這種 header 現實中可能真的會遇到

後面四個測試補的是那四條規則沒交代到的例外,"text/html, application/xml" 是逗號分隔的清單,前面那個不支援要能往下找,"application/json;q=0.9" 要確認 quality factor 有被拿掉,不然字串比對永遠不會相等,空的 registry 連 */* 都要回 null,不能因為「第一個」就爆 exception,最後一個測試把註冊順序倒過來,確認「第一個 converter」講的真的是註冊順序,不是寫死 JSON

assertSame 而不是 assertEquals,因為要確認回來的就是註冊進去的那個實體,FakeConverter 沒有覆寫 equals(),兩個 contentType 一樣的實體不會相等

實作 ConverterRegistry

測試寫完,實作照著補

class ConverterRegistry {
    private val converters = mutableListOf<RelixConverter>()

    fun register(converter: RelixConverter) {
        converters += converter
    }

    fun select(accept: String?): RelixConverter? {
        if (accept.isNullOrBlank()) {
            return converters.firstOrNull()
        }

        val mediaTypes = accept.split(",").map { it.trim().substringBefore(";") }

        for (mediaType in mediaTypes) {
            if (mediaType == "*/*") {
                return converters.firstOrNull()
            }
            val match = converters.firstOrNull { it.contentType == mediaType }
            if (match != null) return match
        }

        return null
    }
}

convertersMutableList 而不是 Map,因為順序有意義,第一個註冊的就是預設,accept.split(",") 處理多個 MIME type,substringBefore(";") 去掉 quality factor (application/json;q=0.9),在這裡不處理 quality 排序,只是把它拿掉

ContentNegotiation Plugin

ConverterRegistry 掛進 RelixApplication

import kotlinx.serialization.json.Json

class ContentNegotiationConfig {
    internal val registry = ConverterRegistry()

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

object ContentNegotiation : RelixPlugin<ContentNegotiationConfig> {
    override fun createDefaultConfig() = ContentNegotiationConfig()

    override fun install(application: RelixApplication, config: ContentNegotiationConfig) {
        application.converterRegistry = config.registry
    }
}

使用者的安裝方式

app.install(ContentNegotiation) {
    json()
}

json()RelixSerializationConverter 註冊進 registry,如果以後要支援 XML 或 Protobuf,加一個 xml()protobuf() 就好,不用動框架本體

RelixApplication 需要一個 converterRegistry 屬性讓 RelixCall 存取

class RelixApplication {
    var converterRegistry: ConverterRegistry = ConverterRegistry()
    // ... 其他屬性
}

TestKit 演進,補上 JSON 驗證

TestKit 已經從第 06 篇的 MVP 演進了好幾次,我們在這篇會新增一個 assertJsonEquals 的 helper,這個 helper 用到 kotlin.test,只能待在 test/,檔案放 JsonAssertions.kt

import kotlinx.serialization.json.Json
import kotlin.test.assertEquals

fun assertJsonEquals(expected: String, actual: String) {
    val expectedJson = Json.parseToJsonElement(expected)
    val actualJson = Json.parseToJsonElement(actual)
    assertEquals(expectedJson, actualJson)
}

這個檔案一定要放在 test/ 底下,不是跟原本的 RelixTestKit 放在一起,kotlin.test 只在 test module 才拿得到,同樣的程式碼放進 src/ 會編譯失敗,錯誤訊息是 Unresolved reference 'test',這時候 IDE 會建議你補一個 org.jetbrains.kotlin:kotlin-test 的 dependency,不要照做,那等於把測試框架拉進正式的 classpath,把檔案移回 test/ 才是對的

Json.parseToJsonElement() 把兩邊都解析成 JsonElement,然後用 assertEquals 比對。JsonElementequals() 會遞迴比對所有欄位,這樣就不怕空白差異、不怕欄位順序

{"name":"Relix","age":1}{"age":1, "name":"Relix"} 當成字串不相等,解析成 JsonElement 之後相等。測試要驗的是回傳的資料對不對,不是字串長什麼樣子,用結構比對才不會因為序列化那邊換個欄位順序就失敗

TDD 寫 ok(value) 的整合測試

converter 跟 registry 各自的測試都有了,接下來是把它們串起來的 ok(value),這個要 plugin 有裝、routing 走得到、response 出得來才測得完,所以回到 TestKit 寫整合測試,檔案放 ContentNegotiationTest.kt

TestUser 已經在 RelixSerializationConverterTest.kt 宣告過,同一個 package 直接使用就好

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class ContentNegotiationTest {

    private fun createApp(): RelixApplication {
        val app = RelixApplication()
        app.install(ContentNegotiation) {
            json()
        }
        app.routing {
            get("/user") {
                ok(TestUser("Relix", 1))
            }
        }
        return app
    }

    @Test
    fun `ok value with Accept json returns json`() {
        val app = createApp()
        val testKit = RelixTestKit(app)

        val response = testKit.handleRequest(
            method = "GET",
            path = "/user",
            headers = mapOf("Accept" to listOf("application/json")),
        )

        assertEquals(200, response.statusCode)
        assertTrue(
            response.headers["Content-Type"]
                ?.first()?.contains("application/json") == true,
        )
        val body = response.body.toString(Charsets.UTF_8)
        assertTrue(body.contains("\"name\""))
        assertTrue(body.contains("\"Relix\""))
        assertTrue(body.contains("\"age\""))
    }

    @Test
    fun `ok value with Accept wildcard returns json`() {
        val app = createApp()
        val testKit = RelixTestKit(app)

        val response = testKit.handleRequest(
            method = "GET",
            path = "/user",
            headers = mapOf("Accept" to listOf("*/*")),
        )

        assertEquals(200, response.statusCode)
        assertTrue(
            response.headers["Content-Type"]
                ?.first()?.contains("application/json") == true,
        )
    }

    @Test
    fun `ok value with unsupported Accept returns 406`() {
        val app = createApp()
        val testKit = RelixTestKit(app)

        val response = testKit.handleRequest(
            method = "GET",
            path = "/user",
            headers = mapOf("Accept" to listOf("text/xml")),
        )

        assertEquals(406, response.statusCode)
    }

    @Test
    fun `ok value without Accept header returns json`() {
        val app = createApp()
        val testKit = RelixTestKit(app)

        val response = testKit.handleRequest(
            method = "GET",
            path = "/user",
        )

        assertEquals(200, response.statusCode)
        assertTrue(
            response.headers["Content-Type"]
                ?.first()?.contains("application/json") == true,
        )
    }

    @Test
    fun `ok string still returns text plain`() {
        val app = RelixApplication()
        app.install(ContentNegotiation) { json() }
        app.routing {
            get("/hello") { ok("Hello!") }
        }

        val testKit = RelixTestKit(app)
        val response = testKit.handleRequest("GET", "/hello")

        assertEquals(200, response.statusCode)
        assertTrue(
            response.headers["Content-Type"]
                ?.first()?.contains("text/plain") == true,
        )
    }

    @Test
    fun `json body matches the expected structure`() {
        val app = createApp()
        val testKit = RelixTestKit(app)

        val response = testKit.handleRequest(
            method = "GET",
            path = "/user",
            headers = mapOf("Accept" to listOf("application/json")),
        )

        assertJsonEquals(
            """{"name":"Relix","age":1}""",
            response.body.toString(Charsets.UTF_8),
        )
    }
}

六個測試

  1. Accept JSON → 200 + Content-Type JSON + body 含正確欄位
  2. Accept wildcard → 200 + JSON (*/* 走預設)
  3. Accept XML → 406 (不支援的格式)
  4. 沒有 Accept header → 200 + JSON (無 header 走預設)
  5. ok("Hello!") → text/plain (舊的字串版本不受影響)
  6. body 內容驗證,用 assertJsonEquals 比對整個結構

前四個測試對應 select() 的四條規則,差別是這次走完整條路徑,從 Accept header 進去、response 出來,第五個測試的是相容性,ok(String) 是第 05 篇就有的東西,不能因為多了泛型版本就被搶走,第六個測試驗的是資料本身,前面只確認了 body 裡有 nameage 這些字,這裡才真的比對值

ok(value) 的實作

測試定義好行為,接下來要動 ok(),而這裡有個第 05 篇就埋好的伏筆

第 05 篇那些便利的工廠方法都是 top-level function,跟 RelixResponse 放在同一個檔案,當時就說過,等到要做 ok(user) 這種需要讀 Accept header 的版本,就會把 ok() 搬進 RelixCall,現在就是那個時候,因為新版本要拿 request.header("Accept")application.converterRegistry,這兩個都是 call 的狀態

所以這一步是加一個泛型版本,同時把字串版本也補一份進 RelixCall,兩個多載放在同一個類別裡

import kotlin.reflect.typeOf

class RelixCall(
    val application: RelixApplication,
    val request: RelixRequest,
    internal var pathParams: Map<String, String> = emptyMap(),
    internal var matchedRoute: Route? = null,
) {
    inline fun <reified T : Any> ok(value: T): RelixResponse {
        val accept = request.header("Accept")
        val converter = application.converterRegistry.select(accept)
            ?: return RelixResponse(
                406,
                mapOf("Content-Type" to listOf("text/plain")),
                "Not Acceptable".toByteArray(),
            )

        val body = converter.encode(value, typeOf<T>())
        val headers = mapOf(
            "Content-Type" to listOf("${converter.contentType}; charset=utf-8"),
        )
        return RelixResponse(200, headers, body)
    }

    // 字串版本也要是 member,理由見下面
    fun ok(text: String): RelixResponse {
        return RelixResponse(
            200,
            mapOf("Content-Type" to listOf("text/plain; charset=utf-8")),
            text.toByteArray(Charsets.UTF_8),
        )
    }

    // ...
}

字串版本一定要一起補進來,只加泛型版本會有問題,Kotlin 的 overload resolution 會先看 member function,member 找得到匹配的就不會再往 top-level 找,所以只要 RelixCall 上出現一個叫 ok 的成員,第 05 篇那個 top-level 的 ok(text: String) 在 handler 裡就被遮蔽了,所以這個時候寫 ok("Hello!") 會匹配到 ok(value: T)T 推導成 String,結果是走 content negotiation 回一個 JSON 字串 "Hello!",Content-Type 跟著變成 application/json,前面的第五個測試就是驗證這件事,兩個 ok() 都是 member 的時候,StringT : Any 具體,ok("Hello!") 才會走字串版本

第 05 篇那個 top-level 版本不用刪,它只是在 handler 裡被遮蔽,router 或 pipeline 這些不在 RelixCall receiver 上的地方照樣用得到,notFound()created()noContent() 這次都不用動,其中 created() 到第 21 篇做 created(value) 的時候會碰到跟這裡一模一樣的問題

inline + reifiedtypeOf<T>() 在呼叫端展開,拿到完整的型別資訊 (包含泛型),converter 拿到 KType 就能找到正確的 serializer

在 main 裡組起來跑一次

裝上 ContentNegotiation,兩個路由一個回物件、一個回字串

import kotlinx.serialization.Serializable

@Serializable
data class User(val name: String, val age: Int)

fun main() {
    val app = RelixApplication()

    app.install(Logging) {
        logger = ConsoleLogger()
    }
    app.install(ContentNegotiation) {
        json()
    }

    app.routing {
        get("/user") { ok(User("Relix", 1)) }
        get("/hello") { ok("Hello!") }
    }

    JdkHttpServerAdapter(app).start(8080)
}

ok(User(...)) 走的是 content negotiation 那個泛型版本,ok("Hello!") 走的是舊的字串版本,兩個 overload 在同一個 app 裡共存

指定 JSON

curl -i -H "Accept: application/json" localhost:8080/user
HTTP/1.1 200 OK
Content-type: application/json; charset=utf-8
Content-length: 24

{"name":"Relix","age":1}

prettyPrint = false 所以是一行,沒有縮排也沒有多餘空白,24 bytes 剛好就是那串 JSON 的長度

不指定 Accept

curl -i localhost:8080/user
HTTP/1.1 200 OK
Content-type: application/json; charset=utf-8
Content-length: 24

{"name":"Relix","age":1}

結果跟上面一樣,還是 JSON,但走的規則不一樣,這裡有個容易誤會的地方,curl 沒有「不送 Accept」這回事,指令上不寫,它預設還是會帶 Accept: */*,所以這一趟走的是 select() 的第二條規則,回第一個 converter,不是第一條

完全不送 Accept

要把 header 整個拿掉得用另一個寫法,-H 後面的冒號留空,curl 就不會送出這個 header

curl -i -H "Accept:" localhost:8080/user
HTTP/1.1 200 OK
Content-type: application/json; charset=utf-8
Content-length: 24

{"name":"Relix","age":1}

這一趟才是第一條規則,select() 收到的是 null,回第一個 converter,結果跟前面兩種一樣都是 JSON,四條規則裡就這條最容易被跳過,因為 curl 預設的 */* 會把它蓋掉

要一個沒註冊的格式

curl -i -H "Accept: application/xml" localhost:8080/user
HTTP/1.1 406 Not Acceptable
Content-type: text/plain
Content-length: 14

Not Acceptable

registry 裡只有 JSON converter,找不到 XML 就回 null,ok() 直接回 406,這條路在測試裡驗過,但實際打一次才看得到 curl 那端拿到什麼

字串路由不受影響

curl -i localhost:8080/hello
HTTP/1.1 200 OK
Content-type: text/plain; charset=utf-8
Content-length: 6

Hello!

ok("Hello!") 完全不碰 registry,也就不會有 406 的問題,Accept 送什麼都一樣

log 這邊五筆都記得到

[Relix] GET /user -> 200 (11ms)
[Relix] GET /user -> 200 (0ms)
[Relix] GET /user -> 200 (0ms)
[Relix] GET /user -> 406 (0ms)
[Relix] GET /hello -> 200 (0ms)

第一筆 11ms、後面都 0ms,這是 JVM 的暖身,第一次走到序列化那條路要把 serializer 找出來、把相關的類別載進來,之後就都是熱路徑了

常見陷阱與設計取捨

notFound()created() 要不要也搬進 RelixCall ?

判斷標準是需不需要讀 call 的狀態,ok(value) 需要 Accept header、要拿 registry,所以它非得是 member 不可,notFound("Not Found") 從頭到尾只用得到參數本身,留在 top-level 就好,handler 裡照樣直接呼叫,top-level function 本來就不需要 receiver,等到哪天要做 notFound(errorObject) 這種也走 content negotiation 的版本,才會碰到跟 ok() 一樣的搬家問題,第 27 篇的 StatusPages 處理錯誤回應結構化的時候會再遇到一次

不過這個分法有個代價,同一組 response helper 從此散在兩個地方,ok()RelixCall 裡,notFound()created()noContent()RelixResponse.kt 裡,要找的時候得先想一下它在哪邊,如果覺得這樣不好維護,把它們全部移進 RelixCall 也是完全合理的做法,Ktor 自己就是這樣,respond()respondText() 這些通通掛在 ApplicationCall 上,一個入口全部找得到

真的要全部移的話,記得 top-level 版本不能直接刪掉,第 05 篇那組 RelixResponseTest 是拿不到 RelixCall 的,router 找不到路由時回的 notFound() 也不在 handler 裡,這些呼叫點要嘛留著 top-level 版本,要嘛跟著改寫,這篇先維持現在的分法,把「因為需要 call 狀態才搬」這個理由留得清楚一點,之後想整理隨時可以整理

Accept header 的 quality factor 要不要處理 ?

Accept: application/json;q=0.9, text/html;q=1.0 表示 client 比較想要 HTML,完整的 content negotiation 要解析 quality factor 並排序,這裡先不做直接用出現順序,原因是 API server 收到的 Accept 通常就是 application/json*/*,很少有 quality factor 的複雜場景

為什麼 converter 回 ByteArray 而不是 String ?

因為 HTTP response body 是 byte stream,JSON 碰巧是 UTF-8 字串,但如果以後要支援 Protobuf 或 MessagePack 這類 binary format,converter 必須回 byte array,在這裡先統一介面,以後擴展的時候就不需要改


小結

Content Negotiation 把 JSON 處理從 handler 層提升到框架層,ConverterRegistry 管理已註冊的 converter,select() 根據 Accept header 選出合適的,ContentNegotiation plugin 讓使用者用 install(ContentNegotiation) { json() } 一行就可以搞定,ok(value)RelixCall 上自動走 content negotiation,handler 不碰任何序列化細節,序列化本身交給 kotlinx.serialization,框架靠 RelixConverter 介面跟它解耦,要換實作只要再寫一個 converter

測試跟著分成三層,RelixSerializationConverterTest 只管序列化本身,ConverterRegistryTest 管選擇規則,這兩個不用起 server 也不用 TestKit,直接呼叫就好,ContentNegotiationTest 才走完整路徑,分開的好處是壞掉的時候看測試名稱就知道問題在哪一層


下一篇

下一篇會補上 Request 這個方向,receive<T>() 從 request body 自動反序列化,加上 queryParam<T>() 做型別安全的 query parameter 讀取


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin 手刻 Ktor 從零開始 Day 19 Request Body 的讀取與解析
下一篇
Kotlin 手刻 Ktor 從零開始 Day 21 型別安全的 Request 讀取,receive<T>() 與 queryParam<T>()
系列文
Kotlin 手刻 Ktor 從零開始26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言