Порівнюємо Spring Data JDBC та JPA
Я Сергій Моренець, Java-розробник, письменник, викладач, спікер на ІТ-конференціях. Хочу познайомити читачів DOU зі своєю статтею — результатом мого дворічного вивчення, а потім використання нового перспективного фреймворку Spring Data JDBC. Він настільки зацікавив мене, що ми додали його в наш тренінг з Java Persistence. Думаю, що стаття буде корисна всім, хто працює з реляційними СУБД у Java-проєктах як можливість дізнатися щось нове, а також переосмислити свій поточний досвід.
Java та реляційні СУБД
Мало хто зараз пам’ятає, що бібліотека JDBC з’явилася тільки в JDK 1.1, оскільки спочатку Java планувалася лише як середовище для розробки аплетів. Спочатку Java-розробники могли використовувати тільки чистий JDBC для взаємодії з СУБД, але майже відразу вилізли його незручності для використання:
- Ручне керування ресурсами (наприклад, з’єднаннями).
- Ручне керування транзакціями.
- Необхідність писати mapping код між класами та таблицями.
- Необхідність знати (розширення) SQL, специфічний для використовуваної СУБД.
- Часте дублювання коду.
Згодом також вилізла ще одна проблема, спочатку непомітна — блокуючий характер JDBC-драйверів та їхніх API. І відповідно, будь-яка надбудова над JDBC також використовувала блокуючий (синхронний) підхід.
Тому в кінці
- Конфігурацію для mapping (спочатку за допомогою XML, а потім Java).
- Сесія, кеш першого рівня та dirty checking.
- Автоматична генерація схеми.
- Кеш другого рівня.
- Lazy loading.
- Різні стратегії mapping для асоціацій та ієрархій.
- Нова мова HQL, не прив’язана до SQL та бази даних.
І багато іншого. По суті, можливостей було так багато, що навіть зараз, у 2022 році, у Hibernate немає суперників у своїй ніші. Але конкуренти є (EclipseLink, DataNucleus, Apache OpenJPA). І вийшла цікава картина. Дуже багато enterprise-проєктів (тоді ще монолітні) використовували Hibernate, але при цьому Hibernate ніяким боком не ставився до Enterprise Java (Java EE).
Цей недогляд ліквідували в 2006 році, коли у Java EE увійшла JPA 1.0 як нова специфікація. По суті, з Hibernate видерли те, що можна віднести до абстракції роботи з ORM (анотації, API), а Hibernate та інші фреймворки стали JPA провайдерами. Часи змінювалися, у 2019 році JPA перейменували на Jakarta Persistence, але суть не змінилася.
Як тільки з’явився JPA, перехід між різними ORM-технологіями ставав простішим, тому що ви використовували JPA-фасад, згадуючи про Hibernate тільки при написанні конфігурації, а також розгрібанні проблем з performance та виправленням помилок. Але в JPA залишилися багато незручностей, які тяглися ще з JDBC:
- Ручне управління ресурсами та транзакціями.
- Дублювання коду у типових операціях.
- Необхідність знання мови запитів (спочатку SQL, потім HSQL, а далі JPQL).
Декого з них було виправлено в Spring ORM (інтеграції Spring Framework та Hibernate/JPA), а деякі залишилися до 2008 року, коли з’явився новий революційний проєкт Spring Data. Це сімейство підпроєктів, пов’язаного з доступом як до реляційних (Spring Data JPA), так і до нереляційних даних (NoSQL, ElasticSearch). Я назвав його революційним, тому що це вже була не просто інтеграція Spring і чогось ще, а реалізація тих фіч, які давно чекали:
- Абстракція Repository, що включає популярні CRUD операції.
- Посторінкове виведення та сортування.
- Query methods (для статичних запитів), для яких взагалі не потрібно знати мови запитів до СУБД.
- Query by example (для динамічних запитів).
Всі підпроєкти об’єднує загальна залежність Spring Data Commons, яка дозволяє легше і простiше використовувати нові підпроєкти, якщо ви вже знаєте базовий API. Але, по суті, Spring Data JPA був ще одним рівнем абстракції над JPA, які перейняв «у спадок» ті проблеми, які дісталися ще від Hibernate. Це стосувалося навіть блокуючого API. Крім того, тут був досить високий поріг входу, тому що все одно потрібно було знати основи JPA і Hibernate. Проте він багатьох влаштовував, тому більшість перейшла на нього без жодних проблем. Але була частина розробників, яким потрібна була «золота середина» між JDBC та JPA. Таким консенсусом міг би стати проєкт Spring JDBC, але вiн не був ORM технологією, а просто дозволяв простіше використовувати JDBC.
Розробники ж хотіли легковагову ORM-технологію, яка б з одного боку не тягла за собою
При цьому відразу оголосили, що це не буде «другий Hibernate» або спроба створити «краще, ніж Hibernate». Ідея була взяти ORM у дещо спрощеному вигляді і відмовитися від тих основних фіч Hibernate, які або обтяжують код, або ускладнюють роботу з ним:
- Сесія (кеш першого рівня).
- Dirty tracking.
- Lazy loading.
- Cascading.
Розберемо їх по порядку. У Spring Data JDBC немає сесії або будь-якого іншого контейнера, який би зберігав (кешував) сутності. Будь-яка операція з даними виконується моментально, відповідно у сутностей немає і стану (persisted, detached) як у Hibernate. Якщо немає сесії, немає і dirty tracking. Розробники самі повинні стежити за зміною своїх об’єктів. Немає i lazy loading. Усі асоціації відразу завантажуються під час завантаження основного об’єкта (Aggregate Root). Відповідно, коли ви зберігаєте об’єкт, автоматично зберігаються і всі асоціації (навіть якщо вони не змінювалися). Це може призвести до деяких проблем з performance, якщо у вас є досить багато зв’язків.
Відмінності Spring Data JDBC від Spring Data JPA
Розглянемо приклади, які допоможуть вам краще зрозуміти нову технологію та її ключові відмінності від Spring Data JPA.
Допустимо, у вас в JPA є сутність «товар»:
@Getter @Setter @Table @Entity public class Product { @Id @GeneratedValue private Integer id; private String name; @Column(name = "PRICE") private Double price; }
В JPA три анотації є обов’язковими для сутності (@Table, @Entity, @Id). Крім того, Hibernate вимагає гетери/сеттери і дефолтний конструктор, роблячи наш об’єкт mutable. Далі необхідно створити інтерфейс-репозиторій, куди можна додати методи для запитів:
public interface ProductRepository extends JpaRepository<Product, Integer> { List<Product> findByNameAndPrice(String name, double price); long countByPrice(double price); }
У Spring Data JDBC mapping буде схожим:
@Getter @Setter @Table public class Product { @Id private Integer id; private String name; @Column("PRICE") private Double price; }
Але при цьому всі інструкції будуть не з JPA, а з Spring Data. Відразу впадають у вічі відмінності у можливостях налаштування. Якщо в JPA в анотації @Column можна вказати різні нюанси роботи зі стовпцем, то в Spring Data JDBC — лише нову назву стовпця. Що стосується репозиторію, то він буде відрізнятися тільки базовим інтерфейсом (CrudRepository, а не JpaRepository).
public interface ProductRepository extends CrudRepository<Product, Integer> { List<Product> findByNameAndPrice(String name, double price); long countByPrice(double price); }
Чи достатньо цього, щоб розпочати роботу з Spring Data JDBC? На жаль, ні, навіть якщо ви використовуєте Spring Boot і автоконфігурацію. По-перше, потрібно написати SQL-файл, який буде створювати вашу базу даних i таблицi (якщо їх немає або для тестового варіанта). По-друге, потрібні спеціальні біни з Spring JDBC, оскільки цей компонент активно використовується під капотом для запитів. І по-третє, потрібно явно оголосити бін TransactionManager, якщо нам потрібні декларативні транзакції:
@Configuration @EnableJdbcRepositories public class JdbcConfig extends AbstractJdbcConfiguration { @Bean public PlatformTransactionManager transactionManager() { return new DataSourceTransactionManager(dataSource()); } @Bean public DataSource dataSource() { return new EmbeddedDatabaseBuilder().setType(EmbeddedDatabaseType.H2).addScript("dbinit.sql").build(); } @Bean public NamedParameterJdbcTemplate namedTemplate(DataSource dataSource) { return new NamedParameterJdbcTemplate(dataSource); } }
Тепер початкова конфігурація готова. Поговоримо про плюшки. У Hibernate були певні складнощі, якщо наша сутність була immutable (read-only). Тут ви можете навіть використовувати Java-записи для цього:
@Table public record Product (@Id Integer id, String name, Double price) {}
Перейдемо до запитів. Одна з переваг JPQL запитiв в тому, що якщо ви їх вказали в репозиторіях або іменованих запитах, то вони валідуються (на коректність синтаксису) при завантаженні Spring контексту. У Spring Data JDBC немає JPQL, але ви можете використовувати SQL:
@Query("SELECT * FROM PRODUCTS WHERE id=:id") Product findWithSQLById(@Param("id") int id);
При цьому синтаксис SQL запиту буде перевірено лише під час виконання, оскільки Spring Data JDBC сам цього робити не вміє.
Те, що Spring Data JPA та Spring Data JDBC — родинні проєкти, не означає, що вони й працюватимуть однаково. Тому я радив би включати логування (принаймні на стадії розробки), щоб бачити, які SQL будуть генеруватися. Наприклад, розберемо випадок, коли вам не потрібні всі дані про товар, а лише ідентифікатор та назву. Таке завдання вирішується за допомогою проєкцій, які Spring Data JDBC підтримує:
public record ProductDTO(int id, String name) {}
Але якщо ви додасте такий метод до вашого репозиторію:
List<ProductDTO> findBy();
То при дослідженні згенерованого SQL виявиться, що спочатку будуть завантажені всі дані про товар (включаючи асоціації), а потім ці дані будуть перетворені в ProductDTO. Як вихід із становища — написати свій оптимізований SQL:
@Query("select id,name FROM PRODUCTS") List<ProductDTO> findBy();
Ще один приклад — ідентифікатори сутності. У Hibernate (але не JPA) є можливість використовувати існуючі генератори або написати свої (на базі інтерфейсу IdentifierGenerator). Spring Data JDBC такої функціональності не підтримує. Але оскільки він побудований на Spring Framework, то активно використовує таку фічу як lifecycle events. Будь-яка операція з сутністю призводить до генерації відповідної події. Якщо ви хочете використовувати UUID як тип ідентифікатора:
@Table("ORDERS") public class Order { @Id private UUID id;
то вам потрібно буде додати обробник події BeforeSaveEvent, в якому згенерувати ідентифікатор для нового об’єкта:
@Bean public ApplicationListener<BeforeSaveEvent<?>> beforeSave() { return event -> { Object entity = event.getEntity(); if(entity instanceof Order order && order.getId() == null) { order.setId(UUID.randomUUID()); } }; }
Щоправда, тут можуть виникнути кілька незручностей:
1) Цей обробник викликається для всіх сутностей, тому доводиться постійно робити приведення типів.
2) Він викликається тільки для root aggregate, тому якщо вам потрібно ініціалізувати ідентифікатори дочірніх сутностей, то це доведеться робити вручну.
3) Події в Spring відправляються і обробляються синхронно, тому блокуватимуть основну операцію.
Ситуацію виправляє універсальний типізований API EntityCallback, який підтримує і блокуючий, і реактивний режим:
@Bean public BeforeSaveCallback<Order> beforeSaveCallback() { return (order, aggregateChange) -> { if (order.getId() == null) { order.setId(UUID.randomUUID()); } return order; }; }
Продуктивність
Будь-яке дослідження нової технології має включати аналіз її ефективності, тим більше, що ми маємо такий чудовий інструмент як JMH. Для тестування обрано наступну конфігурацію:
- JMH 1.33
- JDK 17.0.1
- Spring Boot 2.6.2
- H2 1.4.200
- Intel Core i9, 8 cores
- 32 GB
- 10 ітерацій обчислення, 10 ітерацій прогріву (warm-up)
Як типові операції я обрав пошук і збереження сутностей (простого та aggregate root). Це конфігурація асоціації для Spring Data JDBC
@MappedCollection(idColumn = "CATEGORY", keyColumn = "CATEGORY") private List<Product> products;
А це для JPA (з lazy loading):
@OneToMany(mappedBy = "category") private List<Product> products;
Ось як виглядають два benchmarks з тестування пошуку відповідно для Spring Data JDBC та Spring Data JPA варіантiв:
@Benchmark public Product springDataJdbcQuery() { return productJdbcRepository.findByName("phone"); } @Benchmark public Product springDataJpaQuery() { entityManager.clear(); return productRepository.findByName("phone"); }
Тестування дало наступнi результати:
Як ви бачите, за продуктивністю Spring Data JDBC поступається Spring Data JPA. Це не дивно для агрегатних сутностей, де будь-яка операція обов’язково включає додаткові операції з дочірніми сутностями, але досить дивно виглядає для простих сутностей. Тим не менш, проєкт розвивається, і будемо сподіватися на покращення у продуктивності, тим більше я сам порушив цю тему на їхньому GitHub.
Висновки
Spring Data JDBC можна назвати ORM на мінімалках (Lite ORM). Він не вимагає знання Hi-bernate/JPA, достатньо лише базових знань з Spring Data та вміння в окремих випадках писати SQL-запити. Такий фреймворк впевнено займає своє місце між Spring Data JPA і Spring JDBC і добре підходить для невеличких проєктів з простою моделлю даних. На відміну від Spring Data JPA, він не впирається в витоки (Hibernate) і цілком може розвиватися ще довгий час, тим більше що йому всього 4 роки. Зараз він підтримує найпопулярніші реляційні СУБД за допомогою діалектів (за аналогією з Hibernate).
Більше того, Spring Data JDBC мав і свою історичну місію. Успіх цього проєкту та позитивне сприйняття спільноти призвело до того, що у жовтні 2018 року стартував ще один революційний проєкт — Spring Data R2DBC, який об’єднав Spring Data, проєкт Reactor та дозволяв використовувати реактивні драйвери від виробників СУБД. А це дозволило створити на базі Spring Boot повністю реактивний стек для реляційних enterprise-додатків (Spring Security Reactive, Spring WebFlux, Netty, Spring Data R2DBC).
10 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів