iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
Software Development

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

Kotlin 手刻 Ktor 從零開始 Day 13 實作同步版 Pipeline,把 Middleware 串成鏈

  • 分享至 

  • xImage
  •  

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

第 12 篇我們定義了兩個型別

  • Next = RelixCall.() -> RelixResponse
  • RelixMiddleware = RelixCall.(next: Next) -> RelixResponse

這篇要把它「跑起來」,把一串 middleware 串成一個可執行的 pipeline,並用測試確認順序、短路、before/after 行為

TDD 先寫執行順序測試

Pipeline 的 bug 幾乎都是「順序不對」,不是型別不對,所以測試要用 trace list 記錄每個 middleware 的執行足跡

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

class PipelineTest {

    @Test
    fun `middlewares execute in order - before and after`() {
        val trace = mutableListOf<String>()

        val middlewareA: RelixMiddleware = { next ->
            trace += "A-before"
            val response = next()
            trace += "A-after"
            response
        }

        val middlewareB: RelixMiddleware = { next ->
            trace += "B-before"
            val response = next()
            trace += "B-after"
            response
        }

        val handler: Next = {
            trace += "handler"
            ok("done")
        }

        val pipeline = buildPipeline(handler, listOf(middlewareA, middlewareB))
        val call = RelixCall(RelixApplication(), RelixRequest("GET", "/", emptyMap(), emptyMap(), ByteArray(0)))
        pipeline(call)

        assertEquals(
            listOf("A-before", "B-before", "handler", "B-after", "A-after"),
            trace,
        )
    }

    @Test
    fun `middleware can short-circuit`() {
        val trace = mutableListOf<String>()

        val authMiddleware: RelixMiddleware = { _ ->
            trace += "auth"
            RelixResponse(401, emptyMap(), "Unauthorized".toByteArray())
        }

        val loggingMiddleware: RelixMiddleware = { next ->
            trace += "logging-before"
            val response = next()
            trace += "logging-after"
            response
        }

        val handler: Next = {
            trace += "handler"
            ok("done")
        }

        // logging 在外層,auth 在內層
        val pipeline = buildPipeline(handler, listOf(loggingMiddleware, authMiddleware))
        val call = RelixCall(RelixApplication(), RelixRequest("GET", "/", emptyMap(), emptyMap(), ByteArray(0)))
        val response = pipeline(call)

        assertEquals(401, response.statusCode)
        // auth 短路了,handler 不會執行
        // logging 的 after 還是會跑(因為 auth 回傳了 response)
        assertEquals(listOf("logging-before", "auth", "logging-after"), trace)
    }

    @Test
    fun `empty middleware list runs handler directly`() {
        val handler: Next = { ok("hello") }

        val pipeline = buildPipeline(handler, emptyList())
        val call = RelixCall(RelixApplication(), RelixRequest("GET", "/", emptyMap(), emptyMap(), ByteArray(0)))
        val response = pipeline(call)

        assertEquals(200, response.statusCode)
        assertEquals("hello", response.bodyAsText())
    }

    @Test
    fun `middleware can modify response`() {
        val addHeader: RelixMiddleware = { next ->
            val response = next()
            response.header("X-Custom", "modified")
        }

        val handler: Next = { ok("original") }

        val pipeline = buildPipeline(handler, listOf(addHeader))
        val call = RelixCall(RelixApplication(), RelixRequest("GET", "/", emptyMap(), emptyMap(), ByteArray(0)))
        val response = pipeline(call)

        assertEquals("original", response.bodyAsText())
        assertEquals(listOf("modified"), response.headers["X-Custom"])
    }

    @Test
    fun `middleware can use CallContext to share data`() {
        val setTraceId: RelixMiddleware = { next ->
            context.set("traceId", "abc-123")
            next()
        }

        val handler: Next = {
            val traceId = context.get<String>("traceId")
            ok("trace: $traceId")
        }

        val pipeline = buildPipeline(handler, listOf(setTraceId))
        val call = RelixCall(RelixApplication(), RelixRequest("GET", "/", emptyMap(), emptyMap(), ByteArray(0)))
        val response = pipeline(call)

        assertEquals("trace: abc-123", response.bodyAsText())
    }
}

用 fold 把 middleware 串成 pipeline

實作寫進第 12 篇開的 Pipeline.kt,跟 NextRelixMiddleware 兩個 typealias 放在一起

串 middleware 的核心想法是「從後往前包」

  • 最內層是 handler
  • 外面包最後一個 middleware
  • 再外面包倒數第二個
  • 一直包到第一個

