Вероятно, первое, что приходит вам на ум, когда вы реализуете запрос с помощью 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 и рассмотрим некоторые примеры использования.