KtorSeniorCoding
Как интегрировать базу данных (например, с Exposed ORM) в приложение Ktor?
Интеграция Exposed в Ktor требует: HikariCP DataSource, инициализации Database.connect() в модуле, выполнения всех JDBC-операций через newSuspendedTransaction(Dispatchers.IO) и явного закрытия пула при ApplicationStopped.
Почему интеграция требует внимания
Ktor по умолчанию запускает обработчики в coroutine на Dispatchers.Default. Exposed использует JDBC — блокирующий API. Если вызывать transaction { } напрямую без переключения диспетчера, coroutine заблокирует поток event loop и throughput упадёт до числа CPU-ядер. Правильная интеграция требует явного newSuspendedTransaction(Dispatchers.IO) { }.
Зависимости (build.gradle.kts)
dependencies {
// Ktor
implementation("io.ktor:ktor-server-core:2.3.12")
implementation("io.ktor:ktor-server-netty:2.3.12")
implementation("io.ktor:ktor-server-content-negotiation:2.3.12")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.12")
// Exposed
implementation("org.jetbrains.exposed:exposed-core:0.53.0")
implementation("org.jetbrains.exposed:exposed-dao:0.53.0")
implementation("org.jetbrains.exposed:exposed-jdbc:0.53.0")
implementation("org.jetbrains.exposed:exposed-kotlin-datetime:0.53.0")
// Connection pool + driver
implementation("com.zaxxer:HikariCP:5.1.0")
implementation("org.postgresql:postgresql:42.7.3")
}
Определение таблицы и DAO
import org.jetbrains.exposed.sql.Table
import org.jetbrains.exposed.dao.LongEntity
import org.jetbrains.exposed.dao.LongEntityClass
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.dao.id.LongIdTable
object Users : LongIdTable("users") {
val name = varchar("name", 255)
val email = varchar("email", 255).uniqueIndex()
val createdAt = long("created_at").default(0L)
}
class User(id: EntityID<Long>) : LongEntity(id) {
companion object : LongEntityClass<User>(Users)
var name by Users.name
var email by Users.email
var createdAt by Users.createdAt
}
Инициализация DataSource и Database в модуле
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import io.ktor.server.application.*
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction
fun Application.configureDatabase() {
val config = HikariConfig().apply {
jdbcUrl = environment.config.property("database.url").getString()
driverClassName = "org.postgresql.Driver"
username = environment.config.property("database.user").getString()
password = environment.config.property("database.password").getString()
maximumPoolSize = 10
minimumIdle = 2
connectionTimeout = 30_000
idleTimeout = 600_000
maxLifetime = 1_800_000
isAutoCommit = false // Exposed управляет транзакциями сам
}
val dataSource = HikariDataSource(config)
// Привязываем DataSource к Exposed
Database.connect(dataSource)
// Создаём таблицы (для dev/test; в production — Flyway/Liquibase)
transaction {
SchemaUtils.createMissingTablesAndColumns(Users)
}
// Закрываем пул при остановке приложения
environment.monitor.subscribe(ApplicationStopped) {
dataSource.close()
}
}
Suspend-транзакции в route-обработчиках
import io.ktor.server.routing.*
import io.ktor.server.response.*
import io.ktor.server.request.*
import io.ktor.http.*
import kotlinx.coroutines.Dispatchers
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import kotlinx.serialization.Serializable
@Serializable
data class CreateUserDto(val name: String, val email: String)
@Serializable
data class UserDto(val id: Long, val name: String, val email: String)
fun Application.userRoutes() {
routing {
post("/users") {
val dto = call.receive<CreateUserDto>()
// newSuspendedTransaction переключает на Dispatchers.IO
val user = newSuspendedTransaction(Dispatchers.IO) {
User.new {
name = dto.name
email = dto.email
createdAt = System.currentTimeMillis()
}
}
call.respond(HttpStatusCode.Created, UserDto(user.id.value, user.name, user.email))
}
get("/users/{id}") {
val id = call.parameters["id"]?.toLongOrNull()
?: return@get call.respond(HttpStatusCode.BadRequest)
val user = newSuspendedTransaction(Dispatchers.IO) {
User.findById(id)
} ?: return@get call.respond(HttpStatusCode.NotFound)
call.respond(UserDto(user.id.value, user.name, user.email))
}
}
}
// Сборка модуля
fun Application.module() {
install(ContentNegotiation) { json() }
configureDatabase()
userRoutes()
}
Подводные камни
- Blocking transaction на Dispatchers.Default. Обычный
transaction { }блокирует coroutine-поток; при высокой нагрузке все потоки пула оказываются заняты JDBC-ожиданием, и приложение зависает. Всегда используйтеnewSuspendedTransaction(Dispatchers.IO). - isAutoCommit = false обязателен. HikariCP по умолчанию включает autoCommit; Exposed ожидает, что коммит управляется им самим. Без этой настройки
rollback()не работает корректно. - Lazy-loading внутри транзакции. В Exposed DAO обращение к связанным объектам (например,
user.posts) после закрытия транзакции бросаетIllegalStateException: Transaction is not in progress. Всё нужно прочитать внутриnewSuspendedTransaction { }. - N+1 запросы. Exposed DAO легко провоцирует N+1: если в цикле обращаться к связанным сущностям, каждый доступ порождает новый SELECT. Используйте
with(relation)или DSL-запросы с явными JOIN. - SchemaUtils в production.
SchemaUtils.createMissingTablesAndColumns()не поддерживает миграции (не удаляет лишние колонки, не переименовывает). В production нужны Flyway или Liquibase. - Пул соединений и размер.
maximumPoolSizeнужно согласовать сmax_connectionsв PostgreSQL. По умолчанию у PG 100 соединений; несколько реплик приложения легко исчерпают лимит. - Утечка соединения при необработанном исключении. Если coroutine отменяется в середине
newSuspendedTransaction, Exposed делает rollback, но соединение возвращается в пул только если HikariCP корректно обрабатывает отмену. Проверяйте метрикуhikaricp.connections.activeпод нагрузкой. - Несовместимость версий Exposed. API Exposed менялось между 0.40.x и 0.50.x (переименование методов в DAO, новый синтаксис upsert). Фиксируйте версию явно и проверяйте changelog перед обновлением.
Common mistakes
- Путать термин «database exposed» с соседним механизмом Ktor.
- Не называть границу lifecycle, transaction, thread или request для «database exposed».
- Игнорировать production-эффекты «database exposed»: latency, SQL shape, memory, security или observability.
What the interviewer is testing
- Попросить объяснить механизм «database exposed» на минимальном примере.
- Проверить, видит ли кандидат failure mode и диагностику для «database exposed».
- Уточнить, какие настройки или API меняют «database exposed» в реальном сервисе.