foldRight 正好做這件事

fun buildPipeline(handler: Next, middlewares: List<RelixMiddleware>): Next {
    return middlewares.foldRight(handler) { middleware, next ->
        { middleware(next) }
    }
}

foldRight 從 list 的最後一個元素開始往前走,每一步把目前的 middleware 包在 next 外面,最後得到一個 Next,執行它就會依序跑完所有 middleware 和 handler

為什麼是 foldRight 不是 fold ? 因為我們要讓 list 裡第一個 middleware 在最外層,foldRight 從右邊開始包,所以第一個 middleware 最後才被包上去,結果它就在最外層

如果你更習慣 fold,也可以先 reverse

fun buildPipeline(handler: Next, middlewares: List<RelixMiddleware>): Next {
    return middlewares.asReversed().fold(handler) { next, middleware ->
        { middleware(next) }
    }
}

這兩個的結果一樣,用哪個看你覺得哪個讀起來順,我個人偏好 foldRight,因為不需要 reverse

fold 的執行過程圖解

這段如果只看程式碼很抽象。我們用兩個 middleware 走一遍

假設 middlewares = [A, B],handler = H

foldRight 從右邊開始

步驟 1:取 B,current next = H
         新的 next = { B(H) }
         → 意思是「執行 B,B 裡面的 next 是 H」

步驟 2:取 A,current next = { B(H) }
         新的 next = { A({ B(H) }) }
         → 意思是「執行 A,A 裡面的 next 是 { B(H) }」

最終 pipeline = { A({ B(H) }) }

執行這個 pipeline 時

1. A 的 before 邏輯跑
2. A 呼叫 next() → 進入 B
3. B 的 before 邏輯跑
4. B 呼叫 next() → 進入 H
5. H 回傳 response
6. B 拿到 response,跑 after 邏輯
7. A 拿到 response,跑 after 邏輯

這就是上一篇說的洋蔥模型,fold 負責把函式一層一層包起來,呼叫 next() 負責穿進下一層

關鍵在 closure capture,每次 foldRight 的 lambda 執行時,當下的 next 會被新的 lambda 記住,也就是說,{ middleware(next) } 不只是下一段程式碼,它也帶著「下一個要呼叫誰」這個狀態,Kotlin 編譯後會把這類 lambda 轉成物件,captured 變數會放在物件欄位裡,Pipeline 跑起來時,不是在查一張額外的表,而是每一層函式都記得自己的下一層是誰

不用 fold 的兩種寫法

fold 這種 API 不是每個人都覺得直覺,「從右往左包」也要在腦中轉一下,同樣的東西可以用遞迴寫,也可以用最土法煉鋼的 for 迴圈寫

遞迴版

fun buildPipelineRecursive(
    handler: Next,
    middlewares: List<RelixMiddleware>,
): Next {
    if (middlewares.isEmpty()) {
        return handler
    }

    val first = middlewares.first()
    val rest = middlewares.drop(1)
    val inner = buildPipelineRecursive(handler, rest)

    return { first(inner) }
}

邏輯更明確,如果沒有 middleware,直接回 handler,否則拿第一個 middleware,把剩下的遞迴包成 inner,然後把第一個 middleware 包在 inner 外面

一般迴圈版

沒有遞迴也沒有 fold,就是一個變數從後往前一直被重新包起來

fun buildPipelineLoop(handler: Next, middlewares: List<RelixMiddleware>): Next {
    var next = handler
    for (i in middlewares.indices.reversed()) {
        val middleware = middlewares[i]
        // 關鍵:先用 val 固定住這一層的 next
        val current = next
        next = { middleware(current) }
    }
    return next
}

middlewares.indices.reversed() 讓索引從最後一個往前走,跟 foldRight 的方向一樣,包出來的層次也就一樣

中間那行 val current 看起來很多餘,但拿掉就會炸

為什麼一定要多一個 val current

直覺的寫法是這樣

// 這是錯的,執行會爆 StackOverflowError
next = { middleware(next) }

兩者看起來完全等價,真的跑下去只會拿到一串長到看不完的 stack trace

問題出在 Kotlin 捕捉 var 的方式,lambda 抓到的是「變數本身」,不是「寫下這行時的那個值」,編譯後 Kotlin 會把這個 var 包成一個 Ref 物件,lambda 裡拿到的是 Ref 的參考

