Lombok — это отличный инструмент, с которым Java-код становится чище и лаконичнее. Однако есть несколько нюансов, которые надо учитывать при его использовании с JPA. В этой статье мы выясним, как неправильное применение Lombok может повлиять на производительность приложений или даже привести к ошибкам. Разберемся, как этого избежать не теряя преимуществ Lombok.

Мы разрабатываем JPA Buddy — плагин для IntelliJ IDEA, который упрощает работу с JPA. Прежде чем приступить к разработке, мы проанализировали сотни проектов на GitHub, чтобы понять, как именно программисты взаимодействуют с JPA. Оказалось, что многие из них используют Lombok.

Использовать Lombok в проектах с JPA вполне можно, но нужно учитывать его некоторые особенности. Анализируя проекты, мы увидели, что разработчики снова и снова наступают на те же грабли. Именно поэтому мы добавили в JPA Buddy целый ряд инспекций кода для Lombok. Давайте рассмотрим наиболее распространенные проблемы, с которыми вы можете столкнуться при использовании Lombok с JPA.

Некорректно работающий HashSet (и HashMap)

Анализируя проекты, мы часто видели сущности, помеченные @EqualsAndHashCode или @Data. В документации по аннотации @EqualsAndHashCode сказано следующее:

По умолчанию реализации методов будут использовать все нестатические и не транзиентные поля. Однако вы можете явно указать используемые поля, пометив их с помощью аннотаций @EqualsAndHashCode.Include или @EqualsAndHashCode.Exclude.

Как правильно реализовать equals()/hashCode() для JPA-сущностей — вопрос не тривиальный. Сущности мутабельны по своей природе. Даже ID зачастую генерируется базой данных, то есть изменяется после первого сохранения сущности. Получается, не существует полей, на основе которых можно было бы консистентно вычислить hashCode.

Докажем это на практике. Создадим тестовую сущность:

@Entity
@EqualsAndHashCode
public class TestEntity {

   @Id
   @GeneratedValue(strategy = GenerationType.IDENTITY)
   @Column(nullable = false)
   private Long id;

}

И выполним следующий код:

TestEntity testEntity = new TestEntity();
Set<TestEntity> set = new HashSet<>();

set.add(testEntity);
testEntityRepository.save(testEntity);

Assert.isTrue(set.contains(testEntity), "Entity not found in the set");

Assert в последней строчке упадет с ошибкой, хотя сущность добавлена в set всего несколькими строками выше. Если использовать Delombok на этой сущности, мы увидим, что @EqualsAndHashCode под капотом реализует следующий код:

public int hashCode() {
   final int PRIME = 59;
   int result = 1;
   final Object $id = this.getId();
   result = result * PRIME + ($id == null ? 43 : $id.hashCode());
   return result;
}

При первом сохранении сущности ID изменяется. Соответственно, меняется и hashCode. Именно поэтому HashSet и не может найти объект, который мы только что создали, так как он ищет его в другом бакете. Проблем бы не было, если бы ID был установлен во время создания объекта сущности (например, в качестве ID использовался бы UUID, генерируемый приложением), но чаще всего за генерацию идентификаторов отвечает именно база данных.

Непреднамеренная загрузка lazy полей

Как было отмечено выше, @EqualsAndHashCode по умолчанию использует все поля сущности. Такой же подход используется и для @ToString:

Любой класс может быть помечен аннотацией @ToString, чтобы Lombok сгенерировал реализацию метода toString(). По умолчанию сгенерированный метод toString() возвращает строку, содержащую имя класса и значения всех полей через запятую.

Получается, эти методы вызывают equals()/hashCode()/toString() на каждом поле сущности, включая lazy поля. Это может привести к их непреднамеренной загрузке.

Например, вызов hashCode() на lazy ассоциации @OneToMany может спровоцировать подгрузку всех связанных сущностей. Это может серьезно сказаться на производительности приложения или вызвать LazyInitializationException, если вызов произойдет вне транзакции.

Мы считаем, что вообще не стоит использовать @EqualsAndHashCode и @Data на сущностях, в JPA Buddy есть для этого инспекция:

