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

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

Спойлер

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

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

Аннотация @EqualsAndHashCode

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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 коллекции, несмотря на то, что значение одного из полей изменится. 

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

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

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

Аннотация @ToString

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Аннотация @Data

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Комментарии (11)


  1. LeshaRB
    16.08.2024 10:31
    +3

    Вроде ж было уже похоже
    Да и от вашей компании

    https://habr.com/ru/companies/haulmont/articles/564682/


    1. honest_niceman Автор
      16.08.2024 10:31

      Да, согласен, похожая статья уже была. Основные тезисы тут остались примерно такими же, но добавили больше примеров, а также более детально рассмотрели работу методов equals() и hashCode(). Ну и новый инструмент заюзали, чтобы решить все проблемы с которыми столкнулись :)


      1. BugM
        16.08.2024 10:31

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


  1. Gmugra
    16.08.2024 10:31
    +1

    Спасибо за статью, познавательно.

    Но JPA != Hibernate.

    Как бы существуют и другие JPA runtime-ы (EclipseLink, OpenJPA)

    И вообще говоря, не то чтобы это хороший стиль без острой необходимости, использовать runtime-зависимый код.

    И как тогда реализовать корректно equals , без использования HibernateProxy?


    1. honest_niceman Автор
      16.08.2024 10:31
      +1

      Добрый день, спасибо)

      Да, про то что JPA != Hibernate – хорошее замечание, согласен, что стоило это отметить в начале статьи. Учту на будущее :)

      Для EclipseLink Amplicode генерирует реализацию методов придерживаясь схожего принципа, но используя org.springframework.data.util.ProxyUtils#getUserClass(java.lang.Object):

      @Override public final boolean equals(Object o) { if (this == o) { return true;
      }
      if (o == null || ProxyUtils.getUserClass(this) != ProxyUtils.getUserClass(o)) { return false;
      }
      Climber climber = (Climber) o;
      return getId() != null && Objects.equals(getId(), climber.getId());
      } @Override public final int hashCode() { return ProxyUtils.getUserClass(this) .hashCode();
      }


  1. ayrtonSK
    16.08.2024 10:31

    Хорошая статья, тоже натыкалась на эти проблемы. Особенно equals в коллекциях.

    Вместо @Data лучше использовать Getter Setter.


    1. honest_niceman Автор
      16.08.2024 10:31
      +1

      Спасибо :)


  1. exception_prototype
    16.08.2024 10:31
    +1

    Есть плагин для IDEA - JPA Buddy, он как раз умеет генерировать equals и hashCode почти также как и в данной статье.


    1. honest_niceman Автор
      16.08.2024 10:31
      +1

      Да, всё так. Ну и, кстати, JPA Buddy – тоже наша разработка :) Теперь она находится "под крылом" JetBrains, а функциональность, которая ранее была реализована в JPA Buddy переехала в Amplicode.


      1. exception_prototype
        16.08.2024 10:31
        +1

        Круто! Спасибо!


  1. janna_melnikova
    16.08.2024 10:31
    +1

    Статья интересная, нужно ещё прощупать руками, чтобы понять до конца.