關鍵是讀值的時機,lambda 裡的 next 要等到 lambda 真的被呼叫時才去 Ref 裡拿值,而那已經是 pipeline 開始執行的時候,迴圈早就跑完了,此時 next 裡放的是最外層那個 pipeline,於是這個 lambda 呼叫的其實是它自己,一路遞迴下去就把 stack 撐爆

多宣告一個 val current,就是在迴圈的這一輪把值固定下來,val 賦值之後不會再變,lambda 捕捉到的東西也就不會被下一輪改掉,行為才跟 foldRight 一致

foldRight 和遞迴版不用擔心這件事,foldRightnext 是 lambda 參數,遞迴版的 inner 是區域 val,兩個都不會被後續改寫,也就沒有這個問題

三種寫法怎麼選

三種寫出來的 pipeline 完全一樣,差別只在讀起來的感覺和要付的代價

  • foldRight 最短,前提是你得先知道 fold 的方向,它跟 fold 包出來的順序剛好相反
  • 遞迴最直白,「第一個包住剩下的」講出來就是程式碼長的樣子,代價是組裝 pipeline 時它自己會遞迴 n 層,foldRight 和迴圈版組裝時都只是跑迴圈,至於執行時,三種一樣都是 n 層巢狀 closure,這點沒差
  • 迴圈最土法煉鋼,沒有遞迴也沒有 fold,但多了 val current 這個必須解釋的細節

把 Pipeline 接進 RelixApplication,先寫整合測試

Pipeline 做好了,接下來要把它接到實際的 request 處理流程裡

這次要驗的不只是 pipeline 自己,而是 middleware、routing、TestKit 串在一起會不會照預期跑,所以測試不寫在 PipelineTest,而是加進第 06 篇開的 RelixApplicationTest.kt,那裡本來就放著透過 TestKit 打進來的測試

@Test
fun `middleware works with routing through TestKit`() {
    val trace = mutableListOf<String>()

    val app = RelixApplication()
    app.use { next ->
        trace += "middleware-before"
        val response = next()
        trace += "middleware-after"
        response
    }
    app.routing {
        get("/hello") {
            trace += "handler"
            ok("Hello!")
        }
    }

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

    assertEquals(200, response.statusCode)
    assertEquals("Hello!", response.bodyAsText())
    assertEquals(listOf("middleware-before", "handler", "middleware-after"), trace)
}

這個測試現在連編譯都過不了,RelixApplication 還沒有 use() 這個方法,就算硬補上去,handle() 也還不知道 middleware 的存在,trace 只會拿到 handler 一筆

實作 use() 與 handle()

class RelixApplication {
    private val middlewares = mutableListOf<RelixMiddleware>()

    fun use(middleware: RelixMiddleware) {
        middlewares += middleware
    }

    fun handle(call: RelixCall): RelixResponse {
        val terminal: Next = when (
            val result = router.match(call.request.method, call.request.path)
        ) {
            is MatchResult.Matched -> {
                call.pathParams = result.pathParams
                call.matchedRoute = result.route
                result.route.handler
            }
            is MatchResult.NotFound -> { { notFound() } }
            is MatchResult.MethodNotAllowed -> {
                { methodNotAllowed(result.allowedMethods) }
            }
        }

        val pipeline = buildPipeline(terminal, middlewares)

        val response = pipeline(call)

        return if (call.request.method == "HEAD") {
            response.copy(body = ByteArray(0))
        } else {
            response
        }
    }
}

use() 把 middleware 加到 list 裡,handle() 先完成路由匹配,把 path params 放進原本的 call,再選出 pipeline 最內層的 terminal handler,這個順序很重要,所有 middleware 與 handler 都會看到同一個 RelixCall,也不會因為匹配後建立新物件而遺失 context

測試通過了,而且 TestKit 一行都不用改,因為它呼叫的是 application.handle(call),而 handle() 現在會把 middleware 串進去,這就是把 pipeline 集中在 handle() 的好處,上層不用知道 middleware 的存在

最後補一個容易搞混的地方,middleware 的安裝順序決定執行順序,先 use 的在最外層

val app = RelixApplication()
app.use(loggingMiddleware)   // 最外層,先進先出
app.use(authMiddleware)      // 第二層
app.routing {
    get("/hello") { ok("Hello!") }
}

常見陷阱與設計取捨

每次 request 都重新 buildPipeline,不會慢嗎 ?

