Не используйте Lombok с JPA, пока не прочтете эту статью

Lombok — действительно отличный инструмент. Одна строчка кода, и все ваши JPA сущности перестают корректно работать ;) Но это только в том случае, если вы не знаете, какие фичи Lombok можно использовать вместе с JPA, а какие лучше не стоит. 

В этой статье я расскажу про большинство подводных камней, с которыми можно столкнуться, используя Lombok вместе с JPA, и про то, как их обойти используя Amplicode.

Спойлер

В большинстве случаев я буду использовать изображения для демонстрации фрагментов кода. Такой подход позволит мне выделить важные части и более подробно их объяснить. Если вы хотите проверить всё самостоятельно, запустив код о котором пойдет речь, то найти его можно на GitHub.

Статья также доступна в формате видео на YouTube и VK Видео, так что можно и смотреть, и читать — как вам удобнее!

Аннотация @EqualsAndHashCode

Первая аннотация, которая может вызвать проблемы — это аннотация @EqualsAndHashCode. Что тут говорить? Сами по себе методы equals() и hashCode() — тема, способная вызвать немало жарких споров, а уж в контексте JPA и подавно! Чего только стоят десятки вопросов на Stackoverflow в стиле «Как правильно переопределить equals() и hashCode() для JPA сущности?» и примерно такое же количество статей, пытающихся ответить на этот вопрос.

Что самое интересно, несмотря на кажущееся обилие информации, верную реализацию найти все еще практически невозможно.

Lombok, помимо прочего, генерирует реализации методов, не подходящие для использования вместе с JPA сущностями. Почему? Рассмотрим простейший тест в качестве примера. 

У нас есть сущность с несколькими полями, в тесте мы создаем экземпляр этой сущности, кладем его в HashSet, после чего сохраняем сущность и пытаемся найти ее в коллекции, в которую мы ее, собственно, только что положили. 

bed2b3e7b25eac8e0bba06690a240c25.png

Несмотря на кажущуюся банальность, тест не пройдет.

2c1d289e42a9f89c8e6419ca368a62df.png

Все дело в том, что Lombok генерирует реализации методов equals() и hashCode(), отталкиваясь от всех полей, объявленных в сущности. Давайте убедимся в этом. Для этого воспользуемся действием Delombok от IntelliJ IDEA:

630c30df4156d81f94ed8d007e46a53b.png

Как видите, и для equals(), и для hashCode() Lombok использует все поля, объявленные в сущности:

386fb94d415e33b8f0b37f4d396f8015.png

Так как у нас есть поле id, значение которого после создания сущности — null, и изменяется на какое-то конкретное значение только после сохранения в базу, значение hashCode у этой самой сущности будет отличаться до и после сохранения в базу данных.

Проверим, что это действительно так, установив точку останова в тесте и запустив его в режиме отладки. Как видите, изначально id у нашей сущности null:  

025f4855d06c79915188e742a83ce6e2.png

Однако, сразу после сохранения id у нашей сущности меняется:  

c736644eb8ec6824dccaf15d0d38b4a5.png

Как следствие, меняется и значение hashCode. Так как мы кладем сущность в hashSet еще до того момента, как ее id меняется, то ее позиция в hashSet рассчитывается относительно старого значения hashCode. И теперь, когда мы пытаемся найти сущность с новым id, у нас ничего не получается, так как в методе java.util.HashMap#getNode мы смотрим, есть ли нужный нам элемент, по индексу, рассчитанному на основе нового значения hashCode

fb7aae030b01e3b0d645721f2eb178c6.png

С текущим значением hashCode мы действительно не положили ни одной сущности в наш hashSet. Поэтому метод java.util.HashMap#getNode() возвращает null, и, следовательно, метод java.util.HashMap#containsKey() возвращает false. Метод java.util.HashSet#contains() также возвращает false, и тест падает. 

26242639f8c629836eb69150f8862ed8.png

Исходя из этого, можно сделать первый вывод о том, что не следует рассчитывать значение hashCode, отталкиваясь от полей в сущностях.

На самом деле, если бы мы вообще никак не переопределяли методы equals() и hashCode(), то текущий тест бы прошел. Давайте уберем аннотацию от Lombok и запустим тест еще раз.

4ae5b6bda2f0032ba08604c0da232705.png

Тест действительно проходит, ведь по умолчанию значение hashCode будет рассчитано случайно, и никак не будет изменяться в дальнейшем, как бы мы ни изменяли значения полей. 

Но вот другой тест базовая реализация провалит. В тесте мы сравниваем два объекта, представляющих собой одну и ту же запись в базе данных, но расположенных в двух различных persistent контекстах. Для эмуляции этой ситуации мы:

  1. Сохраняем сущность

  2. Получаем ее при помощи EntityManager и выполняем операцию detach()

  3. Затем получаем сущность еще раз, используя метод find()

  4. И, наконец, проверяем объекты на равенство

bf51a95fb0aceae0d3ed7d3ce9ac1f3f.png

Запустим тест:

