Використовуємо Jakarta Data в Java-застосунках. Частина 2

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

Всім привіт. Я Сергій Моренець, розробник, тренер, викладач, спікер і технічний письменник, хочу поділитися з вами своїм досвідом використання такої технології як Jakarta Data. У першій частині я розповів про основну функціональність Jakarta Data та про те, як мігрувати Hibernate-проєкти на нову технологію. У другій частині поділюся досвідом її використання та тими помилками й проблемами, які з’явилися після міграції. Сподіваюся, що це буде корисним для всіх, хто хоче більше дізнатися про сучасні тенденції та зміни у світі Enterprise Java.

Запускаємо тести

Така велика міграція завжди становить великий ризик регресії. Але через те, що у нас на проєкті дуже високий рівень покриття коду тестами, практично всі помилки вилізли б на етапі тестування. Тому перейдемо до інтеграційних тестів. Якщо раніше ми використовували в них Session при створенні репозиторіїв, то тепер StatelessSession:

@BeforeAll
static void setup() {
SessionFactoryBuilder builder = new SessionFactoryBuilder(ConfigProvider.getConfig());
StatelessSession session = builder.getSessionFactory().openStatelessSession();
CityRepository repository = new CityRepository_(session);
StationRepository stationRepository = new StationRepository_(session);
}

Спробуємо перевірити, наскільки правильно працює зараз наш код. Запускаємо тести та отримуємо помилку:

Caused by: org.hibernate.TransientObjectException: Object passed to upsert() has an unsaved identifier value: model.entity.City

at org.hibernate.internal.StatelessSessionImpl.idToUpsert(StatelessSessionImpl.java:313)

А за нею ще одну:

Caused by: jakarta.persistence.TransactionRequiredException: Executing an update/delete query

at org.hibernate.internal.AbstractSharedSessionContract.checkTransactionNeededForUpdateOperation(AbstractSharedSessionContract.java:559)

Почнемо з першої помилки. Ось як виглядає згенерований метод save():

