iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
Software Development

Kotlin Ktor 實戰 101系列 第 9 篇

Kotlin Ktor 實戰 101 Day 09 Plugin 機制與 Phase-based Pipeline

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260909/20121948LfQo3aVCtG.jpg

上一篇結尾把問題挑明了,install(Resources)、install(IgnoreTrailingSlash),install 這個動作已經出現好幾次,每次都是一行帶過,這篇把它拆開來看,看 plugin 掛上去的那一刻發生什麼事、掛在請求處理流程的哪個位置,還有多個 plugin 之間的順序怎麼決定,這也是 day 01 就預告過的,Relix 系列 收尾時留了一個排序伏筆

這篇要完成什麼

  • 拆開 install(...),看它背後做了哪幾件事,順便發現 routing 其實也是一個 plugin
  • 認識 ApplicationCallPipeline 的 5 個 phase,每個 phase 的語意和典型住戶
  • 寫 3 個測試證明,interceptor 的執行順序由 phase 決定,不是註冊順序
  • 實測 proceed() 的前後包夾,Relix middleware 的「進去一層、出來一層」在 Ktor 長什麼樣
  • 回應 Relix 結尾那句「plugin 系統沒辦法幫你排順序」,看 Ktor 怎麼把這個問題變小
  • 跟 ASP.NET Core 對照,middleware 的順序責任在誰身上,phase 又對應到 .NET 的哪一層

install 到底做了什麼

從使用端看,install 就一行,install(Resources),頂多後面帶一個設定用的 lambda

這一行在 Ktor 3.5.2 的原始碼裡做的事,節錄並簡化之後長這樣

val registry = pluginRegistry
return when (val installedPlugin = registry.getOrNull(plugin.key)) {
    null -> {
        val installed = plugin.install(this, configure)
        registry.put(plugin.key, installed)
        installed
    }
    plugin -> installedPlugin // 只有登記的實體就是 plugin 本身才會走這裡
    else -> throw DuplicatePluginException(/* 這個 key 已經登記了其他實體 */)
}

主要是 3 件事,先拿 plugin 的 key 查註冊表,沒裝過就呼叫 plugin.install(...) 然後登記起來,只有登記進去的實體就是 plugin 物件本身,第 2 次安裝才會直接拿回原本那份,createApplicationPlugin 產生的 plugin 會登記另一個 PluginInstance,同一個 application 再安裝一次會走 DuplicatePluginException,不會直接沿用舊設定

routing { } 可以寫很多次,是因為它在呼叫 install 前先用 pluginOrNull(RoutingRoot) 查過,找到既有的 routing root 就直接把新設定套上去,這是 routing 這層包裝提供的行為,不是 application plugin 重複安裝的通則

注意 install 這個動作本身完全不知道 plugin 要做什麼,它只負責查有沒有裝過、呼叫、登記,「掛上去之後改變什麼行為」是 plugin.install(...) 裡面的事,每個 plugin 自己決定

所以 plugin 是什麼 ? 就是一個知道怎麼把自己掛上 application 的物件,帶一個當 key 的名字、一個 install 函式,這個描述聽起來門檻很低,實際上也真的很低,低到我們每天在寫的 routing { } 都算一個,看它的原始碼

public fun Application.routing(configuration: Routing.() -> Unit): RoutingRoot =
    pluginOrNull(RoutingRoot)?.apply(configuration) ?: install(RoutingRoot, configuration)

routing { } 只是 install(RoutingRoot) 的包裝,第 1 次呼叫就安裝,之後再呼叫就把設定套到既有的那份上,而 RoutingRoot 的 install 函式裡,藏著這篇的主角

override fun install(pipeline: Application, configure: Routing.() -> Unit): RoutingRoot {
    val routingRoot = RoutingRoot(pipeline).apply(configure)
    pipeline.intercept(Call) { routingRoot.interceptor(this) }
    return routingRoot
}

建好路由樹之後,它對 application 呼叫了 intercept(Call),把「解析路由、執行 handler」這整件事註冊成 Call 這個位置上的一個 interceptor,從 day 05 寫到現在的所有路由,全部住在這一個 interceptor 裡面。那 Call 是什麼 ? 這就要把 pipeline 打開來看了

一條分好段的 pipeline

