iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0
Software Development

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

Kotlin 手刻 Ktor 從零開始 Day 06 RelixCall 與可測核心,Application / Engine / Handler

  • 分享至 

  • xImage
  •  

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

這篇是整個系列的第一個工程轉折點,前面幾篇我們把 Request 和 Response 做好了,但它們還是各自獨立的資料結構,這篇要做的是把它們放進一個請求生命週期裡,並且建一套 TestKit,能在不開 port 的情況下測完整流程

具體會完成四件事

  • RelixCall,一次請求的生命週期核心物件
  • RelixHandler,以 RelixCall 為 receiver 的 handler 型別 (RelixCall.() -> RelixResponse)
  • Application / Engine / Adapter 的責任拆分
  • MVP 版 TestKit,不啟動真實伺服器,handleRequest() 直接走框架內部流程

這篇的檔案異動比前幾篇大,先把全貌講在前面

  • 新增 RelixCall.ktRelixHandler.ktJdkHttpServerAdapter.ktRelixTestKit.kt
  • 改寫 RelixApplication.kt,第 03 篇塞在它裡面的 HttpServer 整批搬去 JdkHttpServerAdapter.kt
  • 測試多一個 RelixApplicationTest.kt,第 03 篇的 HelloRelixTest.ktJdkHttpServerAdapterTest.kt 取代
  • RelixRequest.ktRelixResponse.ktHttpExchangeExt.kt 這三個一行都不用改,第 04、05 篇準備好的零件,這篇只是把它們接起來

定義 RelixCall,一個請求一個物件

到目前為止我們有 RelixRequestRelixResponse,但框架還需要一個東西把「這次請求」的所有資訊串起來,檔案放 RelixCall.kt

class RelixCall(
    val application: RelixApplication,
    val request: RelixRequest,
) {
    // 後面會擴充
    // - 第 09 篇:pathParams
    // - 第 12 篇:CallContext(attributes)
    // - 第 21 篇:receive<T>()
    // - 第 23 篇:principal<T>()
    // - 第 26 篇:resolve<T>()
}

為什麼不把所有東西塞進 RelixRequest ? 因為 RelixRequest 代表的是「HTTP 原始資訊」,而 RelixCall 代表的是「這次請求在框架裡的完整生命週期」,path parameters 是路由系統算出來的、principal 是認證 middleware 放進來的、attributes 是各個 middleware 之間共享資料用的,這些東西跟 HTTP 規格無關,不應該混在 RelixRequest

application 則是讓 handler 能回頭拿到框架層的東西,目前還用不到它,在第 20、21 篇的 converterRegistry 與第 26 篇的 container 都是從這裡取得

用圖來看整個生命週期

Client sends HTTP request
          │
          ▼
┌─────────────────────┐
│   JDK HttpServer    │  ← 底層 HTTP engine
│   (HttpExchange)    │
└─────────┬───────────┘
          │ adapter
          ▼
┌─────────────────────┐
│   RelixRequest      │  ← HTTP 原始資訊(method/path/headers/query/body)
└─────────┬───────────┘
          │
          ▼
┌─────────────────────┐
│   RelixCall         │  ← 請求生命週期(request + pathParams + context + ...)
│   ├── request       │
│   ├── pathParams    │  (第 09 篇加入)
│   └── context       │  (第 12 篇加入)
└─────────┬───────────┘
          │ handler
          ▼
┌─────────────────────┐
│   RelixResponse     │  ← 回應(statusCode/headers/body)
└─────────┬───────────┘
          │ adapter
          ▼
┌─────────────────────┐
│   JDK HttpServer    │
│   (HttpExchange)    │
└─────────────────────┘

定義 RelixHandler 為 RelixCall.() -> RelixResponse

Handler 是整個框架最核心的型別,內容只有一行,開一個新檔案 RelixHandler.kt 放它

typealias RelixHandler = RelixCall.() -> RelixResponse

typealias 做的事只有一件,幫一個已經存在的型別取另一個名字,RelixHandler 不是新型別,它就是 RelixCall.() -> RelixResponse 本人,編譯完之後 bytecode 裡也找不到 RelixHandler 這個名字,所以後面凡是吃 RelixCall.() -> RelixResponse 的地方都可以直接塞 RelixHandler,反過來也行,中間不需要任何轉換,取名字純粹是為了讓函式簽名讀起來有意義

