iT邦幫忙

2026 iThome 鐵人賽

0
Software Development

Kotlin Ktor 實戰 101系列 第 35 篇

Kotlin Ktor 實戰 101 Day 35 檔案怎麼放,把 todo-api 從一個 package 搬成五個

  • 分享至 

  • xImage
  •  

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

這篇不加東西,處理一件 34 天都沒有正面講的事,主程式所有的 .kt 檔全部平放在 com.cashwu.todo 這一個 package 裡,Application.kt 199 行

不是沒有講過檔案該怎麼分,day 07 把路由搬進 TodoRoutes.kt,day 14 跟 day 15 各開一個檔案放驗證規則跟錯誤映射,3 篇講的是同一件事,day 14 那句「Application.kt 是組裝的地方,不是規則住的地方」

但那 3 篇講的都是「這段程式碼要不要離開 Application.kt」,沒有一篇講離開之後要去哪裡,所有的檔案平放在同一個 package 裡,那個檔可以碰那個檔,沒有任何東西擋著

官方文件有一頁叫 Application structure,專門講這件事,這篇拿那頁的內容來比對一次

這篇要完成什麼

  • 讀官方那頁 Application structure,5 種 pattern 跟那張選擇表
  • 對出 todo-api 是表上的哪一格,以及為什麼 layered 那棵樹套不上去
  • 第 1 步分 package,量補了幾行 import、package 之間誰 import 誰、第 1 次編譯掛在哪
  • 列出編譯器看不到的 5 處字串引用,解釋為什麼一個都沒壞
  • 第 2 步把 module() 拆成 4 個,Application.kt 從 199 行變 19 行,順便抓出一個放錯檔案的函式
  • 第 3 步把 4 個 module 直接列進 application.yaml,順序寫錯 3 種寫法各炸在哪一層
  • startup: concurrent 解了哪一半、沒解哪一半
  • 最後怎麼選,以及代價
  • 跟 Relix 的對照
  • 這個拆法沒有對錯,以及還沒做的事

官方那一頁怎麼說

Application structure 開頭先講 Ktor Project Generator 產出來的東西,單一 module、單一 Application.kt,原文說這個骨架 "minimal and intended to get you up and running",接著補一句 "does not scale well as the project grows",這個系列從 day 02 開始就是那個骨架,34 篇之後 Application.kt 就是它說的那個「grows」

然後是一張選擇表,依應用的大小給建議

應用規模 建議
Small services few modules with simple dependency injection
Medium applications feature-based structure grouping routes, services, and data models
Large/domain-heavy systems domain-driven approach
Microservices hybrid styles where each service represents a domain slice

表底下有一句要先看到,這些 pattern "not mutually exclusive",不是選了一種就不能借另一種的東西,等一下 todo-api 就會借

5 種 pattern 各附一棵目錄樹,這篇只貼 2 棵,Layered 是照技術角色分

config/
plugins/
controller/
service/
repository/
domain/
dto/

Feature-based 是照功能分,每個功能自己一個資料夾,路由、service、DTO 都在裡面

app/
├─ customer/
│  ├─ CustomerRoutes.kt
│  ├─ CustomerService.kt
│  └─ CustomerDto.kt
└─ order/
   └─ ...