a957b97102811a49e15742b6c34f8326.png

В этом случае JVM посчитала, что firstFetched и secondFetched объекты не равны. Я думаю никто не будет спорить с тем, что куда более логичным был бы противоположный исход, ведь обе сущности связаны с одной и той же записью в базе данных.

Что ж, оставить реализацию по умолчанию тоже не получится.

В таком случае, давайте наконец посмотрим, как будет выглядеть верная реализация методов equals() и hashCode() и разберемся, как она работает. 

Для того, чтобы побороть обе проблемы, описанные выше, сгенерируем реализации методов equals() и hashCode() при помощи Amplicode. Для этого обратимся к панели Amplicode Designer (1). В результате получим следующий код (2).

d225f8ad3043a0f7347469c182c75272.png

Amplicode знает о тех проблемах, с которыми мы столкнулись ранее и не только, и генерирует верную реализацию этих методов, поэтому давайте просто проанализируем, что он нам сгенерировал, и поймем, как эти самые методы работают. 

Начнём с метода equals(). Первые две строчки довольно очевидны. Если текущий объект — это тот, который передали в качестве параметра, то возвращаем true. Если передали null, то возвращаем false.

@Override
public final boolean equals(Object o) {
   if (this == o) return true;
   if (o == null) return false;
   ...
}

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

@Override
public final boolean equals(Object o) {
   ...
   Class oEffectiveClass = o instanceof HibernateProxy
           ? ((HibernateProxy) o).getHibernateLazyInitializer()
           .getPersistentClass()
           : o.getClass();
   Class thisEffectiveClass = this instanceof HibernateProxy
           ? ((HibernateProxy) this).getHibernateLazyInitializer()
           .getPersistentClass()
           : this.getClass();
   if (thisEffectiveClass != oEffectiveClass) return false;
   ...
}

На самом деле, это действительно важный аспект в реализации этих методов. Так как и переданный объект, и текущий могут оказаться Hibernate proxy, то и значения классов у этих самых объектов будут отличаться, но это никак не должно отразиться на сравнении сущностей, связанных с одной и той же записью в базе данных. Именно поэтому использование простого метода instanceOf() для проверки принадлежности к текущему классу здесь не подойдет. А ведь именно такой код генерирует Lombok, и именно такой код довольно часто советуют на StackOverflow.

Наконец, если все проверки пройдены успешно, мы получаем id текущего объекта, а также объекта, полученного в качестве параметра. 

@Override
public final boolean equals(Object o) {
   ...
   User user = (User) o;
   return getId() != null && Objects.equals(getId(), user.getId());
}

Важно заметить, что для получения id мы используем именно метод getId(), а не обращаемся к полю напрямую. В случае с Hibernate, если обращаться к полю напрямую, то proxy объект будет проинициализирован в любом случае. А вот если обращаться к полю id через метод getId() и при этом не забыть сделать методы equals() и hashCode() финальными, то в таком случае инициализации proxy объекта не будет, так как эта ситуация считается исключительной в Hibernate и обрабатывается особым образом. Следовательно, мы избежим как дополнительного запроса в базу данных, так и LazyInitializationException

Реализация hashCode()в целом нам теперь довольно понятна. Мы генерируем числовое значение, отталкиваясь от класса с учетом proxy. 

@Override
public final int hashCode() {
   return this instanceof HibernateProxy
           ? ((HibernateProxy) this).getHibernateLazyInitializer()
           .getPersistentClass()
           .hashCode()
           : getClass().hashCode();
}

Замечу, что для генерации hashCode мы теперь не используем ни одного поля. Следовательно, при изменении любого из полей у нас значение hashCode останется прежним, и мы сможем найти сущность в любой hash-based коллекции, несмотря на то, что значение одного из полей изменится. 

Давайте проверим, работает ли наша реализация, для чего запустим оба теста. 

0b500003527d1b7e964f065c97111750.png

Тесты прошли успешно. 

Теперь вы не только знаете, почему не стоит использовать аннотацию @EqualsAndHashCode от Lombok со своими JPA сущностями, но и как должна выглядеть корректная реализация методов equals() и hashCode().

Аннотация @ToString

А вот используя аннотацию @ToString от Lombok, вы также можете серьезно снизить производительность вашего приложения или даже вызвать StackOverflowError прямо в runtime. 

По умолчанию Lombok включает абсолютно все поля в метод toString(), в том числе и ассоциативные. Давайте убедимся в этом. Для этого снова воспользуемся действием Delombok от IntelliJ IDEA:  

c14715a159ee2e7a3e4a597a9baaffce.png

Как правило, ссылочные поля на уровне JPA делают ленивыми, а OneToMany и ManyToMany ассоциации являются таковыми по умолчанию.

51cd016874094f91b44964ed1fd9be67.png

И вряд ли мы бы ожидали увидеть дополнительные запросы в базу после того, как залоггировали какую-нибудь сущность. Не так ли?