第 12 篇做 middleware 時會再定義一個型別一模一樣、只是名字不同的 Next,到時候會看到「透明別名」這個性質帶來什麼影響

typealias 在 Kotlin 只能宣告在 top-level,不能塞進 class 或 function 裡面,所以它一定是獨立的一行,為了一行開一個檔案值不值得 ? 這裡的理由是它後面會一直被動到,第 13 到 14 篇要把它改成 suspend,而 middleware、plugin、routing DSL 也全都靠這個型別對接,放在一個一眼就找得到的地方比較省事

這一行有兩個重點,我們逐步拆解

第一,它是一個函式型別

RelixHandler 接受一個 RelixCall,回傳一個 RelixResponse。如果你把 receiver lambda 展開來看,等同於

// 只是拿來對照,不要真的寫進 RelixHandler.kt
typealias RelixHandler = (RelixCall) -> RelixResponse

差別只在於 receiver lambda 的 RelixCall 變成了函式裡的 this

從 Kotlin 編譯器的角度看更直觀,RelixCall.() -> RelixResponse 編譯後會產生一個實作 Function1<RelixCall, RelixResponse> 的物件,第一個參數就是 receiver,換句話說,下面這兩段在 bytecode 層面幾乎一模一樣

// 寫法 A:receiver lambda
val a: RelixCall.() -> RelixResponse = { ok("Hello!") }
a.invoke(call)        // 或 call.a()

// 寫法 B:普通函式型別
val b: (RelixCall) -> RelixResponse = { it.ok("Hello!") }
b.invoke(call)        // 或 b(call)

差別在呼叫語法與函式內 this 的指向,receiver lambda 和相同結構的普通 lambda 都會編譯成函式物件,不需要額外包一層 receiver adapter

至於 lambda 為什麼會對應到 FunctionNFunctionN 是型別契約而不是產生策略、JVM 用 invokedynamic 在裡面做了什麼,這些在上一個系列的 Kotlin Lambda 從零開始 Day 02:Lambda 本質解密 拆得很細,這篇就不重講一次

第二,thisRelixCall

因為 RelixCall.() 是 receiver lambda,在 handler 裡面 this 就是 RelixCall。這代表你可以直接呼叫 RelixCall 上面的方法,不需要額外的參數

// 如果用普通函式
val handler: (RelixCall) -> RelixResponse = { call ->
    val name = call.request.queryParameters["name"]?.firstOrNull() ?: "World"
    ok("Hello, $name!")  // ok() 是 top-level function
}

// 如果用 receiver lambda(我們的設計)
val handler: RelixCall.() -> RelixResponse = {
    val name = request.queryParameters["name"]?.firstOrNull() ?: "World"
    ok("Hello, $name!")  // this = RelixCall,所以 request 可以直接存取
}

receiver lambda 讓 handler 寫起來更像 DSL,你不用寫 call.request,直接寫 request 就好,等在第 09 篇加了 pathParam() 之後,handler 裡可以直接寫 pathParam("id") 而不是 call.pathParam("id")

我們會在第 10 篇專門把 Lambda with Receiver 的原理講清楚,這篇先用「體驗」建立直覺

如果你對 lambda 本身還不太熟,或是想先把底層補起來再往下看,可以參考上一個系列

為什麼先用同步版本 ?

目前的 RelixHandler 是同步的,不是 suspend,這是刻意的,前面先用同步跑完所有核心邏輯,等第 13-14 篇 Pipeline 做完之後再升級成 suspend,你不需要先理解 coroutine 就能跟上前面十幾篇的內容

重新整理 RelixApplication

一個可演進的框架入口,要把這些責任分開

  • Application,對外 API (註冊路由、安裝 middleware/plugin)
  • Router,路由規則與匹配 (第 07-11 篇逐步建立)
  • Engine / Adapter,把底層 HttpExchange 轉成 RelixCall,把 RelixResponse 寫回去

第 03 篇的 RelixApplication 是三件事混在一起的,它自己 create server、自己 createContext、自己在 lambda 裡把 Hello, Relix! 拼成 bytes 寫回去。這一節動的就是它,搬完之後的分配是這樣

