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» в реальном сервисе.

Sources

Related topics