iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0
Software Development

Kotlin 手刻 Ktor 從零開始系列 第 28 篇

Kotlin 手刻 Ktor 從零開始 Day 28 Testing Utilities,框架內建的測試工具

  • 分享至 

  • xImage
  •  

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

到目前為止,Relix 的測試工具跟著系列一路演進,從 06 篇做了最陽春的 RelixTestKit,只能送 request 拿 response,第 11 篇加了 routing DSL 支援,第 14 篇升級成 suspend,第 20 篇開始要驗 JSON response

散落在各篇的測試程式碼大概長這樣,先手動建 RelixApplication(),再手動呼叫 RelixTestKit(app).handleRequest(...),每個測試檔都重複一堆樣板,如果每個 test case 都手動組裝 app,可讀性會很慘

這篇要做一件收斂的事,把散落的測試能力整理成穩定的 TestRelixApplication 與 relixTest { } DSL

目標 API

做完之後,一個測試從頭到尾長這樣

@Test
fun `basic GET returns 200`() = relixTest {
    routing {
        get("/hello") { ok("Hello!") }
    }

    val response = handleRequest(HttpMethod.Get, "/hello")

    assertEquals(200, response.status)
    assertEquals("Hello!", response.bodyAsText())
}

install()、routing {} 跟正式用法一模一樣,測試不用學另一套 API,assertion 就寫在 block 裡面,整個 test method 是一個 expression body

入口為什麼不叫 testApplication,因為這個名字第 11 篇用掉了,那篇在 RelixTestKit.kt 加了一個 top-level 的 testApplication(),收 RelixApplication.() -> Unit、回傳 RelixTestKit,新入口如果同名,兩個多載的差別只在 lambda 的 receiver,編譯器選不出來

e: Overload resolution ambiguity between candidates:
fun testApplication(setup: RelixApplication.() -> Unit): RelixTestKit
fun testApplication(config: RelixConfig = ..., block: TestRelixApplication.() -> Unit): Unit

前面的文章已經有十幾個測試檔在用舊的那個,所以新的測試直接改名 relixTest,舊的就原封不動留著

測試工具分成三個部分

  • TestRequestBuilder 把 method、header、body、query string 組成一個 RelixRequest,不需要 app,也不需要跑 pipeline
  • TestResponse 反過來,包住一份 RelixResponse,負責把 body 讀成字串、讀成 JSON、或者直接比對,一樣不需要 app
  • TestRelixApplication 跟 relixTest { } 把兩邊接起來,建立 app、送 request、走完整 pipeline,這個部分才需要真的 RelixApplication

實作放 TestRelixApplication.kt,跟第 06 篇的 RelixTestKit.kt 同一層,HttpMethod 獨立成 HttpMethod.kt,因為 builder 跟 handleRequest() 兩邊都會用到

放 src/ 有一個限制要先講,這兩個檔案裡不能出現 kotlin.test,第 20 篇的 assertJsonEquals 就是因為用了 kotlin.test.assertEquals 才必須待在 test/,這裡的 TestResponse.assertJsonEquals() 要跟框架一起待在 src/,所以比對不過的時候是自己 throw AssertionError,不呼叫 kotlin.test

TDD 先確認 TestRequestBuilder 組出來的 request

最裡面這個部分是「一堆設定進去、一個 RelixRequest 出來」,不需要 app 也不需要 pipeline,測試檔案放 TestRequestBuilderTest.kt

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull

class TestRequestBuilderTest {

    @Test
    fun `the enum turns into the uppercase method string`() {
        val request = TestRequestBuilder(HttpMethod.Get, "/hello").build()

        assertEquals("GET", request.method)
        assertEquals("/hello", request.path)
    }

    @Test
    fun `the same header name keeps every value`() {
        val builder = TestRequestBuilder(HttpMethod.Get, "/")
        builder.header("X-Custom", "one")
        builder.header("X-Custom", "two")

        val request = builder.build()

        assertEquals(listOf("one", "two"), request.headers["X-Custom"])
    }

    @Test
    fun `bearerToken writes the authorization header`() {
        val builder = TestRequestBuilder(HttpMethod.Get, "/me")
        builder.bearerToken("abc")

        val request = builder.build()

        assertEquals("Bearer abc", request.header("Authorization"))
    }

