Spring Data JPA: проекции в запросах
Вероятно, первое, что приходит вам на ум, когда вы реализуете запрос с помощью Spring Data JPA — это проекции. Это связано с тем фактом, что проекции определяют атрибуты сущности и столбцы базы данных, возвращаемые вашим запросом. А возможность выбирать нужные столбцы очень важна для вашей бизнес-логики. Также это имеет решающее значение для производительности вашего приложения и поддерживаемости вашего кода. Вы можете (и должны) выбрать такую проекцию, которая минимизирует накладные расходы и предоставляет данные в наиболее удобной для использования форме.
Типы проекций, поддерживаемые Spring Data JPA
На основе возможностей работы с запросами JPA, Spring Data JPA предоставляет несколько вариантов для определения идеальной проекции для вашего юзкейса. Вы можете:
Использовать скалярную проекцию (scalar projection), состоящую из одного или нескольких столбцов базы данных, которые возвращаются как Object[]. Эта проекция обеспечивает высокую производительность операции чтения, но используется довольно редко. Это связано с тем, что DTO-проекции предлагают те же преимущества, но гораздо проще в использовании.
Использовать DTO-проекцию, которая позволяет выбрать определенный вами набор столбцов базы данных. Она использует их в вызове конструктора и возвращает один или несколько unmanaged объектов. Это отличная проекция, если вам не нужно изменять выбранные данные.
Использовать проекцию сущности (entity projection), которая выбирает все столбцы базы данных, связанные с указанными вами классом сущностью, и возвращает их как managed объект. Рекомендуется использовать именно этот вид проекций, если вам нужно будет изменить полученную информацию.
Вы можете использовать все три типа проекций с производных (derived) и пользовательскими запросами Spring Data JPA. Spring предоставит вам необходимый шаблонный код. Кроме того, это также упрощает использование DTO-проекций и позволяет динамически определять проекцию, возвращаемую методом репозитория.
Скалярные проекции
Скалярные проекции позволяют выбирать атрибуты сущностей, необходимые для вашей бизнес-логики, исключая все остальные. Эта особенность делает эту проекцию отличным выбором для всех операций чтения, если результаты не будет возвращен в виде множества Object[]-ов.
@Repository
public interface BookRepository extends JpaRepository<Book, Long> {
@Query("SELECT b.id, b.title FROM Book b")
List<Object[]> getIdAndTitle();
}
Результат запроса, помещенный в Object[], трудно использовать. Вам нужно запоминать, в какой позиции вы выбрали тот или иной атрибут объекта. Кроме того, вам необходимо приводить нужный элемент к правильному типу. Хорошая новость заключается в том, что всего этого можно избежать и определить DTO-проекцию для конкретного юзкейса.
DTO-проекции
При использовании DTO-проекции вы указываете persistence provider’у проецировать каждую запись результата вашего запроса в unmanaged объект. Как я уже рассказывал в предыдущей статья, они работают намного лучше, чем сущности, если вам не нужно изменять выбранные данные. И, в отличие от скалярных проекций, они также очень просты в использовании. Это связано с тем, что DTO именованы и строго типизированы.
DTO JPA
Задача DTO-класса — обеспечить эффективное и строго типизированное представление данных, возвращаемых вашим запросом. Для этого DTO-класс обычно определяет только набор атрибутов, геттеры и сеттеры для каждого из них и конструктор, который устанавливает все атрибуты.
public class AuthorSummaryDTO {
private String firstName;
private String lastName;
public AuthorSummaryDTO(String firstName, String lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
public String getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
}
Чтобы использовать этот класс в качестве проекции на чистом JPA, вам нужно добавить выражение-конструктор в ваш запрос. Оно описывает вызов конструктора. Оно начинается с ключевого слова new, за которым следует полное имя DTO-класса и список параметров конструктора.
@Repository
public interface AuthorRepository extends CrudRepository<Author, Long> {
@Query("SELECT new com.thorben.janssen.spring.jpa.projections.dto.AuthorSummaryDTO(a.firstName, a.lastName) FROM Author a WHERE a.firstName = :firstName")
List<AuthorSummaryDTO> findByFirstName(String firstName);
}
Как вы можете видеть из фрагмента кода, приведенного выше, этот подход можно использовать в аннотации @Query Spring Data JPA. Затем ваш persistence provider выполняет запрос, который выбирает столбцы, отмеченные указанными атрибутами сущности, и выполняет описанный вызов конструктора.
2020-07-12 20:42:09.875 DEBUG 61380 --- [ main] org.hibernate.SQL : select author0_.first_name as col_0_0_, author0_.last_name as col_1_0_ from author author0_ where author0_.first_name=?
В дополнение к этому Spring предоставляет несколько других опций для select’а DTO-проекции.
Упрощенные DTO Spring Data
Вы можете использовать DTO-проекции в производном запросе без выражения-конструктора. Если DTO-класс имеет только один конструктор и имена его параметров совпадают с именами атрибутов класса сущности, Spring сам сгенерирует запрос с требуемым выражением-конструктором.
@Repository
public interface AuthorRepository extends CrudRepository<Author, Long> {
List<AuthorSummaryDTO> findByFirstName(String firstName);
}
Это упрощает формирование запроса и обеспечивает преимущества производительности запроса, который выбирает только необходимые столбцы из базы данных.
2020-07-12 20:43:23.316 DEBUG 61200 --- [ main] org.hibernate.SQL : select author0_.first_name as col_0_0_, author0_.last_name as col_1_0_ from author author0_ where author0_.first_name=?
DTO-интерфейсы
Вместо определения класса с конструктором всех аргументов вы также можете использовать интерфейс в качестве DTO-проекции. Если ваш интерфейс определяет геттеры только для базовых атрибутов, то все будет идентично проекции, которую я показывал вам ранее.
public interface AuthorView {
String getFirstName();
String getLastName();
}
Уточню, что для каждого атрибута, который вы хотите использовать в своей проекции, ваш интерфейс должен предоставить геттер. Кроме того, имя этого метода должно совпадать с именем метода-геттера, определенного в классе сущности, используемом в вашем запросе.
@Repository
public interface AuthorRepository extends CrudRepository<Author, Long> {
AuthorView findViewByFirstName(String firstName);
}
В этом примере интерфейс AuthorView
и сущность Author
определяет методы getFirstName()
и getLastName()
. Когда вы используете интерфейс AuthorView в качестве типа возврата в AuthorRepository
, Spring Data JPA сгенерирует класс, реализующий интерфейс.
Это делает эту форму DTO-проекции очень удобной в использовании. И, как вы можете видеть в фрагменте кода, сгенерированный SQL оператор выбирает только столбцы, указанные интерфейсом.
2020-07-12 20:57:35.590 DEBUG 38520 --- [ main] org.hibernate.SQL : select author0_.first_name as col_0_0_, author0_.last_name as col_1_0_ from author author0_ where author0_.first_name=?
Ситуация немного меняется, если ваш интерфейс отображает ассоциации с другими объектами или использует язык выражений Spring.
Сопоставление вложенных ассоциаций
Чтобы иметь возможность включать ассоциации с другими сущностями в вашу проекцию, Spring Data JPA должен использовать другой подход. Он выбирает базовые объекты и выполняет программное сопоставление.
В следующем примере сущность Author
определяет метод getBooks()
, который возвращает список (List) и всех книг (Book) написанных конкретным автором. Вы можете указать Spring Data сопоставить это список к со списком BookView
объектов, добавив метод List<BookView> getBooks()
в интерфейс AuthorView
.
public interface AuthorView {
String getFirstName();
String getLastName();
List<BookView> getBooks();
interface BookView {
String getTitle();
}
}
Если вы это сделаете, Spring Data JPA получит сущность Author
и инициирует другой запрос для каждого автора, чтобы получить связанные с ним сущности Book
. Это создаст проблему с n+1 запросами, что может вызвать серьезные проблемы с производительностью. Вы можете избежать этого, предоставив пользовательский запрос, используя спецификатор JOIN FETCH.
2020-07-12 21:20:00.471 DEBUG 54180 --- [ main] org.hibernate.SQL : select author0_.id as id1_0_, author0_.first_name as first_na2_0_, author0_.last_name as last_nam3_0_, author0_.version as version4_0_ from author author0_ where author0_.first_name=?
2020-07-12 21:20:00.503 DEBUG 54180 --- [ main] org.hibernate.SQL : select books0_.author_id as author_i4_1_0_, books0_.id as id1_1_0_, books0_.id as id1_1_1_, books0_.author_id as author_i4_1_1_, books0_.title as title2_1_1_, books0_.version as version3_1_1_ from book books0_ where books0_.author_id=?
На следующем шаге Spring Data использует объект сущности Author
для создания инстанса сгенерированной реализации интерфейса AuthorView
. С точки зрения производительности это неправильный подход. Ваш запрос выбирает слишком много столбцов, и вашему persistence provider’у необходимо разбирать с еще одним объектом сущности. Из-за этого производительность этой проекции хуже, чем производительность DTO-проекции без сопоставленной ассоциации.
Использование языка выражений Spring
Вы также можете использовать язык выражений (Expression Language) Spring в определении вашего интерфейса. Это позволяет предоставить выражение, которое будет обрабатываться во время выполнения для сопоставления одного или нескольких атрибутов сущности с атрибутом DTO.
public interface BookSummary {
@Value("#{target.title + '-' + target.author.firstName}")
String getBookNameAndAuthorName();
}
В приведенном выше примере Spring объединит название (title) книги и атрибут firstName ассоциированного автора, чтобы установить атрибут bookNameAndAuthorName.
Внутри этой проекции используется тот же подход, который я объяснял ранее. Spring Data JPA извлекает сущность Book и использует его для выполнения программного сопоставления.
Проекции сущности
Проекции сущности являются наиболее часто используемыми. Persistence context управляет всеми сущностями, возвращаемыми репозиторием Spring Data. Таким образом, каждое изменение атрибута будет сохраняться в базе данных, и вы сможете получить лениво инициализированные ассоциации. Это стоит нам накладных расходов при операций чтения, но делает эти проекции оптимальными для всех операций записи.
Проекции сущности на сегодняшний день являются самыми простыми в использовании. Все стандартные методы запросов, предоставляемые методами репозитория Spring Data JPA, возвращают их. И, если вы определяете свой собственный метод репозитория, вам нужно использовать только класс сущности в качестве возвращаемого типа.
@Repository
public interface AuthorRepository extends CrudRepository<Author, Long> {
@Query("select a from Author a left join fetch a.books")
List<Author> getAuthorsAndBook();
}
Затем Spring Data JPA использует сопоставление, предоставленное persistence provider’ом. Оно выбирает все столбцы, сопоставленные классами сущностей, и сопоставляет каждую возвращенную запись объекту управляемой сущности.
Динамические проекции
Вы можете добавить параметр типа класса в метод репозитория, чтобы использовать один и тот же запрос с разными проекциями. Это позволит вам определять предпочтительный возвращаемый тип в вашем бизнес-коде.
@Repository
public interface AuthorRepository extends CrudRepository<Author, Long> {
<T> T findByLastName(String lastName, Class<T> type);
}
В зависимости от класса, который вы предоставляете при вызове метода репозитория, Spring Data JPA использует один из ранее описанных механизмов для определения проекции и ее сопоставления. Например, если вы предоставляете DTO-класс, Spring Data JPA генерирует запрос с выражением-конструктором. Затем ваш persistence provider выбирает необходимые столбцы базы данных и возвращает DTO.
AuthorSummaryDTO authorSummaryDTO = authorRepo.findByLastName("Janssen", AuthorSummaryDTO.class);
Заключение
Spring Data JPA поддерживает все три проекции, определенные спецификацией JPA. Проекции сущности лучше всего подходят для операций записи. Кроме того, вы должны использовать DTO-проекции на основе классов, когда вам нужны только операции чтения.
Всех остальных форм проекций лучше избегать. Скалярные проекции очень неудобны в использовании и затрудняют поддержку вашего кода. DTO-проекции на основе интерфейса работают не очень хорошо, потому что они требуют дополнительный этап для извлечения сущности и сопоставления.
В завершение хочу пригласить вас на бесплатный урок, в рамках которого мы поговорим о JHipster, а точнее о том, почему это стало так "модно и молодёжно", затронем Rapid Application Development и рассмотрим некоторые примеры использования.