Обновление Hibernate через боль

Меня зовут Андрей Аркаев. Я занимаюсь разработкой на Java с 2002 года. Сейчас развиваю бизнес-систему для контактных центров в Naumen. Как и многие другие бизнес-системы мы используем ORM. В статье поговорим о Hibernate, так как эта библиотека распространена для Java.

Наш продукт развивается более 15 лет и накопил в себе много кода, в том числе легаси. Мы начинали с Hibernate версии 3 и прошли через 3 больших версии.

Если посмотреть статистику привязки к версиям Hibernate в Maven Central, то окажется, что к версии 5.5 привязано 400 проектов, к 5.6 — 1200 проектов, а к свеженькой 6.1 — буквально 180 проектов. То есть 6.1 еще только набирает обороты. Уже появляются первые доклады, как всё хорошо и радужно при обновлении на новую версию, однако реальность обычно другая. Поэтому интересно разобраться, какие есть сложности и проблемы, и как переходить на эту версию.

Сделаю ремарку: эта статья не рассказывает обо всех изменениях, а только о самом интересном и важном для меня и моих проектов.

А зачем вообще обновлять?

Баги и безопасность. Скорее всего, вы не захотите сами править баги, поэтому обновиться придется.

Совместимость библиотек / фреймворка. Бывает, что вы вынуждены обновить Hibernate, потому что привязаны к чему-то. Например, у вас SpringBoot или Micronaut. Если поднимаете версию фреймворка, то зачастую приходится обновлять и Hibernate.

Новая функциональность. Иногда хочется получить новые фичи, чтобы упростить себе жизнь и писать меньше кода.

Задел на будущее. Можно и не обновляться, но, когда возникнет необходимость, придется провести значительно большую работу за раз, чем если бы вы делали это постепенно. Я знаю проект с большим количеством кода, плотно сидящий на старых решениях Hibernate. На текущий момент я оцениваю его шансы на обновление на свежие версии Hibernate как околонулевые из-за количества единовременных трудозатрат.

Если для себя и руководства вы решили, что аргументов достаточно, смотрим, что можем сделать.

Обновление Hibernate через боль

Нет Hibernate — нет проблем. Если всё перевести на SQL, то и обновлять Hibernate будет не нужно. Однако если вы начали использовать Hibernate, то, наверняка на это была причина, и отказываться от него будет странным решением.

Код в рамках JPA. Подойдет, если у вас весь код в рамках JPA без кастомизаций. Однако и здесь можно собрать проблемы, о которых поговорим дальше.

Обновляем только Hibernate. Когда вы не в рамках каких-то фреймворков, и можете обновить только эту библиотеку.

Используем Hibernate вместе с фреймворком. Hibernate обычно интегрируется в ту версию фреймворка, которая у вас есть. Просто так перепрыгнуть через версию вы, скорее всего, не сможете. Все распространенные современные фреймворки за исключением Helidon, живут на текущей ветке 5.6. Helidon использует Hibernate 6, но пока его аудитория только нарастает. Это скорее исключение, чем правило. Наверняка большинство использует Hibernate в связке со Spring Boot.

Как можно подготовиться к обновлению?

В первую очередь нужно почитать Migration Guide.

  • Обязательно прочитайте его от начала до конца. Игнорировать ничего нельзя, вслепую обновлять не стоит.
  • В Migration Guide мы увидим, что Hibernate 6.0 уже не поддерживается и придется прыгать через версию сразу на 6.1.
  • Теперь требуется Java 11.
  • Стоит проверить проект на deprecated код, который удалили в новой версии.
  • Есть разные неочевидные изменения, о которых поговорим ниже.
  • Убедитесь в наличии совместимой версии библиотеки L2-кэша, если вы используете что-то нестандартное.
  • Чтобы обновление принесло меньше сюрпризов, всегда пишите и прогоняйте тесты.

А теперь пройдемся по проблемам

В первую очередь Hibernate перешел на Jakarta Persistence API и вам нужно смигрировать код на использование этого API. Есть множество инструментов, чтобы перевести весь код на jakarta-пакеты, в том числе в Idea (Migrate Packages and Classes). Однако в нашем случае мы не смогли полностью перевести стек на Jakarta, всё же миграция была частичной.

Лайфхак: если у вас только Hibernate и вы не пользуетесь фреймворками, то можно сначала перейти на версию 5.6-jakarta и уменьшить количество одновременных изменений.

А если у меня Spring Boot?

Обновление Hibernate через боль