Ktor 處理請求的主幹叫 ApplicationCallPipeline,而且 Application 這個類別本身就繼承它,所以待會在測試裡可以對 application 直接呼叫 intercept,pipeline 的內容物是一串 interceptor,每個請求進來,依序流過它們,特別的地方在於這串 interceptor 不是一個扁平的清單,而是預先分好了 5 段,每一段叫一個 phase,依序是

  • Setup,準備 call 和它的屬性,還輪不到業務邏輯,典型住戶是 CallId,在最前面替每個請求發一個識別碼,後面所有人都能用
  • Monitoring,觀測用,原始碼註解直接點名 logging、metrics、錯誤處理,day 16 裝 CallLogging 時會再回來看這一段
  • Plugins,大多數功能型 plugin 的家,原始碼註解就一句,Most plugins should intercept this phase,day 10 會遇到的 onCall,寫進去的邏輯就是掛在這裡
  • Call,處理請求、產生回應,剛剛看到了,routing 住這裡
  • Fallback,處理沒人接的請求,engine 啟動時預設在這裡掛了一個 interceptor,發現 call 沒被處理就回 404,day 03 打 /nothing-here 拿到的那個 404 就是它給的

phase 的順序是固定的,Setup 先、Fallback 後,每個請求都照這個次序流過 5 段

request
   │
   ▼
┌──────────────┐
│ Setup        │  發識別碼、準備 call 的屬性
└──────┬───────┘
       ▼
┌──────────────┐
│ Monitoring   │  logging、metrics
└──────┬───────┘
       ▼
┌──────────────┐
│ Plugins      │  大多數 plugin 的 onCall
└──────┬───────┘
       ▼
┌──────────────┐
│ Call         │  routing 解析路徑、跑 handler
└──────┬───────┘
       ▼
┌──────────────┐
│ Fallback     │  沒人接就回 404
└──────┬───────┘
       ▼
response

interceptor 註冊的時候必須指定 phase,等於在宣告「我這段邏輯屬於流程的哪個位置」,右邊那欄不是規定,只是各段最常見的住戶,真正被框架保證的只有左邊的先後

順帶一提,3.5 版的原始碼裡其實還躲了第 6 個 phase,一個 internal 的 Validators,夾在 Plugins 和 Call 之間,註解說它是給 authentication、rate limiting、CORS 這類守門 plugin 用的,它沒有開放給使用者,先知道有這個位置就好,day 11 裝 CORS 和 RateLimit 的時候會再碰到這一帶

phase 之間的順序保證,馬上就能拿一個在 day 06 實驗過的 IgnoreTrailingSlash 來驗證,整個 plugin 的原始碼只有這樣

public val IgnoreTrailingSlash: ApplicationPlugin<Unit> = createApplicationPlugin("IgnoreTrailingSlash") {
    onCall { call ->
        call.ignoreTrailingSlash = true
    }
}

它做的事就只是在 call 上蓋一個章,這招能成立,靠的正是 phase 順序,onCall 掛在 Plugins,routing 住在 Call,Plugins 一定先跑,所以 routing 解析路徑時,章一定已經蓋好了,讀到就改用寬鬆的尾斜線規則,2 個 plugin 誰也不認識誰,一個蓋章、一個讀章,中間的順序由 phase 擔保

實驗,順序由 phase 決定

講了半天保證,該拿出證據了,intercept 不是 plugin 專用的 API,我們自己也可以直接呼叫,正好拿來做實驗,開一個新檔案 src/test/kotlin/com/cashwu/todo/PipelinePhasesTest.kt,第 1 個測試把 5 個 phase 各掛一個 interceptor,每個只做一件事,把自己的 phase 名字加進一個 list。重點是註冊順序故意打亂,Fallback 最先註冊、Monitoring 最後

package com.cashwu.todo

import io.ktor.client.request.get
import io.ktor.server.application.ApplicationCallPipeline
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import kotlin.test.Test
import kotlin.test.assertEquals

class PipelinePhasesTest {
    @Test
    fun `execution order follows phase order not registration order`() = testApplication {
        val visited = mutableListOf<String>()
        application {
            intercept(ApplicationCallPipeline.Fallback) { visited.add("Fallback") }
            intercept(ApplicationCallPipeline.Call) { visited.add("Call") }
            intercept(ApplicationCallPipeline.Setup) { visited.add("Setup") }
            intercept(ApplicationCallPipeline.Plugins) { visited.add("Plugins") }
            intercept(ApplicationCallPipeline.Monitoring) { visited.add("Monitoring") }
            routing {
                get("/") {
                    call.respondText("ok")
                }
            }
        }

        client.get("/")

        assertEquals(listOf("Setup", "Monitoring", "Plugins", "Call", "Fallback"), visited)
    }
}

這次的實驗完全不碰 Application.kt,interceptor 都掛在測試自己建的 application 上,既有的測試完全不受影響。跑 ./gradlew test,這個測試通過,如果執行順序由註冊順序決定,list 裡會是 Fallback 開頭的打亂版,斷言會直接失敗,但實際上 5 個名字整整齊齊按 phase 排,install 的先後,intercept 的先後,都不影響跨 phase 的執行順序