Как всегда, обратимся к тесту. Он будет довольно простым:  

  1. Вставляем несколько записей в базу данных для трех таблиц

  2. Получаем только одного user по id

  3. Выводим его в консоль, используя метод toString()

80aaed0c5a08cdec8d621d1c0e92ab33.png

Сразу после обращения к методу toString() мы получаем еще два запроса в базу данных. 

c67d080ff69d96c864c7985f1f04247b.png

На самом деле я немного схитрил и добавил аннотацию @Transactional над тестом. В противном случае тест упал бы с LazyInitializationException, так как после обращения к методу toString() была бы произведена попытка обратиться к базе данных в условиях отсутствия открытой транзакции.

Более того, если мы воспользуемся аннотацией @ToString для каждой из сущностей, которая использует двустороннюю ассоциацию, то приложение упадет со StackOverflowError. Чтобы это продемонстрировать, давайте также добавим аннотацию @ToString и для сущности Post

e2d4642f4a4ae4dd2f8c17482e5196d4.png

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

Поэтому все ассоциативные поля (или, по крайней мере, *ToMany ассоциации) следует исключать из генерации для метода toString().

Amplicode знает об этом и подсвечивает нам проблемное место. Кроме того, предлагается сразу два возможных решения проблемы:

  1. Первое и самое простое — это исключить все ассоциативные поля из генерации для метода toString(), используя аннотацию @ToString.Exclude.

1ce21ba169f8df5868955001a43193b8.png

  1. Альтернативно, Amplicode предлагает сгенерировать реализацию toString() опять же без ленивых ассоциаций.

74e3cfddbecc93ed2de40446399ce796.png

Вам решать, какой подход вам нравится больше. Я выберу первый и запущу тест ещё раз.

256f854ca4b8bfbe1c6cb7d53a38bd13.png

Как вы видите, теперь никаких дополнительных запросов в базу данных не происходит после обращения к методу toString(). Именно такого результата мы и хотели добиться.

Аннотация @Data

Аннотация @Data от Lombok включает в себя аж 6 аннотаций:

61040c7cf9ae4d6fda699793820f2fc0.png

Как мы уже знаем, две из них являются опасными к применению вместе с JPA сущностями. Это аннотации @ToString и @EqualsAndHashCode.

Подробно про проблемы, которые могут возникнуть, когда мы используем @EqualsAndHashCode или @ToString, уже было рассказано выше, но подведем итог еще раз. 

Используя аннотацию @Data от Lombok, вы можете столкнуться с:

  1. Некорректными сравнениями сущностей

  2. Непреднамеренной загрузкой ленивых коллекций

  3. StackOverflowError прямо в рантайме

Вместо аннотации @Data лучше использовать безопасные ассоциации @Getter, @Setter, @RequiredArgsConstructor и @ToString вместе с @ToString.Exclude над ассоциативными полями. А методы equals() и hashCode() лучше переопределить самостоятельно. Напомнить, что использовать аннотацию @Data — не лучшая идея и исправить ситуацию в один клик вам поможет Amplicode:

a41f90f7799999c8888f4b2247917a92.png

Аннотации @Builder и @AllArgsConstructor

Аннотация @Builder от Lombok реализует для нас целый паттерн проектирования всего лишь одной строчкой, но, к сожалению, ломает JPA спецификацию, удаляя конструктор без параметров, обязательный для JPA сущностей.

Давайте убедимся в этом:

50895b72738d8e83b784f2b0265eab48.png

Как видите, теперь у моей сущности есть конструктор с параметрами, а вот без него — нет.

Кстати, то же самое делает и аннотация @AllArgsConstructor

b020a22965be10742bcc472d949f9bc3.png

Если мы попробуем сохранить сущность, которая использует одну из этих аннотаций, то получим JpaSystemException.

2d325265b2a166b7d3e9af474fb5ba2f.png

Так что не забывайте добавлять аннотацию @NoArgsConstructor когда используете аннотации @Builder или @AllArgsConstructor. Amplicode поможет вам не забыть об этом благодаря инспекции и добавить нужные аннотации или сгенерировать нужные конструкторы в один клик:

8f4d6425d27e17b9101d0fa829279bb6.pngc035617837aabf7e30529991623dba14.png

Итоги. Так ли плох Lombok?

Подводя итог, хочется отметить, что Lombok — действительно полезная и удобная библиотека, которая позволяет сократить огромное количество шаблонного кода. Но ей, как и любой другой библиотекой, нужно уметь правильно пользоваться и держать в голове, что у всего есть свои преимущества и недостатки. 

Однако кажется, что в связке с Amplicode можно обойти большинство недостатков Lombok, которые у него есть в контексте использования вместе с JPA.

6d6ffb21b999bdda706810ca526261b0.png

Подписывайтесь на наши Telegram и YouTube, чтобы не пропустить новые материалы про Amplicode, Spring и связанные с ним технологии!

А если вы хотите попробовать Amplicode в действии — то можете установить его абсолютно бесплатно уже сейчас, как в IntelliJ IDEA/GigaIDE, так и в VS Code.

© Habrahabr.ru