Фреймворки достаточно глубоко интегрируют Hibernate и просто поднять версию Hibernate не удастся. Если у вас Spring Boot 2.x, то придется жить с Hibernate версии 5. А при обновлении на Spring Boot 3, переходить на Hibernate 6 версии. Это очень большая проблема. Обновление Hibernate само по себе проблема, а в этом случае нужно перейти на Java 17 и дополнительно обновить весь стек со всеми связанными библиотеками — это может стать блокирующим фактором для огромного количества проектов.

Если вы надеетесь, что с другим фреймворком такой проблемы не будет, то я не столь оптимистичен. Считаю, что остальные будут обновляться тем же путем.

Я составил план, по которому проходил процесс:

  • Убрать deprecated-код.
  • Обновить библиотеки, которые можно обновить независимо. Если у вас фреймворк, смотрите какие библиотеки с ним совместимы и поднимайте их до максимально возможной версии. Лучше сделать это отдельно и предсказуемо, чем разом и с большой долей неопределенности.
  • Затем обновить Java. Важно сделать это после обновления библиотек, так как не все библиотеки имеет forward-compatibility. Особенно это относится к сериализаторам и библиотекам работающим с байт-кодом.
  • Перейти на Jakarta-пакеты.
  • Обновить Hibernate + Framework.
  • Уйти в отпуск и «случайно» забыть телефон 🙂

Избавляемся от Deprecated-кода

Обновление Hibernate через боль

Почитайте Migration guide, чтобы выбрать важные для вас моменты. Например, для нас самым больным моментом является удаление HibernateCriteria.

Так как продукту более 15 лет, есть много мест, где динамические запросы написаны с помощью этого механизма. Прямой и обратно совместимой замены этому не предлагается, поэтому нужно искать альтернативу и переписывать код с помощью другого решения. Здесь начинаются муки выбора.

Вот пример запроса, который написан на Hibernate Criteria. Для простоты без динамического изменения. Тут все просто: одна строчка говорит, из какой сущности берем, какое поле хотим достать и по какому условию.

List<String> result = em.unwrap(Session.class) .createCriteria(Person.class) .setProjection(Projections.property("login")) .add(Restrictions.eq("department", department)) .list();

И даже HibernateCriteriaBuilder, который расширяет стандартный CriteriaBuilder, не выглядит хорошим решением. Вот так запрос будет выглядеть на JPA Criteria.

public List<String> jpaCriteria(Department department) { CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery<String> crt = cb.createQuery(String.class); Root<Person> root = crt.from(Person.class); crt.where(cb.equal(root.get(Person_.department), department)); crt.select(root.get(Person_.login)); return em.createQuery(crt).getResultList(); }

Здесь есть статическая типизация на базе Metamodel Generator, но точно нельзя сказать, что такой запрос легко написать или прочитать.

В итоге наш выбор свелся к выбору среди существующих фреймворков: JOOQ, MyBatis, Querydsl, BlazePersistance. Мы выбираем в зависимости от сервиса и его размеров. Ниже наш топ решений:

  • Spring Data, если можно обойтись только этим.
  • JPA Criteria / HibernateCriteriaBuilder, если кода мало, а ради пары запросов не хочется подключать другие тяжелые библиотеки.
  • QueryDSL — читаемость, типизация.
  • Blaze Persistence — читаемость и сложные запросы.

После перехода на свежую версию начинаем разбираться с возникающими проблемами

Первая проблема — у нас не компилируется код, так как поменялись сигнатуры некоторых методов. Особенно это затронуло интерфейсы Interceptor и связанные с пользовательскими типами. Вместо Serializable-идентификаторов, теперь используется класс Object. Разработчики разумно рассудили, что требование доставляло больше неудобств, но теперь нужно время на адаптацию кода.

Пример такого изменения:

boolean onSave(Object entity, Serializable id, Object[] state, String[] propertyNames, Type[] types);

Изменилось на:

boolean onSave(Object entity, Object id, Object[] state, String[] propertyNames, Type[] types);

Радуемся, что в Java есть статическая типизация и такие ошибки хорошо видно, а чинятся они просто.

Изменена работа с @Type

Казалось бы, что можно сломать в аннотациях, но вместо строки теперь нужно передавать класс. Это требует ручного изменения в коде. Потенциальный плюс — это должно помогать для GraalVM Native, так как избавляет код от излишнего указания вспомогательной информации для компилятора.

А еще можно перейти на стандартную аннотацию @Convert и использовать что-то подходящее из коробки. Теперь у нас целых три варианта работы с boolean-значениями: YesNoConverter, TrueFalseConverter и NumericBooleanConverter. Пример их использования:

