Розширені можливості Spring Data
Всім привіт. Я Сергій Моренець, розробник, викладач, спікер і технічний письменник. Хочу поділитися своїм досвідом роботи з такою цікавою технологією як Spring Data, і розкрити ті теми, які з нею пов’язані.
Я вже розповідав про Spring Data в одній з попередніх статей, але там йшлося більше про порівняння Spring Data JPA і Spring Data JDBC. Взагалі розробники часто використовують Spring Data для CRUD-операцій та простих запитів, через що може виникнути хибне відчуття, що це дуже проста технологія, до якої не варто ставитися серйозно (інша справа, Hibernate, наприклад).
Нещодавно я вирішував одне практичне завдання для свого проєкту, яке спочатку мені здалося рутинним, але в результаті призвело до тривалого дослідження та тестування. У мене набралося достатньо матеріалу для окремої статті. Крім того, ми розглядаємо цей матеріал на деяких тренінгах. У цій статті я хочу показати розширені можливості Spring Data, які, я сподіваюся, дадуть глибші знання з цієї технології.
Spring Data і репозиторії
Думаю, всі знають, що проєкт Spring Data зародився далекого
Якщо ви використовували 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
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.
Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

4 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів