1. Зачем нам JDBC и почему он выглядит как «три матрёшки»
Когда вы впервые видите JDBC-код, он часто кажется странным: почему, чтобы просто «достать список расходов», нужно открыть соединение, потом создать statement, потом получить result set, потом куда-то двигать курсор… На самом деле JDBC — это низкоуровневый стандартный API для SQL в мире JVM, и он честно отражает реальность: база данных — это внешний ресурс, запрос — отдельная операция, результат — поток строк.
Если запомнить «три матрёшки», всё резко упрощается:
- Connection — «я подключился к базе, у меня есть канал связи».
- PreparedStatement — «я подготовил SQL-запрос (с параметрами), готов выполнить».
- ResultSet — «я получил табличный результат, читаю строки по одной».
Чтобы было совсем визуально, вот схема жизненного цикла запроса:
flowchart TD
A["DriverManager.getConnection(...)"] --> B["Connection"]
B --> C["prepareStatement(sql)"]
C --> D["PreparedStatement"]
D -->|SELECT| E["executeQuery() → ResultSet"]
D -->|INSERT/UPDATE/DELETE| F["executeUpdate() → Int"]
E --> G["rs.next() → читаем колонки"]
G --> H["Закрываем ResultSet / Statement / Connection"]
Главная мысль лекции: вы научитесь писать JDBC-код так, чтобы он был (1) безопасным, (2) читаемым, (3) без утечек ресурсов.
2. Connection: подключение и работа с ресурсами
Соединение с базой данных — это не «обычный Kotlin-объект», который можно создать и забыть. Оно держит внешние ресурсы: сокеты, файловые дескрипторы, блокировки, внутренние структуры драйвера. Поэтому Connection обязательно нужно закрывать, даже если вам кажется, что «программа маленькая и всё равно сейчас закончится». Ровно так и рождаются баги, которые проявляются только «через час на проде».
Минимальный скелет подключения обычно выглядит так (покажу на SQLite, потому что это самый простой вариант для примеров):
import java.sql.DriverManager
fun main() {
val url = "jdbc:sqlite:app.db"
DriverManager.getConnection(url).use { conn ->
println("connected=${!conn.isClosed}") // connected=true
}
}
Обратите внимание на use(). Он делает важную вещь: гарантирует close() в конце блока. Это именно тот случай, где Kotlin помогает не забыть закрыть ресурс. Идиома use() как раз и рекомендована для AutoCloseable-ресурсов, чтобы не писать вручную finally { close() }.
Небольшая оговорка про url
В учебных примерах мы будем держать url строкой в коде. В реальном проекте его обычно передают через конфиг/переменные окружения, но это уже вопрос инфраструктуры, а не ядра JDBC.
3. PreparedStatement: параметры вместо конкатенации
Если вы когда-нибудь хотели сделать так:
val sql = "SELECT id FROM users WHERE email = '$email'"
то JDBC (и ваша будущая карьера) мягко просит вас остановиться и подумать. Конкатенация строк в SQL — это и про баги с кавычками, и про проблемы с экранированием, и про риск SQL-инъекций.
Правильный подход: SQL фиксированный, а значения передаются через параметры ?.
import java.sql.Connection
fun findUserIdByEmail(conn: Connection, email: String): Long? {
val sql = "SELECT id FROM users WHERE email = ?"
conn.prepareStatement(sql).use { st ->
st.setString(1, email) // индексация параметров начинается с 1!
st.executeQuery().use { rs ->
return if (rs.next()) rs.getLong("id") else null
}
}
}
Здесь уже видно «две матрёшки»: statement и result set тоже закрываются через use().
Почему индексация параметров начинается с 1
Потому что JDBC — древний, как легенды о табличках Excel без автосохранения. Это историческое решение API: первый параметр — индекс 1. Не 0. Не спрашивайте. Просто примите, как погоду.
4. executeQuery() и executeUpdate()
В JDBC есть очень практичное разделение: одни запросы возвращают таблицу строк (SELECT), другие — просто меняют данные (INSERT/UPDATE/DELETE). Поэтому методы разные.
Для SELECT используем executeQuery() и получаем ResultSet.
import java.sql.Connection
fun countUsers(conn: Connection): Int {
val sql = "SELECT COUNT(*) AS cnt FROM users"
conn.prepareStatement(sql).use { st ->
st.executeQuery().use { rs ->
rs.next()
return rs.getInt("cnt")
}
}
}
Для INSERT/UPDATE/DELETE используем executeUpdate() и получаем число изменённых строк.
import java.sql.Connection
fun deleteUser(conn: Connection, id: Long): Int {
val sql = "DELETE FROM users WHERE id = ?"
conn.prepareStatement(sql).use { st ->
st.setLong(1, id)
return st.executeUpdate() // 0 или 1 (в идеале)
}
}
Число изменённых строк — это ваш минимальный «датчик адекватности». Если вы ожидали удалить одну запись, а удалили 17 — это повод насторожиться.
5. ResultSet: курсор, next() и маппинг в data class
ResultSet — это не список. Это «курсор» по результату. В начале он стоит до первой строки, и пока вы не вызвали rs.next(), читать колонки нельзя. Это одна из самых частых ошибок новичков: «почему у меня всё null/падает?».
Пример: читаем одну запись
import java.sql.Connection
data class User(val id: Long, val email: String, val name: String)
fun findUser(conn: Connection, email: String): User? {
val sql = "SELECT id, email, name FROM users WHERE email = ?"
conn.prepareStatement(sql).use { st ->
st.setString(1, email)
st.executeQuery().use { rs ->
return if (!rs.next()) null else User(
id = rs.getLong("id"),
email = rs.getString("email"),
name = rs.getString("name")
)
}
}
}
Тут есть тонкий момент: rs.getString(...) в мире Kotlin — это platform type (String!). То есть Kotlin позволит присвоить его в String, но если в БД реально лежит NULL, вы можете получить сюрпризы позже. На уровне начинающих достаточно правила: если колонка может быть NULL, храните её как String?.
Пример: читаем много строк в список
Пусть у нас учебное приложение — консольный трекер расходов (мы его уже «мысленно» строили на предыдущих темах про архитектуру слоёв). Добавим модель расхода:
data class Expense(
val id: Long,
val amountCents: Long,
val category: String,
val note: String?
)
Теперь чтение списка расходов:
import java.sql.Connection
fun listExpenses(conn: Connection): List<Expense> {
val sql = "SELECT id, amount_cents, category, note FROM expenses ORDER BY id DESC"
conn.prepareStatement(sql).use { st ->
st.executeQuery().use { rs ->
val result = mutableListOf<Expense>()
while (rs.next()) {
result += Expense(
id = rs.getLong("id"),
amountCents = rs.getLong("amount_cents"),
category = rs.getString("category"),
note = rs.getString("note") // пусть будет nullable по смыслу
)
}
return result
}
}
}
Здесь видно два «закона ResultSet»:
- чтобы прочитать строку — сначала next(),
- чтобы прочитать много строк — while (rs.next()).
Колонка по имени и по индексу
Можно читать и так, и так: rs.getLong(1) или rs.getLong("id"). По индексу обычно чуть быстрее, но для обучения и читаемости по имени почти всегда лучше: меньше шансов перепутать порядок колонок при изменении SQL.
6. Закрытие ресурсов: use {} и читаемость
JDBC-объекты почти всегда являются ресурсами: Connection, PreparedStatement, ResultSet. Если их не закрывать, вы получаете утечки. Иногда утечки проявляются быстро, иногда через 10–30 минут, и это худший жанр багов: «оно работает, пока не перестаёт».
Старый подход: try/finally
Иногда полезно понимать механику «вручную», чтобы ценить use().
import java.sql.Connection
import java.sql.PreparedStatement
fun closeQuietly(st: PreparedStatement?) {
try { st?.close() } catch (_: Exception) {}
}
fun example(conn: Connection) {
val st = conn.prepareStatement("SELECT 1")
try {
st.executeQuery()
} finally {
closeQuietly(st)
}
}
Работает, но выглядит как мини-ритуал.
Kotlin-подход: use {} и вложенность
Kotlin рекомендует use() для AutoCloseable, чтобы закрытие происходило автоматически.
import java.sql.DriverManager
fun pingDb(url: String): Int {
return DriverManager.getConnection(url).use { conn ->
conn.prepareStatement("SELECT 1 AS one").use { st ->
st.executeQuery().use { rs ->
rs.next()
rs.getInt("one")
}
}
}
}
Да, это «три use подряд». Зато это железобетонно: даже если запрос упадёт с исключением, ресурсы будут закрыты в обратном порядке.
Как сделать читаемее, не теряя безопасность
Когда вложенность начинает резать глаз, обычно помогают маленькие функции-обёртки. Например, функция «выполни с соединением»:
import java.sql.Connection
import java.sql.DriverManager
inline fun <T> withConnection(url: String, block: (Connection) -> T): T {
return DriverManager.getConnection(url).use { conn ->
block(conn)
}
}
Использование:
fun isAlive(url: String): Boolean =
withConnection(url) { conn -> !conn.isClosed }
Мы не используем здесь никаких «будущих тем», просто применяем уже знакомые функции и лямбды, чтобы сделать JDBC-код менее «лесенкой».
7. Слой хранения: JdbcExpenseRepository без магии
Важно не только уметь написать запрос, но и уметь не размазать JDBC по всему приложению. На прошлых архитектурных темах мы уже обсуждали идею границ: CLI/сценарии не должны знать деталей хранения. Здесь мы сделаем маленький репозиторий, который скрывает JDBC-внутренности.
Начнём с интерфейса (внешний мир видит только его):
interface ExpenseRepository {
fun add(amountCents: Long, category: String, note: String?): Long
fun listLatest(limit: Int): List<Expense>
}
Теперь реализация. В этой лекции мы не углубляемся в транзакции и «получение сгенерированного id» для всех баз (это отдельная тема и у драйверов бывают нюансы), поэтому сделаем простой учебный вариант: метод add вернёт 1L как «успех».
import java.sql.DriverManager
class JdbcExpenseRepository(private val url: String) : ExpenseRepository {
override fun add(amountCents: Long, category: String, note: String?): Long {
val sql = "INSERT INTO expenses(amount_cents, category, note) VALUES (?, ?, ?)"
DriverManager.getConnection(url).use { conn ->
conn.prepareStatement(sql).use { st ->
st.setLong(1, amountCents)
st.setString(2, category)
st.setString(3, note)
st.executeUpdate()
return 1L // для простоты: «успех»
}
}
}
override fun listLatest(limit: Int): List<Expense> {
val sql = """
SELECT id, amount_cents, category, note
FROM expenses
ORDER BY id DESC
LIMIT ?
""".trimIndent()
return DriverManager.getConnection(url).use { conn ->
conn.prepareStatement(sql).use { st ->
st.setInt(1, limit)
st.executeQuery().use { rs ->
val items = mutableListOf<Expense>()
while (rs.next()) {
items += Expense(
id = rs.getLong("id"),
amountCents = rs.getLong("amount_cents"),
category = rs.getString("category"),
note = rs.getString("note")
)
}
items
}
}
}
}
}
Здесь мы используем LIMIT ? как параметр. Это удобно: запрос остаётся фиксированным, и вам не нужно собирать строку SQL конкатенацией.
Мини-таблица: типы в JDBC и частые промахи
Когда новичок начинает маппить строки в data class, он часто путается: getInt или getLong? setString или setObject? Можно ли писать null? Чтобы снизить хаос, держите под рукой маленькую таблицу соответствий «на каждый день».
| SQL-смысл (упрощённо) | PreparedStatement.set... | ResultSet.get... | Комментарий |
|---|---|---|---|
| целое число | |
|
если сумма/ID может быть большим — чаще берут Long |
| текст | |
|
getString может вернуть null, если в БД NULL |
| nullable значение | |
|
в Kotlin лучше хранить как String?, если поле необязательное |
Если вы чувствуете, что типы «плывут», полезно сделать правило: в коде приложения иметь стабильные типы (Long для денег в центах, String для категории), а SQL под это подстраивать.
8. Типичные ошибки при работе с JDBC
Ошибка №1: SQL конкатенацией вместо параметров (?).
Когда значения «вклеивают» в строку SQL, вы ловите проблемы с кавычками, экранированием и потенциально открываете двери SQL-инъекциям. Даже если вы уверены, что «пользователь не злой», злым может оказаться формат данных. Привычка использовать PreparedStatement и setString/setLong/... решает это на уровне техники, а не надежды.
Ошибка №2: забыли, что параметры нумеруются с 1, и поставили setString(0, ...).
Это классика. JDBC живёт по правилу «первый параметр — индекс 1». В результате с нулём вы получите исключение, часто в самый неподходящий момент. Помогает простая дисциплина: держать set...() прямо рядом с SQL, чтобы визуально совпадали порядок ? и порядок set.
Ошибка №3: вызвали rs.getLong(...) до rs.next().
ResultSet начинается «до первой строки». Пока вы не сделали next(), текущей строки не существует. Для одной записи используйте паттерн if (rs.next()) { ... } else null, для списка — while (rs.next()) { ... }. Это не вкусовщина, это контракт API.
Ошибка №4: перепутали executeQuery() и executeUpdate().
Если вы вызываете executeQuery() для INSERT, драйвер может ругаться, а может вести себя странно. Держите простое правило: SELECT → executeQuery() (и ResultSet), INSERT/UPDATE/DELETE → executeUpdate() (и Int количества строк). Это делает код предсказуемым и облегчает проверку результата.
Ошибка №5: не закрыли ресурсы (или закрыли только Connection, но забыли ResultSet).
В JDBC ресурсов много, и каждый может держать внешние штуки. Если вы закрыли соединение, но забыли закрыть ResultSet, драйвер может держать хвосты дольше, чем вам бы хотелось. Самый простой и надёжный стиль в Kotlin — вложенные use() для всех JDBC-объектов, которые вы создаёте. Это и есть причина, почему use() так ценится для AutoCloseable.
Ошибка №6: считаете, что getString() «точно не null», потому что у вас в Kotlin тип String.
JDBC-методы из Java часто выглядят для Kotlin как platform types, и компилятор не всегда заставит вас обработать null. Если колонка в БД допускает NULL, не изображайте героя: храните поле в модели как String? и обрабатывайте null явно. Это дешевле, чем отлаживать NPE через неделю.
Ошибка №7: держите JDBC-код в main и размазываете SQL по всему приложению.
Первые пару запросов так «быстрее», но дальше вы получаете проект, где изменения схемы требуют искать SQL по десяти файлам. Гораздо спокойнее выделять слой хранения: репозиторий/DAO, который единственный знает про JDBC, а остальной код работает с методами add, listLatest и вашими data class моделями.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