@Convert(converter=YesNoConverter.class) boolean isActive;

Пользовательские диалекты

Сильно поменяли пользовательские диалекты. Если у вас есть свои надстройки, то придется адаптировать под новый механизм. Например, мы использовали Jsonb в связке с Hibernate, когда это еще не было мейнстримом.

Когда я начал разбираться в этой проблеме, даже в Migration Guide про это ничего не было. Пришлось читать исходники. Основная идея в том, чтобы вместо диалекта на каждую версию БД был один универсальный диалект для вендора БД, в котором есть поддержка всех версий сразу. Условно код изменился так.

Было:

public class PGSQLMapDialect extends PostgreSQL9Dialect { public PGSQLMapDialect() { registerColumnType(...); }

Стало:

public class PGSQLMapDialect extends PostgreSQLDialect { public PGSQLMapDialect() { super(DatabaseVersion.make(9, 5)); } protected void registerColumnTypes(TypeContributions typeContributions, ServiceRegistry serviceRegistry) { // ... } }

Закончили с ошибками компиляции, начинаем разбираться с Runtime-ошибками

SQL-функции в запросе

Такой запрос начал падать.

select substring( e.description, 21, 11, octets) From AnEntity e

Было изменено поведение и sql-функции нужно указывать явно:

select substring( e.description, 21, 11, sql(octets) ) From AnEntity e

Не могу сказать, что это сильно критично при разработке, но как найти все такие места в существующем проекте… Я нашел проблему только с помощью тестов.

Изменение в JPQL

Еще важное и, казалось бы, безобидное изменение. Если на Hibernate 5 следующий запрос возвращал List с двумя объектами.

from Person p join p.address

То в Hibernate 6 будет возвращен только первый объект List. Лучшее решение в этом случае не экономить символы, и во всех запросах явно прописать select.

Меняются стандартные типы

Хорошо, что об этом написали в Migration Guide: изменился мапинг на даты. Теперь даты по умолчанию хранятся не просто в UTC, а с таймзонами. В описании изменения есть приписка: смигрируйте столбец с датами на тип с таймзоной. Тут у меня всплывает плохое предчувствие про таблицы, где десятки миллионов строк… В этом случае лучше вернуть старое поведение с помощью предлагаемого ключика и разбираться отдельно. Эту проблему легко пропустить, поэтому важно не создавать схему средствами Hibernate, а использовать отдельные решения, такие как flyway или liquibase.

Мы избавились от множества deprecated-кода и обновились. Что имеем теперь?

Появился новый deprecated-код

hbm.xml помечен как @deprecated

Думаю, не все застали описание ORM-сущностей через hbm.xml, но этот способ был жив, а теперь помечен как устаревший и будет удален. Если вы его используйте, то у вас проблемы. Перейти с hbm на аннотации с сохранением поведения не самая простая задача, потому что есть много несовместимостей при одновременной работе через hbm.xml и @Entity. У нас на проекте такой переход занял где-то полгода. Я тратил примерно по 2 часа в день плюс время на ревью и правку ошибок. Возможно, в наше время разумнее использовать современные инструменты вроде JPABuddy для генерации всех сущностей по схеме в БД за один раз, чем делать такой перевод постепенно. К сожалению, на тот момент у меня такой опции не было.

У интерфейса Session пометили устаревшими методы

А именно save(), saveOrUpdate(). Если вкратце, то эти методы делают слишком много проверок, что сказывается на производительности. Чтобы привить чувство прекрасного и оптимального, теперь нужно явно указывать, что вы пытаетесь сделать через методы persist() и merge(). Эта проблема уже обсуждалась на Хабре.

Еще пометили устаревшим метод load(). Я считаю, что это одно из самых полезных изменений, так как ошибиться в правильном выборе load() и get() очень просто. Теперь у нас два варианта getReference и get(). В одном случае получаем proxy-объект без проверки наличия, в другом объект явно запрашивается в БД.

А есть ли плюшки в новой версии?

Обновление Hibernate через боль

Поддержка новых типов

Плюшек завезли немало. Из коробки появилась поддержка Json и массивов. К сожалению, я не могу сейчас что-то порекомендовать по этой функциональности. У меня много вопросов, как это реализовано в каждой конкретной БД. Например, в Postgres есть Jsonb, а также в Postgres 15 должен был появиться Json по SQL-стандарту (к сожалению, в финальной версии функциональность откатили). А в Oracle, наоборот, только json по SQL-стандарту, причем только начиная с версии 12с.

В итоге Hibernate говорит, что будет использовать fallback вплоть до хранения в строках, если база не поддерживает. Но если вы делаете какие-то сложные типы, то наверняка подразумеваете, что будете не только хранить, но и иметь быстрый доступ к нужным данным, а значит, создавать индексы. Значит, вы точно должны контролировать и понимать, с какими типами данных в БД вы работаете. Мой совет: при внедрении этой фичи хорошо продумайте все аспекты.

Подняли размер Enum с TINYINT (1 байт) до SMALLINT (2 байта). Другой вопрос: кто делает Enum больше 256 значений? Наверное, в кодогенерации это вполне может произойти, поэтому это маленькое, но улучшение.

Больше потребление памяти

Как повышенное потребление может быть улучшением… Дело в том, что когда вы кэшируйте результат Query, Hibernate кэширует не результат запроса, а только идентификатор из сущности, которая была в нем получена.

То есть вы делаете первый запрос, помечаете, что он должен быть закэширован → он одним запросом выполняется и возвращает вам результат → скажем, получает 10 сущностей → вы повторяете запрос → он возвращает из кэша 10 id → и делает 10 запросов, чтобы поднять информацию по этим идентификаторам, если их нет в L2-кэше.

В итоге вместо одного запроса получаем 10, хотя ожидали, что все данные уже должны быть в кэше. Теперь результат Query кэшируется полностью и поведение более предсказуемое.

Улучшили работу с Multi Tenancy

Multi Tenancy означает, что вы можете один Hibernate заставить работать с несколькими схемами, базами, либо с разным партициями в БД и переключаться между ними.

Есть отдельные инструкции, как настроить Hibernate под каждый из случаев, но для понимания, вот как изменяется способ работы с tenant-ами в основном коде.

Добавляется новое поле с аннотацией @TenantId.

@Entity public class Person { @TenantId private Srting tenant; @Id @GeneratedValue private Long id; private String name; //… }

После этого можно следующим образом переключаться между tenant-ами и работать с его объектами.

@AutoWired TenantIdentifierResolver currentTenant; private Person createPerson(String Schema, String name) { currentTenant.setCurrentTenant(schema); return txTemplate.execute(tx -> { Person person = Person.named(name); return person.save(person); }); }

Производительность

