С выходом Spring 6 и Spring Boot 3 Java 17+ стала базовой версией фреймворка.
Поэтому сейчас самое время начать использовать компактные Java Records в качестве объектов передачи данных (DTO, Data Transfer Object) для различных вызовов баз данных и API.
Предпочитаете ли вы читать или смотреть, давайте рассмотрим несколько подходов к использованию записей Java в качестве DTO, которые применимы к Spring Boot 3 с Hibernate 6 в качестве поставщика персистентности.
База данных примера
Следуйте этим инструкциям, если вы хотите установить базу данных примера и поэкспериментировать самостоятельно. В противном случае смело пропускайте этот раздел:
Загрузите набор данных Chinook Database (музыкальный магазин) для синтаксиса PostgreSQL.
Запустите экземпляр YugabyteDB, распределенной базы данных, совместимой с PostgreSQL, в Docker:
mkdir ~/yb_docker_data
docker network create custom-network
docker run -d --name yugabytedb_node1 --net custom-network \
-p 7001:7000 -p 9000:9000 -p 5433:5433 \
-v ~/yb_docker_data/node1:/home/yugabyte/yb_data --restart unless-stopped \
yugabytedb/yugabyte:latest \
bin/yugabyted start \
--base_dir=/home/yugabyte/yb_data --daemon=false
Создайте базу данных chinook
в YugabyteDB:
createdb -h 127.0.0.1 -p 5433 -U yugabyte -E UTF8 chinook
Загрузите набор данных примера:
psql -h 127.0.0.1 -p 5433 -U yugabyte -f Chinook_PostgreSql_utf8.sql -d chinook
Далее создайте пример приложения Spring Boot 3::
Создайте шаблон приложения, используя Spring Boot 3+ и Java 17+ со Spring Data JPA в качестве зависимости: https://start.spring.io/
Добавьте драйвер PostgreSQL в
pom.xml
файл:
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.5.4</version>
</dependency>
Задайте параметры подключения к YugabyteDB в файле
application.properties
:
spring.datasource.url = jdbc:postgresql://127.0.0.1:5433/chinook
spring.datasource.username = yugabyte
spring.datasource.password = yugabyte
Все готово! Теперь вы можете следовать остальной части руководства.
Модель данных
База данных Chinook поставляется с множеством отношений, но двух таблиц будет более чем достаточно, чтобы показать, как использовать записи Java в качестве DTO.
Первая таблица — это Track
, а ниже приведено определение соответствующего класса JPA сущности:
@Entity
public class Track {
@Id
private Integer trackId;
@Column(nullable = false)
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "album_id")
private Album album;
@Column(nullable = false)
private Integer mediaTypeId;
private Integer genreId;
private String composer;
@Column(nullable = false)
private Integer milliseconds;
private Integer bytes;
@Column(nullable = false)
private BigDecimal unitPrice;
// Геттеры и сеттеры опущены
}
Вторая таблица Album
имеет следующий класс JPA сущности:
@Entity
public class Album {
@Id
private Integer albumId;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private Integer artistId;
// Геттеры и сеттеры опущены
}
В дополнение к классам сущностей создайте Java Record (запись) с именем TrackRecord
, в которой хранится краткая, но описательная информация о песне:
public record TrackRecord(String name, String album, String composer) {}
Наивный подход
Представьте, что вам нужно реализовать конечную точку REST, которая возвращает краткое описание песни. API должен предоставлять названия песен и альбома, а также имя автора.
Созданный ранее класс TrackRecord
может содержать требуемую информацию. Итак, давайте создадим запись, используя наивный подход, который получает данные через классы сущностей:
Добавьте следующий репозиторий JPA:
public interface TrackRepository extends JpaRepository<Track, Integer> {
}
Добавьте метод сервиса Spring Boot, который создает экземпляр
TrackRecord
класса сущностиTrack
. Последний извлекается с помощью экземпляраTrackRepository
:
@Transactional(readOnly = true)
public TrackRecord getTrackRecord(Integer trackId) {
Track track = repository.findById(trackId).get();
TrackRecord trackRecord = new TrackRecord(
track.getName(),
track.getAlbum().getTitle(),
track.getComposer());
return trackRecord;
}
Решение выглядит простым и компактным, но оно очень неэффективно, поскольку Hibernate нужно сначала создать две сущности — Track
и Album
(см. track.getAlbum().getTitle()
).
Для этого он генерирует два SQL-запроса, которые запрашивают все столбцы соответствующих таблиц базы данных:
Hibernate:
select
t1_0.track_id,
t1_0.album_id,
t1_0.bytes,
t1_0.composer,
t1_0.genre_id,
t1_0.media_type_id,
t1_0.milliseconds,
t1_0.name,
t1_0.unit_price
from
track t1_0
where
t1_0.track_id=?
Hibernate:
select
a1_0.album_id,
a1_0.artist_id,
a1_0.title
from
album a1_0
where
a1_0.album_id=?
Hibernate выбирает 12 столбцов в двух таблицах, но TrackRecord
нужно только три столбца!
Это пустая трата памяти, вычислительных и сетевых ресурсов, особенно если вы используете распределенные базы данных, такие как YugabyteDB, которые распределяют данные по нескольким узлам кластера.
TupleTransformer
Наивный подход можно легко исправить, если запрашивать только те записи, которые требуются API, а затем преобразовывать набор результатов запроса в соответствующую Java-запись.
Модуль Spring Data в Spring Boot 3 основан на Hibernate 6. В этой версии Hibernate интерфейс ResultTransformer
разделен на два интерфейса — TupleTransformer
и ResultListTransformer
.
Класс TupleTransformer
поддерживает Java-записи, поэтому реализация public TrackRecord getTrackRecord(Integer trackId)
может быть оптимизирована следующим образом:
@Transactional(readOnly = true)
public TrackRecord getTrackRecord(Integer trackId) {
org.hibernate.query.Query<TrackRecord> query = entityManager.createQuery(
"""
SELECT t.name, a.title, t.composer
FROM Track t
JOIN Album a ON t.album.albumId=a.albumId
WHERE t.trackId=:id
""").
setParameter("id", trackId).
unwrap(org.hibernate.query.Query.class);
TrackRecord trackRecord = query.setTupleTransformer((tuple, aliases) -> {
return new TrackRecord(
(String) tuple[0],
(String) tuple[1],
(String) tuple[2]);
}).getSingleResult();
return trackRecord;
}
entityManager.createQuery(...)
создает JPA запрос, который возвращает три столбца, необходимые для классаTrackRecord
.query.setTupleTransformer(...)
создает TupleTransformer, поддерживающий Java-записи, что означает, что экземплярTrackRecord
может быть создан в реализации трансформера.
Этот подход более эффективен, чем предыдущий, поскольку больше не нужно создавать классы сущностей, и вы можете легко создать Java-запись с помощью TupleTransformer
.
Кроме того, Hibernate генерирует один SQL-запрос, который возвращает только необходимые столбцы:
Hibernate:
select
t1_0.name,
a1_0.title,
t1_0.composer
from
track t1_0
join
album a1_0
on t1_0.album_id=a1_0.album_id
where
t1_0.track_id=?
Однако у этого подхода есть один очень заметный минус — реализация public TrackRecord getTrackRecordV2(Integer trackId)
стала длиннее и многословнее.
Java-запись в JPA-запросе
Есть несколько способов сократить предыдущую реализацию. Один из них заключается в создании экземпляра Java-записи в JPA-запросе.
Во-первых, расширьте реализацию интерфейса TrackRepository
с помощью пользовательского запроса, который создает экземпляр TrackRecord
из запрашиваемых столбцов базы данных:
public interface TrackRepository extends JpaRepository<Track, Integer> {
@Query("""
SELECT new com.my.springboot.app.TrackRecord(t.name, a.title, t.composer)
FROM Track t
JOIN Album a ON t.album.albumId=a.albumId
WHERE t.trackId=:id
""")
TrackRecord findTrackRecord(@Param("id") Integer trackId);
Затем обновите реализацию public TrackRecord getTrackRecord(Integer trackId)
следующим образом:
@Transactional(readOnly = true)
public TrackRecord getTrackRecord(Integer trackId) {
return repository.findTrackRecord(trackId);
}
Итак, реализация метода представляет собой однострочную инструкцию, которая получает экземпляр TrackRecord
прямо из JPA-репозитория. Максимально просто.
Но это еще не все. Есть еще одна небольшая проблема. JPA-запрос, который создает Java-запись, требует указать полное имя пакета для класса TrackRecord
:
SELECT new com.my.springboot.app.TrackRecord(t.name, a.title, t.composer)...
Давайте найдем способ обойти это требование. В идеале Java-запись должна быть создана без имени пакета:
SELECT new TrackRecord(t.name, a.title, t.composer)...
Утилиты Hypersistence
Библиотека Hypersistence Utils поставляется с множеством полезных функций для Spring и Hibernate. Одна из функций позволяет создавать экземпляр Java-записи в JPA-запросе без указания имени пакета.
Давайте включим библиотеку и эту функцию, связанную с Java-записями, в приложении Spring Boot:
Добавьте артефакт библиотеки в Maven для Hibernate 6: https://github.com/vladmihalcea/hypersistence-utils
Создайте пользовательский
IntegratorProvider
, который регистрирует классTrackRecord
в Hibernate:
public class ClassImportIntegratorProvider implements IntegratorProvider {
@Override
public List<Integrator> getIntegrators() {
return List.of(new ClassImportIntegrator(List.of(TrackRecord.class)));
}
}
Обновите файл
application.properties
, добавив этот пользовательскийIntegratorProvider
:
spring.jpa.properties.hibernate.integrator_provider=com.my.springboot.app.ClassImportIntegratorProvider
После этого вы можете обновить JPA-запрос в методе TrackRepository.findTrackRecord(...)
, удалив имя пакета Java-записи (com.my.springboot.app
) из строки запроса:
@Query("""
SELECT new TrackRecord(t.name, a.title, t.composer)
FROM Track t
JOIN Album a ON t.album.albumId=a.albumId
WHERE t.trackId=:id
""")
TrackRecord findTrackRecord(@Param("id") Integer trackId);
Это так просто!
Резюме
Последние версии Java, Spring и Hibernate имеют ряд значительных усовершенствований, которые упрощают и делают более увлекательным программирование на Java.
Одним из таких улучшений является встроенная поддержка Java-записей, которые теперь можно легко использовать в качестве DTO в приложениях Spring Boot.