@Override
public City save(@Nonnull City entity) {
if (entity == null) throw new IllegalArgumentException("Null entity");
try {
if (session.getIdentifier(entity) == null)
session.insert(entity);
else
session.upsert(entity);
return entity;
}

Якщо об’єкт city новий, то для нього викликається insert, інакше upsert. Метод upsert у StatelessSession — досить новий, він з’явився в Hibernate 6.3 і виконує наступне завдання. Якщо переданий об’єкт існує, він його оновлює, інакше вставляє новий. Але при цьому ніколи не генерує ідентифікатор сутності та видає помилку, якщо ідентифікатор не ініціалізований, що в нас і сталося.

Чому тут з’являється помилка? Справа в тому, що у нас ідентифікатор типу int. Він ніколи не може бути null, але може бути неініціалізований (нуль), що означає новий запис:

@MappedSuperclass
@Setter @EqualsAndHashCode(of="id«)
public abstract class AbstractEntity {
private int id;

З якоїсь причини генератор думає, що ідентифікатор не може бути примітивним типом. І якщо його значення не null (нуль), викликає upsert, який вже правильно сприймає об’єкт як новий. Але оскільки він не може генерувати значення ідентифікаторів, викидає помилку. Таким чином, згенерований код є некоректним, але ми не можемо його змінювати. Тому єдиний варіант — це змінити тип ідентифікатора на посилання (Integer):

private Integer id;

Запускаємо тести далі та отримуємо нову помилку:

jakarta.data.exceptions.DataException: could not execute statement [NULL not allowed for column «CREATED_AT»; SQL statement:

insert into CITY (CREATED_AT,CREATED_BY,DISTRICT,MODIFIED_BY,NAME,REGION,ID) values (?,?,?,?,?,?,default) [23502-214]] [insert into CITY (CREATED_AT,CREATED_BY,DISTRICT,MODIFIED_BY,NAME,REGION,ID) values (?,?,?,?,?,?,default)]

З якоїсь причини значення створеного поля виявилося неініціалізованим. Розберемося, чому.

Аудит поля

Hibernate має чотири способи для заповнення аудит-полів:

  1. Callback-методи з JPA.
  2. Спеціальні анотації-маркери з Hibernate.
  3. Entity listeners з JPA.
  4. Event listeners з Hibernate.

Зараз ми використовуємо перший варіант, найбільш популярний, оскільки він є частиною JPA (Jakarta Persistence):

@PrePersist
public void prePersist() {
if (getId() == null) {
setCreatedAt(LocalDateTime.now());
} 
}

Але схоже на те, що ці callback-методи не викликаються при використанні StatelessSession. І тут саме час з’ясувати, а чим відрізняється StatelessSesion від звичної нам Session? Найголовніша відмінність — це, як випливає з назви, відсутність стану, а саме:

  1. Немає кешу першого рівня.
  2. Немає dirty tracking.

Але це ще не все. Документація розповідає, що у StatelessSession також немає:

  1. Cascading.
  2. Підтримки events/interceptors.
  3. Підтримки кешу другого рівня.
  4. Ініціалізації колекцій у сутностей.

Тобто виходить, що цей об’єкт є дуже простою надбудовою над JDBC і призначений для групових операцій. Так що ж, нам аудит взагалі ніяк не реалізувати? Спробуємо три варіанти, розпочавши з entity listeners:

@EntityListeners(AuditListener.class)
public abstract class AbstractEntity {
public class AuditListener {
@PrePersist
public void onInsert(AbstractEntity entity) {
entity.setCreatedAt(LocalDateTime.now());
}
}

На жаль, вони також не викликаються, далі анотація @CreationTimestamp з Hibernate:

@CreationTimestamp
private LocalDateTime createdAt;

Вона також не задіяна. І останній варіант — event listeners із Hibernate:

public class AuditListener implements PreInsertEventListener {
@Override
public boolean onPreInsert(PreInsertEvent event) {
if (event.getEntity() instanceof AbstractEntity entity && entity.getId() == null) {
String[] properties = event.getPersister().getPropertyNames();
List<String> propertiesList = Arrays.asList(properties);
event.getState()[propertiesList.indexOf(AbstractEntity_.CREATED_AT)] = LocalDateTime.now();
}
return false;
}
}

А ось вони якраз викликаються, якщо їх не забути вказати у properties:

hibernate.event.listener.pre-insert=model.entity.AuditListener

Таким чином, завдання аудиту вирішено. Тепер перейдемо до другої проблеми при виконанні цієї операції із CityReposiory:

@Query("delete from City2″)
void deleteAll();

З цим пов’язаний цікавий момент, оскільки в згенерованих методах CityRepository/StationRepository_ немає нічого пов’язаного з транзакціями. Більше того, як випливає з документації, саме Jakarta EE контейнер відповідає за управління транзакціями при роботі з Jakarta Data репозиторіями. Жодної вбудованої підтримки або управління транзакціями, як у Spring Data, тут немає. Тому ми маємо два виходи. Перший це явне створення та завершення транзакцій, ніби ми використовуємо JPA/Hibernate:

private void execute(StatelessSession session, Runnable action) {
Transaction tx = null;
try {
tx = session.beginTransaction();
action.run();
tx.commit();
} catch (Exception ex) {
if(tx != null) {
tx.rollback();
}
throw new PersistenceException(ex);
}
}
@Override
public void deleteCities() {
execute(cityRepository.session(), cityRepository::deleteAll);
}

Або складніший варіант — використання специфікації Jakarta Transactions. Вона дозволяє декларативно керувати транзакціями за допомогою анотації @Transactional, але потрібнний сумісний Jakarta EE контейнер, а це зараз тільки Glassfish.

Продовжуємо запускати тести та знаходимо ще одну проблему. Якщо поглянути на згенерований метод saveAll, то там з якоїсь незрозумілої причини відсутній виклик insert, а upsert, як ми вже знаємо, не може генерувати ідентифікатори нових об’єктів:

@Override
public List saveAll(@Nonnull List entities) {
if (entities == null) throw new IllegalArgumentException("Null entities«);
try {
for (var _entity : entities) {
session.upsert(_entity);
}
return entities;
}

Тому цей метод зараз підходить тільки для збереження існуючих об’єктів, а будь-який виклик saveAll для створення населених пунктів призводить до виключення:

@Override
public void saveCities(List<City> cities) {
cityRepository.saveAll(cities);
}

Вирішення цієї проблеми виглядає дивно — потрібно відмовлятися від saveAll і просто викликати save() для кожної сутності окремо:

@Override
public void saveCities(List<City> cities) {
cities.forEach(cityRepository::save);
}

Продовжуємо запускати тести, і знаходимо нові помилки, пов’язані з тим, що StatelessSession не підтримує cascading. Тому коли зберігаємо населений пункт, зв’язані станції не зберігаються. Доводиться явно зберігати дочірні сутності:

@Override
public void saveCity(City city) {
cityRepository.save(city);
if(city.getStations() != null) {
city.getStations().forEach(stationRepository::save);
}
}

Знову запускаємо тести і тепер виводиться помилка від нашого контейнера:

org.glassfish.hk2.api.UnsatisfiedDependencyException: There was no object available in __HK2_Generated_0 for injection at SystemInjecteeImpl(requiredType=CityRepository,parent=GeographicServiceImpl,qualifiers={},position=0,optional=false,self=false,unqualified=null,471707375)

Справа в тому, що ми зараз використовуємо HK2, а його поточна версія поки не підтримує Jakarta Data і не може зробити auto-wiring (injection):

@Named
public class GeographicServiceImpl implements GeographicService {
private final CityRepository cityRepository;
private final StationRepository stationRepository;
@Inject
public GeographicServiceImpl(CityRepository cityRepository, StationRepository stationRepository) {
this.cityRepository = cityRepository;
this.stationRepository = stationRepository;
}

Тому доводиться явно вказувати згенеровані класи у конструкторі:

@Inject
public GeographicServiceImpl(SessionFactoryBuilder sessionFactoryBuilder) {
this.cityRepository = new CityRepository_(sessionFactoryBuilder.getSessionFactory().openStatelessSession());
this.stationRepository = new StationRepository_(sessionFactoryBuilder.getSessionFactory().openStatelessSession());
}

Знову запускаємо нові тести та з’являється помилка при видаленні населених пунктів:

Caused by: org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException: Referential integrity constraint violation: «FKL8S21EMVHB86AUBH7M389HRLO: PUBLIC.STATION FOREIGN KEY(CITY_ID) REFERENCES PUBLIC.CITY(ID) (1)»; SQL statement:

delete from CITY c1_0 [23503-214]

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

Справа в тому, що в JPA є дуже зручний атрибут orphalRemoval, який дає змогу видаляти всі пов’язані сутності при видаленні root aggregate:

@OneToMany(cascade = CascadeType.ALL, mappedBy = «city», orphanRemoval = true)
public Set<Station> getStations() {
return CommonUtil.getSafeSet(stations);
}

Але схоже на те, що і тут це не підтримується, тому потрібно явно видаляти дочірні сутності. І фінальна помилка, вже у системних тестах, при спробі створення станції:

jakarta.data.exceptions.DataException: could not execute statement [Referential integrity constraint violation: «FKL8S21EMVHB86AUBH7M389HRLO: PUBLIC.STATION FOREIGN KEY(CITY_ID) REFERENCES PUBLIC.CITY(ID) (1)»; SQL statement:

insert into STATION (ZIP_CODE,STREET,HOUSE_NO,APARTMENT,CITY_ID,X,Y,CREATED_AT,CREATED_BY,MODIFIED_BY,PHONE,TRANSPORT_TYPE,ID) values (?,?,?,?,?,?,?,?,?,?,?,?,default) [23506-214]] [insert into STATION (ZIP_CODE,STREET,HOUSE_NO,APARTMENT,CITY_ID,X,Y,CREATED_AT,CREATED_BY,MODIFIED_BY,PHONE,TRANSPORT_TYPE,ID) values (?,?,?,?,?,?,?,?,?,?,?,?,default)]

Цікаво, що цей код працює в інтеграційних тестах, але не працює у системних, коли ми запускаємо Jakarta EE контейнер і викликаємо цей код через API REST. Причину цієї помилки з’ясувати не вдалося, але вона фактично не дозволяє нам використовувати в нашому проєкті Jakarta Data, навіть з тими численними обмеженнями, а саме:

  1. Численні неточності при генерації реалізацій репозиторіїв.
  2. Відсутність підтримки кешу другого рівня.
  3. Відсутність підтримки interceptors, JPA callbacks та аудит анотацій.
  4. Відсутність cascading та завантаження колекцій.
  5. Відсутність вбудованої підтримки транзакцій.
  6. Якщо JPA/Jakarta Persistence підтримуються багатьма провайдерами, то Jakarta Data фактично поки що одна реалізація — Hibernate.

Фактично це робить Jakarta Data дуже схожим на ще один проєкт із цієї сфери — Spring Data JDBC. Тому якщо у вас великий повноцінний проєкт на базі JPA/Hibernate/Spring Data, перенести його на Jakarta Data буде або дуже складно або неможливо.

Для яких випадків варто використовувати Jakarta Data:

  1. Нові проєкти на базі Jakarta EE, де потрібний стандартний набір функціональності для доступу до даних.
  2. Проєкти на базі JDBC або сторонніх бібліотек, яким підійде Jakarta Data та її фічі.
  3. Проєкти, де переважно необхідні операції над наборами (bulk) сутностей.
  4. Проєкти, де критична швидкодія, включно зі стартом застосунку. Оскільки реалізації репозиторіїв генеруються під час складання, а не старту.

Benchmarks

І продуктивність може стати вирішальним фактором при виборі технології, але тут необхідні benchmarks. Тому давайте порівняємо швидкодію таких технологій.:

  • Spring Data JPA
  • Spring Data JDBC
  • Jakarta Data

Для цього створимо тестовий проект, де перевіримо ефективність операцій:

  1. Створення запису.
  2. Пошук запису по ідентифікатору з наступною зміною.
  3. Пошук запису з поля (Query Method).

Сутність буде максимально простою:

@Getter
@Setter
@Entity
public class Product {
@Id
@GeneratedValue
private Integer id;
private String name;
}

Цікаво, що для Jakarta Data і Spring Data JPA можна буде використовувати ту саму сутність. Ось як виглядає сутність для Spring Data JDBC:

@Getter
@Setter
@Table("PRODUCTS")
public class Product {
@Id
private Integer id;
private String name;
}

Для тестування було обрано наступну конфігурацію:

  • JMH 1.37
  • JDK 22.0.2
  • Spring Data JPA 3.3.3
  • Spring Data JDBC 3.3.3
  • Hibernate 6.6.0
  • H2 2.2.224
  • Intel Core i9, 8 cores
  • 32 GB
  • 10 ітерацій обчислення, 10 ітерацій прогріву (warm-up)

І наступні benchmarks:

@Benchmark
public Product jakartaDataQuery() {
return productRepository.findByName("phone");
}
@Benchmark
public Product updateProductJakartaData() {
Product product = productRepository.findById(id).orElseThrow();
product.setName("Car");
return productRepository.save(product);
}
@Benchmark
public Product insertProductJakartaData() {
Product product = new Product();
product.setName("PC");
return productRepository.save(product);
}

Результати тестування по Jakarta Data:

Benchmark Mode Cnt Score Error Units
JakartaDataBenchmarking.insertProductJakartaData avgt 5 4660.518 377.069 ns/op
JakartaDataBenchmarking.jakartaDataQuery avgt 5 11987.586 434.950 ns/op
JakartaDataBenchmarking.updateProductJakartaData avgt 5 8705.372 26.234 ns/op

Результати тестування по Spring Data JPA:

Benchmark Mode Cnt Score Error Units
SpringDataJpaBenchmarking.insertProductJpa avgt 5 9748.280 1336.184 ns/op
SpringDataJpaBenchmarking.springDataJpaQuery avgt 5 16056.908 92.368 ns/op
SpringDataJpaBenchmarking.updateProductJpa avgt 5 16465.165 98.954 ns/op

Результати тестування по Spring Data JDBC:

Benchmark Mode Cnt Score Error Units
SpringDataJdbcBenchmarking.insertProductJdbc avgt 5 24065.464 8538.499 ns/op
SpringDataJdbcBenchmarking.springDataJdbcQuery avgt 5 20981.203 783.250 ns/op
SpringDataJdbcBenchmarking.updateProductJdbc avgt 5 30027.220 225.857 ns/op

Висновки

У результаті Jakarta Data показала дворазову перевагу за швидкістю перед Spring Data JPA при записі даних і приблизно півкратну перевагу в запитах. Це видатний результат, враховуючи, що Spring Data вже 16 років, за які були проведені численні оптимізації швидкодії. Що стосується Spring Data JDBC, то воно поступилося Jakarta Data в 2-8 разів, і це ще дивовижніший результат. Оскільки Spring Data JDBC — це лише надбудова над JDBC і за ідеєю вона повинна працювати набагато швидше.

У результаті наразі міграція на Jakarta Data в нашому проєктi неможлива через різні проблеми і необхідність змінювати клієнтський код у різних місцях, чого не було б, якби ми, наприклад, переходили на Spring Data JPA. Фактично ми дописуємо ту функціональність, яка підтримується. Тому залишаємося на чистому Hibernate.

Підбиваючи підсумки безуспішної міграції, можна визначити дві речі. Перша полягає в тому, що Jakarta Data є кроком вперед у порівнянні з JPA/Jakarta Persistence, оскільки пропонує більший рівень абстракції, вирішення стандартних CRUD-завдань та написання запитів. З іншого боку, реалізація від Hibernate пропонує лише одне рішення з використанням StatelessSession, яке має багато обмежень і може бути використане для найпростіших проєктів з нескладною моделлю даних. Але використання StatelessSession дозволяє досягти прискорення ефективності та зменшення витрат пам’яті за рахунок відмови від кешів першого та іншого рівня.

👍ПодобаєтьсяСподобалось5
До обраногоВ обраному1
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

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