  • Для улучшения производительности JPQL-парсера перешли на версию antlr 4 (была версия 2).
  • Переключились на чтение результатов JDBC с алиасов на чтение по позиции. Сколько бы ни оптимизировали доступ по имени, доступ по позиции всегда быстрее. Ну и теперь SQL-запросы в БД выглядят значительно проще.
Обновление Hibernate через боль

Это изменение также затронуло сигнатуры методов для работы с пользовательскими типами. Если они у вас есть, вам тоже нужно их перевести на позиционное чтение ResultSet.

Значительно расширены возможности HQL/JPQL

Hibernate изменил внутреннее представление и ввел прослойку, которая называется Semantic Query Model. Если раньше JPA Criteria преобразовывалась сначала в JPQL, а затем уже в SQL, то теперь такие запросы преобразуются во внутреннее древовидное представление, а уже из него напрямую формируется SQL.

Это изменение открыло большое окно возможностей для развития. Теперь в JPQL появилась поддержка оконных функций.

SELECT ROW_NUMBER() OVER( PARTITION BY at.account.id ORDER BY at.createdOn ) AS balance FROM AccountTransaction at ORDER BY at.id

Раньше такие запросы можно было писать только на SQL. Также можно отметить поддержку UNION, INTERSECT и EXCEPT, которых иногда очень не хватает.

Еще в JQPL теперь можно использовать множество распространенных функций, однако полного списка я не нашел. Вероятно, проще смотреть в исходниках метода org.hibernate.dialect.Dialect#initializeFunctionRegistry(). Из примеров можно указать EXTRACT, CEILING, FLOOR, EXP, LN, POWER, ROUND, SIGN.

Подводим итог

Изменений в Hibernate 6 очень много: есть как полезные, так и сомнительные и опасные, но явно требующие внимания.

Если вы думаете, что обновление произойдет просто и вы обойдетесь малой кровью, скорее всего, это не так. Основательно готовьтесь к переезду. Постарайтесь идти в рамках стандарта JPA, это сильно упрощает жизнь. Побольше покрывайте тестами, используйте testcontainers, так как некоторые проблемы возникают только на конкретной базе. Не доверяйте Hibernate создание схемы — создавайте схему, используя liquibase или flyway. Ищете и применяйте вспомогательные инструменты, такие как Idea Ultimate (к сожалению, CE версия сильно ограничена) и плагин для неё JPA Buddy.

Начать дискуссию