
day 19 結尾寫的是「下一篇把 Exposed 裝進來,建立 table 定義,接上資料庫連線,然後處理連線這種 AutoCloseable 資源要怎麼交給 DI 管」,這 3 件事會在這篇做完,順序是先用 Table DSL 把 todos 定義出來,再把連線參數從 application.yaml 一路接到連線池,最後把連線池交給容器,連收尾一起交出去
object Todos : Table() 的欄位定義,跟 Exposed 產生出來的 DDLapplication.yaml 一路走到連線池,day 19 欠的那筆createdAt 存進去變成本地時間,跨時區實測差 8 小時varchar(100) 到底在數什麼,3 層長度規則各算各的build.gradle.kts 的 dependencies { } 加 5 行
implementation("org.jetbrains.exposed:exposed-core:1.5.0")
implementation("org.jetbrains.exposed:exposed-jdbc:1.5.0")
implementation("org.jetbrains.exposed:exposed-java-time:1.5.0")
implementation("com.zaxxer:HikariCP:7.1.0")
implementation("com.h2database:h2:2.4.240")
exposed-core 是 DSL 本體,exposed-jdbc 是走 JDBC 的執行端 (另外還有一個 exposed-r2dbc,那是真正非阻塞的那條路,這個系列不用它),exposed-java-time 讓 java.time 的型別可以直接當欄位型別,Todo 的 createdAt 從 day 13 起就是 java.time.Instant,所以選 java-time 這個模組,另一個 exposed-kotlin-datetime 對應的是 kotlinx-datetime
版本這裡要看清楚,Exposed 1.0 是個分水嶺,package 從 org.jetbrains.exposed.sql 整批換成 org.jetbrains.exposed.v1.core 跟 org.jetbrains.exposed.v1.jdbc,網路上大部分教學都還停在 0.x,import 直接照抄會找不到類別。這篇用的是 1.5.0,Database、SchemaUtils 在 org.jetbrains.exposed.v1.jdbc,transaction 再往下一層在 org.jetbrains.exposed.v1.jdbc.transactions,Table、ResultRow 在 org.jetbrains.exposed.v1.core 底下
還有 2 個 0.x 的習慣在 1.x 會撞牆,addLogger 從 top-level 函式變成 Transaction 的成員函式,寫 import org.jetbrains.exposed.v1.core.addLogger 會編譯失敗,把 import 拿掉直接呼叫反而通,SqlExpressionBuilder.eq 這種寫法標了 deprecation level ERROR,1.x 要改用同名的 top-level 函式
這篇用的資料庫是 H2,2.4.240 是目前 Maven Central 上的最新版,HikariCP 7.1.0 編譯目標是 Java 11,跑在這個專案的 JDK 21 上沒有問題
H2 跟 HikariCP 為什麼是這樣選,2024 年那次鐵人賽的 Spring Boot 系列寫過,可以直接參考,這裡只講結論,H2 的好處是不用另外裝一個資料庫,jdbc:h2:mem:todo 一行就有一個可以用的資料庫,開發跟測試都不必先開 server,代價是 process 停掉資料就沒了,Day 17 Spring Boot 與 H2 資料庫 有它的特性跟 3 種模式,HikariCP 則是 JVM 這邊連線池的預設選擇,Spring Boot 從 2.x 起就內建它,Day 18 JdbcTemplate 與 JdbcClient 最後一節有整理,差別在 Spring Boot 幫你把 2 個都裝好、設定寫在 properties 裡就有了,Ktor 沒有預設,這篇後面那幾節就是在自己接這些線
新開一個檔案 src/main/kotlin/com/cashwu/todo/TodoDatabase.kt,table 定義放最上面
package com.cashwu.todo
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.javatime.timestamp
object Todos : Table("todos") {
val id = integer("id").autoIncrement()
val title = varchar("title", TITLE_MAX_LENGTH)
val done = bool("done").default(false)
val createdAt = timestamp("created_at")
override val primaryKey = PrimaryKey(id)
}
4 個欄位對應 day 13 定案的 Todo(id, title, done, createdAt),Table("todos") 的字串是資料庫裡的表名,沒給的話 Exposed 會拿 object 的名字去推,TITLE_MAX_LENGTH 是 day 14 定的那個 const val TITLE_MAX_LENGTH = 100,長度上限現在有 2 個使用者,一個是 RequestValidation,一個是這裡,後面「varchar(100) 到底在數什麼」那節會講這件事沒有想像中單純
Table 之外還有 IntIdTable 這個選擇,它幫你把 id 宣告好,欄位型別會變成 EntityID<Int>。它是 DAO 那套 (IntEntity、Entity 那些 class) 的入口,本身在 exposed-core 裡就有,但 DAO 的實體類別要另外裝 exposed-dao,這個系列走 DSL 這條路,資料是 data class、查詢是明確的 SQL 建構,所以用最單純的 Table,欄位型別就是 Int 不用再剝一層,DAO 風格的比較留到 day 22
Exposed 幫這個 object 產生出來的 DDL 長什麼樣,SchemaUtils.createStatements(Todos) 會告訴你,它回的是一個 List<String>,一句 DDL 一個元素
這段程式碼主要是確認用而已,開一個新檔案 src/test/kotlin/com/cashwu/todo/ScratchTest.kt 來放
package com.cashwu.todo
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import kotlin.test.Test
class ScratchTest {
@Test
fun `exposed table test`() {
val database = Database.connect(
"jdbc:h2:mem:ddl",
driver = "org.h2.Driver",
user = "sa",
password = "",
)
transaction(database) {
SchemaUtils.createStatements(Todos).forEach(::println)
}
}
}
SchemaUtils 來自 org.jetbrains.exposed.v1.jdbc,外面那層 transaction 不能省,DDL 的語法每套資料庫不一樣,Exposed 要從連線才知道對面是誰,少了它會拿到 IllegalStateException: No transaction in context.
build.gradle.kts 的 testLogging 只開了 passed 跟 failed 這 2 個事件,測試裡 println 的東西不會出現在畫面上,要加 -i 才看得到,這一跑會吐一大堆 Gradle 的內部訊息,所以後面接一個 grep
./gradlew --no-daemon test --tests "com.cashwu.todo.ScratchTest" -i | grep "CREATE TABLE"
CREATE TABLE IF NOT EXISTS TODOS (ID INT AUTO_INCREMENT PRIMARY KEY, TITLE VARCHAR(100) NOT NULL, DONE BOOLEAN DEFAULT FALSE NOT NULL, CREATED_AT TIMESTAMP(9) NOT NULL)
看完後可以把 ScratchTest.kt 刪掉,可以不留在專案裡
4 件事一次確認完,autoIncrement() 在 H2 是 AUTO_INCREMENT,.default(false) 變成 DDL 上的 DEFAULT FALSE,Kotlin 的非 null 型別直接對應 NOT NULL,而 timestamp() 對應的是 TIMESTAMP(9),9 位小數也就是奈秒
day 19 小結收尾寫的是「day 18 那筆 TodoConfig 的驗收沒有兌現,往後挪到 day 20」,正文寫得更明白,「真正會用到的是 day 20,資料庫的連線字串、使用者名稱、連線池大小都得從 application.yaml 進來,那時候設定會從容器一路走到資料庫連線那一層」
src/main/resources/application.yaml 的 todo 節點底下多一個 database
todo:
requestIdHeader: '$REQUEST_ID_HEADER:X-Request-Id'
responseTimeHeader: '$RESPONSE_TIME_HEADER:X-Response-Time'
database:
url: '$DB_URL:jdbc:h2:mem:todo'
driver: '$DB_DRIVER:org.h2.Driver'
user: '$DB_USER:sa'
password: '$DB_PASSWORD:'
poolSize: '$DB_POOL_SIZE:5'
day 17 那套 $環境變數:預設值 的寫法照舊,有 2 個小地方要先確認
url 那一行寫的是 $DB_URL:jdbc:h2:mem:todo,整串總共 4 個冒號,Ktor 拿第 1 個冒號當分界,左邊是環境變數的名字 DB_URL,右邊剩下的整串 jdbc:h2:mem:todo 都算預設值,後面那 3 個冒號不會再被切一次,所以 JDBC 連線字串可以原封不動寫進去
password 的預設值是空字串,冒號後面什麼都不寫,Ktor 不會因此當成「沒有預設值」而在啟動時丟 ApplicationConfigurationException
這個空字串只服務目前不需要密碼的 H2 範例,換成 PostgreSQL 或部署到正式環境之前,要改回 day 17 的 password: '$DB_PASSWORD',拿掉預設值,讓漏設密碼時直接啟動失敗
src/main/kotlin/com/cashwu/todo/TodoConfig.kt 多一個巢狀的 data class,TodoConfig 多一個欄位
@Serializable
data class TodoConfig(
val requestIdHeader: String,
val responseTimeHeader: String,
val database: DatabaseConfig,
)
@Serializable
data class DatabaseConfig(
val url: String,
val driver: String,
val user: String,
val password: String,
val poolSize: Int,
)
poolSize 宣告成 Int,YAML 那邊是字串 "$DB_POOL_SIZE:5",反序列化的時候會轉
負責轉的是 kaml,它是 ktor-server-config-yaml 底下實際在讀 YAML 的那個函式庫,day 17 追過這條路,getAs<T>() 最後走到的是 kaml 的 decodeFromYamlNode,data class 的欄位型別是什麼,它就照著 kotlinx.serialization 的規則轉,day 17 那 5 個欄位全是 String,這是第 1 次驗證非字串的型別轉換走得通,ConfigurationTest 那個 the todo node maps onto the data class 順便把預設值全部綁住
@Test
fun `the todo node maps onto the data class`() {
val expected = TodoConfig(
requestIdHeader = "X-Request-Id",
responseTimeHeader = "X-Response-Time",
database = DatabaseConfig(
url = "jdbc:h2:mem:todo",
driver = "org.h2.Driver",
user = "sa",
password = "",
poolSize = 5,
),
)
assertEquals(expected, todoConfig("application.yaml"))
}
todoConfig(path) 是 ConfigurationTest.kt 裡的一個 private helper,一行 ApplicationConfig(path).property("todo").getAs(),跟 day 17 放進 TestApp.kt 的 todoTestConfig 做同一件事,差別是收一個 path,後面幾個測試要餵不同的設定檔
TodoConfig 多一個欄位這件事會先讓測試編譯不過,day 17 那個測試原本寫的是 TodoConfig("X-Request-Id", "X-Response-Time"),2 個位置參數,現在建構子有 3 個參數,少一個編不過,所以才改成上面那個帶具名參數的寫法,整個專案直接 new TodoConfig 的地方只有這裡一個,補完就過了
src/test/resources/ 底下那 2 個設定檔反而不用動,config-required-env.yaml 測的是環境變數解不開,那一關在 kaml 反序列化之前就爆了,走不到少一個 database 節點這件事,config-missing-field.yaml 那個測試斷言的是訊息裡有 responseTimeHeader,現在它少的欄位變成 2 個,kaml 報出來的還是 responseTimeHeader,斷言照樣成立
連線池自己則是 TodoDatabase.kt 裡的一個函式,收一個 DatabaseConfig 回一個 HikariCP 的 data source
fun todoDataSource(config: DatabaseConfig): HikariDataSource =
HikariDataSource(
HikariConfig().apply {
jdbcUrl = config.url
driverClassName = config.driver
username = config.user
password = config.password
maximumPoolSize = config.poolSize
poolName = "todo-pool"
}
)
poolName 是給 log 看的,取了名字之後 HikariCP 每一行 log 前面都會帶著它,多個的時候分得出來誰是誰
一個 DataSource 的擴充函式,一樣放 TodoDatabase.kt
fun DataSource.connectAndSeed(seed: List<Todo> = defaultTodos): Database {
val database = Database.connect(this)
transaction(database) {
SchemaUtils.create(Todos)
if (Todos.selectAll().empty()) {
seed.forEach { todo ->
Todos.insert {
it[title] = todo.title
it[done] = todo.done
it[createdAt] = todo.createdAt
}
}
}
}
return database
}
Database.connect 收一個 DataSource,也可以收 url 跟帳密自己開連線,交給連線池管比較實際,SchemaUtils.create 產生的是 CREATE TABLE IF NOT EXISTS,重跑不會炸
種子資料前面那個 empty() 判斷是必要的,不然每次啟動就多 3 筆,它的 SQL 是 SELECT ... FROM TODOS LIMIT 1,有沒有資料只要問第 1 筆存不存在就好
seed 預設是 defaultTodos,就是 day 12 那 3 筆待辦,跟 InMemoryTodoRepository 的 initial 參數同一個來源,測試要一張空表就傳 emptyList()
toTodo() 是把一列查詢結果換成 Todo 的函式,跟 table 定義一樣放在 TodoDatabase.kt
fun ResultRow.toTodo(): Todo = Todo(
id = this[Todos.id],
title = this[Todos.title],
done = this[Todos.done],
createdAt = this[Todos.createdAt],
)
ResultRow 用欄位物件當 key 取值,型別是從 Todos.id 這些欄位的宣告推出來的,this[Todos.id] 就是 Int,拼錯欄位名這種事不會發生,因為根本沒有欄位名可以拼,createdAt 這裡直接就是 Instant,timestamp() 對應的就是這個型別,一行轉換都不用,後面「createdAt 存進去變成了本地時間」那節會發現這件事沒那麼美好
Todos.insert { } 裡面沒有指定 id,3 筆的 id 是資料庫的 AUTO_INCREMENT 給的
這篇的測試開一個新檔案 src/test/kotlin/com/cashwu/todo/TodoDatabaseTest.kt
第 1 個測試就是驗這件事,開頭一個 private helper withDatabase,放在 class 外面的檔案層級,負責照設定開一個連線池、連上去、建表、塞種子資料,跑完把池子關掉
package com.cashwu.todo
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import kotlin.test.Test
import kotlin.test.assertEquals
private fun <T> withDatabase(
name: String,
seed: List<Todo> = defaultTodos,
block: (Database) -> T,
): T {
val source = todoDataSource(
DatabaseConfig(
url = "jdbc:h2:mem:$name",
driver = "org.h2.Driver",
user = "sa",
password = "",
poolSize = 2,
)
)
return source.use { block(it.connectAndSeed(seed)) }
}
class TodoDatabaseTest {
@Test
fun `the seed data lands in the table and the ids come from the database`() {
withDatabase("seed") { database ->
val rows = transaction(database) { Todos.selectAll().map { it.toTodo() } }
assertEquals(listOf(1, 2, 3), rows.map { it.id })
assertEquals(defaultTodos.map { it.title }, rows.map { it.title })
assertEquals(defaultTodos, rows)
}
}
}
todoDataSource、DatabaseConfig 跟 connectAndSeed 就是這節跟上一節寫的那 3 個,差別只在 DatabaseConfig 不從 application.yaml 讀,每個測試自己給一份,名字不同的 in-memory 資料庫彼此不會踩到,use 結束就關掉。這個檔案後面幾節還會再加測試,只貼 @Test 那一層,都是接在這個 class 裡面,Todos.insert、Instant、ZoneOffset、ExposedSQLException 這些 import 跟著補
3 筆照順序進去,拿到 1、2、3,跟 defaultTodos 寫死的 id 剛好一樣,所以最後那個 assertEquals 整包比對也成立
day 18 講 DI 生命週期的時候寫了「day 20 接資料庫的時候這一段會派上用場,連線池就是最典型的 AutoCloseable」。Application.kt 的 dependencies { } 多 2 行
dependencies {
provide<TodoConfig> { this@module.property("todo") }
provide<Clock> { Clock.systemUTC() }
provide<DataSource> { todoDataSource(resolve<TodoConfig>().database) }
provide<Database> { resolve<DataSource>().connectAndSeed() }
provide<TodoRepository> { InMemoryTodoRepository(resolve()) }
}
val config: TodoConfig by dependencies
val repository: TodoRepository by dependencies
@Suppress("UNUSED_VARIABLE")
val database: Database by dependencies
TodoConfig 現在有下游了,provide<DataSource> 那一行的 provider 裡面 resolve<TodoConfig>(),設定從 YAML 進容器,從容器進連線池,中間 module() 一個字都沒碰到
註冊的型別參數寫的是 javax.sql.DataSource 而不是 HikariDataSource,理由跟 day 19 的 provide<TodoRepository> 一樣,相依的單位是介面,換掉實作的時候別人不用跟著改,函式的回傳型別倒是留著 HikariDataSource,測試要看 maximumPoolSize 才不用轉型
最後那個 by dependencies 是刻意的,day 19 的結論是啟動驗證只涵蓋 by dependencies 宣告的 key 跟從那些 key 順著 provider 走得到的東西,Database 沒有任何人 by 的話,連線要等到第 1 個請求打進來才會建立,宣告了它,連線池跟建表就都在啟動階段完成,順便讓後面那個「連不上」的情境提早爆出來。變數本身沒人讀,所以掛了一個 @Suppress
啟動的 log 看得到整條線
11:24:12.550 INFO [no-call-id] c.z.h.HikariDataSource -- todo-pool - Starting...
11:24:12.634 INFO [no-call-id] c.z.h.p.HikariPool -- todo-pool - Added connection conn0: url=jdbc:h2:mem:todo user=SA
11:24:12.637 INFO [no-call-id] c.z.h.HikariDataSource -- todo-pool - Start completed.
11:24:12.705 INFO [no-call-id] Application -- Application started in 0.349 seconds.
11:24:12.776 INFO [no-call-id] Application -- Responding at http://0.0.0.0:8080
Ctrl-C 的時候
11:24:41.956 INFO [no-call-id] c.z.h.HikariDataSource -- todo-pool - Shutdown initiated...
11:24:41.958 INFO [no-call-id] c.z.h.HikariDataSource -- todo-pool - Shutdown completed.
沒有人寫過 close()。ApplicationStopping 的時候容器把建過的東西倒序走一遍,凡是 AutoCloseable 就關掉,HikariDataSource 剛好是。day 18 那個 Probe 測試證明的機制,這裡接上了真的資源
這件事寫成測試,順便把 poolSize 從設定走到池子這條線也綁住,這節的 2 個測試都走 HTTP、都要一個真的 application,所以不開新檔案,接在 day 18 那個 src/test/kotlin/com/cashwu/todo/DependencyInjectionTest.kt 的 class 裡面
@Test
fun `the pool takes its size from the config and closes with the application`() {
var source: HikariDataSource? = null
testApplication {
todoApplication()
application {
source = dependencies.resolve<DataSource>() as HikariDataSource
}
client.get("/todos")
assertEquals(todoTestConfig.database.poolSize, source!!.maximumPoolSize)
assertFalse(source.isClosed)
}
assertTrue(source!!.isClosed)
}
todoApplication() 跟 todoTestConfig 都是 TestApp.kt 裡的東西,前者是 day 18 開始用的測試 helper,後者是 day 17 放進去、直接讀 application.yaml 的設定,testApplication { } 的 block 裡面池子還開著,block 結束 application 停掉,池子跟著關
表建好、資料進去了,透過容器拿到 Database 就查得到,這是 DependencyInjectionTest 的第 2 個新測試
@Test
fun `the module creates the table and seeds it before the first request`() = testApplication {
var rows: List<Todo> = emptyList()
todoApplication()
application {
rows = transaction(dependencies.resolve<Database>()) {
Todos.selectAll().map { it.toTodo() }
}
}
assertEquals(HttpStatusCode.OK, client.get("/todos").status)
assertEquals(defaultTodos, rows)
}
第 1 個請求還沒送出去,表就已經在了
把 logback 的 Exposed logger 開到 DEBUG,啟動的時候就看得到 Exposed 送出去的每一句 SQL
<logger name="Exposed" level="DEBUG"/>
0.x 的教學都會叫你在 transaction 裡面寫 addLogger(StdOutSqlLogger),1.x 不用,Database.connect 預設就帶了一個走 slf4j 的 logger,logback 這一行開下去就有了,自己再 addLogger(Slf4jSqlDebugLogger) 的話每句 SQL 會印 2 次,這一行是臨時打開來看 SQL 的,這篇看完就拿掉了,最後的 logback.xml 跟 day 17 那版一模一樣,正式環境把每一句 SQL 都印出來只是在洗版
3 筆種子資料的 INSERT 印出來是這樣
INSERT INTO TODOS (TITLE, DONE, CREATED_AT) VALUES ('買牛奶', TRUE, '2026-08-27T16:00:00')
defaultTodos 第 1 筆的 createdAt 是 Instant.parse("2026-08-27T08:00:00Z"),這裡卻是 16:00,差的 8 小時就是 Asia/Taipei,timestamp() 對應的 SQL 型別是 TIMESTAMP,沒有時區資訊,Exposed 把 Instant 換算成 JVM 預設時區的當地時間再存進去
在同一個 process 裡看不出問題,寫進去 8 小時、讀出來 8 小時再換算回來,round trip 是對的,換一台機器就不一定是了,用檔案模式的 H2 做這個實驗,前面那個 src/test/kotlin/com/cashwu/todo/ScratchTest.kt 再拿出來用一次,這次 2 個方法,一個寫一個讀
class ScratchTest {
private fun tzDatabase(): Database = Database.connect(
"jdbc:h2:file:./build/tz-demo",
driver = "org.h2.Driver",
user = "sa",
password = "",
)
@Test
fun `tz write`() {
transaction(tzDatabase()) {
SchemaUtils.create(Todos)
Todos.deleteAll()
Todos.insert {
it[title] = "買牛奶"
it[done] = true
it[createdAt] = Instant.parse("2026-08-27T08:00:00Z")
}
println("=== write in ${ZoneId.systemDefault()}")
}
}
@Test
fun `tz read`() {
transaction(tzDatabase()) {
println("=== read in ${ZoneId.systemDefault()}")
println("=== instant ${Todos.selectAll().first()[Todos.createdAt]}")
exec("SELECT CAST(created_at AS VARCHAR) FROM todos") { rs ->
rs.next()
println("=== raw ${rs.getString(1)}")
}
}
}
}
jdbc:h2:file: 開頭的是檔案模式,資料落在 build/tz-demo.mv.db,2 個 JVM 才看得到同一份資料,in-memory 的話第二支連線拿到的是一個空的新資料庫,exec 那段把欄位 cast 成字串再撈出來,看的是資料庫裡真正躺著的那個值,不經過 driver 的型別轉換
先用預設時區跑寫的那個,再用 TZ=UTC 起一個 JVM 跑讀的那個,一樣要 -i 才看得到 println
./gradlew --no-daemon test --tests "com.cashwu.todo.ScratchTest.tz write" -i | grep "==="
TZ=UTC ./gradlew --no-daemon test --tests "com.cashwu.todo.ScratchTest.tz read" -i | grep "==="
=== write in Asia/Taipei
=== read in UTC
=== instant 2026-08-27T16:00:00Z
=== raw 2026-08-27 16:00:00
同一份資料,同一行程式碼,讀出來的 Instant 差了 8 小時,資料庫裡躺的 2026-08-27 16:00:00 沒有任何資訊可以說明它是哪個時區的 16:00,寫的人跟讀的人得剛好在同一個時區才對得上,這種東西在本機怎麼測都對,上線之後容器跑 UTC 就會出事
修法是換一個欄位型別,TodoDatabase.kt 的 import 跟 Todos 各改一行
-import org.jetbrains.exposed.v1.javatime.timestamp
+import org.jetbrains.exposed.v1.javatime.timestampWithTimeZone
object Todos : Table("todos") {
val id = integer("id").autoIncrement()
val title = varchar("title", TITLE_MAX_LENGTH)
val done = bool("done").default(false)
- val createdAt = timestamp("created_at")
+ val createdAt = timestampWithTimeZone("created_at")
代價是欄位型別從 Instant 變成 OffsetDateTime,同一個檔案裡有 2 個地方要跟著改,connectAndSeed 的寫入端
Todos.insert {
it[title] = todo.title
it[done] = todo.done
- it[createdAt] = todo.createdAt
+ it[createdAt] = todo.createdAt.atOffset(ZoneOffset.UTC)
}
跟 toTodo() 的讀出端
fun ResultRow.toTodo(): Todo = Todo(
id = this[Todos.id],
title = this[Todos.title],
done = this[Todos.done],
- createdAt = this[Todos.createdAt],
+ createdAt = this[Todos.createdAt].toInstant(),
)
Todo 這個 data class 不動,OffsetDateTime 只活在 TodoDatabase.kt 裡面,route 跟序列化那一層拿到的還是 Instant
改完之後 ScratchTest.kt 會編譯不過,tz write 那行 it[createdAt] = Instant.parse(...) 給的是 Instant,欄位現在收的是 OffsetDateTime,Kotlin 報 None of the following candidates is applicable,一個測試檔編不過,整包測試都跑不起來,這個 scratch 測試的任務到這裡結束了,把 ScratchTest.kt 刪掉,順手也把 build/tz-demo.mv.db 刪掉,那個檔案裡的表還是舊的欄位型別,SchemaUtils.create 產生的是 CREATE TABLE IF NOT EXISTS,不會幫你改過去
換完之後 DDL 多了 WITH TIME ZONE
CREATE TABLE IF NOT EXISTS TODOS (ID INT AUTO_INCREMENT PRIMARY KEY, TITLE VARCHAR(100) NOT NULL, DONE BOOLEAN DEFAULT FALSE NOT NULL, CREATED_AT TIMESTAMP(9) WITH TIME ZONE NOT NULL)
INSERT 送出去的值也變了,'2026-08-27T16:00:00' 變成 '2026-08-27T08:00:00Z',資料庫裡存的是 2026-08-27 08:00:00+00,再跑一次剛剛那個 UTC 的實驗,讀出來是 2026-08-27T08:00:00Z,跟寫進去的一樣
那個 +00 是 H2 真的把 offset 存進欄位裡,寫一個 +08:00 的值進去,撈出來的原始值就是 2026-08-27 16:00:00+08,PostgreSQL 的 timestamptz 依文件說的是另一套做法,值正規化成 UTC 存,不留 offset,跨時區讀出來一樣不會跑掉,結論不變,只是機制不同,day 23 接上 PostgreSQL 的時候會實際看一次
另一條路是不換型別,改成在 main 的第 1 行把 JVM 預設時區設成 UTC,那也能解決問題,但它是一個全域設定,任何一個忘了設的環境都會壞,而且從資料庫本身看不出來為什麼要這樣。欄位型別帶著時區走,資訊留在資料裡
TIMESTAMP(9) 那個 9 也順手驗一下,奈秒有沒有被截掉,測試接在 TodoDatabaseTest.kt 的 class 裡面
@Test
fun `an instant keeps its value and its nanoseconds`() {
val precise = Instant.parse("2026-10-10T12:00:00.123456789Z")
withDatabase("instant", seed = emptyList()) { database ->
transaction(database) {
Todos.insert {
it[title] = "倒垃圾"
it[done] = false
it[createdAt] = precise.atOffset(ZoneOffset.UTC)
}
}
assertEquals(precise, transaction(database) { Todos.selectAll().first().toTodo().createdAt })
}
}
9 位數一位都沒掉,PostgreSQL 的 timestamptz 只保留到微秒,需要確認的是最後 3 位會直接被截掉,還是會參與四捨五入 ? day 23 會真的寫進 PostgreSQL 看答案
day 14 定 TITLE_MAX_LENGTH 的時候留了一筆符筆,原話是「現在用的 String.length 算的是 UTF-16 code unit,不完全等於使用者看見的字數,碰到 emoji 時可能更早超過 100,day 20 用 Exposed 建表時要再確認資料庫的長度規則,決定是否改用 code point 或 grapheme cluster 計數」
答案是這條路上有 3 層檢查,3 層算的東西不一樣
先把 2 個名詞分清楚
code point 是 Unicode 給每一個字元的編號,A 是 U+0041、中 是 U+4E2D、🎉 是 U+1F389,一個字元一個號碼
code unit 則是實際存放的時候切出來的單位,Kotlin 跟 Java 的 String 用 UTF-16,一個 code unit 是 16 bits,String.length 數的就是這個
U+FFFF 以內的字元,一個 code point 剛好塞得進一個 code unit,A 跟 中 都是 length == 1,emoji 大多在 U+FFFF 以上,UTF-16 得拿 2 個 code unit 湊出來,這一對叫 surrogate pair,所以 "🎉".length 是 2,"🎉".codePointCount(0, 2) 是 1,同一個字元,2 種數法差一倍
再往上其實還有一層 grapheme cluster,也就是使用者眼裡的「一個字」,像膚色修飾或是用 ZWJ 接起來的家庭 emoji,一個 cluster 裡面有好幾個 code point,day 14 那筆待辦講的第 3 種計數方式就是它,這篇用不到,因為資料庫那邊根本不認得這個概念
測試接著寫在 TodoDatabaseTest.kt,這節的測試都要往表裡塞一個指定的標題,所以再補一個 private helper,一樣放在 class 外面的檔案層級
private fun insertTitle(database: Database, title: String) = transaction(database) {
Todos.insert {
it[Todos.title] = title
it[done] = false
it[createdAt] = FIXED_NOW.atOffset(ZoneOffset.UTC)
}
}
FIXED_NOW 是 day 18 放進 TestApp.kt 的那個固定時間,寫入的 .atOffset(ZoneOffset.UTC) 就是上一節換成 timestampWithTimeZone 之後補上的轉換
第 1 層是 Kotlin 的 String.length,也就是 day 14 那個 RequestValidation 用的,算 UTF-16 code unit,一個 BMP 以外的 emoji 算 2 個
第 2 層是 Exposed 自己的檢查,它在送出 SQL 之前就會攔,測試放 TodoDatabaseTest.kt
@Test
fun `a hundred and one code points never reach the database`() {
withDatabase("points", seed = emptyList()) { database ->
val error = assertFailsWith<IllegalArgumentException> {
insertTitle(database, "a".repeat(101))
}
assertContains(error.message.orEmpty(), "exceeds length (101 > 100)")
}
}
101 個 a 丟出來的是 IllegalArgumentException,訊息是「Value can't be stored to database column because exceeds length (101 > 100)」,這不是資料庫回的錯誤,是 Exposed 在 client 端擋下來的
第 3 層才是 H2,有趣的是這 2 層數的單位不一樣,測試放 TodoDatabaseTest.kt
@Test
fun `a hundred emoji pass the exposed check and h2 rejects them`() {
val hundred = "🎉".repeat(100)
withDatabase("units", seed = emptyList()) { database ->
val error = assertFailsWith<ExposedSQLException> { insertTitle(database, hundred) }
assertEquals(100, hundred.codePointCount(0, hundred.length))
assertEquals(200, hundred.length)
assertContains(error.message.orEmpty(), "Value too long for column")
}
}
100 個 🎉 是 100 個 code point,200 個 code unit,Exposed 那層數 code point,100 沒有超過 100,放行,H2 那層數 code unit,200 超過 100,擋下來
差別就寫在 ColumnType.kt 裡,VarCharColumnType.validateValueBeforeUpdate 算的是 value.codePointCount(0, value.length)
override fun validateValueBeforeUpdate(value: String?) {
if (value is String) {
val valueLength = value.codePointCount(0, value.length)
require(valueLength <= colLength) {
"Value can't be stored to database column because exceeds length ($valueLength > $colLength)"
}
}
}
H2 那邊的錯誤訊息則是這樣,括號裡的 200 就是它自己數出來的長度
org.h2.jdbc.JdbcSQLDataException: Value too long for column "TITLE CHARACTER VARYING(100)": "U&'\\+01f389\\+01f389\\+01f389\\+01f389\\+01f389\\+01f389\\+01f389\\+01f389\\+01f389\\+01f... (200)"; SQL statement:
INSERT INTO TODOS (TITLE, DONE, CREATED_AT) VALUES (?, ?, ?) [22001-240]
反過來 50 個 🎉 剛好是 100 個 code unit,3 層都過,存進去讀回來還是 100,測試放 TodoDatabaseTest.kt
@Test
fun `a title of a hundred code units fits`() {
val fifty = "🎉".repeat(50)
withDatabase("fits", seed = emptyList()) { database ->
insertTitle(database, fifty)
assertEquals(100, fifty.length)
assertEquals(100, transaction(database) { Todos.selectAll().first()[Todos.title].length })
}
}
所以 day 14 那筆待辦的結論是,現在不用改,String.length 數的單位跟 H2 的 VARCHAR(100) 一樣,而且 code point 數永遠小於等於 code unit 數,validation 這一關過得了的東西,H2 一定收得下,3 層裡面最嚴的那一層剛好排在最前面
但這個結論主要是在 H2 上,PostgreSQL 的 varchar(100) 數的是字元,也就是 code point,換過去之後 100 個 emoji 在資料庫那邊是合法的,我們的 validation 卻會先擋掉,兩邊的嚴格程度就對調了,要不要改成 code point 計數,等 day 23 真的換上 PostgreSQL 再看實際行為
day 19 有一句話留了個尾巴,「等到 day 20 接上真的資料庫,『資料庫連不上會回什麼』就不用真的去拔網路線」,答案是不會回什麼,因為 server 根本起不來
把 DB_URL 指到一個沒有人在聽的 port
DB_URL="jdbc:h2:tcp://localhost:9999/nope" ./gradlew run
11:23:39.389 ERROR [no-call-id] Application -- Cannot resolve org.jetbrains.exposed.v1.jdbc.Database
at com.cashwu.todo.ApplicationKt.main(Application.kt:23)
11:23:39.389 ERROR [no-call-id] Application -- Dependency resolution failed:
- org.jetbrains.exposed.v1.jdbc.Database: Failed to initialize pool: Connection is broken: "java.net.ConnectException: Connection refused: localhost:9999" [90067-240]
Exception in thread "main" io.ktor.server.plugins.di.DependencyInjectionException: Some dependencies could not be resolved; check logs for details
HikariCP 預設會 fail fast,池子初始化的時候先要一條連線,要不到就丟,這個例外從 provide<DataSource> 的 lambda 裡冒出來,HikariDataSource 的建構子就是池子初始化的地方,然後順著 provide<Database> 這條相依鏈往上報,被 day 18 講的那個啟動驗證接住,訊息裡寫的是解不開的 key Database,原因則是底下那個 Failed to initialize pool,一整條鏈的頭尾都在
基本上這是好事,連不上資料庫的服務起來了也沒有用,每個請求都會 500,還不如在啟動階段就結束,讓部署流程知道這一版沒起來,如果反過來希望「資料庫暫時掛掉但 server 照跑」,那就要把 by dependencies 那行拿掉、讓連線延後到第 1 次用的時候,再自己處理重試,那是另一種取捨,不在這篇的範圍
同一批 log 裡還夾了一行 WARN Application -- Exception during cleanup for org.jetbrains.exposed.v1.jdbc.Database; continuing,那是 day 19 看過的收尾流程,容器關閉的時候想把這個 key 對應的物件拿出來 close,跑 provider 又踩到同一個缺口,記一筆 log 然後繼續往下關
到這裡資料庫已經連得上、表建得起來、資料查得到,但 API 還是走 InMemoryTodoRepository,GET /todos 回的 3 筆跟資料庫裡的 3 筆長得一模一樣,來源卻是記憶體裡那個 ArrayList
先把它換過去試試看,新開一個 ExposedTodoRepository.kt
package com.cashwu.todo
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
class ExposedTodoRepository(
private val clock: Clock,
private val database: Database,
) : TodoRepository {
override fun findAll(limit: Int?): List<Todo> = transaction(database) {
val query = Todos.selectAll()
if (limit != null) query.limit(limit)
query.map { it.toTodo() }
}
override fun findById(id: Int): Todo? = transaction(database) {
Todos.selectAll().where { Todos.id eq id }.singleOrNull()?.toTodo()
}
override fun create(title: String, done: Boolean): Todo = transaction(database) {
val inserted = Todos.insert {
it[Todos.title] = title
it[Todos.done] = done
it[createdAt] = Instant.now(clock).atOffset(ZoneOffset.UTC)
}
inserted.resultedValues!!.first().toTodo()
}
}
Application.kt 那行改成 provide<TodoRepository> { ExposedTodoRepository(resolve(), resolve()) },然後 ./gradlew test
測試全部通過,測試檔案一個字都沒改,day 19 那句「TodoRepository 這個介面建立之後,換掉後面那個實作就是一件單純的事,route 那一層一行都不用動」是對的,而且連測試那一層也不用動
day 19 那組 60 個併發 POST 也再打一次
清單長度 63 (預期 63)
不同 id 數 63
重複的 id []
63 筆一筆不少,id 沒有重複,這次不是 synchronized 的功勞,是 AUTO_INCREMENT 加上 transaction,60 個請求打進去的期間,這一輪最慢的一個回應是 13 毫秒,連線池只有 5 條,這個量級還看不出排隊
那為什麼不留 ? 因為 day 21 要先把 CRUD 跟 transaction 的語意講清楚,transaction { } 到底做了什麼、巢狀 transaction 怎麼算、失敗的時候 rollback 到哪裡,那些東西講完之前把 repository 換過去,等於把一整包沒解釋的行為推上線,真正換掉 InMemoryTodoRepository 是 day 22 的事,那篇還要處理 repository 邊界跟阻塞 I/O,transaction { } 是 blocking 的,它現在跑在 Netty 的 worker thread 上,這件事得跟連線池大小一起談
實驗做完就把 ExposedTodoRepository.kt 刪掉了,Application.kt 那行也改回來
測試裡每個 testApplication 結束時連線池會關掉,jdbc:h2:mem:todo 這種 in-memory 資料庫在最後一條連線斷掉的時候整個消失,下一個測試拿到的是全新的空資料庫,再次建立資料表、重新塞 3 筆種子資料,every application gets its own todo list 那個測試斷言第 1 個 application 建完是 4 筆、第 2 個 application 是 3 筆,它會通過就是因為這件事,換成 PostgreSQL 之後資料不會自己消失,測試之間的隔離就得自己想辦法,那是 day 25 Testcontainers 的題目
這一節這次很短,因為 Relix 沒有走到這裡,那個系列從 day 01 到 day 32 手刻的是框架本身,routing、pipeline、plugin、DI 容器、測試工具,day 29 跟 day 30 那個 todo API 是拿手刻框架寫的應用範例,資料一直放在 LinkedHashMap 裡
這是合理的邊界,資料庫存取不是 web 框架的一部分,Ktor 自己也沒有官方 ORM,Exposed 是另一個獨立的專案,只是剛好都是 JetBrains 做的,手刻一個 web 框架不需要順便手刻一個 ORM,那是另一個系列的份量
不過 Relix 留了 2 個路標,這篇剛好各碰到一個,day 29 講 in-memory 實作的並行問題時寫了「真實應用要嘛用 ConcurrentHashMap + 原子操作,要嘛就交給具有交易語意的資料庫 repository」,剛剛那個實驗就是後面那半句的實測,60 個併發下 63 筆一筆不少。day 31 談效能的時候寫了「阻塞式資料庫 driver 仍可明確移到 Dispatchers.IO」,配的範例是 withContext(Dispatchers.IO) { userRepository.findAll() },那句話講的正是 Exposed 的 transaction { } 現在的處境,day 22 會在 Ktor 這邊實際量一次
連線池那段可以,資料庫本身跟建表那段不行
todoDataSource 這個函式沒什麼問題,HikariCP 是 JVM 世界的預設選擇,參數從設定進來、池子交給容器管、關閉自動處理,正式專案也是這樣接,要補的是幾個現在沒設的參數,connectionTimeout、maxLifetime、leakDetectionThreshold 這些,還有 poolSize 的 5 是隨手填的,真正的數字要看資料庫端的連線上限跟服務的並行量,不是越大越好
jdbc:h2:mem:todo 就只是個過渡,資料在記憶體裡,process 停掉就沒了,這件事跟 day 19 那個 InMemoryTodoRepository 的缺點一模一樣,只是換了一層皮,它現在的價值是讓開發跟測試不用先裝資料庫,PostgreSQL 是 day 23
SchemaUtils.create 也是,它會建不存在的表,但它不會處理「表已經存在,而且欄位跟現在的定義不一樣」這件事,加一個欄位、改一個型別、改長度,它都不管,schema 的演進要版本化,每一次變更是一個可以往前套用的檔案,這是 Flyway 那類工具在做的事,跟 day 23 排在同一篇
種子資料寫在程式碼裡也只適合示範,defaultTodos 從 day 12 活到現在,它的角色是「讓端點有東西可回」,真正的專案不會在啟動的時候往資料表塞假資料
Exposed、HikariCP 和 H2 3 個相依加進來,object Todos : Table("todos") 4 個欄位對應 Todo 的 4 個屬性
連線參數 5 個欄位進了 DatabaseConfig,provide<DataSource> 的 provider 裡 resolve<TodoConfig>(),day 19 欠的那筆兌現了,poolSize 也是這個系列第 1 個非字串的設定欄位,SchemaUtils.create 加上一個 empty() 判斷完成建表跟種子資料,資料表裡 3 筆種子資料的 id 改由 AUTO_INCREMENT 產生,連線池是 AutoCloseable,application 停止的時候容器自己關掉,day 18 那個機制接上了真的資源
timestamp() 存下去的是本地時間,用檔案模式的 H2 寫進去再用 TZ=UTC 的 JVM 讀出來差 8 小時,換成 timestampWithTimeZone() 之後資料庫裡存的是 2026-08-27 08:00:00+00,兩邊讀出來一樣,代價是欄位型別變成 OffsetDateTime,connectAndSeed 跟 toTodo 各多一個轉換
長度規則有 3 層,Kotlin 的 String.length 數 UTF-16 code unit、Exposed 的 client 端檢查數 code point、H2 的 VARCHAR(100) 數 code unit,100 個 emoji 是 100 個 code point、200 個 code unit,Exposed 放行、H2 擋下,而 day 14 那個 validation 因為數的單位跟 H2 一樣所以最嚴,不用改,PostgreSQL 那邊的差異留給 day 23
資料庫連不上的話 HikariCP fail fast,例外被啟動驗證接住,server 直接不起來,repository 換成 Exposed 版試跑過一次,測試零修改全過,60 個併發 POST 拿到 63 筆,但真正換掉是 day 22
表建好了、連線通了,但整包 API 還沒有一個字是從資料庫來的,下一篇補上完整的 CRUD,insert、select、update、deleteWhere 4 組 DSL 怎麼寫,transaction { } 到底包了什麼、巢狀的時候算幾個交易、rollback 的邊界在哪裡,還有 day 19 那個 read-modify-write 的 lost update 問題,交給資料庫的交易語意之後長什麼樣
同步刊登於 Blog
圖片來源:AI 產生