Особливості переходу на Hibernate 6. Покращення та зворотна сумісність зі старими версіями

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

Всім привіт. Я Сергій Моренець, розробник, викладач, спікер та технічний письменник. Хочу поділитися з вами своїм досвідом переходу на нову версію Hibernate 6, яка вийшла два місяці тому. Ця версія розроблялася цілих 6 (!) років і мені як розробнику, який використовує цю технологію з 2008 року, було цікаво на практиці розібрати та застосувати її. Відповідно, досвід міграції кількох наших проєктів і став фундаментом для цієї статті. Ми також розглядаємо Hibernate на нашому тренінгу з роботи з реляційними базами даних. Найголовніше, на що б я хотів звернути увагу — це питання покращень та зворотної сумісності зі старими версіями.

Основні зміни в Hibernate 6

Якщо уважно вивчити документацію, то головне, чим розробники Hibernate змогли похвалитися, це:

  • Перехід на JDK 11.
  • Підтримка Jakarta Persistence 3.0 (і Jakarta EE 9).
  • Поліпшення під час роботи з JDBC (доступ до стовпців у вибірці ResultSet за їх індексом, а не по імені).
  • Міграція на ANTLR 4.0 (теж покращення продуктивності).
  • Нова Semantic Query Model (SQM), яка використовується як при роботі з JPQL/HQL, так і Criteria API для того, щоб перетворити їх на SQL.

Звичайно, найбільш фундаментальна зміна — це підтримка Jakarta Persistence 3.0, яку ми знали раніше як JPA. Вперше за майже 20 років існування нова специфікація несумісна з попередніми версіями через сумно вiдому заборону Oracle на використання пакетів javax.*. Ця заборона, по-перше, загальмувала розробку самої Jakarta EE, по-друге, порушила зворотну сумісність.

В результаті склалася цікава картина. Jakarta EE 9 все-таки вийшла в грудні 2020-го року, і вперше за довгий час Hibernate перестав бути JPA провайдером, тому що він не підтримував її. Крім того, було безліч проєктів, які залишалися на старій Java EE 8 (Jakarta EE 8) і не планували міграції. Тому розробникам Hibernate довелося створити новий артефакт hibernate-core-jakarta, і лише у червні 2021 року вийшов Hibernate 5.5, який був сумісний із Jakarta EE 9. Тому Hibernate 5.5 існує для двох версій Jakarta EE. Але таке двовладдя існує тільки для варіанту 5.x. У Hibernate 6 підтримка всіх старих версій Jakarta EE припинена.

У новій версії розробникам довелося перенести пакети всіх типів з javax.* в jakarta.*, а також усі властивості javax.persistence.* на jakarta.persistence.* та вміст XSD файлів. Така ж процедура загрожує і всім розробникам, але якщо вам не хочеться витрачати на цей час, є автоматичні засоби міграції, як Eclipse Transformer, створений Open Liberty. Ця зміна нічого не дає розробникам, але дозволяє припинити топтання на місці і просунутися в розвитку Enterprise Java. Як результат підтримки Jakarta EE 9 Hibernate теж довелося перейти на JDK 11.

Якщо ви все ж таки пройшли цей етап, давайте поговоримо про те, які ще зміни на вас чекають

Зміни, які можуть вас не порадувати

Проєкт Hibernate стартував у 2001 році, і хоча він був призначений для сегменту корпоративної Java (Java EE), але довгий час розвивався самостійно. І тільки в 2006 році Java-суспільство схаменулося і створило специфікацію JPA 1.0, фактично на основі того API, яке вже було в Hibernate. З цього моменту у вас було два вибори:

  • Використовувати Hibernate через прошарок JPA.
  • Використовувати без JPA, одержуючи всі ті можливості, які відсутні в JPA (наприклад, формули або кешування 2-го рівня).

З тих пір Hibernate постійно оновлював свій код, прагнучи позбавитися свого внутрішнього API на користь JPA, і це знайшло своє відображення у версії 6.0. По-перше, дуже багато методів з інтерфейсу Session оголошено як deprecated з підозрою, що їх можуть швидко видалити, наприклад, saveOrUpdate (його немає в аналогічному EntityManager з JPA):

        * @deprecated use {@link #merge(String, Object)} or {@link #persist(String, Object)}
        */
       @Deprecated(since = "6.0")
       void saveOrUpdate(String entityName, Object object);

Документація пропонує замість нього:

       session.saveOrUpdate(station));

використовувати merge або persist (які є в EntityManager):

                     if (station.getId() == 0) {
                           session.persist(station);
                     } else {
                           session.merge(station);
                     }