第 03 篇 RelixApplication 裡的東西 這篇搬去哪
HttpServer.create()server.start()stop()port JdkHttpServerAdapter
BindException 包成 IllegalStateException JdkHttpServerAdapter.start(),行為不變
createContext("/hello") { ... } 裡寫死的那段回應 刪掉,改成由使用者註冊的 handler 決定
註冊 handler、處理一個 call 第 03 篇沒有這兩件事,是這一節要補的

搬完之後 RelixApplication 從「一個會開 port 的東西」變成「一個不知道 HTTP server 存在的東西」,連 import com.sun.net.httpserver 都不會有,這是這篇最關鍵的,因為只有這樣它才測得動,後面的 TestKit 也才有得寫

先寫測試

RelixApplication 剩下的責任只有兩件,註冊一條 handler、拿一個 call 換一個 response,測試就照這兩件事寫

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

class RelixApplicationTest {

    @Test
    fun `registered handler produces the response`() {
        val app = RelixApplication()
        app.handle("GET", "/hello") {
            ok("Hello, Relix!")
        }

        val request = RelixRequest(
            method = "GET",
            path = "/hello",
            headers = emptyMap(),
            queryParameters = emptyMap(),
        )
        val call = RelixCall(app, request)
        val response = app.handle(call)

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

注意這個測試從頭到尾沒有 HttpServer、沒有 port、沒有 HTTP client,它就是一般的物件測試,第 03 篇那組測試做不到這件事,不是因為當時偷懶,是因為當時的 RelixApplication 把 server 綁在身上,你想測它就只能連它一起啟動

讓測試通過

實作照著測試的需求寫就好

class RelixApplication {

    private var handler: RelixHandler = { notFound() }

    fun handle(method: String, path: String, handler: RelixHandler) {
        // 暫時只支援一條路由,下一篇做 Map-based routing
        this.handler = handler
    }

    fun handle(call: RelixCall): RelixResponse {
        return handler(call)  // call.handler(),因為是 receiver lambda
    }
}

上面那段是進去的,不是整個檔案重寫,在第 03 篇留下來的 start()stop()port 和那個 createContext 的 lambda 現在先原封不動放著,前面的表格說它們要搬去 adapter,但那是下面的事,現在動它只會讓第 03 篇的 HelloRelixTest 測試問題

所以這一節結束時,RelixApplication 是一個暫時的混合體,一半是新的可測核心,一半是還沒搬走的 server 程式碼,等 adapter 那一節搬完,我們再回來把舊的那一半刪掉

後面 Router 會取代這個簡陋的實作,但 handle(call): RelixResponse 這個方法會一直留著,因為接下來的 TestKit 和 Adapter 都要靠它

重複的組裝抽成 RelixTestKit

上面那個測試會過,但它有個問題,真正跟這個測試有關的只有 "GET"/hello,其他都是為了把 RelixRequest 湊出來才寫的樣板

val request = RelixRequest(
    method = "GET",
    path = "/hello",
    headers = emptyMap(),
    queryParameters = emptyMap(),
)
val call = RelixCall(app, request)
val response = app.handle(call)

接下來還有查詢字串、JSON、沒註冊 handler 的 404 要驗,每一個都得把這幾行再抄一次,這種重複就是抽工具的時機,而且抽出來的東西剛好就是這篇的第四個目標,MVP 版的 TestKit,檔案放 RelixTestKit.kt,它跟其他框架程式碼一樣放 src/,不是放 test/,TestKit 是框架提供的東西,測試只是拿它來用

class RelixTestKit(private val application: RelixApplication) {

    fun handleRequest(
        method: String = "GET",
        path: String = "/",
        headers: Map<String, List<String>> = emptyMap(),
        queryParameters: Map<String, List<String>> = emptyMap(),
        body: ByteArray = ByteArray(0),
    ): RelixResponse {
        val request = RelixRequest(
            method = method,
            path = path,
            headers = headers,
            queryParameters = queryParameters,
            body = body,
        )
        val call = RelixCall(application, request)
        return application.handle(call)
    }
}

它做的事跟剛剛那三行一模一樣,差別是每個參數都給了預設值,測試只要寫自己在乎的那幾個,這也是「TestKit 不開 port」的全部祕密,它沒有任何魔法,就是直接建 request、包成 call、交給 application

它被放在 src/ 而不是 test/,因為它是要給框架的使用者用的,使用者要測自己的 handler,跟我們測框架本身用的是同一套工具

TestKit 會隨系列演進,第 11 篇支援 routing DSL、第 14 篇支援 suspend handler、第 20 篇支援 Content Negotiation、第 28 篇整理成穩定的 TestRelixApplication

用 TestKit 補完 RelixApplication 的測試

第一個測試先改寫成 TestKit 的版本,驗的東西完全沒變,只是把樣板拿掉,另外三個直接照新寫法補上

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

class RelixApplicationTest {

    @Test
    fun `registered handler produces the response`() {
        val app = RelixApplication()
        app.handle("GET", "/hello") {
            ok("Hello, Relix!")
        }

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

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

    @Test
    fun `handler can access request query parameters`() {
        val app = RelixApplication()
        app.handle("GET", "/greet") {
            val name = request.queryParameters["name"]?.firstOrNull() ?: "World"
            ok("Hello, $name!")
        }

        val testKit = RelixTestKit(app)
        val response = testKit.handleRequest(
            method = "GET",
            path = "/greet",
            queryParameters = mapOf("name" to listOf("Relix")),
        )

        assertEquals("Hello, Relix!", response.bodyAsText())
    }

    @Test
    fun `handler can return json response`() {
        val app = RelixApplication()
        app.handle("GET", "/status") {
            json("""{"status":"ok"}""")
        }

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

        assertEquals(200, response.statusCode)
        assertEquals("""{"status":"ok"}""", response.bodyAsText())
        assertEquals(
            listOf("application/json; charset=utf-8"),
            response.headers["Content-Type"]
        )
    }

    @Test
    fun `default handler returns 404`() {
        val app = RelixApplication()
        // 不註冊任何 handler

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

        assertEquals(404, response.statusCode)
    }
}

第二個測試順便驗到了 receiver lambda,handler 裡面寫的是 request.queryParameters,不是 call.request.queryParameters,前面講的 this 就是這樣用的

這些測試跑起來是毫秒等級,因為完全不需要啟動 server,從現在開始,後面每篇的 TDD 都會用這個方式

封裝 JDK HttpServer Adapter

TestKit 快是有代價的,它從 RelixRequest 開始、到 RelixResponse 結束,中間那一段真的 HTTP 完全被跳過,換句話說,第 03 篇那些直接操作 HttpExchange 的程式碼,剛剛那些測試一行都沒碰到,它偏偏又是最不該漏掉的一塊,等一下你會看到那裡有一個 catch (e: Exception),那是目前整個框架唯一擋在「handler 爆炸」和「client 端連線莫名其妙斷掉」之間的東西,這種程式碼沒被跑過等於沒寫

所以這一層要另外開一組會啟動 server 的測試,數量不用多,把 TestKit 涵蓋不到的部分驗到就好

另外,這一節的性質跟前面兩節不一樣,JdkHttpServerAdapter 沒有任何新功能,它就是第 03 篇那段 server 程式碼換一個地方放,搬家的規矩是先確定保護網在,再動手,而這張網第 03 篇就織好了,HelloRelixTest.kt 開真的 server 打 HTTP 的測試,剛好也可以驗到要搬的這段程式碼 (因為對外的介面沒有改變)

先把測試搬過來

所以先動測試,把 HelloRelixTest 改寫成對著 JdkHttpServerAdapter 的版本,檔名也跟著換成 JdkHttpServerAdapterTest.kt

import java.net.BindException
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
import kotlin.test.assertTrue

class JdkHttpServerAdapterTest {

    private val application = RelixApplication()
    private val adapter = JdkHttpServerAdapter(application)

    @BeforeTest
    fun setUp() {
        adapter.start(port = 0)
    }

    @AfterTest
    fun tearDown() {
        adapter.stop()
    }

    private fun send(path: String, body: String? = null): HttpResponse<String> {
        val builder = HttpRequest.newBuilder()
            .uri(URI("http://localhost:${adapter.port}$path"))
        val request =
            if (body == null) builder.GET().build()
            else builder.POST(HttpRequest.BodyPublishers.ofString(body)).build()
        return HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString())
    }

    @Test
    fun `request reaches the handler and response comes back`() {
        application.handle("GET", "/") { ok("Hello, Relix!") }

        val response = send("/hello")

        assertEquals(200, response.statusCode())
        assertEquals("Hello, Relix!", response.body())
    }

    @Test
    fun `handler sees the parsed request`() {
        application.handle("POST", "/") {
            ok("${request.method} ${request.path} ${request.bodyAsText()}")
        }

        val response = send("/echo?x=1", body = "payload")

        assertEquals("POST /echo payload", response.body())
    }

    @Test
    fun `throwing handler returns 500 instead of hanging`() {
        application.handle("GET", "/") { error("boom") }

        val response = send("/")

        assertEquals(500, response.statusCode())
        assertEquals("Internal Server Error", response.body())
    }

    @Test
    fun `default handler returns 404 over real HTTP`() {
        val response = send("/nothing-here")

        assertEquals(404, response.statusCode())
    }

    @Test
    fun `occupied port throws IllegalStateException`() {
        // setUp 裡的 adapter 已經佔住 adapter.port
        val another = JdkHttpServerAdapter(RelixApplication())

        val error = assertFailsWith<IllegalStateException> {
            another.start(port = adapter.port)
        }

        assertTrue(error.message!!.contains("already in use"))
        assertIs<BindException>(error.cause)
    }
}

五個裡有三個是第 03 篇就有的,第一個和第四個差別只在啟動的東西從 RelixApplication 換成 JdkHttpServerAdapter,回應的內容也從寫死的改成 handler 給的,第 03 篇那個驗 Content-Type 的沒有跟著搬,因為第 05 篇的 writeResponse() 已經把它驗過了,這裡不需要為了同一件事再開一次 server

第五個是 port 被佔用的案例,斷言一個字都不用改,只是改成拿兩個 JdkHttpServerAdapter 去搶同一個 port,BindException 包成 IllegalStateException 的那段等一下會原封不動跟著搬進 adapter 的 start(),行為沒變,測試就不該變,這個也是驗證搬家有沒有搬乾淨的一部分。它同時是這組裡唯一不用送 HTTP request 的一個,因為它要驗的是 start() 本身

那句 assertIs<BindException>(error.cause) 也不是多寫的,包裝的用意是換一個看得懂的訊息,不是把原始例外丟掉,真的出事的時候那條 BindException 還是要查得到

第二個和第三個是新的,它們驗的是第 03 篇還不存在的行為,也就是 adapter 跟 RelixApplication 之間那條線

第二個測試驗的是「接線有沒有接對」,toRelixRequest() 在第 04 篇測過、writeResponse() 在第 05 篇測過,但那兩篇證明不了 adapter 有把它們接到正確的位置上,這個測試讓 handler 直接把 method、path、body 三個值原封不動吐回來,接錯任何一條線都會露餡

第三個測試是這一組裡最重要的,handler 直接丟例外,client 端要收到 500,而不是收到一個永遠不會結束的連線。這種 bug 在測試裡不會顯示成「失敗」,而是顯示成「卡住」,等到 CI 因為 timeout 掛掉才發現,所以更值得先寫下來

再把實作搬過來

該失敗的測試都立好了,現在才動實作,把 start()stop()port 三個成員從第 03 篇的 RelixApplication 原封不動搬過來,createContext 裡面則從「寫死 Hello, Relix!」換成「翻譯給 application」,把 HttpExchangeRelixCall / RelixResponse 的轉換集中在一個地方

import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpServer
import java.net.InetSocketAddress

class JdkHttpServerAdapter(
    private val application: RelixApplication,
) {
    private lateinit var server: HttpServer
    var port: Int = 0
        private set

    fun start(port: Int = 8080) {
        try {
            server = HttpServer.create(InetSocketAddress(port), 0)
        } catch (e: java.net.BindException) {
            throw IllegalStateException(
                "Port $port is already in use. Try a different port or use port 0 for auto-assign.",
                e
            )
        }

        server.createContext("/") { exchange ->
            try {
                val request = exchange.toRelixRequest()
                val call = RelixCall(application, request)
                val response = application.handle(call)
                exchange.writeResponse(response)
            } catch (e: Exception) {
                // 暫時的 fallback,第 16 篇會做 ErrorHandling middleware
                val body = "Internal Server Error".toByteArray()
                exchange.sendResponseHeaders(500, body.size.toLong())
                exchange.responseBody.use { it.write(body) }
            }
        }

        server.start()
        this.port = server.address.port
    }

    fun stop() {
        server.stop(0)
    }
}

讀 request 的規則集中 (toRelixRequest())、寫 response 的規則集中 (writeResponse()),後面要加 HEAD 處理、body size limit、error handling,都只需要改 adapter 這一層

createContext("/") 用根路徑 / 當 catch-all,因為路由匹配的工作交給 RelixApplication (或之後的 Router),不是 JDK HttpServer 的事

最後刪掉 RelixApplication 的舊那一半

實作搬完,RelixApplication 裡原本那份 start()stop()portcreateContext 就變成沒人呼叫的重複程式碼,這時候才輪到刪它,刪完之後它真的只剩下前面寫的那兩個方法,import com.sun.net.httpserver 也可以一起拿掉,前面表格說的那一刀,到這裡才算真的落下去

第 03 篇的 HelloRelixTest.kt 也一起刪,它呼叫的 app.start() 已經不在 RelixApplication 上面,而它驗的行為都由 JdkHttpServerAdapterTest 接手了

拆成兩個類別之後,真的要跑起來就變成兩行,一行組裝 application,一行把它插上 server,可以把原本的 main 的內容改掉

fun main() {
    val app = RelixApplication()
    app.handle("GET", "/hello") { ok("Hello, Relix!") }

    JdkHttpServerAdapter(app).start(8080)
}

至於後面幾篇要加的 HEAD 處理、body size limit、error handling,因為都會改在 adapter 這一層,也都會需要回到這組測試補上對應的案例

常見陷阱與設計取捨

為什麼 handler 用 receiver lambda 而不是介面 ?

有些框架用介面來定義 handler

interface Handler {
    fun handle(call: RelixCall): RelixResponse
}

這樣做沒有錯,但在 Kotlin 裡,函式型別比介面更靈活,你可以用 lambda 直接定義 handler,不需要每次都寫一個匿名類別。而且 receiver lambda 還有額外的好處,handler 裡面寫 request 就好,不用寫 call.request

註冊時的 method 和 path 目前是被忽略的

目前 app.handle("GET", "/hello", handler) 裡的 method 和 path 其實沒有真的被用來做路由匹配,RelixApplication 現在只存最後一個 handler,不管什麼 path 都會走那個 handler。這是刻意的簡化,下一篇我們就會用 Map 做真正的路由

這也是為什麼前面 adapter 的測試裡,send("/hello") 明明註冊的是 "/",還是拿得到回應

adapter 裡的 try-catch

JdkHttpServerAdapter 註冊給 createContext("/") 的那個 lambda 用了一個大的 try-catch,如果 handler 拋了例外,adapter 會回 500,這是暫時的 fallback,不是最終設計,第 16 篇 ErrorHandling middleware 會取代它


小結

這篇建立了框架的骨架,RelixCall 是生命週期核心、RelixHandler 用 receiver lambda 讓 handler 寫起來像 DSL、Application / Engine / Adapter 各有各的責任

最重要的是 MVP TestKit,不過它不是憑空設計出來的,而是先把 RelixApplication 從 server 上拆下來,讓它變成一個測得動的普通物件,再把測試裡重複的那幾行組裝抽出來的結果,順序反過來就寫不出這個東西,這也是為什麼這篇要先動 RelixApplication

從現在開始我們不需要啟動 server 就能做端到端測試,後面的路由系統、middleware、plugin,都會建在這個骨架上面


下一篇

下一篇我們進入路由系統,先用最簡單的 Map<String, RelixHandler> 做 URL 對應,做出 404 與 method 過濾,並討論這種方式的限制


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin 手刻 Ktor 從零開始 Day 05 Response 的封裝,設計直覺的 RelixResponse
下一篇
Kotlin 手刻 Ktor 從零開始 Day 07 最簡單的路由,用 Map 做 URL 對應
系列文
Kotlin 手刻 Ktor 從零開始18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言