另外 3 種用講的就好,Modular 是每個功能一個 fun Application.customerModule() { //… },再配 Gradle 多模組,官方的樹是 db/core、db/postgres、db/mongo、server/core、server/admin、server/banking 那種 (這篇不會碰)

DDD 是 domain/customer/ 底下放 Customer、CustomerService、CustomerRepository 3 個檔,server/ 底下放 Authentication、Customers、Orders

Microservice 是每個 service 自己一整套 domain/ repository/ service/ dto/ controller/ plugins/ Application.kt

那頁還提到一種部署方式叫 modulith,application.yaml 的 ktor.application.modules 底下列多個 module 一起載入,第 3 步會拿它做實驗

todo-api 是表上的哪一格

todo-api 只有一個 feature,todo,其他全部是橫切的東西,auth、設定、錯誤處理、logging、健康檢查、OpenAPI,每一個都是所有功能都要用的東西,自己不算一個功能,對到表上就是 medium 那一格,feature-based

先試過 layered 那棵樹,套不太上去,todo-api 沒有 service 這層,day 19 之後路由直接叫 TodoRepository,中間沒有任何東西,硬照那棵樹拆,service/ 跟 controller/ 2 個資料夾會是空的,或者為了填它們去發明一層本來不存在的東西,這個系列到 day 34 為止沒有一篇需要 service 層,這篇也不打算為了目錄樹長一個出來

所以是 feature-based,但橫切的東西 feature-based 那棵樹沒有給位置,這時候「not mutually exclusive」那句就派上用場,跟 layered 借 config/ 跟 plugins/ 2 個名字,auth 要不要算 feature 猶豫了一下,它有自己的路由 (/login、/refresh、/me、登入頁)、有自己的 provide、有自己的 plugin,行為上跟 todo 一樣是一整組,所以自己一個 package,health 也一樣,雖然只有一個檔

第一步,分 package

目標長這樣,Application.kt 留在 root,其他 20 個檔分進 5 個 package

com/cashwu/todo/
├─ Application.kt
├─ config/   TodoConfig.kt
├─ plugins/  ErrorHandling.kt Logging.kt RequestTiming.kt OpenApi.kt
├─ auth/     Auth.kt Authorization.kt
├─ health/   Health.kt
└─ todo/     Todo.kt TodoRepository.kt ExposedTodoRepository.kt TodoDatabase.kt
             TodoRoutes.kt TodoValidation.kt TodoEvents.kt TodoSocket.kt TodoHtml.kt
             TodoImporter.kt InstantSerializer.kt

這一步只搬檔案跟改 package 行,module() 一個字不動,先確認「分 package」這件事本身沒弄壞什麼,再去動 module

搬法是寫一支 script,每個檔改 package 行,然後掃它用到哪些 top-level 名字、那些名字現在住在哪個 package,補一行 import com.cashwu.todo.<pkg>.*,用星號 import 是為了少改一點,這一步要看的是結構,不是 import 清單好不好看

測試 34 個檔留在原本的 com.cashwu.todo 不搬

package 之間誰 import 誰

script 補 import 的時候順便把方向記下來,main 這邊是這樣

config  ← plugins, auth, todo, root
plugins ← auth, todo, root
auth    ← todo, root
health  ← root
todo    ← root

箭頭左邊是被 import 的那個

  • config 誰都要
  • plugins 被 auth 跟 todo 用 (ApiException 在那裡),plugins 不需要 auth 或 todo 的任何東西
  • auth 被 todo 用 (authenticate(TODO_AUTH) 那些常數),auth 不需要 todo 的任何東西
  • health 跟 todo 只有根 package 的 Application.kt 會碰,沒有循環

這張表在平放的時候畫不出來,相依一直都在,只是同一個 package 裡什麼都看得到,方向沒有地方顯示,分了 package 之後它變成一行一行的 import,才有東西可以看

第一次編譯只掛一個地方

e: .../todo/TodoHtml.kt:6:24 Unresolved reference 'Todos'.

TodoHtml.kt 內文那幾個 id 都是 kotlinx.html.id 那個 HTML 屬性,這行其實直接刪掉就好,Todos 搬去 todo 之後全名變了,而 script 補的是 import com.cashwu.todo.todo.*,星號 import 帶進來的是 todo package 的 top-level 名字,Todos 這個 object 在裡面,但 Todos.id 這種 object 成員不在星號的範圍裡,那行 import 還是指向舊的全名,改成 import com.cashwu.todo.todo.Todos.id 就過了

測試那邊 ScratchTest.kt 有一個 import com.cashwu.todo.Todos.title 是同一種問題,同樣改法,就這 2 處,其他全部靠星號 import 過

跑 ./gradlew test,全部通過 0 失敗

編譯器看不到的字串

搬 package 的時候編譯錯誤反而不用怕,它會擋在那裡給你看,怕的是那些寫在字串裡的 package 名,編譯器不會檢查,要跑到那一行才知道

搬之前先把它們找出來,平放版一共 5 處

  • application.yaml 的 - com.cashwu.todo.ApplicationKt.module
  • build.gradle.kts 的 mainClass.set("com.cashwu.todo.ApplicationKt"),day 33 打包用的
  • Authorization.kt 的 KtorSimpleLogger("com.cashwu.todo.authorization")
  • TransactionTest.kt 的 CREATE ALIAS IF NOT EXISTS SLEEP FOR "com.cashwu.todo.SlowSql.sleep"
  • TestApplicationTest.kt 那個 listOf("com.cashwu.todo.ApplicationKt.module"),day 24 對 ktor.application.modules 的斷言

另外 StatusPagesTest.kt 有一個 assertFalse(response.bodyAsText().contains("com.cashwu.todo")),它驗的是錯誤回應不能把 package 名漏出去,比對的是前綴,搬完還是同一個前綴,不算在內

這次 5 處一個都沒壞,理由各不一樣,前 2 處跟第 5 處指的是 ApplicationKt,Application.kt 留在根 package 所以全名沒變,這是把它留在根的原因之一,第 3 處的 logger 名字本來就是自訂字串,跟 Authorization.kt 住哪裡無關,搬去 auth 它還是叫 com.cashwu.todo.authorization,第 4 處比較有意思

SlowSql.kt 沒有人在用,搬家才發現

SlowSql.kt 16 行,在 main 裡沒有任何人用它,TransactionTest.kt 自己有一份同名的 object SlowSql,H2 那句 CREATE ALIAS ... FOR "com.cashwu.todo.SlowSql.sleep" 指的是測試那一份,main 那份是一個沒有人碰的檔案

平放的時候看不出來,21 個檔排在一起,每個看起來都在用,搬家時每個檔都要決定去哪一個 package,輪到它的時候答案是「沒有人 import 它,也沒有人在同 package 裡直接叫它」,直接刪掉,分 package 的副作用之一,每個檔都要回答一次「你是誰的」,答不出來的就是卡在那裡

第二步,把 module 拆成四個

package 分完,要來處理 Application.kt 了,因為 module() 裡面 install 跟 provide 都還在

第 2 步要把它拆成 4 個 module function,除了 config 只有幾個 data class 之外,其他 4 個 package 各一個

package com.cashwu.todo

import com.cashwu.todo.auth.authModule
import com.cashwu.todo.health.healthModule
import com.cashwu.todo.plugins.pluginsModule
import com.cashwu.todo.todo.todoModule
import io.ktor.server.application.*
import io.ktor.server.netty.*

fun main(args: Array<String>) {
    EngineMain.main(args)
}

fun Application.module() {
    pluginsModule()
    authModule()
    healthModule()
    todoModule()
}

module() 還在,名字跟簽名都沒變,這件事很重要,等一下講測試的時候會回來

拆的原則只有一條,dependencies { provide<...> }、install(...)、routing { } 3 種東西都跟著它服務的 feature 走,照種類分的話會變成「所有 install 放一個檔、所有 provide 放一個檔」,那樣只是把原本的程式碼切成 3 個份而已,相依方向一樣看不到

todoModule

新增一個檔案 src/main/kotlin/com/cashwu/todo/todo/TodoModule.kt

fun Application.todoModule() {
    dependencies {
        provide<DataSource> { todoDataSource(resolve<TodoConfig>().database) }
        provide<Database> { resolve<DataSource>().connectAndMigrate() }
        provide<TodoRepository> { ExposedTodoRepository(resolve(), resolve()) }
        provide<TodoEvents> { TodoEvents() }
        provide<HttpClient> { todoImportClient(CIO.create(), resolve<TodoConfig>().importer) }
        provide<TodoImporter> { TodoImporter(resolve(), resolve<TodoConfig>().importer, resolve()) }
    }
    val repository: TodoRepository by dependencies
    val events: TodoEvents by dependencies
    val importer: TodoImporter by dependencies

    install(RequestValidation) {
        todoValidation()
    }
    install(WebSockets) {
        webSocketOptions()()
    }

    routing {
        authenticate(TODO_AUTH) {
            todoEventsRoute(events)
            todoImportRoute(importer, events)
        }
        authenticate(SESSION_AUTH, TODO_AUTH) {
            todoRoutes(repository, events)
            todoUiPage(repository)
        }
    }
}

2 個 install 為什麼在這裡而不是 plugins,要講一下

install(RequestValidation) { todoValidation() } 原本在 Application.kt,看起來是個「plugin」,該去 plugins/,但 todoValidation() 裡的規則綁的是 CreateTodoRequest 跟 UpdateTodoRequest,這 2 個型別住在 todo,放去 plugins 就會讓 plugins 反過來 import todo,第 1 步那張表裡 plugins ← todo 的箭頭會多一條反向的,RequestValidation 這個 plugin 是通用的,但裝上去的規則是 todo 的,跟著規則走

install(WebSockets) 同一個道理,全專案只有 /todos/events 一條路由用它,day 30 加的,它是 todo 的東西,不是每個 feature 都要用的橫切能力,放 plugins 只會讓人以為別的地方也在用

資料庫的 provide 也在這裡,DataSource、Database、TodoRepository 3 個 provide 是 day 20 之後搭起來的,目前只有 todo 用資料庫,所以住 todo,哪天 auth 的使用者名單從 application.yaml 搬進資料庫,這 3 個 provide 才有理由往上搬去 plugins,現在沒有那個需求

authModule

新增一個檔案 src/main/kotlin/com/cashwu/todo/auth/AuthModule.kt

fun Application.authModule() {
    dependencies {
        provide<UserDirectory> { UserDirectory(resolve<TodoConfig>().auth.users) }
        provide<TokenIssuer> { TokenIssuer(resolve<TodoConfig>().auth.jwt, resolve()) }
    }
    val config: TodoConfig by dependencies
    val directory: UserDirectory by dependencies
    val issuer: TokenIssuer by dependencies

    install(Sessions) {
        cookie<TodoSession>(SESSION_COOKIE) {
            cookie.path = "/"
            cookie.httpOnly = true
            cookie.extensions["SameSite"] = "Lax"
            transform(
                SessionTransportTransformerMessageAuthentication(
                    config.auth.jwt.secret.toByteArray()
                )
            )
        }
    }
    install(Authentication) {
        todoAuth(config.auth, issuer)
        todoSessionAuth()
    }

    routing {
        tokenRoutes(directory, issuer)
        loginPageRoutes(directory)
        authenticate(TODO_AUTH) {
            meRoute()
        }
    }
}

Sessions 跟 Authentication 2 個 plugin 在這裡,它們是 day 26 跟 day 32 裝的,服務的對象是「誰可以進來」,是 auth 自己的事,TODO_AUTH、SESSION_AUTH 這些 provider 名字也住在 auth,todoModule 的 authenticate(TODO_AUTH) 是從這裡 import 過去的

loginPageRoutes 放錯檔案,是搬家逼出來的

loginPageRoutes(directory) 那一行它是 day 32 寫的,寫的時候順手放進 TodoHtml.kt,理由大概是「反正都是 HTML 頁面」,第 1 步分 package 的時候它跟著 TodoHtml.kt 進了 todo,編譯過、測試過,沒有任何東西抱怨

第 2 步拆 module 才擋住,它用的是 UserDirectory、TodoSession、UI_PATH,3 個全部是 auth 的東西,跟 todo 一點關係都沒有,留在 todo 有 2 個後果,一是 todoModule 要為了它多 resolve 一個 UserDirectory,todo 本來不需要知道使用者名單長什麼樣,二是 authModule 要呼叫它就得 import todo,變成 auth → todo,跟本來就有的 todo → auth 湊成循環,第 1 步那張沒有循環的表就破了

搬去新開的 src/main/kotlin/com/cashwu/todo/auth/LoginPage.kt,內容一字不改,只是換了 package,這件事在平放的時候永遠不會被發現,TodoHtml.kt 跟 Auth.kt 在同一個 package,誰叫誰都合法,分了 package 之後它變成一條反向的 import,拆 module 之後它變成一個多餘的 resolve,2 次都是結構逼它現形的

pluginsModule 跟 healthModule

src/main/kotlin/com/cashwu/todo/plugins/PluginsModule.kt 這裡只貼開頭跟結尾

@OptIn(ExperimentalKtorApi::class)
fun Application.pluginsModule() {
    dependencies {
        provide<TodoConfig> { this@pluginsModule.property("todo") }
        provide<Clock> { Clock.systemUTC() }
    }
    val config: TodoConfig by dependencies

    install(CallId) {
        todoCallId(config.requestIdHeader)
    }
    // CallLogging、RequestTiming、ContentNegotiation、StatusPages 也在這裡

    routing {
        get("/") {
            call.respondText("Hello, Ktor!")
        }
        docsRoutes(config.openapi)
        get("/boom") { throw RuntimeException("資料庫密碼是 hunter2") }.hide()
    }
}

TodoConfig 跟 Clock 的 provide 放這裡,因為它們是最先跑、大家都要的東西,StatusPages、ContentNegotiation、CallId、CallLogging、RequestTiming 5 個 plugin 都是橫切的,每一條路由都會經過,放 plugins 沒有爭議,/boom 那條 day 15 的實驗路由跟著過來,它本來就跟 StatusPages 放在一起

src/main/kotlin/com/cashwu/todo/health/HealthModule.kt,整個檔就是一個 routing { healthRoutes() },沒有 provide 也沒有 install,它會不會太小到不需要自己一個 module ? 會,但「有路由的 package 就有自己的 module」這條規則比「health 例外併進 plugins」好講,而且第 3 步要拿它跟另外 3 個一起排順序

測試一行沒改

拆完再跑 ./gradlew test,一樣全部通過 0 失敗,而測試那 34 個檔一行都沒改

這件事靠的是 module() 還在,TestApp.kt 的 todoApplication() helper,day 18 到 day 24 一路長出來的那個,呼叫的是 module(),它不知道也不需要知道 module() 裡面現在是 4 行呼叫還是 199 行程式碼,如果第 2 步把 module() 拿掉、改成讓測試各自呼叫 4 個 module,那 34 個檔都要改,保留一個入口,讓拆分是入口內部的事,這是重構的老規矩

第三步,四個 module 直接列進 yaml

官方那頁講的 modulith 是這樣,application.yaml 的 ktor.application.modules 底下不列 module(),直接列 4 個 module function,Ktor 依清單順序一個一個載入,todo-api 現在有 4 個 module function 了,試一次

ktor:
  application:
    modules:
      - com.cashwu.todo.plugins.PluginsModuleKt.pluginsModule
      - com.cashwu.todo.auth.AuthModuleKt.authModule
      - com.cashwu.todo.health.HealthModuleKt.healthModule
      - com.cashwu.todo.todo.TodoModuleKt.todoModule

./gradlew run 起 server,資料庫用環境變數指到 H2,DB_URL='jdbc:h2:mem:todo;DB_CLOSE_DELAY=-1'、DB_DRIVER=org.h2.Driver、DB_USER=sa、DB_PASSWORD='',再給 JWT_SECRET、ALICE_PASSWORD、BOB_PASSWORD,PORT=8099

13:51:02.067 INFO  [no-call-id] Application -- Application started in 0.736 seconds.
13:51:02.147 INFO  [no-call-id] Application -- Responding at http://0.0.0.0:8099

curl -i http://localhost:8099/health/live 第 1 行 HTTP/1.1 200 OK,GET /todos 沒帶 token 回 401,起得來,行為跟走 module() 一樣

但 ./gradlew test 有 1 個失敗,就是 day 24 那個 configure merges the yaml underneath the overrides

expected: <[com.cashwu.todo.ApplicationKt.module]> but was: <[com.cashwu.todo.plugins.PluginsModuleKt.pluginsModule, com.cashwu.todo.auth.AuthModuleKt.authModule, com.cashwu.todo.health.HealthModuleKt.healthModule, com.cashwu.todo.todo.TodoModuleKt.todoModule]>

那個測試斷言 yaml 裡的 module 清單是什麼,清單換了它就失敗,這是前面〈編譯器看不到的字串〉那 5 處的第 5 處,第 1 步跟第 2 步都沒碰到它,第 3 步一改 yaml 就碰到了,它失敗是對的,它驗的就是這個

順序寫錯 1,todo 排在 auth 前面

yaml 的順序改成 plugins、health、todo、auth,loginPageRoutes 還在 todo 的那個版本

Exception in thread "main" io.ktor.server.plugins.di.MissingDependencyException: Could not resolve dependency for `com.cashwu.todo.auth.UserDirectory`

先炸的是 DI,跟 day 18〈少一個相依會怎樣〉那節看到的是同一個例外,todoModule 裡的 val directory: UserDirectory by dependencies 一被讀到就要解析,而 authModule 排在後面,provide<UserDirectory> 還沒跑到

把 loginPageRoutes 搬去 auth 之後,todoModule 不再碰 UserDirectory,同一個順序再跑一次

Exception in thread "main" io.ktor.server.application.MissingApplicationPluginException: Application plugin AuthenticationHolder is not installed

換成 plugin 那一層炸,todoModule 的 authenticate(TODO_AUTH) 跑的時候,install(Authentication) 還沒發生,這跟 day 26 把 install(Authentication) 放在 routing { } 後面看到的是同一個例外、同一個字串,那篇的結論是「這 2 種錯是啟動失敗,server 根本起不來」,換成 module 順序寫錯,結論一樣

2 次炸的層不一樣,同一個順序錯誤,DI 那層先攔到就不會走到 plugin 那層,把 loginPageRoutes 搬走等於把 DI 這道攔截拿掉,錯誤才往下掉到 plugin,順序錯誤不只一種症狀,看到哪一種取決於前面哪一層先碰到

順序寫錯 2,auth 排在 plugins 前面

yaml 改成 auth、plugins、health、todo

Exception in thread "main" io.ktor.server.plugins.di.MissingDependencyException: Could not resolve dependency for `com.cashwu.todo.config.TodoConfig`

authModule 第 1 行就要 resolve<TodoConfig>(),而 TodoConfig 的 provide 在 pluginsModule,這個沒有第 2 層,TodoConfig 是所有 module 的第 1 個相依,過不了它什麼都不會發生

module 參數注入也一樣

Ktor 3.2 起 module function 可以直接宣告參數,由 DI 注入,What's new in 3.2.0 加在 module 上的 3 件事之一,把 authModule 改成 fun Application.authModule(config: TodoConfig),順序對的時候起得來,200 跟 401 都跟上面一樣,順序錯的話 (auth 在 plugins 前)

Exception in thread "main" java.lang.IllegalArgumentException: Failed to inject parameter `config: com.cashwu.todo.config.TodoConfig` in module function `fun io.ktor.server.application.Application.authModule(com.cashwu.todo.config.TodoConfig): kotlin.Unit`
Caused by: io.ktor.server.plugins.di.MissingDependencyException: Could not resolve dependency for `com.cashwu.todo.config.TodoConfig`

外面多包了一層 IllegalArgumentException,訊息比較好讀,知道是哪個 module 的哪個參數,但 Caused by 是同一個 MissingDependencyException,參數注入只是把 by dependencies 搬到簽名上,順序的問題一樣

startup: concurrent

Modules 那頁講 ktor.application.startup 預設 sequential,可以設 concurrent,原文 "You can also launch all application modules independently, so when one is suspended, the others are not blocked.",聽起來像是解順序問題的東西,嘗試看看

第 1 次寫錯地方,把 startup: concurrent 放在 ktor: 底下,跟 deployment: 同一層,結果跟 sequential 一模一樣,同樣的例外、同樣的訊息,看 stack trace 才發現不對,裡面還是 ApplicationModules_jvmKt$LoadSequentially$1.loadModules,LoadSequentially 這個名字說明設定根本沒生效,key 放錯層 Ktor 不會警告,只是當作沒設,正確的位置是 ktor.application.startup

ktor:
  application:
    modules:
      - ...
    startup: concurrent

放對之後,順序錯 (auth 在 plugins 前),authModule 改成 suspend fun Application.authModule(),第 1 行改成 val config = dependencies.resolve<TodoConfig>(),DI 這一層不炸了,resolve() 真的等到 pluginsModule 把 TodoConfig 放進來,官方 Dependency resolution 那頁講的 "Direct resolution returns the dependency immediately or suspends until it becomes available" 就是這個,但接著炸的是

Exception in thread "main" io.ktor.server.application.MissingApplicationPluginException: Application plugin AuthenticationHolder is not installed

todoModule 的 authenticate(TODO_AUTH) 不會等 authModule 的 install(Authentication),這 2 個 API 都沒有等別人的能力,參數注入那個版本也一樣,主例外是 AuthenticationHolder is not installed,Failed to inject parameter 變成 Suppressed 的附在後面

所以 concurrent 解了一半,DI 的順序它解了,因為 resolve 會 suspend,plugin 安裝的順序它沒解,因為 install 不會

順序寫對加 concurrent 連跑 3 次都起得來,但這是 3 次沒撞到,不是保證,官方那句 "launch all application modules independently" 的意思是 4 個 module 各自獨立啟動,todoModule 的 authenticate 跟 authModule 的 install(Authentication) 誰先誰後沒有東西規定,3 次剛好 auth 先,第 4 次不一定

最後怎麼選

yaml 改回只列 com.cashwu.todo.ApplicationKt.module,順序寫在 Application.module() 那 4 行裡,startup 維持預設的 sequential

理由有 3 個,順序只寫在一個地方,而且是 Kotlin,改順序要動的是程式碼,會進 code review、會被編譯,測試的 todoApplication() helper 走的是同一個 module(),測試看到的順序跟正式環境一樣,不會出現「yaml 排一種、測試排另一種」,day 24 那個斷言不用改

代價是官方 modulith 那種「yaml 換清單就換組合」的彈性沒有拿到,那種彈性是給「同一份 jar 在不同環境載不同的 module」用的,todo-api 目前沒有這個需求,4 個 module 永遠一起載,那就讓它們永遠一起載,寫死在 module() 裡

concurrent 也沒有開,它省下來的是啟動時間,todo-api 起來不到 1 秒,省不了什麼,換來的是 plugin 安裝順序變成 race,不划算

小結

官方 Application structure 那頁的表把 todo-api 放在 medium 那一格,所以這篇選了 feature-based,橫切的東西跟 layered 借 config/ 跟 plugins/ 2 個名字,分完 package 之後 package 之間的方向沒有循環,5 處寫在字串裡的 package 名一個都沒壞,module() 拆成 4 個之後 Application.kt 變的很簡潔,測試一行沒改,拆的過程逼出 2 件平放時看不到的事,loginPageRoutes 放錯檔案,SlowSql.kt 沒有人在用

4 個 module 列進 yaml 的實驗證明順序是要管理的東西,寫錯會依前面哪一層先碰到而炸在 DI 或 plugin,startup: concurrent 只解 DI 那一半,最後 yaml 改回只列 module(),順序寫在 Kotlin 裡

這個拆法沒有對錯,todo-api 只有一個 feature,橫切的東西比 feature 還多,feature-based 加 2 個借來的資料夾剛好夠用,換一個專案可能是 layered 比較順,feature 多到一定程度可能就該考慮官方那棵 Gradle 多模組的樹,下次開專案我不一定會照這篇的方式,要看那時候的需求跟專案長成什麼樣子,官方那張表也是這個意思,它給的是幾種可以組合的選項,不是一個標準答案

還沒做的事

  • 測試那 34 個檔沒有跟著分 package,補的 import 也全是星號,真的要搬的話該換成具名 import
  • 多模組 Gradle 跟 modulith 那種 yaml 換清單就換組合的做法都沒碰,todo-api 目前沒有這個需求
  • concurrent 那 3 次成功只是沒撞到,沒有寫一個會穩定重現 race 的測試

下一篇

todo-api 專案到這裡真的算是完成一個段落了,下一篇是整個系列回顧


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Ktor 實戰 101 Day 34 真的起一台 server 的 end to end 測試
系列文
Kotlin Ktor 實戰 101 共 35 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言