З одного боку, це є логічним рішенням. З іншого боку, ми якраз і використовували saveOrUpdate, щоб не замислюватися про те, а як визначити, чи це нова сутність або persistent. У тому ж Spring Data в репозиторіях є зручний метод save(), який виконує ту саму функцію. Але якщо для saveOrUpdate є альтернатива, то деякі методи оголошені deprecated без будь-якої заміни:

        * @deprecated With no real replacement
        */
       @Deprecated( since = "6.0" )
       void replicate(String entityName, Object object, ReplicationMode replicationMode) ;

Ще одна біда торкнулася такого компонента як Hibernate Criteria. Коли в JPA 2.0 створювалася аналогічна специфікація (Criteria API), то вона в результаті була реалізована абсолютно несхожим чином, і розробники Hibernate замислилися, що робити зі своїми Criteria. Адже підтримувати відразу два API не так просто. При цьому пікантність ситуації була в тому, що Criteria API був складніший у використанні, і як зауважив один із Hibernate експертів Влад Міхальча, навіть він сам не зможе написати найпростіший Criteria API код без доступу до Інтернету. У результаті у Hibernate 5.2 вони були оголошені deprecated (це 2016-й рік). І ось у Hibernate 6.0 повністю видалені, дискусію з приводу доцільності такого кроку можна прочитати тут. І це при тому, що Hibernate Criteria пропонували ще й більше функціональності.

Тепер, якщо у вас у проєкті все ще використовуються Hibernate Criteria:

public List<Station> findAllByCriteria(StationCriteria stationCriteria) {
              return query(session -> {
                     Criteria criteria = session.createCriteria(Station.class);
 
                     if (stationCriteria.transportType() != null) {
                           criteria.add(Restrictions.eq(Station.FIELD_TRANSPORT_TYPE, stationCriteria.transportType()));
                     }
 
                     if (!StringUtils.isEmpty(stationCriteria.name())) {
                           criteria = criteria.createCriteria(Station.FIELD_CITY);
                           criteria.add(Restrictions.eq(City.FIELD_NAME, stationCriteria.name()));
                     }
                      return criteria.list();
               });
}

То вам потрібно або не переходити на Hibernate 6, або переписувати цей фрагмент коду на динамічний JPQL, або на JPA Criteria API:

       public List<Station> findAllByCriteria(StationCriteria stationCriteria) {
              return query(session -> {
 
                     CriteriaBuilder builder = session.getCriteriaBuilder();
                     CriteriaQuery<Station> criteria = builder.createQuery(Station.class);
                     Root<Station> root = criteria.from(Station.class);
 
                     List<Predicate> predicates = new ArrayList<>();
                     if (stationCriteria.transportType() != null) {
                           predicates.add(builder.equal(root.get(Station.FIELD_TRANSPORT_TYPE), stationCriteria.transportType()));
                     }
                     if (!StringUtils.isEmpty(stationCriteria.name())) {
                         Join<Station, City> city = root.join(Station.FIELD_CITY);
                           predicates.add(builder.equal(city.get(City.FIELD_NAME), stationCriteria.name()));
                     }
                     Predicate predicate = builder.and(predicates.toArray(new Predicate[] {}));
                     criteria.select(root).where(predicate);
                     TypedQuery<Station> query = session.createQuery(criteria);
                     return query.getResultList();
              });
       }

Ще одна проблема — з Hibernate 6 зник клас SchemaExport та весь його пакет org.hibernate.tool.hbm2ddl. Цей клас зручний тим, що з його допомогою можна згенерувати DDL для створення схеми бази даних:

SchemaExport schema = new SchemaExport();
schema.setDelimiter(";");
schema.setOutputFile(folder + "schema_" + dialect.getSimpleName() + ".sql");
schema.create(EnumSet.of(TargetType.SCRIPT), metadata.buildMetadata());

Спершу могло здатися, що клас просто був вилучений, тому що у керівництві міграції про це немає жодного слова. Однак детальне дослідження показує, що ці класи перенесені в нову залежність hibernate-ant. В принципі, таке перенесення логічне, тому що ці класи не використовуються безпосередньо в Hibernate Core, а по суті, потрібні для утилітних завдань. Але згадати про це було б не зайвим. Я створив на це ticket.

Я думаю, всі добре знають таку властивість як Hibernate Dialect. Вона містить специфічні для певної бази даних налаштування та mapping даних. На жаль, Hibernate не завжди може з драйвера дістати тип бази даних і найголовніше — версію (бо вони не завжди повертаються драйвером). Тому, наприклад, для Postgres існує цілих 10 класів діалектів. І так для багатьох інших баз даних. Швидше за все, розробникам Hibernate набридло плодити нові класи діалектів, тому в 6.0 всі вони оголошені deprecated. А як заміна їм доданий новий інтерфейс DatabaseVersion, в реалізаціях якого можна визначити три атрибути версії СУБД — major, minor і micro:

/**
 *
 * @deprecated use {@code MySQLDialect(800)}
 */
@Deprecated
public class MySQL8Dialect extends MySQLDialect {
 