目前每次 handle() 都會呼叫 buildPipeline,如果 middleware list 不會在 runtime 改變 (通常不會),你可以在第一次呼叫時 cache pipeline,但 foldRight 對一個幾十個元素的 list 做一次,成本微乎其微,在效能真的成為問題之前,不要提前最佳化

middleware 裡的 this 是哪個 RelixCall ?

handle(call) 傳進來的那個 call,因為 pipeline(call)call 當成 receiver 傳入,所有 middleware 和 handler 裡的 this 都是同一個 RelixCall 實例,所以 middleware A 往 context 放的東西,middleware B 和 handler 都拿得到

為什麼不用 Chain 物件或 Interceptor 介面 ?

第 12 篇比較各家框架時提過 Relix 刻意不做 chain 物件,那時候還沒有程式碼,現在有了,可以講得更具體

Servlet 那種 FilterChain 要你實作 doFilter,chain 物件本身得記住「現在走到第幾個 filter」,Tomcat 的 ApplicationFilterChain 裡就有一個 pos 欄位,每呼叫一次往前推一格,fold 版本沒有這個游標,走到第幾層是被編碼在 closure 的巢狀結構裡,也就是前面 val current 那段在講的事,每一層自己記得下一層是誰,不需要一個共用的計數器

介面的版本會給你一個名字,出事的時候 stack trace 上寫的是 LoggingFilter.doFilter,lambda 版本你只會看到一串長得都一樣的 invoke,而且介面有地方可以掛 metadata (順序、phase、名稱),function type 沒有,這個階段幾行就串完了比較划算,等到真的需要 Ktor 那種 phase-based 控制,再把抽象加回來也不遲

想用 class 而不是 lambda 寫 middleware 呢 ?

ASP.NET Core 有兩種寫法,inline 的 app.Use(async (ctx, next) => ...),和一個 class 配 app.UseMiddleware<T>(),Relix 沒有做第二種的專用 API,但因為 RelixMiddleware 只是一個 function type,class 本來就掛得上去

最直接的是讓 class 實作 Function2

class LoggingMiddleware(private val tag: String) : Function2<RelixCall, Next, RelixResponse> {
    override fun invoke(call: RelixCall, next: Next): RelixResponse {
        println("$tag start")
        val response = call.next()
        println("$tag end ${response.statusCode}")
        return response
    }
}

app.use(LoggingMiddleware("http"))

這裡有一個會擋人的地方。你不能寫 class LoggingMiddleware : RelixCall.(Next) -> RelixResponse,編譯器會回你 Extension function type is not allowed as a supertype,只能攤成 Function2,receiver 變成第一個參數,代價是 class 裡沒有 this 可以用,要寫 call.next() 而不是 next()call.context 而不是 context

如果不想被 Function2 綁住,寫一般 class 再傳方法參考也行

class LoggingMiddleware(private val tag: String) {
    fun handle(call: RelixCall, next: Next): RelixResponse { ... }
}

app.use(LoggingMiddleware("http")::handle)

至於 ASP.NET Core 那種 next 從 constructor 進來的寫法,對不上 Relix 的型別,得補一行轉接

class DotNetStyle(private val next: Next) {
    fun invoke(call: RelixCall): RelixResponse { ... }
}

app.use { next -> DotNetStyle(next).invoke(this) }

這正是第 12 篇說「middleware 不持有下一層」的實際後果,next 是呼叫時才傳進來的參數,不是建構時存進欄位的東西,所以同一個 middleware 可以組進任何一條 pipeline,代價就是接不上以 constructor 注入為前提的寫法

兩種風格混用不影響順序,listOf(inlineMiddleware, LoggingMiddleware("http")) 跑出來還是照安裝順序由外往內包


小結

buildPipelinefoldRight 把 middleware 從右往左包成一個 Next,它讓整個框架有了洋蔥模型的能力,RelixApplication.handle() 把 router 查詢包成 handler,再用 pipeline 串上 middleware,上層 (TestKit、Adapter) 完全不用改


下一篇

下一篇把 pipeline 升級成 suspend,handler 和 middleware 都變成 suspend function,讓框架能自然使用 coroutine,重點是 typealias 的變動、JDK adapter 的 runBlocking bridge,以及確保所有既有測試在升級後仍然通過


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin 手刻 Ktor 從零開始 Day 12 Pipeline 的概念,請求處理的洋蔥模型
下一篇
Kotlin 手刻 Ktor 從零開始 Day 14 升級 suspend,讓 Pipeline 支援協程
系列文
Kotlin 手刻 Ktor 從零開始18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言