5.03. Работа с БД
Работа с БД
Работа с постоянным хранилищем данных — одна из центральных задач практически любого нетривиального приложения на языке Java. Независимо от того, разрабатывается ли веб-сервис, настольное приложение или микросервис в распределённой инфраструктуре, рано или поздно возникает необходимость чтения, модификации и сохранения структурированной информации. В экосистеме Java для этой цели сформировалась многоуровневая архитектура доступа к данным, отвечающая разным требованиям к производительности, выразительности кода, уровню абстракции и удобству обслуживания.
Наиболее фундаментальный слой этой архитектуры — JDBC (Java Database Connectivity) — представляет собой стандартный API, входящий в состав Java SE и обеспеченный спецификацией от Oracle. Его задача — предоставить унифицированный программный интерфейс для взаимодействия с реляционными базами данных, независимо от конкретной системы управления базами данных (СУБД). JDBC не содержит реализации для конкретных СУБД; вместо этого он определяет контракт, который реализуется через так называемые JDBC-драйверы — библиотеки, поставляемые разработчиками СУБД (PostgreSQL, MySQL, Oracle и др.) или независимыми сообществами.
Важно понимать, что JDBC — это интерфейсный слой между приложением и СУБД. Он не скрывает SQL, не преобразует объекты автоматически и не управляет транзакциями на высоком уровне. Вместо этого он делает возможным выполнение SQL-инструкций, управление соединениями, обработку результатов и диагностику ошибок в строго типизированной манере, характерной для языка Java. Именно эта «близна к железу» делает JDBC незаменимым в сценариях, где требуется полный контроль над выполняемыми запросами, оптимизация на уровне СУБД или интеграция с системами, не поддерживающими более высокоуровневые абстракции.
Особенности работы с БД в Java
Работа с базами данных в Java обладает рядом специфических черт, которые формируют как возможности, так и сложности разработки:
— Строгая типизация и безопасность. Java — статически типизированный язык, и JDBC, как часть стандартной библиотеки, в полной мере использует эту особенность. Например, при получении значения из ResultSet разработчик обязан явно указать ожидаемый тип (getString, getInt, getTimestamp и т.п.), что предотвращает неявные преобразования и упрощает выявление ошибок на этапе компиляции или ранней стадии выполнения. Однако это требует большей детализации по сравнению с динамическими языками.
— Отсутствие встроенного ORM. В отличие от некоторых других платформ (например, .NET с Entity Framework Core или Python с Django ORM), стандартная поставка Java не включает объектно-реляционное отображение. ORM-решения в Java — это продукт экосистемы: Hibernate, EclipseLink, OpenJPA и др. Это означает, что выбор уровня абстракции остаётся за разработчиком, но также требует дополнительного изучения и интеграционных усилий.
— Сильная зависимость от внешних компонентов. Для работы с конкретной СУБД необходимо наличие соответствующего JDBC-драйвера, который не является частью JDK. Это означает, что приложение не может быть полностью автономным — оно зависит от внешней библиотеки, совместимой с версией СУБД и архитектурой развертывания.
— Управление ресурсами вручную (на низком уровне). JDBC требует явного открытия и закрытия соединений, операторов и наборов результатов. Несоблюдение этого правила приводит к утечкам ресурсов, исчерпанию пулов соединений и падению производительности. Хотя современные практики (try-with-resources, пулы соединений, Spring-managed resources) значительно снижают риски, на уровне API ответственность лежит на программисте.
— Декларативность SQL vs. императивность Java. SQL — язык декларативный: вы описываете что вы хотите получить, а не как. Java же — императивный язык: вы описываете последовательность действий. JDBC служит мостом между этими парадигмами: он позволяет интегрировать SQL в Java-код, но не устраняет семантическую дистанцию между ними. Именно эта дистанция и становится основной мотивацией для появления ORM.
Основные подходы к работе с БД в Java
Можно выделить четыре основных подхода к взаимодействию с базами данных в Java, формирующих иерархию от низкоуровневого к высокоуровневому:
-
JDBC (Java Database Connectivity) — стандартный, низкоуровневый API. Обеспечивает прямое выполнение SQL, полный контроль над соединениями и запросами, но требует значительного объема шаблонного кода: установка соединения, подготовка инструкции, обработка
ResultSet, безопасное освобождение ресурсов, обработка исключений. Подходит для небольших приложений, утилит, инструментальных библиотек, а также для случаев, когда необходима тонкая настройка запросов (например, использование нативных расширений СУБД). -
SQL-фреймворки (обёртки над JDBC) — такие как jOOQ, MyBatis (ранее iBatis), QueryDSL. Они не скрывают SQL, но устраняют большую часть шаблонного кода JDBC, добавляя типобезопасность, fluent-интерфейсы, генерацию кода на основе схемы БД и автоматическое управление ресурсами. Например, jOOQ позволяет строить запросы в стиле DSL, близком к SQL, но с проверкой типов на этапе компиляции. MyBatis позволяет оставлять SQL в XML или аннотациях, но связывает результаты с Java-объектами без необходимости писать
rs.getString("name")вручную. -
ORM (Object-Relational Mapping) — такие как Hibernate, EclipseLink, OpenJPA. Их цель — устранить импедансное несоответствие между объектной моделью Java и реляционной моделью БД. Разработчик работает с обычными Java-классами (сущностями), а ORM автоматически генерирует и выполняет необходимые SQL-запросы, управляет транзакциями, кэшированием, ленивой загрузкой связанных объектов и другими нетривиальными аспектами. Это повышает продуктивность, но вводит дополнительную сложность: нужно понимать поведение ORM, избегать антипаттернов (например, N+1), настраивать стратегии извлечения и синхронизации.
-
Экосистемные фреймворки (Spring Data, Micronaut Data и др.) — надстройки над ORM и SQL-фреймворками, предоставляющие дополнительные абстракции: репозитории, проекции, автоматическую генерацию query-методов по имени, интеграцию с транзакционным менеджером, поддержку реактивного программирования. Spring Data JPA, например, позволяет объявить интерфейс
UserRepository extends JpaRepository<User, Long>, и получить полный набор CRUD-операций без реализации — Spring сгенерирует реализацию динамически во время выполнения или компиляции. Это достигает максимальной декларативности в enterprise-разработке.
Ниже мы подробно рассмотрим первый и наиболее фундаментальный уровень — JDBC.
JDBC
JDBC был введён в Java 1.1 (1997 г.) и с тех пор остаётся неизменным по своей сути: это набор интерфейсов и классов в пакете java.sql (и javax.sql — для расширенных возможностей, таких как пулы соединений и распределённые транзакции). Несмотря на появление более современных подходов, JDBC остаётся основой всех остальных решений — даже Hibernate в конечном итоге вызывает JDBC API, пусть и через несколько слоёв абстракции.
Архитектурная схема JDBC
JDBC следует классической схеме «приложение — драйвер — СУБД». Приложение взаимодействует только с интерфейсами JDBC. При загрузке класса драйвера (например, com.mysql.cj.jdbc.Driver) он регистрируется в DriverManager. Когда приложение запрашивает соединение через DriverManager.getConnection(...), менеджер выбирает подходящий драйвер на основе URL-префикса (например, jdbc:mysql://...), и передаёт управление ему. Драйвер, в свою очередь, устанавливает сетевое соединение с сервером СУБД, аутентифицирует клиента и возвращает реализацию интерфейса Connection, специфичную для этой СУБД.
Таким образом, приложение не зависит от конкретной базы данных на уровне кода — зависимость реализуется через конфигурацию (URL, драйвер в classpath). Это позволяет, например, переключиться с H2 на PostgreSQL, изменив лишь строку подключения и зависимость в сборке.
Ключевые компоненты JDBC
Рассмотрим основные интерфейсы и классы, участвующие в жизненном цикле запроса.
1. DriverManager
Это служебный класс, управляющий загруженными драйверами. В ранних версиях Java требовалось явно вызывать Class.forName("com.mysql.cj.jdbc.Driver"), чтобы загрузить и зарегистрировровать драйвер. Современные драйверы (с JDBC 4.0+, Java 6+) используют механизм Service Provider Interface (SPI): при наличии драйвера в classpath он автоматически регистрируется при первом обращении к DriverManager. Поэтому явный вызов Class.forName(...) сегодня не обязателен — достаточно добавить артефакт в зависимости сборки.
2. Connection
Представляет активное соединение с базой данных. Через него создаются объекты Statement, управляется режим транзакций (setAutoCommit, commit, rollback), извлекаются метаданные базы (getMetaData) и проверяется состояние (isClosed). Соединение — это тяжеловесный ресурс: установка требует сетевого handshake, аутентификации и инициализации сессии на стороне СУБД. Поэтому в реальных приложениях напрямую DriverManager.getConnection() почти не используется. Вместо этого применяются пулы соединений (connection pools): библиотеки вроде HikariCP, Apache Commons DBCP, Tomcat JDBC Pool, которые удерживают пул готовых соединений и выдают их по запросу, автоматически восстанавливая разорванные связи и контролируя время жизни. Использование пула повышает производительность в многопоточной среде и защищает от исчерпания лимитов СУБД.
3. Statement, PreparedStatement, CallableStatement
Эти интерфейсы отвечают за выполнение SQL-инструкций.
-
Statement— базовый интерфейс для выполнения статических SQL-запросов без параметров. Его методы:executeQuery()(дляSELECT),executeUpdate()(дляINSERT,UPDATE,DELETE, DDL),execute()(универсальный, редко используется). Уязвим к SQL-инъекциям при конкатенации строк:String sql = "SELECT * FROM users WHERE name = '" + userInput + "'";
// опасно: userInput = "'; DROP TABLE users; --" -
PreparedStatement— параметризованный вариантStatement. Запрос формулируется с заполнителями (?), а значения подставляются отдельно, через методыsetString,setIntи т.д. Это делает запрос типобезопасным и защищённым от инъекций, поскольку значения передаются в бинарном виде, а не как часть SQL-текста. Кроме того, СУБД может кэшировать план выполнения для таких запросов, что ускоряет повторные вызовы.PreparedStatement— де-факто стандарт для любых запросов с переменными. -
CallableStatement— расширение для вызова хранимых процедур (CALL procedure_name(...)). Позволяет работать с входными (IN), выходными (OUT) и входо-выходными (INOUT) параметрами.
4. ResultSet
Объект, содержащий результат выполнения SELECT-запроса. Представляет собой курсор над строками результата. По умолчанию — прокручиваемый только вперёд (TYPE_FORWARD_ONLY), однопоточный (CONCUR_READ_ONLY). Поддерживаются более сложные режимы (TYPE_SCROLL_INSENSITIVE, TYPE_SCROLL_SENSITIVE, CONCUR_UPDATABLE), но их поддержка зависит от драйвера и СУБД и редко используется в практике.
Каждая строка ResultSet — это упорядоченный набор столбцов, к которым можно обращаться по индексу (начиная с 1) или по имени. Важно: методы getXXX() не создают новых объектов при повторном вызове — они считывают данные из текущей строки буфера драйвера. Перед чтением следующей строки необходимо вызвать next(); при инициализации курсор находится перед первой строкой.
Обработка ResultSet — наиболее трудоёмкая часть JDBC-кода, требующая аккуратного преобразования типов и отображения на доменные объекты.
5. SQLException
Базовый класс исключений JDBC. Он наследуется от java.lang.Exception, но начиная с Java 7 поддерживает цепочки исключений (chained exceptions) и SQL-состояния (SQLState — пятизначный код по стандарту SQL:2003, например, 23505 — нарушение уникального ограничения). Также содержит код ошибки, специфичный для СУБД (getErrorCode()). Корректная обработка SQLException требует анализа SQLState, а не только сообщения, поскольку сообщения могут быть локализованы.
Универсальный алгоритм работы с БД через JDBC
Хотя конкретные реализации различаются, любой сценарий доступа к данным через JDBC можно описать как последовательность из семи этапов. Некоторые из них (например, создание БД) выполняются вне приложения — на этапе подготовки инфраструктуры. Остальные — в коде.
Этап 1. Проектирование схемы данных
Перед написанием кода необходимо определить структуру хранилища: выбрать СУБД (исходя из требований к ACID, масштабируемости, лицензированию), спроектировать таблицы, поля, индексы, внешние ключи, ограничения целостности. В Java это не отражается напрямую, но влияет на формулировку запросов и маппинг объектов. Например, составные первичные ключи потребуют явного указания в аннотациях JPA (@IdClass или @EmbeddedId) или ручной обработки в JDBC.
Этап 2. Подключение JDBC-драйвера
Драйвер добавляется как зависимость сборки. Примеры для Maven:
<!-- PostgreSQL -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.3</version>
</dependency>
<!-- MySQL -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.3.0</version>
</dependency>
<!-- SQLite -->
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>3.44.1.0</version>
</dependency>
Для Oracle драйвер (ojdbc11) не публикуется в Maven Central по лицензионным причинам — его нужно загрузить вручную с сайта Oracle и установить в локальный репозиторий или enterprise-репозиторий (Nexus, Artifactory).
Этап 3. Настройка параметров подключения
Строка подключения (JDBC URL) имеет общий формат:
jdbc:<subprotocol>://<host>:<port>/<database>?<parameters>
Примеры:
jdbc:postgresql://localhost:5432/mydb?currentSchema=public&sslmode=disablejdbc:mysql://192.168.1.10:3306/appdb?useSSL=false&serverTimezone=UTCjdbc:sqlite:/path/to/database.db(файл на диске)
Параметры аутентификации (логин/пароль) обычно передаются отдельно через DriverManager.getConnection(url, user, password). Однако в промышленной разработке они никогда не хранятся в коде — только в конфигурационных файлах (application.properties, application.yml), переменных окружения или secure vault (HashiCorp Vault, AWS Secrets Manager). В Spring Boot это выглядит так:
spring.datasource.url=jdbc:postgresql://db.example.com:5432/app
spring.datasource.username=${DB_USER}
spring.datasource.password=${DB_PASSWORD}
spring.datasource.driver-class-name=org.postgresql.Driver
Этап 4. Установление соединения
Простейший способ (не для production):
String url = "jdbc:postgresql://localhost:5432/test";
String user = "admin";
String password = "secret";
try (Connection conn = DriverManager.getConnection(url, user, password)) {
// работа с БД
} catch (SQLException e) {
// обработка ошибки
}
Использование try-with-resources гарантирует, что соединение будет закрыто даже при возникновении исключения. В enterprise-приложениях вместо DriverManager используется DataSource — интерфейс из javax.sql, инкапсулирующий логику получения соединений. Пулы соединений (HikariCP и др.) реализуют DataSource, и Spring управляет им автоматически:
@Configuration
public class DataSourceConfig {
@Bean
public DataSource dataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost:5432/test");
config.setUsername("admin");
config.setPassword("secret");
config.setMaximumPoolSize(20);
return new HikariDataSource(config);
}
}
Этап 5. Формирование и выполнение запросов
Пример выполнения параметризованного запроса с PreparedStatement:
String sql = "SELECT id, name, email FROM users WHERE created_after > ? AND status = ?";
try (PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setTimestamp(1, Timestamp.from(Instant.now().minus(Duration.ofDays(30))));
stmt.setString(2, "ACTIVE");
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
long id = rs.getLong("id");
String name = rs.getString("name");
String email = rs.getString("email");
// преобразование в доменный объект
}
}
}
Обратите внимание на вложенность try-with-resources: PreparedStatement и ResultSet тоже требуют освобождения, и try-with-resources гарантирует их закрытие в правильном порядке (обратном порядку открытия).
Для модификации данных:
String sql = "INSERT INTO orders (user_id, amount, created_at) VALUES (?, ?, ?)";
try (PreparedStatement stmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
stmt.setLong(1, userId);
stmt.setBigDecimal(2, amount);
stmt.setTimestamp(3, Timestamp.from(Instant.now()));
int rowsAffected = stmt.executeUpdate(); // возвращает количество изменённых строк
// если нужно получить сгенерированный первичный ключ (например, SERIAL в PostgreSQL)
try (ResultSet keys = stmt.getGeneratedKeys()) {
if (keys.next()) {
long orderId = keys.getLong(1);
// использовать orderId
}
}
}
Флаг Statement.RETURN_GENERATED_KEYS обязателен для доступа к getGeneratedKeys(). Не все СУБД поддерживают возврат нескольких сгенерированных ключей (например, при INSERT ... RETURNING * в PostgreSQL это возможно, но интерфейс JDBC ограничен).
Этап 6. Обработка результатов и исключений
Преобразование ResultSet в объект — повторяющаяся задача. Вручную это выглядит так:
public User mapRow(ResultSet rs) throws SQLException {
User user = new User();
user.setId(rs.getLong("id"));
user.setName(rs.getString("name"));
user.setEmail(rs.getString("email"));
user.setCreatedAt(rs.getTimestamp("created_at").toLocalDateTime());
return user;
}
Если столбец может быть NULL, следует использовать проверку rs.wasNull() после getXXX(), или предпочесть getObject() с кастомной логикой. Например, rs.getTimestamp("deleted_at") вернёт null, если поле NULL, но .toLocalDateTime() вызовет NPE — нужно проверять результат перед преобразованием.
Обработка исключений должна учитывать типы ошибок:
SQLTimeoutException— таймаут запроса;SQLTransientException— временная ошибка (например, разрыв сети), возможно повторить;SQLNonTransientException— фатальная ошибка (нарушение ограничения, синтаксическая ошибка), повтор бессмысленен.
Этап 7. Освобождение ресурсов
Как уже отмечалось, try-with-resources — стандартный и рекомендуемый способ. Порядок закрытия:
ResultSetStatement/PreparedStatementConnection
Если ресурсы создаются вне try-блока, закрытие должно происходить в finally с проверкой на null и подавлением исключений при закрытии (так как исключение закрытия менее важно, чем исключение выполнения).
ORM, JPA и экосистемные абстракции
Если JDBC — это инструмент, позволяющий выполнять SQL в среде Java, то объектно-реляционное отображение (ORM) — это парадигма, позволяющая думать о данных в терминах объектов и отношений между ними, а не таблиц и строк. ORM решает так называемую задачу импедансного несоответствия (impedance mismatch) — концептуальную дистанцию между объектно-ориентированной моделью приложения и реляционной моделью хранения.
Эта дистанция проявляется в нескольких аспектах:
— Гранулярность: в объектной модели допустимы сложные вложенные структуры (объект содержит список других объектов, каждый из которых может содержать ещё что-то), тогда как в реляционной модели данные нормализованы по таблицам, и связи реализуются через внешние ключи.
— Наследование: Java поддерживает иерархии наследования, но реляционные СУБД — нет. Как отобразить иерархию классов Payment → CreditCardPayment | BankTransferPayment на таблицы? Есть несколько стратегий (одна таблица на иерархию, одна таблица на подкласс, объединяющая таблица), и ORM должен поддерживать их все.
— Целостность идентичности: в Java два объекта считаются одинаковыми, если a == b (один и тот же экземпляр) или a.equals(b) (логическое равенство). В БД — если значения первичного ключа совпадают. ORM должен отслеживать, какой объект в памяти соответствует какой строке в БД, и не создавать дубликатов.
— Жизненный цикл: объект в Java создаётся через new, уничтожается сборщиком мусора. Строка в БД создаётся через INSERT, удаляется через DELETE, изменяется через UPDATE. ORM вводит понятие состояния сущности: transient (не связан с БД), managed/persistent (отслеживается менеджером, изменения будут сохранены), detached (был связан, но сессия закрыта), removed (отмечен на удаление).
— Ленивые вычисления: в Java поле объекта либо загружено, либо null. В ORM можно объявить связь как ленивую (LAZY), и тогда содержимое будет загружено только при первом обращении к геттеру — даже если объект был получен из БД час назад. Это требует проксирования и управления контекстом.
Именно для стандартизации этих концепций и появился JPA (Java Persistence API).
JPA
JPA — это спецификация, разрабатываемая в рамках Java EE (ныне Jakarta EE), определяющая единый API для работы с постоянным хранилищем в enterprise-приложениях. Важно подчеркнуть: JPA — это не библиотека и не фреймворк. Это набор интерфейсов (EntityManager, Entity, Query, PersistenceContext и др.) и правил их взаимодействия. Реализации JPA предоставляют конкретные провайдеры: Hibernate (наиболее распространённый), EclipseLink (референсная реализация от Eclipse Foundation), OpenJPA (Apache), DataNucleus.
Преимущества стандартизации очевидны: приложение, написанное на JPA, может быть перенесено с Hibernate на EclipseLink простой заменой зависимости и, возможно, нескольких конфигурационных параметров — без изменения основного кода. Это особенно ценно в крупных проектах, где политика выбора технологий может меняться со временем.
JPA строится на трёх китах:
1. Аннотации для описания сущностей
Вместо ручного маппинга ResultSet → объект, разработчик помечает Java-класс аннотацией @Entity и описывает, как его поля соотносятся со столбцами таблицы:
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "full_name", nullable = false, length = 100)
private String name;
@Column(unique = true)
private String email;
@Enumerated(EnumType.STRING)
private Status status;
@Temporal(TemporalType.TIMESTAMP)
@Column(name = "created_at")
private Date createdAt;
// геттеры и сеттеры (или final поля + конструктор — но требует конфигурации)
}
Каждая аннотация — декларативная инструкция для провайдера:
@Entity— класс является сущностью;@Table— уточняет имя таблицы (по умолчанию — имя класса);@Id— первичный ключ;@GeneratedValue— стратегия генерации (автоинкремент, sequence, UUID и др.);@Column— параметры столбца: имя, nullable, unique, length, precision;@Enumerated— как хранить enum: как строку или порядковый номер;@Temporal— для совместимости с Java 7/8 (устарело в JPA 2.2+, гдеLocalDateTimeподдерживается напрямую).
Обратите внимание: нет SQL в коде. Нет упоминания СУБД. Схема базы данных может быть создана автоматически провайдером (для разработки и тестов) или сверена с существующей (validation). Это достигается через свойство javax.persistence.schema-generation.database.action (create, drop-and-create, none, validate).
2. EntityManager — центр управления состоянием
EntityManager — это интерфейс, через который выполняются все операции с сущностями: создание, чтение, обновление, удаление, выполнение запросов. Он инкапсулирует так называемый контекст постоянства (persistence context) — кэш первого уровня, в котором хранятся все управляемые (managed) сущности в рамках одной транзакции.
Пример CRUD-операций:
// получение EntityManager (в Spring Boot — инъекция через @PersistenceContext)
@Transactional
public void transferFunds(Long fromId, Long toId, BigDecimal amount) {
User from = em.find(User.class, fromId); // загружает сущность по ID
User to = em.find(User.class, toId);
if (from.getBalance().compareTo(amount) < 0) {
throw new InsufficientFundsException();
}
from.setBalance(from.getBalance().subtract(amount));
to.setBalance(to.getBalance().add(amount));
// никаких em.update() не требуется!
// изменения автоматически синхронизируются с БД при коммите транзакции (flush)
}
Здесь ключевой момент: изменения в управляемых сущностях отслеживаются автоматически. Это так называемый dirty checking — ORM сравнивает состояние объекта на момент загрузки и перед коммитом, и генерирует UPDATE, только если что-то изменилось. Никакого ручного вызова save() или update() не нужно — за исключением вставки новых (em.persist(entity)).
Также EntityManager предоставляет методы:
persist(entity)— переводит transient-объект в managed;remove(entity)— помечает сущность на удаление;merge(entity)— принимает detached-объект, копирует его состояние в managed-экземпляр и возвращает его;detach(entity)— отвязывает объект от контекста;clear()— очищает весь контекст (редко используется).
3. Язык запросов: JPQL и Criteria API
JPA предлагает два способа формулировать запросы, не используя нативный SQL:
— JPQL (Java Persistence Query Language) — объектно-ориентированный аналог SQL. Вместо имён таблиц и столбцов используются имена классов и полей:
// Выборка всех активных пользователей с балансом > 1000
String jpql = """
SELECT u FROM User u
WHERE u.status = :status AND u.balance > :minBalance
ORDER BY u.createdAt DESC
""";
TypedQuery<User> query = em.createQuery(jpql, User.class);
query.setParameter("status", Status.ACTIVE);
query.setParameter("minBalance", BigDecimal.valueOf(1000));
List<User> users = query.getResultList();
JPQL поддерживает JOIN (включая FETCH JOIN для упреждающей загрузки связей), агрегаты (COUNT, SUM), подзапросы, функции. Он проверяется при компиляции (если использовать TypedQuery), но не является типобезопасным на 100 %: имя поля balance — строка, и ошибка опечатки обнаружится только в runtime.
— Criteria API — программный способ построения запросов. Позволяет конструировать JPQL-подобные запросы в виде Java-кода, с полной типобезопасностью и поддержкой IDE (автодополнение, рефакторинг):
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<User> cq = cb.createQuery(User.class);
Root<User> user = cq.from(User.class);
cq.select(user)
.where(
cb.equal(user.get("status"), Status.ACTIVE),
cb.gt(user.get("balance"), BigDecimal.valueOf(1000))
)
.orderBy(cb.desc(user.get("createdAt")));
TypedQuery<User> query = em.createQuery(cq);
List<User> users = query.getResultList();
Основные преимущества Criteria API:
- безопасность при рефакторинге (изменение имени поля в классе вызовет ошибку компиляции);
- динамическое построение запросов (например, фильтрация по произвольному набору условий);
- совместимость с метамоделью (автогенерируемые классы
User_, гдеUser_.balance— типизированный путь).
Недостаток — громоздкость синтаксиса. Поэтому на практике часто комбинируют: JPQL для статических запросов, Criteria API — для динамических.
Hibernate
Hibernate — полноценный ORM-фреймворк, существовавший до появления JPA и оказавший значительное влияние на формирование стандарта. В режиме JPA он следует спецификации, но предоставляет также нативные API и функции, недоступные через стандартный интерфейс. Это делает его гибким, но привязывает код к Hibernate, если эти функции используются.
Ключевые возможности Hibernate, выходящие за рамки JPA:
— Hibernate Query Language (HQL) — расширение JPQL. Поддерживает:
- нативные функции СУБД (
function('lower', u.name)); - паттерны коллекций (
elements(u.roles)); - обновления и удаления через HQL (
UPDATE User u SET u.status = 'INACTIVE' WHERE ...); - оконные функции (в новых версиях);
- подзапросы в
SELECT.
— Кэширование второго уровня. JPA определяет только кэш первого уровня (в рамках EntityManager). Hibernate добавляет кэш второго уровня — общий для всех сессий в приложении. Он может использовать Ehcache, Infinispan, Caffeine и др. Полезен для часто читаемых, редко изменяемых данных (справочники, настройки). Управление — через аннотации (@Cacheable, @Cache) и конфигурацию.
— Ленивая загрузка и прокси. При запросе сущности с ленивыми связями (@OneToMany(fetch = FetchType.LAZY)) Hibernate создаёт прокси-объект — динамический подкласс, переопределяющий геттеры так, что при первом обращении к коллекции выполняется дополнительный запрос. Это требует, чтобы сессия (Session, аналог EntityManager) была открыта в момент доступа — иначе возникнет LazyInitializationException. В веб-приложениях это решается через Open Session in View (антипаттерн, не рекомендуется) или DTO-проекции с JOIN FETCH.
— Batch-обработка. Для массовой вставки/обновления Hibernate поддерживает пакетную обработку: при включённой настройке hibernate.jdbc.batch_size несколько INSERT объединяются в один сетевой запрос. Требует, чтобы первичные ключи генерировались не через IDENTITY (MySQL, MS SQL), а через SEQUENCE или TABLE, поскольку IDENTITY требует немедленного извлечения сгенерированного ID.
— Кастомные типы и user types. Возможность определить, как Java-тип (например, Money, PhoneNumber) отображается на один или несколько столбцов. Используется @Type, @ColumnTransformer, @Embeddable.
Настройка Hibernate
В Spring Boot основные параметры задаются в application.properties:
# Включить генерацию схемы (только для dev!)
spring.jpa.hibernate.ddl-auto=validate
# Включить логгирование SQL (не в промышленной эксплуатации)
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
# Пул соединений (HikariCP управляется отдельно)
spring.datasource.hikari.maximum-pool-size=20
# Hibernate-specific
spring.jpa.properties.hibernate.jdbc.batch_size=20
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true
spring.jpa.properties.hibernate.cache.use_second_level_cache=true
spring.jpa.properties.hibernate.cache.region.factory_class=jcache
Обратите внимание: spring.jpa.hibernate.ddl-auto=create-drop опасен в production — он уничтожит данные при старте приложения. Рекомендуется использовать инструменты миграций (Flyway, Liquibase) для управления схемой.
Spring Data JPA
Если JPA устраняет boilerplate-код для CRUD, то Spring Data JPA устраняет даже интерфейсы для этого кода. Его идея проста: вместо реализации репозитория вручную, вы объявляете интерфейс, наследуя от JpaRepository<T, ID>, и Spring динамически создаёт реализацию во время запуска приложения.
Базовый репозиторий
public interface UserRepository extends JpaRepository<User, Long> {
// уже доступны:
// Optional<User> findById(Long id);
// List<User> findAll();
// User save(User user);
// void deleteById(Long id);
// и десятки других методов
}
Инъекция в сервис:
@Service
@Transactional
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User register(String email, String name) {
User user = new User();
user.setEmail(email);
user.setName(name);
user.setStatus(Status.PENDING);
return userRepository.save(user); // вставляет или обновляет
}
}
Query Methods: генерация запросов по имени метода
Spring Data позволяет объявлять методы в интерфейсе репозитория, и автоматически генерировать JPQL-запрос на основе имени:
public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByStatusAndBalanceGreaterThan(Status status, BigDecimal minBalance);
Optional<User> findByEmailIgnoreCase(String email);
Page<User> findByCreatedAtBeforeOrderByCreatedAtDesc(
LocalDateTime cutoff, Pageable pageable
);
}
Правила разбора имени метода:
findBy...,readBy...,getBy...,queryBy...,searchBy...,streamBy...— префиксы;And,Or— логические операторы;IgnoreCase,Containing,StartingWith,GreaterThan,Between,In,IsNull— операторы сравнения;OrderBy...Asc/Desc— сортировка;Page<T>,List<T>,Stream<T>,Optional<T>— возвращаемый тип;Pageable,Sort— параметры пагинации и сортировки.
Это мощный инструмент для типовых запросов, но для сложных случаев (JOIN, подзапросы, агрегаты) лучше использовать @Query.
Аннотация @Query
Позволяет явно указать JPQL или нативный SQL:
public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT u FROM User u JOIN FETCH u.roles r WHERE r.name = :role")
List<User> findByRole(@Param("role") String roleName);
@Query(value = """
SELECT u.*, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id
HAVING COUNT(o.id) > :minOrders
""", nativeQuery = true)
List<Object[]> findActiveUsersWithOrderCount(@Param("minOrders") int minOrders);
}
Проекции и DTO
Часто не нужно загружать всю сущность — достаточно нескольких полей. Spring Data поддерживает интерфейсные и классовые проекции:
public interface UserSummary {
String getName();
String getEmail();
int getOrderCount();
}
public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT u.name AS name, u.email AS email, COUNT(o) AS orderCount " +
"FROM User u LEFT JOIN u.orders o GROUP BY u.id")
List<UserSummary> findSummaries();
}
Если объявить UserSummary как интерфейс, Spring создаст динамический прокси. Если как класс с конструктором — будет использоваться конструкторное отображение. Это позволяет избежать избыточной передачи данных и улучшить производительность.
Спецификации и Querydsl
Для сложной динамической фильтрации (например, веб-формы с десятком фильтров) Spring Data интегрируется с:
- JPA Specifications — на основе Criteria API, позволяет строить предикаты как объекты;
- Querydsl — генерирует Q-классы (
QUser), обеспечивающие 100 % типобезопасный fluent-интерфейс.
Пример Specification:
public class UserSpecs {
public static Specification<User> hasStatus(Status status) {
return (root, query, cb) -> cb.equal(root.get("status"), status);
}
public static Specification<User> balanceGreaterThan(BigDecimal amount) {
return (root, query, cb) -> cb.greaterThan(root.get("balance"), amount);
}
}
// Использование
userRepository.findAll(
where(hasStatus(Status.ACTIVE)).and(balanceGreaterThan(BigDecimal.valueOf(1000)))
);
Архитектурный выбор, сопровождение и эксплуатация
Выбор способа взаимодействия с базой данных — стратегическое решение, влияющее на сроки разработки, стоимость сопровождения, гибкость изменений и масштабируемость системы. Ниже мы рассмотрим критерии выбора подхода, управление целостностью данных, обеспечение повторяемости развёртываний и защиту от типовых ошибок.
Сравнительный анализ подходов
Нет универсального «лучшего» решения. Выбор определяется контекстом проекта. Приведём ориентировочную матрицу принятия решений.
| Критерий | JDBC | SQL-фреймворки (MyBatis, jOOQ) | ORM (Hibernate/JPA) | Spring Data JPA |
|---|---|---|---|---|
| Контроль над SQL | Полный | Полный (SQL остаётся явным) | Частичный (можно использовать @Query, но сложные оптимизации требуют знания генерируемых запросов) | Частичный (через @Query или Specifications) |
| Скорость разработки CRUD | Низкая (много шаблонного кода) | Средняя (маппинг проще, чем вручную, но без автоматизации) | Высокая (автоматическое отслеживание изменений, встроенные операции) | Очень высокая (реализация репозиториев не требуется) |
| Производительность (latency / throughput) | Максимальная (минимум накладных расходов) | Близка к JDBC, но с небольшими накладными расходами на маппинг | Ниже из-за overhead (dirty checking, прокси, кэширование), но может быть оптимизирована | Аналогично JPA, но с дополнительным слоем Spring |
| Сложность отладки | Простая (виден каждый запрос, легко логгировать) | Средняя (логгирование настраивается, но есть абстракция) | Высокая (нужно понимать, какие запросы генерируются, как работает кэш, lazy loading) | Аналогично JPA |
| Гибкость схемы БД | Полная (нет привязки к объектной модели) | Полная (SQL пишется вручную) | Ограничена: изменения в БД часто требуют синхронного изменения сущностей | Аналогично JPA |
| Поддержка legacy-БД | Отличная (работает с любой схемой) | Отличная | Сложная (требует адаптации аннотаций, возможно — DTO-слоя) | Сложная |
| Обучение команды | Требует знания SQL и JDBC | Требует знания SQL и фреймворка | Требует глубокого понимания ORM, жизненного цикла сущностей | Требует знания Spring и JPA |
| Типовые сценарии | Утилиты, интеграции, микросервисы с простой моделью, high-load обработчики | Приложения с комплексной бизнес-логикой и тонко настраиваемыми запросами (финансы, аналитика) | Корпоративные приложения, ERP-модули, системы с богатой доменной моделью | Сервисы на Spring Boot с типовыми CRUD-операциями |
Рекомендации по выбору:
— Используйте JDBC напрямую, если:
- Вы пишете утилиту (например, миграцию данных, батч-процесс);
- Требуется максимальная производительность и минимальная задержка;
- Схема БД чрезвычайно нестандартна (например, динамические столбцы, отсутствие первичных ключей);
- Вы интегрируетесь с СУБД, для которой нет качественного драйвера ORM (редко, но бывает).
— Используйте MyBatis или jOOQ, если:
- Команда хорошо знает SQL и предпочитает писать запросы вручную;
- Бизнес-логика требует сложных JOIN, оконных функций, CTE;
- Вы хотите избежать «магии» ORM, но устали от
rs.getString("col"); - jOOQ особенно уместен, когда схема БД стабильна и можно генерировать метамодель.
— Используйте Hibernate/JPA, если:
- У вас большая, сложная доменная модель с наследованием, полиморфными связями;
- Важна скорость разработки новых фич при умеренных требованиях к latency;
- Вы готовы инвестировать в обучение команды и мониторинг SQL-запросов.
— Добавьте Spring Data JPA, если:
- Проект уже использует Spring Boot;
- Большинство операций — стандартные CRUD или простые фильтрации;
- Вы цените декларативность и хотите сократить объём шаблонного кода.
Важно: гибридные подходы допустимы и часто предпочтительны. Например:
- Основная логика на Spring Data JPA;
- Критически важные отчёты — через jOOQ или нативные запросы в
@Query; - Интеграция с legacy-таблицей — через JDBC Template (обёртка Spring над JDBC).
Управление транзакциями
Транзакция — это логическая единица работы, обладающая свойствами ACID (Atomicity, Consistency, Isolation, Durability). В Java управление транзакциями может быть:
- Программным (через
Connection.setAutoCommit(false),commit(),rollback()); - Декларативным (через
@Transactionalв Spring илиUserTransactionв Jakarta EE).
Декларативные транзакции в Spring
Аннотация @Transactional — стандартный способ в Spring Boot. Она работает на уровне прокси: при вызове метода Spring создаёт транзакцию (или присоединяется к существующей), выполняет метод, и при успешном завершении делает commit, при исключении — rollback.
@Service
public class OrderService {
@Transactional
public Order createOrder(Long userId, List<OrderItem> items) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException("User not found"));
Order order = new Order();
order.setUser(user);
order.setItems(items);
order.setStatus(OrderStatus.CREATED);
order = orderRepository.save(order); // flush происходит автоматически
paymentService.charge(user, order.getTotal()); // вызов другого @Transactional-метода
return order;
}
}
Ключевые параметры @Transactional:
-
propagation— как вести себя при вложенном вызове:REQUIRED(по умолчанию) — использовать текущую транзакцию, если есть, иначе создать новую;REQUIRES_NEW— приостановить внешнюю транзакцию и начать новую;NESTED— не поддерживается JPA (только JDBC), создаёт savepoint;NOT_SUPPORTED,NEVER,MANDATORY— для специфических случаев.
-
isolation— уровень изоляции (по умолчанию —ISOLATION_DEFAULT, т.е. уровень СУБД):READ_UNCOMMITTED,READ_COMMITTED,REPEATABLE_READ,SERIALIZABLE;- Выбор влияет на производительность и вероятность аномалий (грязное чтение, неповторяющееся чтение, фантомы).
-
timeout— максимальное время выполнения транзакции (в секундах). -
readOnly— подсказка СУБД, что транзакция только читает данные. Может улучшить производительность (например, в PostgreSQL позволяет использовать «snapshot isolation» без блокировок). -
rollbackFor,noRollbackFor— уточнение, при каких исключениях делать откат (по умолчанию — приRuntimeExceptionиError).
Правила работы с транзакциями
-
Транзакции должны быть как можно короче. Долгие транзакции удерживают блокировки, снижают параллелизм и рискуют превысить таймаут.
-
Не вызывайте
@Transactional-методы из того же класса. Прокси Spring не сработает, и аннотация будет проигнорирована. Решение — вынести в отдельный бин или использоватьAopContext.currentProxy()(не рекомендуется). -
Избегайте бизнес-логики в методах с
@Transactional, если она вызывает внешние сервисы (HTTP, очереди). Если вызовpaymentService.charge()в примере выше завершится успешно, но при коммите БД произойдёт сбой, деньги будут списаны, а заказ не сохранится. Для таких случаев нужны распределённые транзакции (XA) или паттерны вроде Saga. -
Проверяйте поведение при конфликтах. В высоконагруженных системах возможны deadlocks или optimistic lock failures (
OptimisticLockException). Нужно предусмотреть retry-логику (например, через@Retryableиз Spring Retry).
Миграции схемы
Автоматическая генерация схемы через hibernate.hbm2ddl.auto неприемлема в production. Она не обеспечивает:
- Атомарности изменений;
- Отката (rollback);
- Проверки совместимости со старыми версиями приложения;
- Выполнения миграционных скриптов (например, пересчёта данных).
Вместо этого используются инструменты миграций:
Flyway
- Основан на версионированных SQL-скриптах:
src/main/resources/db/migration/
V1__Create_users_table.sql
V2__Add_balance_column.sql
V3__Migrate_status_enum.sql - Каждый скрипт выполняется один раз, информация о применённых миграциях хранится в служебной таблице
flyway_schema_history. - Поддерживает repeatable-миграции (
R__), Java-миграции, шифрование, dry-run. - Интеграция с Spring Boot: достаточно добавить зависимость
flyway-core, и миграции запустятся при старте.
Liquibase
- Описывает изменения в независимом от СУБД формате (XML, YAML, JSON, SQL):
databaseChangeLog:
- changeSet:
id: 1
author: timur
changes:
- createTable:
tableName: users
columns:
- column:
name: id
type: bigint
autoIncrement: true
constraints:
primaryKey: true
- column:
name: email
type: varchar(255)
constraints:
nullable: false
unique: true - Поддерживает генерацию diff между состояниями, откат изменений (
rollback), теги. - Более гибкий, но сложнее в освоении.
Рекомендация: для команд, хорошо знающих SQL и предпочитающих прозрачность — Flyway. Для кросс-платформенных проектов или при частой смене СУБД — Liquibase.
Тестирование доступа к данным
Тесты, затрагивающие БД, должны быть:
- Изолированными (не влиять друг на друга);
- Повторяемыми (один и тот же результат при каждом запуске);
- Быстрыми (по возможности).
Стратегии:
-
Встраиваемая БД (H2)
Лёгкая in-memory СУБД, совместимая с SQL. Подходит для unit- и интеграционных тестов, где важна логика, а не специфика СУБД.@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestConfiguration
static class TestConfig {
@Bean
DataSource dataSource() {
return new EmbeddedDatabaseBuilder()
.setType(EmbeddedDatabaseType.H2)
.addScript("schema.sql")
.addScript("data.sql")
.build();
}
}Минусы: H2 не всегда точно имитирует поведение PostgreSQL/Oracle (например, в части блокировок, типов данных, функций).
-
Testcontainers
Запускает реальную СУБД в Docker-контейнере. Гарантирует 100 % совместимость.@Testcontainers
class UserRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:15");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Test
void shouldFindUserByEmail() { ... }
}Плюсы: максимальная достоверность.
Минусы: медленнее, требует Docker. -
Мокирование
EntityManager
Не рекомендуется для интеграционных тестов — проверяется только логика сервиса, но не взаимодействие с БД.
Типовые ошибки и антипаттерны
1. N+1 Select Problem
Симптом: при загрузке списка сущностей с ленивыми связями выполняется 1 запрос на список + N запросов на каждую связь.
Пример:
List<User> users = userRepository.findAll(); // 1 запрос
for (User user : users) {
System.out.println(user.getRoles().size()); // N запросов (по одному на пользователя)
}
Решения:
- Использовать
JOIN FETCHв JPQL:@Query("SELECT u FROM User u JOIN FETCH u.roles")
List<User> findAllWithRoles(); - Настроить
@EntityGraphдля репозитория; - Отказаться от
LAZYв пользуEAGER(только если связь всегда нужна и объём данных невелик); - Использовать DTO-проекции.
2. LazyInitializationException
Симптом: обращение к ленивой коллекции вне транзакции или после закрытия EntityManager.
Причина: сессия закрыта, прокси не может выполнить запрос.
Решения:
- Загружать связи в той же транзакции, где запрашивается сущность (
JOIN FETCH); - Использовать
@Transactionalна уровне сервиса (не контроллера!); - Применять DTO и маппить данные до выхода из слоя данных;
- Избегать
Open Session in View— это маскирует проблему и создаёт долгие транзакции.
3. Частые flush() и clear()
Симптом: низкая производительность при batch-операциях.
Причина: по умолчанию Hibernate синхронизирует состояние с БД перед каждым запросом (flush), чтобы обеспечить согласованность. При вставке 1000 сущностей это приводит к 1000 round-trip.
Решение:
@Transactional
public void bulkInsert(List<User> users) {
for (int i = 0; i < users.size(); i++) {
em.persist(users.get(i));
if (i % 20 == 0) { // batch size
em.flush();
em.clear(); // освобождает память
}
}
}
- Настройка
hibernate.jdbc.batch_size=20,order_inserts=true.
4. Избыточное использование @Transactional(readOnly = true)
Миф: «readOnly ускоряет запросы».
Реальность: в PostgreSQL это лишь отключает autocommit, но не даёт преимуществ. В Oracle может позволить использовать «read-consistent snapshot», но эффект мал. Гораздо важнее:
- Использовать
PageableвместоfindAll(); - Загружать только нужные поля (проекции);
- Кэшировать справочники.
Современные тенденции
R2DBC и реактивный доступ к данным
Традиционные JDBC/JPA — блокирующие API: поток приложения ждёт ответа от БД. В реактивных системах (Spring WebFlux) это нарушает принцип non-blocking I/O.
R2DBC (Reactive Relational Database Connectivity) — стандарт для неблокирующего доступа к реляционным БД. Поддерживается PostgreSQL, MySQL, Microsoft SQL Server, H2.
Пример:
@Repository
public class ReactiveUserRepository {
private final DatabaseClient client;
public Flux<User> findAll() {
return client.sql("SELECT * FROM users")
.map(row -> new User(row.get("id", Long.class), row.get("name", String.class)))
.all();
}
}
Spring Data R2DBC предоставляет реактивные репозитории:
public interface ReactiveUserRepository extends ReactiveCrudRepository<User, Long> {
Flux<User> findByStatus(Status status);
}
Когда использовать: высоконагруженные сервисы с большим числом одновременных подключений (микросервисы, API-шлюзы), где важна экономия потоков.
Ограничения: нет поддержки JPA, ORM, ленивой загрузки. Только SQL/H2.
Многомодельные подходы
В современных системах часто требуется комбинировать реляционные и нереляционные хранилища:
- PostgreSQL с JSONB — для гибких сущностей;
- Hibernate ORM + Hibernate Search — для полнотекстового поиска через Elasticsearch;
- Spring Data JDBC + Spring Data MongoDB — когда часть данных хорошо ложится в документную модель.
Ключевой принцип: выбирать хранилище под задачу, а не под технологический стек.
Примеры: управление книгами (Book)
Сущность
Book:
id—BIGINT, автоинкремент, PKtitle—VARCHAR(255), NOT NULLauthor—VARCHAR(255), NOT NULLisbn—VARCHAR(17), UNIQUEpublished_year—INTavailable—BOOLEAN, по умолчаниюtrue
СУБД: PostgreSQL (но примеры легко адаптируются под другие через смену драйвера и DDL).
1. Чистый JDBC + HikariCP
Зависимости (Maven):
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.3</version>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>5.1.0</version>
</dependency>
DDL (schema.sql):
CREATE TABLE books (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
author VARCHAR(255) NOT NULL,
isbn VARCHAR(17) UNIQUE NOT NULL,
published_year INT,
available BOOLEAN DEFAULT true
);
Класс Book (DTO/сущность):
public class Book {
private Long id;
private String title;
private String author;
private String isbn;
private Integer publishedYear;
private Boolean available;
// конструкторы, геттеры, сеттеры
public Book() {}
public Book(String title, String author, String isbn, Integer publishedYear) {
this.title = title;
this.author = author;
this.isbn = isbn;
this.publishedYear = publishedYear;
this.available = true;
}
// ... геттеры и сеттеры
}
Класс JdbcBookRepository:
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;
import java.sql.*;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public class JdbcBookRepository {
private final DataSource dataSource;
public JdbcBookRepository() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost:5432/library");
config.setUsername("user");
config.setPassword("password");
config.setMaximumPoolSize(10);
// Автоматическое восстановление разорванных соединений
config.setConnectionTestQuery("SELECT 1");
this.dataSource = new HikariDataSource(config);
}
// Создание книги
public Book save(Book book) {
String sql = """
INSERT INTO books (title, author, isbn, published_year, available)
VALUES (?, ?, ?, ?, ?)
RETURNING id
""";
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
stmt.setString(1, book.getTitle());
stmt.setString(2, book.getAuthor());
stmt.setString(3, book.getIsbn());
stmt.setInt(4, book.getPublishedYear());
stmt.setBoolean(5, book.getAvailable() != null ? book.getAvailable() : true);
int affectedRows = stmt.executeUpdate();
if (affectedRows == 0) {
throw new SQLException("Создание книги не привело к вставке данных.");
}
try (ResultSet generatedKeys = stmt.getGeneratedKeys()) {
if (generatedKeys.next()) {
book.setId(generatedKeys.getLong(1));
} else {
throw new SQLException("Не удалось получить сгенерированный ID.");
}
}
return book;
} catch (SQLException e) {
throw new RuntimeException("Ошибка при сохранении книги", e);
}
}
// Чтение по ID
public Optional<Book> findById(Long id) {
String sql = "SELECT id, title, author, isbn, published_year, available FROM books WHERE id = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setLong(1, id);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
return Optional.of(mapRow(rs));
}
return Optional.empty();
}
} catch (SQLException e) {
throw new RuntimeException("Ошибка при поиске книги по ID", e);
}
}
// Чтение всех доступных книг
public List<Book> findAllAvailable() {
String sql = "SELECT id, title, author, isbn, published_year, available FROM books WHERE available = true";
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql);
ResultSet rs = stmt.executeQuery()) {
List<Book> books = new ArrayList<>();
while (rs.next()) {
books.add(mapRow(rs));
}
return books;
} catch (SQLException e) {
throw new RuntimeException("Ошибка при получении списка книг", e);
}
}
// Обновление
public boolean update(Book book) {
String sql = """
UPDATE books
SET title = ?, author = ?, isbn = ?, published_year = ?, available = ?
WHERE id = ?
""";
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, book.getTitle());
stmt.setString(2, book.getAuthor());
stmt.setString(3, book.getIsbn());
stmt.setInt(4, book.getPublishedYear());
stmt.setBoolean(5, book.getAvailable());
stmt.setLong(6, book.getId());
return stmt.executeUpdate() > 0;
} catch (SQLException e) {
throw new RuntimeException("Ошибка при обновлении книги", e);
}
}
// Удаление
public boolean deleteById(Long id) {
String sql = "DELETE FROM books WHERE id = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setLong(1, id);
return stmt.executeUpdate() > 0;
} catch (SQLException e) {
throw new RuntimeException("Ошибка при удалении книги", e);
}
}
// Вспомогательный метод: ResultSet → Book
private Book mapRow(ResultSet rs) throws SQLException {
Book book = new Book();
book.setId(rs.getLong("id"));
book.setTitle(rs.getString("title"));
book.setAuthor(rs.getString("author"));
book.setIsbn(rs.getString("isbn"));
book.setPublishedYear(rs.getInt("published_year"));
book.setAvailable(rs.getBoolean("available"));
return book;
}
}
Комментарии:
— ИспользованHikariCPдля пулинга — обязательное условие для production.
— Все ресурсы закрываются черезtry-with-resources.
— ОбработкаSQLExceptionс сохранением стека.
—RETURNING id— стандартный способ получения сгенерированного ключа в PostgreSQL.
2. JPA (Hibernate) — сущность и EntityManager
Зависимости (Maven):
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-core</artifactId>
<version>6.4.4.Final</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.3</version>
</dependency>
Класс Book (сущность JPA):
import jakarta.persistence.*;
@Entity
@Table(name = "books")
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String author;
@Column(unique = true, nullable = false)
private String isbn;
@Column(name = "published_year")
private Integer publishedYear;
private Boolean available = true;
// Обязательен конструктор без параметров для JPA
public Book() {}
public Book(String title, String author, String isbn, Integer publishedYear) {
this.title = title;
this.author = author;
this.isbn = isbn;
this.publishedYear = publishedYear;
}
// Геттеры и сеттеры (или final-поля + конструктор — но тогда нужен @Access)
// ...
// equals/hashCode по id (или по business key, например, isbn)
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Book book)) return false;
return id != null && id.equals(book.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}
Настройка EntityManagerFactory (вручную, без Spring):
import org.hibernate.cfg.Configuration;
import jakarta.persistence.EntityManagerFactory;
public class JpaConfig {
public static EntityManagerFactory createEntityManagerFactory() {
return new Configuration()
.addAnnotatedClass(Book.class)
.setProperty("hibernate.dialect", "org.hibernate.dialect.PostgreSQLDialect")
.setProperty("hibernate.connection.driver_class", "org.postgresql.Driver")
.setProperty("hibernate.connection.url", "jdbc:postgresql://localhost:5432/library")
.setProperty("hibernate.connection.username", "user")
.setProperty("hibernate.connection.password", "password")
.setProperty("hibernate.hbm2ddl.auto", "validate") // только валидация!
.setProperty("hibernate.show_sql", "true")
.setProperty("hibernate.format_sql", "true")
.buildSessionFactory();
}
}
Класс JpaBookRepository:
import jakarta.persistence.*;
import java.util.List;
import java.util.Optional;
public class JpaBookRepository {
private final EntityManagerFactory emf;
public JpaBookRepository(EntityManagerFactory emf) {
this.emf = emf;
}
public Book save(Book book) {
EntityManager em = emf.createEntityManager();
EntityTransaction tx = null;
try {
tx = em.getTransaction();
tx.begin();
Book saved = em.merge(book); // persist для новых, merge — для detached
tx.commit();
return saved;
} catch (Exception e) {
if (tx != null && tx.isActive()) {
tx.rollback();
}
throw new RuntimeException("Ошибка при сохранении книги", e);
} finally {
em.close();
}
}
public Optional<Book> findById(Long id) {
EntityManager em = emf.createEntityManager();
try {
return Optional.ofNullable(em.find(Book.class, id));
} finally {
em.close();
}
}
@SuppressWarnings("unchecked")
public List<Book> findAllAvailable() {
EntityManager em = emf.createEntityManager();
try {
return em.createQuery(
"SELECT b FROM Book b WHERE b.available = true", Book.class)
.getResultList();
} finally {
em.close();
}
}
public List<Book> findByAuthorAndYear(String author, int year) {
EntityManager em = emf.createEntityManager();
try {
TypedQuery<Book> query = em.createQuery(
"SELECT b FROM Book b WHERE b.author = :author AND b.publishedYear = :year", Book.class);
query.setParameter("author", author);
query.setParameter("year", year);
return query.getResultList();
} finally {
em.close();
}
}
public void delete(Book book) {
EntityManager em = emf.createEntityManager();
EntityTransaction tx = null;
try {
tx = em.getTransaction();
tx.begin();
em.remove(em.contains(book) ? book : em.merge(book));
tx.commit();
} catch (Exception e) {
if (tx != null && tx.isActive()) {
tx.rollback();
}
throw new RuntimeException("Ошибка при удалении книги", e);
} finally {
em.close();
}
}
}
Комментарии:
— Явное управление транзакциями иEntityManager— необходимо без Spring.
—merge()вместоpersist()позволяет работать с detached-объектами.
—em.contains()проверяет, управляется ли объект текущей сессией.
— Для production рекомендуется использовать@Transactional(см. следующий раздел).
3. Spring Data JPA
Зависимости (Spring Boot):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
application.properties:
spring.datasource.url=jdbc:postgresql://localhost:5432/library
spring.datasource.username=user
spring.datasource.password=password
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
Сущность Book — та же, что в разделе 2 (можно оставить без изменений).
Репозиторий:
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
public interface BookRepository extends JpaRepository<Book, Long> {
// Генерация по имени метода
List<Book> findByAvailableTrue();
List<Book> findByAuthorAndPublishedYear(String author, Integer year);
Optional<Book> findByIsbn(String isbn);
// Проекция: интерфейс
interface BookTitleAuthor {
String getTitle();
String getAuthor();
}
// Возврат проекции
List<BookTitleAuthor> findTop5ByAvailableTrueOrderByPublishedYearDesc();
// Явный JPQL
@Query("SELECT b FROM Book b WHERE LOWER(b.title) LIKE LOWER(CONCAT('%', :query, '%'))")
List<Book> searchByTitleFragment(@Param("query") String query);
// Нативный SQL (осторожно!)
@Query(value = """
SELECT b.*, COUNT(r.id) as reservation_count
FROM books b
LEFT JOIN reservations r ON b.id = r.book_id AND r.status = 'ACTIVE'
GROUP BY b.id
HAVING COUNT(r.id) < :maxReservations
""", nativeQuery = true)
List<Object[]> findBooksUnderReservationLimit(@Param("maxReservations") int maxReservations);
}
Сервис:
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Service
@Transactional
public class BookService {
private final BookRepository bookRepository;
public BookService(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
public Book createBook(String title, String author, String isbn, Integer year) {
Book book = new Book(title, author, isbn, year);
return bookRepository.save(book); // INSERT или UPDATE
}
@Transactional(readOnly = true)
public Optional<Book> getBook(Long id) {
return bookRepository.findById(id);
}
@Transactional(readOnly = true)
public List<Book> getAvailableBooks() {
return bookRepository.findByAvailableTrue();
}
@Transactional(readOnly = true)
public List<BookRepository.BookTitleAuthor> getRecentBookPreviews() {
return bookRepository.findTop5ByAvailableTrueOrderByPublishedYearDesc();
}
public void markAsUnavailable(Long id) {
bookRepository.findById(id)
.ifPresent(book -> {
book.setAvailable(false);
// save() не требуется — изменения отслеживаются автоматически
});
}
}
Комментарии:
—@Transactionalна сервисе — правильный уровень.
—readOnly = trueдля операций чтения — подсказка Hibernate.
— Проекции позволяют избежать загрузки всей сущности.
—save()возвращает managed-сущность — можно использовать для дальнейших операций.
4. Spring Data R2DBC (реактивный)
Зависимости (Spring Boot WebFlux):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-r2dbc</artifactId>
</dependency>
<dependency>
<groupId>io.r2dbc</groupId>
<artifactId>r2dbc-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
application.properties:
spring.r2dbc.url=r2dbc:postgresql://localhost:5432/library
spring.r2dbc.username=user
spring.r2dbc.password=password
Рекорд Book (иммутабельный, для реактивного стиля):
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
@Table("books")
public record Book(
@Id Long id,
String title,
String author,
String isbn,
Integer publishedYear,
Boolean available
) {
public Book(String title, String author, String isbn, Integer publishedYear) {
this(null, title, author, isbn, publishedYear, true);
}
}
Репозиторий:
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.data.repository.reactive.ReactiveSortingRepository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface ReactiveBookRepository extends R2dbcRepository<Book, Long> {
Flux<Book> findByAvailable(boolean available);
Mono<Book> findByIsbn(String isbn);
// Проекция через record
record BookPreview(String title, String author) {}
@Query("SELECT title, author FROM books WHERE available = true ORDER BY published_year DESC LIMIT 5")
Flux<BookPreview> findRecentPreviews();
}
Сервис:
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
@Service
public class ReactiveBookService {
private final ReactiveBookRepository bookRepository;
public ReactiveBookService(ReactiveBookRepository bookRepository) {
this.bookRepository = bookRepository;
}
public Mono<Book> createBook(String title, String author, String isbn, Integer year) {
Book book = new Book(title, author, isbn, year);
return bookRepository.save(book);
}
public Mono<Book> getBook(Long id) {
return bookRepository.findById(id);
}
public Flux<Book> getAvailableBooks() {
return bookRepository.findByAvailable(true);
}
public Flux<ReactiveBookRepository.BookPreview> getRecentPreviews() {
return bookRepository.findRecentPreviews();
}
public Mono<Void> markAsUnavailable(Long id) {
return bookRepository.findById(id)
.flatMap(book -> {
Book updated = new Book(
book.id(),
book.title(),
book.author(),
book.isbn(),
book.publishedYear(),
false
);
return bookRepository.save(updated).then();
});
}
}
Комментарии:
—recordидеально подходит для DTO в реактивном стеке.
— Все методы возвращаютMono/Flux— неблокирующие типы.
— Изменение объекта требует создания нового экземпляра (иммутабельность).
—@Queryв R2DBC поддерживает только нативный SQL (JPQL недоступен).
Сравнение в одном взгляде
| Операция | JDBC | JPA | Spring Data JPA | R2DBC |
|---|---|---|---|---|
| Создание | INSERT ... RETURNING id + getGeneratedKeys() | em.merge() | repository.save() | repository.save() |
| Чтение по ID | SELECT ... WHERE id = ? | em.find() | repository.findById() | repository.findById() |
| Фильтрация | ручной SQL + параметры | JPQL / Criteria | query methods / @Query | @Query (SQL) / методы |
| Изменение | UPDATE ... | dirty checking (авто) | dirty checking (авто) | создание нового объекта |
| Транзакции | ручное управление | EntityTransaction | @Transactional | TransactionalOperator |
| Ленивые связи | нет | LAZY + прокси | как в JPA | не поддерживается |