       public MySQL8Dialect() {
              super( DatabaseVersion.make( 8 ) );

Правда, не дуже зрозуміло, як можна буде вказувати важливу властивість hibernate.dialect, якщо ці класи будуть видалені в майбутньому (адже ми вказуємо клас, а не об’єкт).

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

@Formula("(SELECT COUNT(*) FROM Users)")
private int total;

При спробі виконати нативний (SQL) запит:

List<Product> products = session.createNativeQuery("SELECT * FROM PRODUCTS", Product.class).getResultList();

викидається виключення:

Caused by: org.hibernate.exception.SQLGrammarException: Unable to find column position by name: total

at org.hibernate.exception.internal.SQLExceptionTypeDelegate.convert(SQLExceptionTypeDelegate.java:64)

at org.hibernate.exception.internal.StandardSQLExceptionConverter.convert(StandardSQLExceptionConverter.java:56)

at org.hibernate.engine.jdbc.spi.SqlExceptionHelper.convert(SqlExceptionHelper.java:109)

Оскільки це працювало в попередніх версіях Hibernate, то це регресія, причини якої не зрозумілі. Я відкрив тикет на цю помилку, але поки що він на стадії аналізу.

Приводи для оптимізму

Є зміни, що тішать око. Наприклад, за аналогією з preview features в JDK в Hibernate додали анотацію @Incubating, яка означає, що цей API/SPI може змінитися незабаром:

@Incubating
public interface Query<R> extends SelectionQuery<R>, MutationQuery, TypedQuery<R> {

Як ви бачите, в Hibernate 6 вирішили розділити всі запити на вибірки (selection query) та зміни (mutation query), для чого створили два нові інтерфейси. Тепер ви можете при роботі з Session вказати, який з типів запитів вам потрібен:

MutationQuery mutationQuery = session.createMutationQuery("UPDATE Product SET enabled = 0 WHERE price is null");
mutationQuery.executeUpdate();
SelectionQuery<Product> selectionQuery = session.createSelectionQuery("FROM Product", Product.class);

Тепер у вас менше можливостей помилитися у запиті, тому що якщо ви вкажете SELECT запит у mutation query, це відразу викличе помилку без відправки його до БД:

MutationQuery mutationQuery = session.createMutationQuery("FROM  Product);

Яка доля цього покращення? Якщо воно не потрапить до Jakarta Persistence API, то доведеться вдаватися до подібних хитрощів:

Session session = em.unwrap(Session.class);

Ще одне поліпшення — читання з ResultSet за індексом позиції, а не по імені стовпця має підвищити ефективність, а також зменшити розмір генерованого SQL, тому що більше не потрібно вигадувати додаткові aliases для кожного поля у вибірці.

Інтеграція з Spring Data

Оскільки дуже багато розробників використовується Hibernate у зв’язку з Spring Data, цікаво розглянути, як тут просуваються відносини з міграцією. Тут також не все гладко. Розмови про підтримку Hibernate 6 почалися ще в 2018 році, коли озвучувалися плани одночасної інтеграції і Hibernate 5, і Hibernate 6. Але зворотно-несумісний перехід на Jakarta EE 9 це унеможливив, і в результаті інженери Spring вирішили в майбутніх версіях підтримувати тільки 6-у версію. У результаті, хоча Hibernate 6 вийшов ще 1 квітня, але його підтримка якщо і буде, то не раніше Spring Data JPA 3, який в свою чергу вийде разом із Spring Boot 3 у листопаді цього року. Причина банальна — багато що з публічного SPI/API було змінено (або взагалі видалено), що робить перехід і тестування розтягнутим. До цього додалися деякі помилки, знайдені на етапі тестування шостої версії.

Висновки

Hibernate 6 — нова версія популярного продукту, яка дозволяє йому відповідати специфікації Jakarta EE 9. Хоча вона пропонує поліпшення в плані продуктивності, але в той же час деякі (публічні) API видалені, а деякі — без будь-якої тривіальної заміни, що ускладнює міграцію. Особливість Hibernate в тому, що ви можете використовувати його трьома способами:

  • У чистому вигляді.
  • Через такий прошарок як JPA.
  • Через ще один рівень абстракції — Spring Data.

У цій статті я постарався розібрати всі три випадки, і як виявилося, перехід на Hibernate 6 дуже відрізняється для кожного з них. Також потрібно відзначити, що Hibernate 6 перейшов на JDK 11 як мінімально підтримувану версію, і якщо у вас проєкті все ще JDK 8 (або більш ранні версії), то ви не зможете його використовувати.

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

Дякую за статтю, міграція буде невеселою.

Так, на моїй пам’яті це перший випадок в Enterprise Java після переходу з EJB 1.x на EJB 2.x, коли міграція на нову версію приносить стільки незручностей через несумісність API