這個測試還順便揭露了 2 件事

第 1,這些 interceptor 都沒有呼叫 proceed(),pipeline 照樣往下走,Ktor 的 interceptor 正常返回後,pipeline 就繼續執行下一個,真的想中斷要呼叫 finish()

第 2,請求明明被 routing 接走了,Fallback 的 interceptor 還是執行了,Fallback 不是「沒人接才會啟動的特殊通道」,它就是排在最後的普通 phase,預設 404 那個 interceptor 是自己檢查了 call.isHandled,發現處理過就默默返回

同一個 phase 裡,才輪到註冊順序

跨 phase 的順序解決了,那 2 個 interceptor 掛在同一個 phase 呢 ? 在同一個檔案裡再加一個測試

@Test
fun `same phase runs in registration order`() = testApplication {
    val visited = mutableListOf<String>()
    application {
        intercept(ApplicationCallPipeline.Plugins) { visited.add("先註冊的") }
        intercept(ApplicationCallPipeline.Plugins) { visited.add("後註冊的") }
        routing {
            get("/") {
                call.respondText("ok")
            }
        }
    }

    client.get("/")

    assertEquals(listOf("先註冊的", "後註冊的"), visited)
}

同一個 phase 內,誰先註冊誰先跑,所以 phase 機制沒有把排序問題完全消滅,它把問題切小了,粗排序交給 phase,同段內的細排序仍然看註冊順序,2 個互相依賴的 plugin 如果剛好住同一個 phase,install 的先後還是有意義的,這件事後面裝 plugin 時要小心

proceed() 把「後面的全部」包進來

前面的 interceptor 都是做完一件事就返回,interceptor 還有另一種寫法,呼叫 proceed(),意思是「先把 pipeline 剩下的全部跑完,再回到我這裡」,於是 proceed() 前後各寫一段,就能在請求進來時做一件事、回應出去前再做一件事,第 3 個測試用 2 層包夾驗證這個巢狀結構

@Test
fun `proceed wraps the rest of the pipeline`() = testApplication {
    val visited = mutableListOf<String>()
    application {
        intercept(ApplicationCallPipeline.Monitoring) {
            visited.add("Monitoring 進")
            proceed()
            visited.add("Monitoring 出")
        }
        intercept(ApplicationCallPipeline.Plugins) {
            visited.add("Plugins 進")
            proceed()
            visited.add("Plugins 出")
        }
        routing {
            get("/") {
                visited.add("handler")
                call.respondText("ok")
            }
        }
    }

    client.get("/")

    assertEquals(
        listOf("Monitoring 進", "Plugins 進", "handler", "Plugins 出", "Monitoring 出"),
        visited,
    )
}

跑 ./gradlew test,原有的測試加 3 個新測試全部通過

PipelinePhasesTest > same phase runs in registration order() PASSED
PipelinePhasesTest > execution order follows phase order not registration order() PASSED
PipelinePhasesTest > proceed wraps the rest of the pipeline() PASSED

第 3 個測試的斷言就是 Relix day 12 畫過的洋蔥模型,用這次的 phase 重畫一次長這樣

client
  │
  ▼
┌──────────────────────────────────────┐
│ Monitoring 進                        │
│ ┌──────────────────────────────────┐ │
│ │ Plugins 進                       │ │
│ │ ┌──────────────────────────────┐ │ │
│ │ │ Call:handler                │ │ │
│ │ └──────────────────────────────┘ │ │
│ │ Plugins 出                       │ │
│ └──────────────────────────────────┘ │
│ Monitoring 出                        │
└──────────────────────────────────────┘
  │
  ▼
response

框線的順序就是斷言 list 的順序,從上往下讀一遍,正好是 Monitoring 進、Plugins 進、handler、Plugins 出、Monitoring 出,Monitoring 先進、最後出,Plugins 在它裡面,handler 在最裡面,想量一個請求從進來到回應花了多久,靠的就是這種結構,進去時記時間、回來時算差值,Relix day 15 手刻的 logging middleware 做的就是這件事

跟 Relix 的對照

Relix 在 day 12 手刻的 middleware pipeline 是函式包函式,每個 middleware 收一個 handler、回傳一個包了料的新 handler,一層一層裹上去,到了 day 18 做出 install() 之後,誰包住誰完全由 install 順序決定,先裝的在最外層,當時的結論是這樣說的,「plugin 系統沒有幫你排順序,也沒辦法幫你排,哪個該在外面是語意問題,只有你自己知道」,plugin 彼此不知道對方存在,卻要靠使用者排出一個整體正確的順序,Logging 忘了放最前面,就少記幾層的行為