Аннотацию @ToString можно использовать, если исключить все lazy поля. Для этого надо пометить lazy поля аннотацией @ToString.Exclude или использовать @ToString(onlyExplicitlyIncluded=true) на классе и @ToString.Include на не-lazy полях. В JPA Buddy есть для этого квик-фикс:

Конструктор без аргументов

Согласно спецификации JPA, все сущности должны иметь public или protected конструктор без аргументов. Очевидно, что при использовании @AllArgsConstructor компилятор не генерирует конструктор по умолчанию, это также касается и @Builder:

Применение @Builder к целому классу равноценно применению @AllArgsConstructor (access = AccessLevel.PACKAGE) на классе и @Builder на конструкторе с параметрами.

Поэтому обязательно используйте их с аннотацией @NoArgsConstructor или с конструктором без параметров:

Заключение

С Lombok ваш код выглядит чище, но, как и в случае с любым другим магическим инструментом, важно понимать, как именно он работает и когда его использовать. В противном случае производительность вашего приложения может снизиться, либо оно вовсе может перестать работать корректно. Как вариант, можно положиться на инструменты разработки, которые будут предупреждать вас о потенциальных проблемах.

При работе с JPA и Lombok помните следующие правила:

  1. Избегайте использования аннотаций @EqualsAndHashCode и @Data с JPA сущностями;

  2. Исключайте ленивые поля при использовании аннотации @ToString;

  3. Не забывайте добавлять аннотацию @NoArgsConstructor к сущностям помеченным аннотациями @Builder или @AllArgsConstructor.

