
第 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 篇
HTTP 的內容協商很單純
Accept header 告訴 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 序列化 ? 因為這件事連 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)
框架不應該把 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 就好,框架層不用改
介面定好了,接下來要寫用 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)
}
}
第一個測試很短,確認 contentType 是 application/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,這裡順便確認一個容易誤會的預設行為,nickname 是 null 的時候會輸出 "nickname":null,欄位不會整個消失,要讓它消失得自己設 Json { explicitNulls = false },跟 Jackson 那邊要加 @JsonInclude(NON_NULL) 是同一件事
第五個測試把 JSON 讀回物件,第六個是 round-trip,同一個物件 encode 完再 decode 回來要相等,這兩個在第 21 篇接上 receive<T>() 之後才會真正發揮作用,先寫起來放著
那幾個 @Serializable 的 fixture 宣告在這個測試檔裡,我們沒有宣告 package,所有檔案都在同一個 root package 底下,其他測試檔可以直接用,後面的整合測試就不用再宣告一次 TestUser
用 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,基本上複雜度都是由它處理,框架只留介面
框架需要一個地方管理所有註冊的 converter,並根據 Accept header 選出合適的,這個類別叫 ConverterRegistry,選擇的規則有四條
Accept header → 回第一個 converter (通常是 JSON)Accept 含 */* → 回第一個 converterAccept 明確指定 MIME type → 找到對應的 converterselect() 接收一個字串、回傳一個 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 一樣的實體不會相等
測試寫完,實作照著補
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
}
}
converters 用 MutableList 而不是 Map,因為順序有意義,第一個註冊的就是預設,accept.split(",") 處理多個 MIME type,substringBefore(";") 去掉 quality factor (application/json;q=0.9),在這裡不處理 quality 排序,只是把它拿掉
把 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 已經從第 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 比對。JsonElement 的 equals() 會遞迴比對所有欄位,這樣就不怕空白差異、不怕欄位順序
{"name":"Relix","age":1} 跟 {"age":1, "name":"Relix"} 當成字串不相等,解析成 JsonElement 之後相等。測試要驗的是回傳的資料對不對,不是字串長什麼樣子,用結構比對才不會因為序列化那邊換個欄位順序就失敗
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),
)
}
}
六個測試
*/* 走預設)ok("Hello!") → text/plain (舊的字串版本不受影響)assertJsonEquals 比對整個結構前四個測試對應 select() 的四條規則,差別是這次走完整條路徑,從 Accept header 進去、response 出來,第五個測試的是相容性,ok(String) 是第 05 篇就有的東西,不能因為多了泛型版本就被搶走,第六個測試驗的是資料本身,前面只確認了 body 裡有 name、age 這些字,這裡才真的比對值
測試定義好行為,接下來要動 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 的時候,String 比 T : Any 具體,ok("Hello!") 才會走字串版本
第 05 篇那個 top-level 版本不用刪,它只是在 handler 裡被遮蔽,router 或 pipeline 這些不在 RelixCall receiver 上的地方照樣用得到,notFound()、created()、noContent() 這次都不用動,其中 created() 到第 21 篇做 created(value) 的時候會碰到跟這裡一模一樣的問題
inline + reified 讓 typeOf<T>() 在呼叫端展開,拿到完整的型別資訊 (包含泛型),converter 拿到 KType 就能找到正確的 serializer
裝上 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 產生