В прошлой статье я рассмотрел эволюцию подходов в Java к работе с данными из базы. Я сравнил классический подход с чистым SQL в JDBC, потом ORM Hibernate с его HQL, и наконец стандарт JPA с JPQL, которые часто работают в связке с тем же Hibernate.
Но в этот раз я хочу двинуться дальше и рассмотреть работу с популярным фреймворком Spring: как он позволяет ещё упростить работу с данными.
Spring появился в начале нулевых, как ответ тяжёлым и неудобным технологиям Java EE. Он был не только про работу с данными, но всё же принёс некоторые решения и для этих проблем.
Основной идеей в архитектурном плане стала инверсия (и инъекция) зависимостей – паттерн, который по идее считается основой и классикой программирования, но не всегда применялся, что лишь сильнее запутывало и без того детальный код Java EE. Но кроме этого Spring взял на себя управление транзакциями, сессиями и EntityManager.
Итак, на первом шаге к современности Hibernate позволил разработчикам не собирать объекты из результата запроса к базе вручную, а обращаться с ними, как с объектами Java. Система стала отслеживать изменения и позволять быстрее и легче обновлять данные в базе после работы с ними. Но для каждого обновления, ради одной строчки типа persist требовалось писать целый метод и каждый раз прописывать открытие сессии/начало транзакции, коммит (иначе ничего не сохранится). Всё это тоже казалось слишком монотонным, пока Spring забрал на себя эту работу и сделал код ещё короче. Он позволил шагнуть дальше в сокращении шаблонного кода.
Но! Разработчикам всё ещё приходилось писать одинаковые обращения к базам для разных объектов. Независимо от структуры таблицы в базе, скорее всего, требовалось написать getAll(), getById(Long id), getBy…. для некоторых полей. И третий шаг позволил перешагнуть через эту механическую работу, но понадобился ещё один слой автоматизации – Spring Data JPA.
В итоге мы отошли от паттерна DAO и вместо него используем репозитории. На практике разница часто размыта, потому что функционально для разработчика разницы нет, но Repository обычно рассматривают как более высокоуровневую абстракцию над коллекцией сущностей, тогда как DAO считается объектом доступа к данным с более явной реализацией.
Модели и стандартные аннотации в Spring Data JPA
Так как Spring Data JPA следует стандарту JPA, то все знакомые аннотации сохранятся. То есть JPA-сущности остаются «сами собой» даже при переходе на Spring Data.
Поэтому все аннотации в этой категории сохраняются знакомыми: @Entity, @Table, @Id, @GeneratedValue, @Column, @OneToMany, @ManyToOne, @JoinColumn. И модель таблицы с книгами будет выглядеть так же, как в прошлой статье, где не было ни слова про Spring:
@Entity@Table(name = "book")public class Book { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; @ManyToOne @JoinColumn(name = "author_id") private Author author; @OneToMany(mappedBy = "book") private List<BookReview> reviews = new ArrayList<>();}
Перенос CRUD в Spring Data JPA
Значительные перемены настигают уровень DAO. Вместо нескольких методов с созданием транзакций достаточно написать всего пару строчек:
public interface BookRepository extends JpaRepository<Book, Long> {}
Репозиторий BookRepository сразу же даёт набор методов из интерфейса JpaRepository:
findById(id), findAll(), save(book), delete(book), deleteById(id), count(), existsById(id)
Интерфейс лежит в пакете org.springframework.data.jpa.repository, поэтому, несмотря на название, не приходит со стандартом JPA – это чисто спринговая тема.
Spring во время запуска создаёт proxy и генерирует эти CRUD-методы (реализацию нам писать не нужно, хоть это и интерфейс – всё делает сам спринг). Внутри он использует EntityManager, а EntityManager уже использует Hibernate. Наша матрёшка ещё расширилась.
Для сохранения записей используем метод save(). Он заменяет привычные persist() и merge(). Если сущность новая и не имеет id, Spring Data создаст новую запись. Если id уже существует, то будет выполнено обновление. Под капотом Spring Data всё равно использует механизмы JPA и Hibernate.
Query-методы и автоматическая генерация запросов
Помимо этих стандартных методов в репозиторий хочется добавить методы для обращения к полям класса. Что если мы хотим получить объект не по id, а по названию? Или собрать все книги одного автора? Это возможно с query-методами. На основе названия и аргументов составляется JPQL – методы не требуют реализации.
List<Book> findByTitle(String title);List<Book> findByAuthorName(String name);List<Book> findByYearGreaterThan(Integer year);List<Book> findByTitleContainingIgnoreCase(String title);List<Book> findByAuthorIdOrderByYearDesc(Long authorId);
Просто нужно правильно составить название метода – и готово!
Начинаем с префикса – он описывает, что сделать. Среди вариантов:
find, get и read – чтение записей, обычный select. Может вернуть как одну запись, так и Optional<> или список – тут решение разработчика. Но нужно опасаться ситуации, когда ожидаем мы один результат, а получим сразу несколько – Hibernate выкинет NonUniqueResultException.
delete – удаление нескольких или одного объекта. Часто возвращает void или число удалённых записей.
count – подсчёт записей. Ожидаем число – long или int, например.
exists – проверка существования. Ожидаем boolean.
Но важнее префиксов условия поиска. Именно они решают, что находится в секции WHERE генерируемого запроса.
Пишем название поля сущности в заголовке метода после By. Если колонок несколько, то соединяем их And или Or:
findByTitle(String title)findByTitleAndYear(String title, Integer year)findByTitleOrYear(String title, Integer year)
После выбора поля можно добавить для него условие:
GreaterThan, LessThan – больше или меньше заданного значения
Between – в промежутке между двумя значениями
Like – проверяет содержание подстроки, но требует передачи готового паттерна в стиле SQL со всеми %%
Containing, StartingWith, EndingWith – уже ближе к работе со строками в Java и сравнивает содержимое поля с подстрокой. Spring сам добавляет %%.
IgnoreCase – игнорирование регистра
IsNull, IsNotNull – и так ясно
True, False – поиск булевых значений
И получатся такие примеры:
List<Book> findByYearGreaterThan(Integer year);List<Book> findByTitleContainingIgnoreCase(String title);List<Book> findByAuthorIdOrderByYearDesc(Long authorId);
В последнем примере я показываю сортировку по году. Такой подход удобен, если сортировка всегда происходит одна и та же. Но иногда выбирать колонки для сортировки нужно динамически. Тогда лучше принимать объект типа Sort, а не описывать сортировку сразу же в названии:
List<Book> findByAuthorId(Long authorId, Sort sort);
И тогда вызывается эта функция следующим образом:
bookRepository.findByAuthorId(1L, Sort.by(Sort.Direction.DESC, "year"));
Сортировка автоматически добавится в конце формируемого запроса. Но перед этим Spring проверяет, действительно ли есть указанное поле в этой или связанной с ней модели. Если нет, то получим исключение PropertyReferenceException.
Сортировку также можно указать внутри объекта с информацией о пагинации Pageable.
PageRequest pageRequest = PageRequest.of(0,20,Sort.by("year").descending());bookRepository.findByAuthorId(1L, pageRequest);
Pageable содержит номер страницы, размер страницы, сортировку Sort. Тогда сигнатура такого метода в репозитории будет:
Page<Book> findByAuthorId(Long authorId, Pageable pageable);
Тут Spring автоматически добавит не только ORDER BY, но и LIMIT и OFFSET. Получается, это абстракция поверх методов setFirstResult() и setMaxResults().
Но Page – довольно тяжёлый вариант. Spring тут так же считает количество всех элементов в таблице и предоставляет очень подробную информацию о странице: можем получить информацию о числе всех элементов getTotalElements(), числе страниц getTotalPages(), узнать есть ли следующая страница hasNext() и не только.
Не всегда вся эта информация нужна, поэтому можно использовать другой спринговский класс – Slice. Он не считает все элементы, поэтому работает быстрее. Только подсказывает, есть ли следующая страница.
Slice<Book> findByAuthorId(Long authorId, Pageable pageable);
Но можно, конечно, и отойти от спринговских идей и по классике получать список List, если нам вообще никакая дополнительная информация не нужна.
Ручное указание запросов с аннотациями @Query и @NativeQuery
Если всё же хочется прописать запрос к базе явно, он содержит агрегатные функции, джойны, или просто название метода становится длинным и нечитаемым, то можно указывать текст запроса в аннотации @Query. Запрос пишется на JPQL и полностью копирует то, что раньше мы бы написали в createQuery. Сравним:
public List<Book> getBooksByAuthor(Long authorId) { return em.createQuery(""" SELECT b FROM Book b WHERE b.author.id = :authorId """, Book.class) .setParameter("authorId", authorId) .getResultList();}
@Query(""" SELECT b FROM Book b WHERE b.author.id = :authorId """)List<Book> findBooksByAuthorId(@Param("authorId") Long authorId);
Также можно написать нативный SQL. Для этого нужно строку запроса присвоить атрибуту value, а потом обозначить nativeQuery = true.
@Query( value = """ SELECT * FROM book WHERE author_id = :authorId """, nativeQuery = true)List<Book> findBooksByAuthorIdNative(@Param("authorId") Long authorId);
Если хочется покороче, то можно просто заменить @Queryна @NativeQuery– тогда никаких атрибутов не нужно. Спринг пытается сократить даже такие и без того короткие конструкции.
@NativeQuery(""" SELECT * FROM book WHERE author_id = :authorId """)List<Book> findByAuthorIdNative(@Param("authorId") Long authorId);
Из всех этих примеров видно, что кроме @Query ещё довольно важна аннотация @Param – она заменяет setParameter.
Я в этом вижу более современный подход к JPA-инструменту с @NamedQuery или @NamedNativeQuery, который по той же схеме размещал запросы в аннотациях над моделью. Таким образом, из-за множества запросов класс разрастался, становился плохо читаемым, а архитектурные слои смешивались. Лично мне новый подход кажется более удачным.
Какие преимущества @Query перед его нативным коллегой? Как и в Hibernate, можно создавать не сущности, а DTO – в @NativeQuery это намного сложнее, так как такой запрос орудует именами таблиц.
@Query(""" SELECT new com.example.BookDto(b.id, b.title, b.year) FROM Book b WHERE b.author.id = :authorId """)List<BookDto> findBookDtosByAuthorId(@Param("authorId") Long authorId);
Ну и конечно любимый FETCH JOIN не получится использовать в native-запросах (просто потому что именно FETCH там нет, и придётся орудовать обычными JOIN). А его польза остаётся той же, что и в Hibernate – проблема с ленивой инициализацией не исчезает даже в спринге.
В таких методах также удастся сортировать данные по значениям, которые не являются полями сущностей. По умолчанию, если написать Sort.by(«LENGTH(title)»), получим PropertyReferenceException, так как такого поля не существует. JPA говорит, что такие запросы нужно отклонять. Но Spring предлагает своё решение: JpaSort.unsafe позволяет не проводить проверку и добавлять любые строки в ORDER BY в конце генерируемого запроса.
Сигнатура метода остаётся той же:
@Query(""" SELECT b FROM Book b """)List<Book> findAllBooks(Sort sort);
Но вызов меняется:
bookRepository.findAllBooks(JpaSort.unsafe("LENGTH(title)").descending());
Естественно, с @NativeQuery такая сортировка невозможна, так как запрос на чистом SQL уже составлен вами и не изменяется.
Но всего этого хватает не всегда. Query-методы хорошо работают для простых запросов, а @Query подходит для статичных JPQL-конструкций. Когда запрос нужно собирать динамически в зависимости от десятка фильтров, на сцену выходят Criteria API, Specification и QueryDSL. Именно их мы рассмотрим в следующей части конспекта.
Транзакции и @Transactional в Spring Data
Spring забирает на себя работу с транзакциями, и запись становится значительно короче. Было:
public void updateBook(Book book) { Session session = sessionFactory.openSession(); Transaction tx = session.beginTransaction(); session.merge(book); tx.commit(); session.close();}
Стало:
@Transactionalpublic void updateBook(Long id, String newTitle) { Book book = bookRepository.findById(id).orElseThrow(); book.setTitle(newTitle);}
Тут даже нет save(). Но изменение сохранится, потому что @Transactional открыл транзакцию, потом findById вернул managed entity, а потом Hibernate делает dirty checking и обновление записи. На весь метод с аннотацией @Transactional одна транзакция.
Можно конечно вызывать метод вручную и избегать аннотации, но зачем писать эту лишнюю строчку каждый раз?
public void updateBook(Long id, String newTitle) { Book book = bookRepository.findById(id).orElseThrow(); book.setTitle(newTitle); bookRepository.save(book);}
Аннотацию лучше ставить в сервисах над методами, которые изменяют запись и должны восприниматься как единая операция.
@Transactionalpublic void createBookWithReview(Long authorId, String title, String reviewText) { Author author = authorRepository.findById(authorId).orElseThrow(); Book book = new Book(); book.setTitle(title); book.setAuthor(author); BookReview review = new BookReview(); review.setText(reviewText); review.setBook(book); book.getReviews().add(review); bookRepository.save(book);}
Ставим эту транзакцию на методах, которые занимаются записью и изменениями (чтобы работал dirty-checking), но над методами, которые только читают тоже можно – только с оговоркой, что мы не собираемся менять записи.
@Transactional(readOnly = true)public BookDto getBook(Long id) { Book book = bookRepository.findById(id).orElseThrow(); return new BookDto(book.getId(), book.getTitle());}
Особенно полезна транзакция, когда мы подгружаем данные с Lazy-связями, чтобы не словить исключение:
@Transactional(readOnly = true)public BookDto getBookWithReviews(Long id) { Book book = bookRepository.findById(id).orElseThrow(); int count = book.getReviews().size(); return new BookDto(book.getTitle(), count);}
И напоследок предлагаю взглянуть на результаты рефакторинга со Spring. Взглянем на классы сервиса и DAO у Hibernate/JPA:
public class BookDao { private SessionFactory sessionFactory; public Book getById(Long id) { Session session = sessionFactory.openSession(); try { return session.get(Book.class, id); } finally { session.close(); } } public void update(Book book) { Session session = sessionFactory.openSession(); Transaction tx = session.beginTransaction(); try { session.merge(book); tx.commit(); } catch (Exception e) { tx.rollback(); throw e; } finally { session.close(); } }}
public class BookService { private BookDao dao; public void renameBook(Long id, String newTitle) { Book book =dao.getById(id); book.setTitle(newTitle); dao.update(book); }}
А теперь посмотрим, насколько короче стали эти классы со Spring Data:
public interface BookRepository extends JpaRepository<Book, Long> {}
@Servicepublic class BookService { private final BookRepository repository; public BookService(BookRepository repository) { this.repository = repository; } @Transactional public void renameBook(Long id, String newTitle) { Book book = repository.findById(id).orElseThrow(); book.setTitle(newTitle); }}