    @Test
    fun `setJsonBody sets the content type as well as the body`() {
        val builder = TestRequestBuilder(HttpMethod.Post, "/echo")
        builder.setJsonBody("""{"name":"Relix"}""")

        val request = builder.build()

        assertEquals("application/json", request.header("Content-Type"))
        assertEquals("""{"name":"Relix"}""", request.body.toString(Charsets.UTF_8))
    }

    @Test
    fun `setBody alone leaves the content type empty`() {
        val builder = TestRequestBuilder(HttpMethod.Post, "/echo")
        builder.setBody("hi")

        val request = builder.build()

        assertNull(request.header("Content-Type"))
        assertEquals("hi", request.body.toString(Charsets.UTF_8))
    }

    @Test
    fun `the query string gets split off the path`() {
        val request = TestRequestBuilder(HttpMethod.Get, "/users?page=2&size=10").build()

        assertEquals("/users", request.path)
        assertEquals(listOf("2"), request.queryParameters["page"])
        assertEquals(listOf("10"), request.queryParameters["size"])
    }

    @Test
    fun `a path without a question mark has no query parameters`() {
        val request = TestRequestBuilder(HttpMethod.Get, "/users").build()

        assertEquals("/users", request.path)
        assertEquals(emptyMap(), request.queryParameters)
    }
}

第一個測試驗的是 enum 到字串這一步,RelixRequest.method 從第 04 篇開始就是 String,adapter 那邊拿到的也是 "GET" 這種大寫,builder 要負責把 HttpMethod.Get 轉過去,測試裡才能用打不錯的 enum

第二個測試是同名 header,Set-Cookie 跟 CORS 那些 header 本來就可能出現多次,所以 header() 是往同一個 key 底下追加,不是覆蓋

bearerToken() 那個測試用 request.header("Authorization") 讀,那是第 04 篇加的大小寫無關查法,順便確認 builder 寫進去的 key 拼法沒問題

中間兩個測試講的是同一件事的兩面,setJsonBody() 一次做兩件事,設 body 也設 Content-Type,setBody() 只設 body,Content-Type 保持空的,這樣才測得到「client 沒帶 Content-Type」這種案例,第 20 篇的 Content Negotiation 遇到這種 request 會回 415

最後兩個測試是 query string,測試裡會直接寫 handleRequest(HttpMethod.Get, "/users?page=2") 這種路徑,切開 path 跟 query 是 builder 的事,路徑裡沒有 ? 的時候要拿到空的 map,不是一個裝著空字串的 map

實作 HttpMethod 與 TestRequestBuilder

先是 enum,新增 HttpMethod.kt

enum class HttpMethod {
    Get, Post, Put, Delete, Patch, Head, Options;

    override fun toString(): String = name.uppercase()
}

用 enum 而不是字串,測試裡就不會出現 "GTE" 這種打錯字才發現的問題,toString() 覆寫成大寫,HttpMethod.Get.toString() 回 "GET",跟 RelixRequest 裡的 method 格式一致

再來是 builder,新增 TestRelixApplication.kt,這一輪只放 TestRequestBuilder

class TestRequestBuilder(
    private val method: HttpMethod,
    private val path: String,
) {
    private val headers = mutableMapOf<String, MutableList<String>>()
    private var body: ByteArray = ByteArray(0)

    fun header(name: String, value: String) {
        headers.getOrPut(name) { mutableListOf() }.add(value)
    }

    fun contentType(value: String) {
        header("Content-Type", value)
    }

    fun accept(value: String) {
        header("Accept", value)
    }

    fun bearerToken(token: String) {
        header("Authorization", "Bearer $token")
    }

    fun setBody(text: String) {
        body = text.toByteArray(Charsets.UTF_8)
    }

    fun setBody(bytes: ByteArray) {
        body = bytes
    }

    fun setJsonBody(text: String) {
        contentType("application/json")
        setBody(text)
    }

    internal fun build(): RelixRequest {
        val rawPath = path.substringBefore("?")
        val queryString = if ('?' in path) path.substringAfter("?") else ""

        return RelixRequest(
            method = method.toString(),
            path = rawPath,
            headers = headers,
            queryParameters = parseQuery(queryString),
            body = body,
        )
    }
}

