Розширені можливості Spring Data

💡 Усі статті, обговорення, новини про Java — в одному місці. Приєднуйтесь до Java спільноти!

Всім привіт. Я Сергій Моренець, розробник, викладач, спікер і технічний письменник. Хочу поділитися своїм досвідом роботи з такою цікавою технологією як Spring Data, і розкрити ті теми, які з нею пов’язані.

Я вже розповідав про Spring Data в одній з попередніх статей, але там йшлося більше про порівняння Spring Data JPA і Spring Data JDBC. Взагалі розробники часто використовують Spring Data для CRUD-операцій та простих запитів, через що може виникнути хибне відчуття, що це дуже проста технологія, до якої не варто ставитися серйозно (інша справа, Hibernate, наприклад).

Нещодавно я вирішував одне практичне завдання для свого проєкту, яке спочатку мені здалося рутинним, але в результаті призвело до тривалого дослідження та тестування. У мене набралося достатньо матеріалу для окремої статті. Крім того, ми розглядаємо цей матеріал на деяких тренінгах. У цій статті я хочу показати розширені можливості Spring Data, які, я сподіваюся, дадуть глибші знання з цієї технології.

Spring Data і репозиторії

Думаю, всі знають, що проєкт Spring Data зародився далекого 2008-го року, як не просто ще одна спроба інтеграції Spring і реляційних баз даних (такі вже були Spring ORM і Spring JDBC). Робота з Spring Data базувалася на патерні Репозиторій, який був ключовим компонентом та дозволяв зменшити кількість коду для CRUD-операцій та запитів.

Якщо ви використовували Spring Data JPA (перший проєкт із сімейства Spring Data), то репозиторії були єдиним API, через які ви могли взаємодіяти з JPA. А самі по собі вони були надбудовою на EntityManager з JPA (зараз Jakarta Persistence). З одного боку — це зручно, менше коду для рутинних операцій. З іншого боку, будь-який новий API має обмеження.

Уявімо таке завдання. У вас є проєкт, який використовує Spring Data JPA (Hibernate) і робить це досить успішно. Але ви помітили, що для деяких операцій (lookup) необхідно повернути клієнту список всіх сутностей для того, щоб заповнити список, що випадає (drop-down). Це зробити не складно, тому що в будь-якому репозиторії (наприклад, CityRepository):

public interface CityRepository extends JpaRepository<City, Integer>{
 }

є операція findAll:

       List<City> cities = cityRepository.findAll();

Але цей код повертає всі поля сутності City, включаючи і ті асоціації, які позначені як EAGER. А це надлишкові дані, які не потрібні на UI в даному випадку (потрібний тільки ідентифікатор і назва). Як це змінити? У JDK 14 з’явилася така чудова фіча як Java records, тому можна додати новий запис LookupRecord

public record LookupRecord(int id, String name) {}

А далі є два варіанти. Якщо не хочеться змінювати persistence layer, можна поміняти наш REST-контролер:

       @Autowired
       private CityRepository cityRepository;
      
       @Autowired
       private ModelMapper mapper;
      
       @GetMapping
       public List<LookupRecord> lookup() {
              return cityRepository.findAll().stream().map(city -> mapper.map(city, LookupRecord.class)).toList();
       }

Тут ми покращили performance за рахунок того, що не надсилаємо надмірну інформацію клієнту. Але все одно вона витягується з бази. Це перший мінус. А другий мінус у тому, що доводиться витрачати час на конвертацію. Крім того, не кожна бібліотека конвертації підтримує Java records.

Проєкції

Загалом, саме час згадати про те, що в Spring Data є така чудова фіча як про’кції, які працюють «з коробки» і не потребують додаткової конфігурації:

public interface CityRepository extends JpaRepository<City, Integer>{
       List<LookupRecord> findBy();
}

І все б добре, але своєї уваги вимагають і інші сутності, наприклад, Publisher (видавець):

@Table
@Entity
public class Publisher {
      
       @Id @GeneratedValue
       private int id;
      
       private String title;
}

