Lombok + JPA: Что может пойти не так?

f5c2af69056816f7c4b78318f2b78390.jpg

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 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 есть для этого инспекция:

bd8a317421f9ed78f8deeae461b42c8a.gif

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

2c1fc4e247057f39fa61ae87e039e10f.gif

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

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

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

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

7b1cf2f70dc14b60a9a1bf51e4207d09.png

Заключение

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

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

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

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

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

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

© Habrahabr.ru