contentType()、accept()、bearerToken() 三個都只是 header() 的包裝,省掉手寫 header name 跟 Bearer 這個前綴

build() 標成 internal,測試呼叫得到,因為 Gradle 的 Kotlin JVM 專案裡 test 這個 compilation 跟 main 是關聯的,internal 在測試裡看得見,但應用層拿到 builder 的時候不會看到這個方法,它只該由 handleRequest() 呼叫

query string 交給第 08 篇的 parseQuery(),路徑裡沒有 ? 的時候傳空字串進去,那個函式對空字串本來就是回 emptyMap(),這裡不用多寫一個分支

TDD 先確認 TestResponse 讀出來的東西

第二個部分是反方向,一份 RelixResponse 進去,測試要的各種讀法出來,一樣不需要 app,手工組一個 response 餵進去就測得完,測試檔案放 TestResponseTest.kt

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNull
import kotlin.test.assertTrue

class TestResponseTest {

    private fun responseOf(
        body: String,
        status: Int = 200,
        headers: Map<String, List<String>> = emptyMap(),
    ) = TestResponse(RelixResponse(status, headers, body.toByteArray()))

    @Test
    fun `bodyAsText decodes the bytes as utf-8`() {
        val response = responseOf("哈囉")

        assertEquals(200, response.status)
        assertEquals("哈囉", response.bodyAsText())
    }

    @Test
    fun `json gives back kotlin types not JsonElement`() {
        val response = responseOf("""{"name":"Alice","age":30,"vip":true,"note":null}""")

        val json = response.json()

        assertEquals("Alice", json["name"])
        assertEquals(30, json["age"])
        assertEquals(true, json["vip"])
        assertNull(json["note"])
    }

    @Test
    fun `a quoted true stays a string`() {
        val response = responseOf("""{"flag":"true"}""")

        assertEquals("true", response.json()["flag"])
        assertTrue(response.json()["flag"] is String)
    }

    @Test
    fun `nested objects and arrays convert all the way down`() {
        val response = responseOf("""{"user":{"tags":["a","b"]}}""")

        @Suppress("UNCHECKED_CAST")
        val user = response.json()["user"] as Map<String, Any?>

        assertEquals(listOf("a", "b"), user["tags"])
    }

    @Test
    fun `jsonArray reads a top level list`() {
        val response = responseOf("""[{"id":1},{"id":2}]""")

        val list = response.jsonArray()

        assertEquals(2, list.size)
        @Suppress("UNCHECKED_CAST")
        assertEquals(1, (list[0] as Map<String, Any?>)["id"])
    }

    @Test
    fun `assertJsonEquals ignores key order and whitespace`() {
        val response = responseOf("""{"b":2,"a":1}""")

        response.assertJsonEquals("""{ "a": 1, "b": 2 }""")
    }

    @Test
    fun `assertJsonEquals fails when a value differs`() {
        val response = responseOf("""{"a":1}""")

        assertFailsWith<AssertionError> {
            response.assertJsonEquals("""{"a":2}""")
        }
    }

    @Test
    fun `header reads the first value`() {
        val response = responseOf(
            "ok",
            headers = mapOf("Content-Type" to listOf("text/plain", "text/html")),
        )

        assertEquals("text/plain", response.header("Content-Type"))
        assertNull(response.header("X-Missing"))
    }
}

responseOf() 是這個檔案自己的 helper,跟第 23、27 篇的 emptyCall() 同一招,這個部分測的東西跟 app 無關,所以直接組一個 RelixResponse 就好

第二個測試是這個部分的重點,json() 回來的要是 Kotlin 原生型別,30 是 Int 不是 JsonPrimitive,null 是真的 null,這樣斷言才寫得直覺

第三個測試看起來很像在鑽牛角尖,實際上是 kotlinx.serialization 一個很容易踩到的地方,JSON 字串 "true" 跟 JSON 布林 true 的 content 都是 "true",轉型的時候如果先問 booleanOrNull,字串就會被轉成 Boolean,所以 isString 一定要先判斷,這個測試就是那條防線

