
day 02 寫下這行程式的時候,我們把它當成固定寫法帶過
embeddedServer(Netty, port = 8080, module = Application::module).start(wait = true)
專案可以跑、測試也建好了,這篇回頭把這行拆開來看,它裡面其實有 3 個角色,Application 是框架本體、module 是組裝它的步驟、Netty 是負責網路的 engine,最後我們會用一個實驗證明這個分工是真的,把 engine 從 Netty 整顆換成 CIO,module() 一行都不用改
embeddedServer 那行裡的 3 個角色,Application、module 與 engineserver.core 和 server.netty 為什麼拆成 2 個模組module() 和既有測試都不用動先講一下測試的部分,這篇沒有新增測試,既有的 3 個測試只驗證 Application 行為,testApplication 不會啟動 CIO,CIO 能不能真的監聽 port,會另外用 ./gradlew run 和 curl 確認,兩邊合起來,才足以說明換掉 engine 後 application 行為不變
Application 定義在 server.core 模組裡,也就是 day 02 加的 ktorLibs.server.core 那個相依,整個系列會做的事幾乎都掛在它身上,day 02 註冊的 routing 是掛在它身上,之後每篇要裝的 plugin 也是裝在它身上
要注意的是它的範圍,Application 不負責監聽 port,也不負責解析網路上的 bytes,它只管「拿到一個請求之後,該怎麼處理、怎麼回應」,網路那一段是別人的事
再看 day 02 寫在 Application.kt 裡的另一半
fun Application.module() {
routing {
get("/") {
call.respondText("Hello, Ktor!")
}
}
}
module 是 Application 的 extension function,extension function 可以在不改類別本身的情況下幫它加方法,函式裡的 this 就是那個 Application 實體,所以 routing { } 實際上是 this.routing { },路由是掛在這個 application 上的
module 這個名字不是關鍵字,改叫別的也能跑,它是官方慣例的命名,這個系列照著用,它的工作就一件,把這個 application 該有的東西裝上去,現在只有 routing,之後 plugin、設定都會進到這裡
然後是 module = Application::module 這段,:: 是函式參考 (function reference),傳給 embeddedServer 的不是「執行 module() 的結果」,而是 module 這個函式本身,你呼叫 start(wait = true) 之後,Ktor 先把 application 建立好,拿著這個函式對它執行一次,engine 才開始監聽 port,組裝發生在啟動流程裡,不是發生在你寫那行程式的當下,這也解釋了 day 03 測試裡 application { module() } 那段在做什麼,測試是自己建了一個 application,再把同一個組裝步驟套上去,所以測試走到的路由跟正式跑起來的是同一條
Netty 定義在 server.netty 模組裡,engine 的工作全部在 application 之外,監聽 port、接受連線、把網路上的 HTTP bytes 解析成請求物件、交給 application 處理,再把 application 給的回應寫回網路上
client
│ HTTP bytes
▼
┌────────────────────────┐
│ Engine (server.netty) │ 監聽 port、解析 HTTP、寫回回應
└───────────┬────────────┘
│ 請求物件
▼
┌────────────────────────┐
│ Application │ routing、plugin,決定怎麼回應
│ (server.core) │
└────────────────────────┘
所以 day 02 加 Ktor 相依時是 2 行,不是套件切得比較碎而已,server.core 和 server.netty 這條模組邊界,正好就是「框架處理請求」和「網路傳輸」的邊界,embeddedServer 的第 1 個參數吃的是 engine factory,Netty 就是那個 factory 物件,既然是參數,理論上就可以換,接下來實際換一次
CIO 是 Ktor 另一個官方 engine,細節後面「Engine 怎麼選」那節再說,這裡先拿它當實驗對象,如果前面講的邊界是真的,engine 換掉之後 application 那層應該一動都不用動
第 1 步,在 build.gradle.kts 的 dependencies 區塊加一行
implementation(ktorLibs.server.cio)
第 2 步,Application.kt 只改 2 處,import 那行從 io.ktor.server.netty.Netty 換成 io.ktor.server.cio.CIO,main 裡的第 1 個參數從 Netty 換成 CIO
import io.ktor.server.cio.CIO
fun main() {
embeddedServer(CIO, port = 8080, module = Application::module).start(wait = true)
}
module() 完全不動,一個字都沒改。跑起來
./gradlew run
我實測的啟動 log 是
> Task :run
16:42:09.705 [main] INFO io.ktor.server.Application -- Autoreload is disabled because the development mode is off.
16:42:09.726 [main] INFO io.ktor.server.Application -- Application started in 0.128 seconds.
16:42:09.740 [DefaultDispatcher-worker-2] INFO io.ktor.server.Application -- Responding at http://0.0.0.0:8080
打個請求
curl http://localhost:8080/
Hello, Ktor!
回應跟 Netty 版一模一樣,log 第 1 行的 Autoreload is disabled 這句也順便說一下,Ktor 有一個 development mode,開了之後程式碼變動可以自動重新載入,目前沒開所以它提醒了一句
換完 engine 直接跑測試,測試檔完全不動
./gradlew test
> Task :test
ApplicationTest > root path responds hello() PASSED
ApplicationTest > unknown path responds not found() PASSED
EnvironmentTest > Ktor EmbeddedServer class is available() PASSED
BUILD SUCCESSFUL in 1s
4 actionable tasks: 3 executed, 1 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.7.1/userguide/configuration_cache_enabling.html
3 個測試全部通過,這不意外,day 03 講過 testApplication 不啟動真的 engine、不綁 port,它測的是 Application 這一層,engine 換掉對它沒有影響,這正好從另一個方向驗證了 core 和 engine 的邊界,測試站在邊界的 application 這一側,另一側的 engine 怎麼換都碰不到它
這個系列的基線是 Netty,實驗確認完就還原,import 和 embeddedServer 的第 1 個參數改回 Netty,build.gradle.kts 那行 ktorLibs.server.cio 也拿掉,再跑一次 ./gradlew test 確認 3 個測試照樣通過,專案就回到 day 03 結束時的狀態
Ktor 官方提供好幾個 engine 實作,常見的有這些
這個系列用 Netty,理由很實際,遇到問題時查得到的資料最多,不過經過這篇的實驗你也知道了,這個決定不是單行道,哪天要換,module() 裡累積的所有東西都跟著你走,要動的就是 build.gradle.kts 加一行對應的 engine 相依,再把 Application.kt 的 import 和 embeddedServer 第 1 個參數改掉,就這樣而已
CIO 但忘了加相依
embeddedServer(CIO, ...) 改了,build.gradle.kts 的 ktorLibs.server.cio 沒加,import io.ktor.server.cio.CIO 會直接編譯失敗,unresolved reference
Relix 在 day 06 做過同一條邊界的拆分,那篇把原本混在一起的 RelixApplication 拆成兩半,拆完之後它變成「一個不知道 HTTP server 存在的東西」,連 import com.sun.net.httpserver 都沒有,監聽 port、把 HttpExchange 轉成框架的請求物件,把回應寫回去,這些全部搬進 JdkHttpServerAdapter,main 也變成 2 行,一行組裝 application,一行把它插上 server,當時分開最主要的理由是可測性,RelixApplication 從 server 上拆下來之後才測得動,TestKit 才有得寫
Ktor 把同一條邊界直接做成產品層級的抽象,core 和 engine 拆成 2 個獨立模組,engine 是傳進 embeddedServer 的 factory 參數,而且還多做了一件 Relix 沒做的事,官方維護多個 engine 實作讓你選,Relix 的 adapter 只有 JDK HttpServer 1 個,邊界切出來主要是給測試用的,Ktor 的這條邊界則是兩頭都兌現了,day 03 看到的是可測,這篇看到的是可換,同一條邊界,2 個好處
./gradlew run 和 curl 確認 CIO 能真的啟動與回應,3 個 testApplication 測試則確認 Application 的行為沒變,2 種驗證各管一邊,module() 一行不動,換 engine 的成本就只有 build.gradle.kts 加一行相依,加上 Application.kt 的 import 和 embeddedServer 第 1 個參數,最後專案換回 Netty
下一篇拆 day 02 帶過的另一個部分,routing { get("/") { ... } } 這段 DSL,路由怎麼註冊、path 和 method 怎麼對應 handler、這種寫法為什麼能成立,todo-api 的第 1 個端點也會在那篇開出來
同步刊登於 Blog
圖片來源:AI 產生