С выходом Spring 6 и Spring Boot 3 Java 17+ стала базовой версией фреймворка.

Поэтому сейчас самое время начать использовать компактные Java Records в качестве объектов передачи данных (DTO, Data Transfer Object) для различных вызовов баз данных и API.

Предпочитаете ли вы читать или смотреть, давайте рассмотрим несколько подходов к использованию записей Java в качестве DTO, которые применимы к Spring Boot 3 с Hibernate 6 в качестве поставщика персистентности.

База данных примера

Следуйте этим инструкциям, если вы хотите установить базу данных примера и поэкспериментировать самостоятельно. В противном случае смело пропускайте этот раздел:

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.

Комментарии (0)