assertJsonEquals 正反各測一個,只測「相等的會過」不夠,比對邏輯寫成永遠不丟例外也會過,所以要有一個確認它真的會失敗的案例,用 assertFailsWith<AssertionError> 接住

最後那個測試順便講清楚 header() 的語意,同名 header 有多個值的時候回第一個,找不到回 null

實作 TestResponse

加進 TestRelixApplication.kt

import kotlinx.serialization.json.*

class TestResponse(private val response: RelixResponse) {
    val status: Int get() = response.statusCode
    val headers: Map<String, List<String>> get() = response.headers

    fun bodyAsText(): String = response.body.toString(Charsets.UTF_8)

    fun bodyAsBytes(): ByteArray = response.body

    fun json(): Map<String, Any?> {
        @Suppress("UNCHECKED_CAST")
        return convertJsonElement(Json.parseToJsonElement(bodyAsText())) as Map<String, Any?>
    }

    fun jsonArray(): List<Any?> {
        @Suppress("UNCHECKED_CAST")
        return convertJsonElement(Json.parseToJsonElement(bodyAsText())) as List<Any?>
    }

    fun assertJsonEquals(expected: String) {
        val expectedElement = Json.parseToJsonElement(expected)
        val actualElement = Json.parseToJsonElement(bodyAsText())
        if (expectedElement != actualElement) {
            throw AssertionError("Expected: $expectedElement but was: $actualElement")
        }
    }

    fun header(name: String): String? = headers[name]?.firstOrNull()
}

private fun convertJsonElement(element: JsonElement): Any? {
    return when (element) {
        is JsonNull -> null
        is JsonPrimitive -> {
            // isString 一定要先判斷:JSON 字串 "true" 和 JSON 布林 true 的 content
            // 都是 "true",少了這一層 booleanOrNull 會把字串誤轉成 Boolean
            if (element.isString) {
                element.content
            } else {
                element.booleanOrNull
                    ?: element.intOrNull
                    ?: element.longOrNull
                    ?: element.doubleOrNull
                    ?: element.content
            }
        }
        is JsonArray -> element.map { convertJsonElement(it) }
        is JsonObject -> element.entries.associate {
            it.key to convertJsonElement(it.value)
        }
    }
}

convertJsonElement() 是遞迴的,物件往下拆成 map、陣列往下拆成 list,一路拆到 primitive 才停,巢狀結構才會整份都是原生型別

primitive 那段的順序有講究,isString 先擋掉字串,剩下的才依 Boolean、Int、Long、Double 一路試,intOrNull 在 longOrNull 前面,是為了讓小數字回 Int,測試裡寫 assertEquals(30, json["age"]) 才會過,如果回的是 Long,30 跟 30L 在 assertEquals 眼裡不相等

assertJsonEquals() 比對的是兩份 JsonElement,不是兩個字串,所以 key 順序跟排版空白都不影響,比對失敗自己 throw AssertionError,這就是前面說的,src/ 底下不能碰 kotlin.test

TDD 把 relixTest 串起來

前兩輪都沒有 app,這輪要測的是整合,plugin 有沒有裝進去、routing 走不走得到、request 有沒有真的穿過 pipeline,這條路徑要整條走一遍才知道,測試檔案放 TestRelixApplicationTest.kt

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

class TestRelixApplicationTest {

    @Test
    fun `basic GET returns 200`() = relixTest {
        routing {
            get("/hello") { ok("Hello!") }
        }

        val response = handleRequest(HttpMethod.Get, "/hello")

        assertEquals(200, response.status)
        assertEquals("Hello!", response.bodyAsText())
    }

    @Test
    fun `POST with JSON body`() = relixTest {
        install(ContentNegotiation) { json() }
        routing {
            post("/echo") {
                val body = receive<Map<String, String>>()
                ok(body)
            }
        }

        val response = handleRequest(HttpMethod.Post, "/echo") {
            setJsonBody("""{"name":"Relix"}""")
        }

        assertEquals(200, response.status)
        val json = response.json()
        assertEquals("Relix", json["name"])
    }