同一個問題,Ktor 怎麼處理,看完前面的 phase 就清楚了,plugin 還是彼此不知道對方存在,但它不再對「我在整條流程的第幾位」沉默,它宣告自己屬於哪個 phase,CallLogging 說我是 Monitoring、routing 說我是 Call,框架拿著 phase 的既定順序把大家放到對的位置,IgnoreTrailingSlash 蓋的章 routing 一定讀得到,不管你哪一行才 install 它,Relix 那句「哪個該在外面是語意問題」在 Ktor 依然成立,差別是回答問題的人換了,以前是每個使用者自己顧整條 pipeline 的順序,現在是 plugin 作者宣告一次語意位置,所有使用者共享這個答案

第 2 個測試也提醒我們別把話說滿,同 phase 內的順序仍是註冊順序,Ktor 沒有魔法到能排序一切,它做的是把「需要人腦介入排序」的範圍從整條 pipeline 縮小到同一段裡的少數情況,工程上很多好設計都是這樣,沒有消滅問題,只是把問題縮到大部分時候可以不用想的大小

跟 ASP.NET Core 的對照

寫過 ASP.NET Core 的人看到這裡應該會覺得眼熟,install(...) 對 app.UseXxx()、proceed() 對 await next(context),next 前後各寫一段做出來的洋蔥,跟第 3 個測試印出來的順序長得一樣,連 ApplicationCall 和 HttpContext 擺的位置都差不多,同一個物件一路傳過整條 pipeline

不一樣的地方有 2 個

第 1 個是不往下走的時候會發生什麼事,ASP.NET Core 的 middleware 只要不呼叫 next,後面的全部不會執行,回應就這樣送出去,權限沒過直接回 401 就是靠這個寫法,Ktor 反過來,interceptor 正常返回還是會繼續走下一個,要讓 pipeline 停在這裡,得明確呼叫 finish()

第 2 個就是這篇的主題,ASP.NET Core 的 middleware 順序完全由 Program.cs 裡 Use 的呼叫順序決定,先註冊的在最外層,這跟 Relix day 18 的處境是同一件事,也是為什麼官方文件要專門畫一張「正確的 middleware 順序」示意圖,還得一再提醒 UseAuthentication 要放在 UseAuthorization 前面、2 個又都要在 UseRouting 之後,順序的責任在寫應用程式的人身上,middleware 自己不表態

那 .NET 裡有沒有類似 phase 這種東西 ? 有,只是不在 middleware 這一層,而是在 MVC 的 filter pipeline,Authorization、Resource、Action、Exception、Result 這 5 種 filter,種類之間的順序是寫死的,authorization 一定最先跑、result 一定最後,同一個種類裡面才輪到 Order 屬性和 global、controller、action 的 scope 決定先後

這個形狀就很熟悉了,粗排序按語意種類、細排序按註冊,跟 Ktor 的 phase 是同一套想法,連分段的語意都對得上,Ktor 那個 internal 的 Validators phase,註解點名的 authentication 和 CORS,位置正好就是 .NET 的 authorization filter

真正的差別在於這套東西擺在哪一層,.NET 是 2 層機制,外面是 middleware,順序自己排,裡面是 endpoint 執行時才展開的 filter pipeline,順序按種類,Ktor 只有一層 pipeline,phase 直接長在上面,所有 plugin 不管做什麼,都排進同一條線裡


小結

install 做的事是先查有沒有裝過,再呼叫 plugin 自己的 install、登記起來,掛上 pipeline 的方式由 plugin 決定,連 routing 都是這樣裝進來的,ApplicationCallPipeline 分成 Setup、Monitoring、Plugins、Call、Fallback 5 段,3 個測試證明了跨 phase 的執行順序由 phase 決定、同 phase 內看註冊順序、proceed() 前後包夾形成巢狀結構,Relix 留下的排序問題,Ktor 用「plugin 宣告語意位置」把它縮小到同 phase 內才需要考慮


下一篇

這篇用的 intercept 是底層 API,直接對著 pipeline 動手,適合做實驗看機制,但 Ktor 3 給 plugin 作者的是更高階的 createApplicationPlugin,就是 IgnoreTrailingSlash 原始碼裡那個,用 onCall 這類鉤子描述行為,不用自己面對 phase

下一篇就用它來寫一個自己的 plugin,把這篇看懂的機制變成親手做得出來的東西


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Ktor 實戰 101 Day 08 Type-safe Routing 與 Resources
下一篇
Kotlin Ktor 實戰 101 Day 10 自訂 Plugin
系列文
Kotlin Ktor 實戰 101 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言