І тут виникають дві складності:

  • Не дуже хочеться в кожному репозиторії писати один і той же метод findBy
  • Є сутності (той самий Publisher), де назва може бути не name, а яким завгодно з історичних причин, і змінювати його тільки заради цього випадку ніхто буде

Друга складність істотніша, оскільки Spring Data не підтримує mapping для проєкцій, тому доведеться створювати новий тип для кожного окремого випадку, чого робити не хочеться.

Рішення приходить саме собою. А що, якщо створити базовий інтерфейс для наших репозиторіїв, де потрібен lookup?

public interface BaseLookupRepository<T>
                           extends JpaRepository<T, Integer> {
       List<LookupRecord> findBy();

Тепер наші репозиторії можуть його успадкувати:

public interface CityRepository extends BaseLookupRepository<City>{
        List<LookupRecord> findBy();
}

Правда, тепер, якщо ми запустимо тести, то одразу отримаємо помилку конфігурації:

Caused by: java.lang.IllegalArgumentException: Not a managed type: class java.lang.Object

at org.hibernate.metamodel.model.domain.internal.JpaMetamodelImpl.managedType(JpaMetamodelImpl.java:181)

Проблема в тому, що наш інтерфейс BaseLookupRepository — абстрактний для нас, але абсолютно звичайний для Spring Data, яке намагається створити для нього реалізацію. Але клас сутності тут — Object (generics видаляються в run-time) і природно, що Hibernate не може знайти таку сутність. Тому потрібно явно вказати Spring Data, щоб він не генерував для нового інтерфейсу репозиторій за допомогою анотації @NoRepositoryBean:

@NoRepositoryBean
public interface BaseLookupRepository<T>
                           extends JpaRepository<T, Integer> {
       List<LookupRecord> findBy();

Наразі тести проходять успішно. Але як бути із видавцем? Для нього можна придумати прохідний маневр і явно написати JPQL-запит:

public interface PublisherRepository extends BaseLookupRepository<Publisher>{
       @Override
       @Query("SELECT new it.LookupRecord(id,title) FROM Publisher")
       List<LookupRecord> findBy();
}

Spring Expression Language

Такий код працює, але чомусь не йде думка про те, щоб зробити універсальне рішення без необхідності писати щоразу новий JPQL. І тут нам знову допоможе Spring Data, де є підтримка SpEL (Spring Expression Language). SpEL — потужний механізм, який додає динаміку виразів до статичним Query методам в Spring Data.

Нам можуть знадобитися два вирази:

  • #{#entityName} — назва поточної сутності для того репозиторію, де виконується SpEL.
  • ?#{[0]} — одержання значення першого (індекс 0) аргументу методу. Зазвичай у JPQL використовують іменовані параметри (:name), але такі також допустимі.

Тоді ми можемо додати такий метод у BaseLookupRepository:

@NoRepositoryBean
public interface BaseLookupRepository<T>
                           extends JpaRepository<T, Integer> {
       @Query("SELECT new it.LookupRecord(id,?#{[0]}) FROM #{#entityName}")
       List<LookupRecord> findBy(String column);

У якому динамічно передаватимемо назву стовпця. А потім перевизначити його в PublisherRepository:

public interface PublisherRepository extends BaseLookupRepository<Publisher>{
      
       @Override
       default List<LookupRecord> findBy() {
              return findBy("title");
       }

Але, на жаль, це не працює так, як нам потрібно, тому що Hibernate генерує наступний SQL-запит:

SELECT id, «title» FROM Publisher;

Тобто тут «title» — це просто рядок, а не назва стовпця. Якщо спробувати хитритися і обернути SpEL в косий апостроф:

       @Query("SELECT new it.LookupRecord(id,`?#{[0]}`) FROM #{#entityName}")

То це здивує Hibernate:

Caused by: org.hibernate.query.SemanticException: Could not interpret path expression ’?1′

at org.hibernate.query.hql.internal.BasicDotIdentifierConsumer$BaseLocalSequencePart.resolvePathPart(BasicDotIdentifierConsumer.java:256)

Але здаватися зарано, тому що в JPQL є вираз CASE WHEN і тоді наш JPQL запит можна переписати як, використовуючи text blocks:

@Query("""                
       SELECT new it.LookupRecord(id,
  CASE WHEN :column='title' THEN title
  ELSE name END) FROM #{#entityName}
  """)
List<LookupRecord> findBy(@Param("column") String column);

Тут ми передаємо назву стовпця за допомогою іменного параметра. Але, на жаль, і тут ми отримаємо помилку:

Caused by: org.hibernate.query.SemanticException: Could not interpret path expression ’name’

at org.hibernate.query.hql.internal.BasicDotIdentifierConsumer$BaseLocalSequencePart.resolvePathPart(BasicDotIdentifierConsumer.java:256)

Вся річ у тому, що Hibernate перевіряє не тільки синтаксис запиту, але й наявність полів у сутності (навіть якщо вони не використовуються в run-time). А у нас у класу City немає поля title, а Publisher — name. Не будемо впадати у відчай і передаємо запит у native (тобто SQL)::

       @Query(value = """
       SELECT id,
           CASE WHEN :column = 'title' THEN title
           else name END FROM #{#entityName} 
              """, nativeQuery = true) 
       List<LookupRecord> findBy(@Param("column") String column);

Але й тут на нас чекає розчарування, тому що H2 не менш сувора до валідації запитів і не знайде стовпців (яких і немає):

Caused by: org.h2.jdbc.JdbcSQLSyntaxErrorException: Column «NAME» not found; SQL statement:

SELECT id,

CASE WHEN ? = ’title’ THEN title

else name END FROM Course [42122-214]

at org.h2.message.DbException.getJdbcSQLException(DbException.java:502)

У результаті приходимо до думки, що статичні запити нам не допоможуть, потрібно якось динамічно змінювати JPQL-запит для кожної нестандартної сутності. І нам дуже пощастило, що у Spring Data JPA 3 (вийде цієї осені) з’явилася така крута фіча як QueryRewriter:

QueryRewriter

@FunctionalInterface
public interface QueryRewriter {
 
       String rewrite(String query, Sort sort);
 
       default String rewrite(String query, Pageable pageRequest) {
              return rewrite(query, pageRequest.getSort());
       }
 }

Ви можете реалізувати цей інтерфейс, і в методі rewrite переписати (змінити) запит перед виконанням, як вам заманеться. Але як передати сюди назву рядка? На жаль, поточний API не дозволяє отримати аргументи методу. Тому доводиться використовувати старий добрий ThreadLocal:

@NoRepositoryBean
public interface BaseLookupRepository<T>
                           extends JpaRepository<T, Integer> {
       ThreadLocal<String> COLUMNS = new ThreadLocal<>();
 
       String COLUMN = "'column'";
 
       default List<LookupRecord> findBy(String column) {
              COLUMNS.set(column);
              return findWithReplace();
       }
 
       @Query(value = "SELECT new it.LookupRecord(id," + COLUMN
                     + ") FROM #{#entityName}", queryRewriter =
                     ColumnQueryRewriter.class)
       List<LookupRecord> findWithReplace();

Ми додали константу COLUMN, яка повинна бути рядком (щоб не лаявся Hibernate), а також метод findBy, який встановлюватиме назву стовпця в COLUMNS, а потім викликати findWithReplace. У конфігурації findWithReplace необхідно вказати клас queryRewriter за допомогою аналогічного атрибута. Як буде працювати ColumnQueryRewriter? Тут відбувається найцікавіше:

@Component
public class ColumnQueryRewriter implements QueryRewriter {
 
       @Override
       public String rewrite(String query, Sort sort) {
              String column = COLUMNS.get();
              if(column == null) {
                     return query;
              }
             
              return query.replaceAll(COLUMN, column);
       }
}

У методі rewrite перевіряється, чи є значення ThreadLocal. Якщо є, ми замінюємо ’column’ на назву стовпця. Тепер залишилося викликати новий метод у PublisherRepository:

public interface PublisherRepository extends BaseLookupRepository<Publisher>{
      
       @Override
       default List<LookupRecord> findBy() {
              return findBy("title");
       }

Зрозуміло, у цьому рішенні є моменти, які можна покритикувати:

  • Метод findWithReplace є внутрішнім у нашому компоненті, але його може викликати будь-хто. На жаль, оголосити його як private не можна (не використовуватиметься анотація @Query)
  • Поле COLUMNS також є внутрішнім, але до нього також може звернутися будь-хто, що загрожує side effects

Усього цього могло б не бути, якби API у вигляді QueryRewriter надавав доступ до аргументів Query methods в Spring Data. Сподіватимемося, що так і буде зроблено в майбутніх версіях.

Висновки

У цій статті я розповів про розширені фічі Spring Data JPA, деяких з яких ще немає в стабільній версії. Я хотів показати, наскільки гнучким є цей фреймворк, що для одного завдання можна придумати кілька різних рішень. Сподіваюся, стаття та розглянуті приклади виявляться корисними. Буду радий, якщо читачі в коментарях розкажуть про свій досвід використання просунутих фіч із Spring Data.

Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

👍ПодобаєтьсяСподобалось6
До обраногоВ обраному2
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Для даної задачі

що для деяких операцій (lookup) необхідно повернути клієнту список всіх сутностей для того, щоб заповнити список, що випадає (drop-down

повертання

всі поля сутності City, включаючи і ті асоціації, які позначені як EAGER.

навряд чи буде проблемою, що вимагає projections. Скільки рядків у таблиці і які там EAGER-асоціації? *-to-1 Region? Якщо туди спеціально 1-to-* Users з EAGER не додавати, різницю в швидкодії буде важко виміряти.

А другий мінус у тому, що доводиться витрачати час на конвертацію

Теоретично це так, а практично цей час на порядки менше ніж I/O з БД, їм можна знехтувати.

можна поміняти наш REST-контролер:
       @Autowired
       private CityRepository cityRepository;
      
       @Autowired
       private ModelMapper mapper;
      
       @GetMapping
       public List<LookupRecord> lookup() {
              return cityRepository.findAll().stream().map(city -> mapper.map(city, LookupRecord.class)).toList();
       }

Це, насправді, цілком правильний підхід, якщо, звісно, цей код:

  • знаходиться в Service, разом з repository і mapper;
  • явно вказані transaction boundaries за допомогою одноіменної аннотації;
  • REST-контролер містить тільки Service.

Будь ласка, не подавайте поганий приклад, і не нехтуйте шарами Controller-Service-Repository.

Offtopic. Мапер — це, звісно, справа смаку, але я надаю перевагу MapStruct, тому що:

  • на відміну від ModelMapper, Dozer тощо, це не runtime converter, а code generator;
  • відповідно буде compile check;
  • і мінімальний оверхед, якщо ви вже так про це турбуєтесь.

Більше того, об’єкт LookupRecord з двох полів цілком можна створити без мапера взагалі.

Щодо перфоманса, то, оскільки це типові словники, то, очевидно, найкращим прийомом буде звичайний кеш.

Рішення приходить саме собою. А що, якщо створити базовий інтерфейс для наших репозиторіїв, де потрібен lookup?

А що, якщо почати з об’єкної моделі, як я раджу в своїй статті, і створити базовий інтерфейс для наших класів, де потрібен lookup?

interface LookupRecord {
   Integer getId();
   String getName(); 
}

Для видавця це відповідно буде

@Table
@Entity
@Getter
public class Publisher implements LookupRecord {
      
       @Id @GeneratedValue
       private Integer id;
      
       private String title;

      @Override
      public getName() {
           return title;
      }
}

Тоді не потрібно городити цей лютий оверінженірінг, який, зауважу, до того ж runtime-check.

P.S. Вибачте, що прискіпуюсь, але List<LookupRecord> lookup(), List<LookupRecord> findBy() — на диво невдалі назви для даних методів.

P.P.S. Id в Сity/Publisher-і бажано має бути класом, а не примітивом, щоб бути null, а не нуль для новостворених (transient) entities.

Стаття з набіром прикладів «як не треба робити»

Дякую! Немає на гітхабі цього прикладу?

На жаль, ні. Тут не так багато коду, щоб оформляти окремий репозиторій. Усі приклади коду наведено у статті.

Підписатись на коментарі