    @Test
    fun `request with custom headers`() = relixTest {
        routing {
            get("/check-header") {
                val value = request.headers["X-Custom"]?.firstOrNull() ?: "missing"
                ok(value)
            }
        }

        val response = handleRequest(HttpMethod.Get, "/check-header") {
            header("X-Custom", "my-value")
        }

        assertEquals(200, response.status)
        assertEquals("my-value", response.bodyAsText())
    }

    @Test
    fun `authenticated request`() = relixTest {
        install(Authentication) {
            bearer { token ->
                if (token == "valid") UserPrincipal("1", "Alice") else null
            }
        }
        routing {
            authenticate {
                get("/me") {
                    val user = principal<UserPrincipal>()
                    ok(user.name)
                }
            }
        }

        // 沒 token → 401
        val noAuth = handleRequest(HttpMethod.Get, "/me")
        assertEquals(401, noAuth.status)

        // 有 token → 200
        val withAuth = handleRequest(HttpMethod.Get, "/me") {
            bearerToken("valid")
        }
        assertEquals(200, withAuth.status)
        assertEquals("Alice", withAuth.bodyAsText())
    }

    @Test
    fun `assertJsonEquals ignores key order`() = relixTest {
        install(ContentNegotiation) { json() }
        routing {
            get("/data") {
                ok(mapOf("b" to 2, "a" to 1))
            }
        }

        val response = handleRequest(HttpMethod.Get, "/data")
        response.assertJsonEquals("""{"a":1,"b":2}""")
    }

    @Test
    fun `404 for unknown route`() = relixTest {
        routing {
            get("/exists") { ok("yes") }
        }

        val response = handleRequest(HttpMethod.Get, "/nope")
        assertEquals(404, response.status)
    }
}

這六個測試各自對應一條邏輯,最基本的 GET、帶 JSON body 的 POST、自訂 header、認證、JSON 結構比對、找不到路由的 404

認證那個測試一個 test method 裡送了兩次 request,沒帶 token 拿 401、帶了 token 拿 200,同一個 app 送兩次是刻意的,第 23 篇的 authenticate {} 是掛在路由上的,兩次走的是同一條路由,差別只在 header

最後那個 404 沒有裝任何 plugin,router 找不到路由的預設回應本來就是 404,這個測試確認 relixTest 沒有偷偷改掉框架的預設行為

實作 TestRelixApplication 與 relixTest

一樣加進 TestRelixApplication.kt

import kotlinx.coroutines.runBlocking

class TestRelixApplication(config: RelixConfig = RelixConfig()) {
    private val app = RelixApplication(config)

    fun <TConfig : Any> install(
        plugin: RelixPlugin<TConfig>,
        configure: TConfig.() -> Unit = {},
    ) {
        app.install(plugin, configure)
    }

    fun routing(block: RoutingBuilder.() -> Unit) {
        app.routing(block)
    }

    fun services(block: ServiceContainer.() -> Unit) {
        app.services(block)
    }

    fun use(middleware: RelixMiddleware) {
        app.use(middleware)
    }

    fun handleRequest(
        method: HttpMethod,
        path: String,
        block: TestRequestBuilder.() -> Unit = {},
    ): TestResponse {
        val builder = TestRequestBuilder(method, path)
        builder.block()
        val request = builder.build()
        val response = runBlocking {
            app.handle(RelixCall(app, request))
        }
        return TestResponse(response)
    }
}

fun relixTest(
    config: RelixConfig = RelixConfig(),
    block: TestRelixApplication.() -> Unit,
) {
    TestRelixApplication(config).block()
}

責任分兩塊

  1. 設定 app,install()、routing {}、services {}、use() 直接委派給內部的 RelixApplication,簽章跟正式版本一樣,測試才不用學另一套寫法
  2. 送 request,handleRequest() 用 builder 組出 RelixRequest,透過 app.handle() 走完整 pipeline,回傳包好的 TestResponse

handleRequest() 用 runBlocking 橋接 suspend,第 14 篇 pipeline 改成 suspend 之後,app.handle() 就是 suspend function,測試裡不需要非同步,要的是同步呼叫、馬上拿結果

relixTest() 的 config 給了預設值,所以大多數測試直接 relixTest { } 就好,需要測 development 模式的行為時才傳進去,例如第 27 篇的 StatusPages 會依 development 決定要不要把 exception message 回給 client