Есть еще один вариант: переложите ответственность за соблюдение этих правил на JPA Buddy, его инспекции кода всегда к вашим услугам.

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


  1. maxzh83
    28.06.2021 19:43
    +2

    В случае с equals()/hashCode() из статьи понятно как делать не надо, но не понятно как надо. Особенно когда нет явного естественного ключа.


    1. andreyoganesyan Автор
      28.06.2021 20:21
      +1

      Да, это вопрос на отдельную статью :) Вот хорошая: thorben-janssen.com/ultimate-guide-to-implementing-equals-and-hashcode-with-hibernate
      TL;DR:
      1. Если объекты будут всегда сравниваться в пределах одной сессии Хибернейта, можно equals и hashCode вообще не переопределять
      2. Если есть natural id или id выставляется самим приложением, используем только его в equals и hashCode
      3. Если id генерирует база, используем его в equals и возвращаем из hashCode некоторую константу. Да, это ухудшит производительность коллекций на хешах, но серьезно это начнет влиять с такого количества объектов, что узким местом скорее станет сам факт вытаскивания их из БД. Такова идея, конечно, в реальном мире все зависит от того, как часто к этим коллекциям обращаться


      1. mayorovp
        28.06.2021 20:28
        +2

        Никогда не возвращайте из hashCode константу. Это бессмысленно, поскольку не просто ухудшает производительность хеш-таблиц, но сводит их к массиву. Но если вам достаточно массива, почему бы не использовать ArrayList вместо хеш-таблицы?


        1. andreyoganesyan Автор
          28.06.2021 20:51
          +1

          Если нам нужна коллекция уникальных элементов, заменив HashSet на ArrayList мы переложим ответственность за поддержание уникальности на себя. Придётся каждый раз вызывать contains и все в этом духе, то есть писать лишний код. А в сете это все уже за нас написано, пусть по производительности он будет сведён к массиву в случае константного hashCode. Да и что делать с Map?

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


          1. BugM
            29.06.2021 02:00

            И все равно так не делайте. Это выстрелит через непонятное время.

            Ваши хешмапы превратились в тримапы. logn это не очень много, но все равно легко можно написать код который думая что там o(1) внезапно начнет тормозить на пустом месте. Особенно на активном добавлении-удалении элементов.

            И естесвенно это произойдет на проде в НГ или черную пятницу. Когда минута простоя стоит несколько миллионов. Хорошо если рублей.


            1. poxvuibr
              29.06.2021 13:40
              +1

              И все равно так не делайте. Это выстрелит через непонятное время.

              Вот если написать в hashCode не константу, то да, выстрелит. Не сразу, но обязательно будут плавающие баги.


              Ваши хешмапы превратились в тримапы. logn это не очень много, но все равно легко можно написать код который думая что там o(1) внезапно начнет тормозить на пустом месте.

              Для этого не надо использовать энтити как ключи в хешмапе. А когда используешь коллекции типа HashSet, надо помнить, что нельзя класть туда много элементов. Но вообще надо просто помнить, что надо делать связи OneToMany в которых много элементов


              1. BugM
                29.06.2021 21:18

                Вы прячете в деталь реализации неожиданное усложнение .get из мапы до o(n).

                Пишем типовой поиск всех элементов из соседней коллекции в этой и вот он квадрат.

                И уже не надо много. Уже тысячах на 10 элементов тормоза будут. Которые не ловятся ни тестами ни ручными тестировщикам ни даже нагрузочным тестированием.

                А вот на проде они замечательно выстрелят. В момент максимальной нагрузки. И потратят максимально возможное количество денег бизнеса.

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


                1. poxvuibr
                  30.06.2021 11:05
                  -2

                  Вы прячете в деталь реализации неожиданное усложнение .get из мапы до o(n).

                  Поэтому не надо делать энтити ключами мапы


                  Пишем типовой поиск всех элементов из соседней коллекции в этой и вот он квадрат.

                  По перечисленным выше причинам, так делать не надо.


                  И уже не надо много. Уже тысячах на 10 элементов тормоза будут.

                  Во-первых, по перечисленным выше причинам на надо делать Set из десяти тысяч энтити. Потому что это приведёт к проблемам.


                  Во-вторых, десять тысяч элементов на связи OneToMany это много независимо от коллекции, которая используется для их хранения. В таких случаях лучше делать только связь ManyToOne потом выбирать элементы отдельным запросом.


                  Не надо так софт проектировать.

                  Я тоже говорю об этом.


                  Не надо использовать энтити в качестве ключей хешмепа. Не надо использовать что-то кроме id в equals и hashCode. hashCode должен возвращать константу. equals должен вернуть false если хотя бы один id равен null. Это актуально для энтити в которых нет естественного ключа, а суррогатный ключ не известен сразу после вызова конструктора объекта.


                  Не надо делать OneToMany с большим количеством участников. Это актуально для всех энтити.


                  Правил ещё много, но эти относятся к теме напрямую.


            1. mayorovp
              29.06.2021 14:04
              +1

              Уточнение — чтобы получился TreeMap, нужен способ сравнения записей. А его нет, так что там никакой не логарифм выйдет, а линия.


              1. BugM
                29.06.2021 21:12

                Согласен. Если даже hashCode константа, то рассчитывать на реализацию сравнения глупо.


          1. LaRN
            29.06.2021 11:03
            +1

            А разве нельзя в таком случае создать статичные суррогатное поле, которое будет при создании объекта заполняться некоторым предопределенным значением, хоть GUID - например, и его использовать как базу для расчёта hashcode? Вроде генерация GUID не очень затратна по времени и почти исключает дубликаты.


            1. mayorovp
              29.06.2021 12:59
              +1

              Проще не создавать никакого поля и использовать equals и hashCode по умолчанию.


              1. poxvuibr
                29.06.2021 13:42

                Проще не создавать никакого поля и использовать equals и hashCode по умолчанию.

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


                1. mayorovp
                  29.06.2021 13:58

                  А она так и так будет если программист не следит какие сущности он получает и из каких источников.


                  1. poxvuibr
                    29.06.2021 14:05

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


                    1. mayorovp
                      29.06.2021 14:07

                      Ну а если обе сущности находятся в контексте, и программист не знает какая из них верная — то проблемы будут независимо от того как вы определили equals.


                      1. poxvuibr
                        29.06.2021 14:13

                        По хорошему движок JPA не должен поместить в контекст две разных энтити, указывающих на одну и ту же строку. Вся штука в том, что нужно держать в одной коллекции несколько энтити, которые есть в контексте, которых ещё в нём нет и не забыть про энтити, которые пока не попали даже в БД.


                      1. mayorovp
                        29.06.2021 14:39

                        В данном случае, "контекст" означал не "контекст JPA", а более общее понятие.


                        В случае с контекстом JPA как раз всё понятно: движок за всем проследит независимо от реализации equals.


        1. poxvuibr
          29.06.2021 13:37

          Никогда не возвращайте из hashCode константу.

          За исключением случая, когда речь идёт о hashCode в классе, помечнном аннотацией Entity.


          Это бессмысленно,

          В случае с Entity это крайне осмысленно, потому что equals должен быть завязан на Id и Entity у которых Id == null должны быть не равны друг другу, чтобы можно было сложить их в Set


          поскольку не просто ухудшает производительность хеш-таблиц, но сводит их к массиву.

          Не к массиву, а к дереву, но да, сводит. Поэтому Entity не надо делать ключами в HashMap. Что касается коллекций типа Set — когда речь идёт об Entity, там в любом случае нельзя держать много элементов, так что ничего страшного не случится.


          Но если вам достаточно массива, почему бы не использовать ArrayList вместо хеш-таблицы?

          Обычно так и делают. Но даже в этом случае надо сделать так, чтобы arrayList.contains работал корректно, когда ему отдают Entity с id равным null


          1. mayorovp
            29.06.2021 13:59

            В случае с Entity это крайне осмысленно, потому что equals должен быть завязан на Id

            Зачем?


            1. poxvuibr
              29.06.2021 14:08

              Чтобы когда кладёшь в Set энтити и там уже есть энтити с таким id, новая энтити вытеснила старую.


              Ну и иногда удобно делать arrayList.contains


              1. mayorovp
                29.06.2021 14:43

                Чтобы когда кладёшь в Set энтити и там уже есть энтити с таким id, новая энтити вытеснила старую.

                В таком случае вы завязываетесь на недокументированное поведение.


                А ведь можно вместо Set<EntityType> использовать Map<KeyType, EntityType> и ни на какое недокументированное поведение не завязываться. И бонусом тут будет ускорение работы.


                1. poxvuibr
                  29.06.2021 14:52

                  В таком случае вы завязываетесь на недокументированное поведение.

                  Мне кажется нет такого. Какое поведение из того на что я ссылаюсь не документировано?


                  1. mayorovp
                    29.06.2021 14:55

                    Чтобы когда кладёшь в Set энтити и там уже есть энтити с таким id, новая энтити вытеснила старую.

                    Вот это и недокументировано. Это просто особенность реализации HashSet и TreeSet. Более того, это ещё и на баг похоже, потому что в документации на интерфейс написано следующее:


                    Adds the specified element to this set if it is not already present (optional operation).
                    More formally, adds the specified element e to this set if the set contains no element e2 such that (e==null? e2==null: e.equals(e2)).
                    If this set already contains the element, the call leaves the set unchanged and returns false. In combination with the restriction on constructors, this ensures that sets never contain duplicate elements.


                    1. poxvuibr
                      29.06.2021 15:02

                      Это просто особенность реализации HashSet и TreeSet.

                      Контракт на интерфейс Set гарантирует, что если сделать сначала remove, а потом add, то в коллекции окажется новый объект. Наверное надо было сразу написать про все операции целиком.


                      1. mayorovp
                        29.06.2021 15:05
                        +1

                        Ну, если remove делать — то да, но вы же изначально без remove предлагали


                      1. poxvuibr
                        29.06.2021 16:32

                        Прошу прощения за недостаточную детализацию. Если серьёзно писать на эту тему — комментария не хватит. И вот эту важную деталь, как и многие другие, я не упомянул. Использование коллекций с JPA вообще нетривиальная штука. Мало того, что в некторых случаях надо делать remove а потом add одного и того же объекта, так ещё и надо делать много странных движений для маппинга, если хочешь убрать дополнительные запросы. Плюс надо помнить, что в Hibernate зачастую используется на ArrayList или что-то в этому духе, а какая-нибудь другая структура. Когда я пытаюсь вкратце рассказать, постоянно что-то упускаю.


      1. KivApple
        28.06.2021 20:49
        +2

        Как вариант, можно договориться никогда не пихать в HashMap несохранённые хоть раз сущности. Это звучит гораздо разумнее, чем превращать HashMap в ArrayList.


        1. poxvuibr
          29.06.2021 13:43
          -1

          Это звучит гораздо разумнее, чем превращать HashMap в ArrayList.

          HashMap невозможно превратить в ArrayList. В лучшем случае будет дерево.


          1. mayorovp
            29.06.2021 14:03
            +1

            Чтобы получилось дерево — записи должны быть сравнимыми (Comparable). А без этого сравнивать можно только хеши, которые одинаковы. Так что дерево выродится в LinkedList.


            1. poxvuibr
              29.06.2021 14:06
              -2

              Ну вот я и говорю, что дерево будет в лучшем случае ))


    1. mayorovp
      28.06.2021 20:36
      +3

      Правильно — вообще не трогать эти методы.


      Вариантов-то всего три: сравнивать по ссылке, сравнивать по id, сравнивать по всем атрибутам. Почему плох третий — автор написал, теперь сравним первые два варианта.


      Если у двух сущностей совпадают id — это вовсе не означает что они равны, это означает что их нельзя использовать совместно, поскольку они представляют несогласованное знание об одном и том же предмете. А если сущности с одним id никогда не встречаются — то равенство id и равенство ссылок эквивалентны, но равенство ссылок проще и лишено некоторых недостатков (таких как поведение при незаполненном id). Следовательно, id можно при сравнении не проверять.


      1. a1ex322
        28.06.2021 21:31
        +1

        Есть ещё четвёртый вариант - самому генерировать ключ для мапки в зависимости от требуемой бизнес логикой уникальности: FirstName + LastName, email и т.п. А если уникальность не требуется то и мапка не нужна


        1. mayorovp
          28.06.2021 22:37

          Да, такой вариант есть. Но он ничем не отличается от варианта с id.


          1. a1ex322
            28.06.2021 22:50

            id можно использовать как ключ мапки чтобы, например, убедиться, что в бд не уйдет апдейт двух рекордов с одинаковым id. А свой ключ когда требуется какая-нибудь особая уникальность для новых и/или существующих entity (id пустой в первом случае)


            1. mayorovp
              28.06.2021 22:53

              Но ведь для этого не нужно же перегружать equals и hashCode...


              1. a1ex322
                29.06.2021 00:01

                Да. Честно говоря не ясен их смысл у мутабельных объектов. Добавили в сет, потом поменяли?


                1. poxvuibr
                  29.06.2021 13:47

                  Честно говоря не ясен их смысл у мутабельных объектов.

                  Конкретно в энтити — есть необходимость безопасно добавлять и в Set.


                  Добавили в сет, потом поменяли?

                  Да, добавили в сет пока id == null, потом записали в БД и id сгенерировались и при этом сет должен работать корректно.


                  1. a1ex322
                    29.06.2021 14:14

                    Непонятно. Вы поменяли поле/поля, изменился хеш и объект теперь не в своем бакете.


                    1. poxvuibr
                      29.06.2021 14:22

                      Именно для того, чтобы объект не менял бакет, hashCode должен возвращать константу


                      1. a1ex322
                        29.06.2021 17:12
                        +1

                        А ведь можно вместо Set<EntityType> использовать Map<KeyType, EntityType>

                        hashCode должен возвращать константу

                        я бы предпочел, чтобы вот такое вообще не компилировалось (так как сам на эти грабли наступал): Set<EntityType> и Map<EntityType,...>


      1. poxvuibr
        29.06.2021 13:45

        Вариантов-то всего три: сравнивать по ссылке, сравнивать по id, сравнивать по всем атрибутам. Почему плох третий — автор написал, теперь сравним первые два варианта.

        Правильно сравнивать по Id, но добавить условие, что если id ==null, то equals выдаст false. Ну и плюс вернуть константу в hashCode конечно