@Test
fun `dev mode shows exception message`() = relixTest(
    RelixConfig().apply { development = true },
) {
    // ...
}

relixTest() 本身不回傳任何東西,assertion 寫在 block 裡面,這樣每個 test method 都是自包含的,建立 app、送 request、驗證 response,一氣呵成

從舊的 RelixTestKit 遷移

第 06 篇到第 27 篇的測試用的是 RelixTestKit

// 舊寫法
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)

新寫法

// 新寫法
@Test
fun `GET hello`() = relixTest {
    install(ContentNegotiation) { json() }
    routing { get("/hello") { ok("Hello!") } }

    val response = handleRequest(HttpMethod.Get, "/hello")
    assertEquals(200, response.status)
}

差異

  • 不需要手動建 RelixApplication() 和 RelixTestKit()
  • method 用 enum 不用字串
  • response 是 TestResponse,有 bodyAsText()、json() 等便利方法
  • relixTest { } 當 test method 的 expression body,一個 block 搞定

舊的 RelixTestKit 跟第 11 篇的 testApplication() 都不用刪,新的 TestRelixApplication 是給應用層和實戰篇用的高階 API,兩套並存

常見陷阱與設計取捨

relixTest 每次都建新的 app ?

是的,每個 relixTest { } block 建立全新的 RelixApplication,互不干擾,這是刻意的,測試隔離比效能重要,如果 test A 的 plugin 設定影響了 test B,debug 會非常痛苦

Relix app 本身只配置少量 map 與 list,但測試速度仍取決於你註冊的 service、外部資源與 runner,如果 suite 變慢,先量測 setup 與 handler 各自耗時,不用預先承諾固定數量的測試能在幾毫秒完成

runBlocking { app.handle(...) } 預設使用呼叫端 thread,如果平行測試中的 handler 又切到共享 dispatcher,可能出現資源競爭,這時要限制平行度或注入測試 dispatcher,跟其他框架比較啟動成本時,也要使用相同依賴與測試範圍,不能拿完整 application context 和純 in-process handler 直接相比

為什麼 json() 回 Map 而不是 JsonElement ?

Map<String, Any?> 比 JsonElement 更好用,response.json()["name"] 直接拿到 "Alice",不需要 .jsonPrimitive.content,代價是型別不精確 (Any?),但測試裡通常馬上就 assert 了,問題不大

如果需要精確比對整個 JSON 結構,用 assertJsonEquals(),它走 JsonElement 比對,結構化且不受格式影響

bearerToken() 為什麼不叫 authorization() ?

因為 bearerToken("abc") 比 authorization("Bearer abc") 更不容易寫錯,Bearer 是目前最常見的認證方式,給它一個專用的 builder 方法合理,如果以後要支援 Basic auth,再加 basicAuth(user, pass) 就好

setBody() 為什麼不順手把 Content-Type 設成 JSON ?

因為那樣就測不到「client 沒帶 Content-Type」這種案例了,第 20 篇的 Content Negotiation 遇到沒帶 Content-Type 的 request 會回 415,這條路徑要測得到,setBody() 就得保持乾淨,想要 JSON 的時候明確呼叫 setJsonBody()


小結

TestRelixApplication 把「建立 app → 送 request → 驗 response」封裝成一個 DSL,relixTest { } 是進入點,裡面的 install()、routing {} 跟正式寫法一致

三個部分各自負責一塊,TestRequestBuilder 處理 header、body、query string 的組裝,TestResponse 提供 bodyAsText()、json()、assertJsonEquals() 等測試常用方法,TestRelixApplication 把兩邊接到真的 pipeline 上,每個 test case 都是獨立的 app instance,測試隔離不互相干擾


下一篇

下一篇開始整合實戰,用 Relix 打造 Todo API,先把 CRUD 端點與 in-memory repository 做完整,並用測試確認每個行為


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin 手刻 Ktor 從零開始 Day 27 Status Pages,統一的錯誤回應處理
下一篇
Kotlin 手刻 Ktor 從零開始 Day 29 綜合實戰 (上),用 Relix 打造 Todo API
系列文
Kotlin 手刻 Ktor 從零開始 共 32 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言