Как использовать Hibernate: основные проблемы и их решения

Меня зовут Андрей Слободяник, я уже более 10 лет работаю в Java enterprise проектах. В каждом из них были данные в базе, а доступ к ним осуществлялся с помощью JPA/Hibernate. При этом фреймворк использовался, как мне кажется, не совсем правильно: код мог быть компактнее, а производительность выше.

Эта статья — о наболевшем: основных проблемах, способах их исправления, и, главное, подходу, где уместен Hibernate.

Слова «JPA» как стандарт и «Hibernate» как реализация используются как синонимы.

Проверочный вопрос

@Column(name = "name", nullable = false, length = 32)
private String name;
  • Для чего нужен атрибут nullable?
  • Можно ли создать и сохранить entity, если в поле name будет значение null?
  • Проверяется ли длина поля?
  • В чем отличие этих атрибутов @Column и аннотаций @javax.validation.constraints.NotNull и @Size?

Если вы не уверены в ответах, добро пожаловать в статью.

Подход

Hibernate не нужен автоматически везде, где есть БД. Начинать следует не с неё. JPA по самому своему определению применяется, когда оказывается, что объекты Java нужно где-то хранить между выключениями приложения. Один из возможных вариантов — реляционная база данных.

Поэтому создайте удобные классы, описывающие вашу доменную модель так, как будто JPA у вас нет. Используйте всю выразительность Java: различные типы, композицию, наследование, коллекции, Maps, Enums. Только потом переведите её на JPA: добавьте ID, отношения и каскады. Проверьте созданные Hibernate таблицы, если необходимо, поправьте их.

Звучит слишком широко и абстрактно, поэтому давайте разберём на конкретном примере.

Техническое задание

Предположим, мы разрабатываем магазин с заказами.

У заказа есть:

  • дата создания;
  • статус — новый, в обработке и т. д.;
  • заказ может быть «срочный»;
  • адрес доставки (каждый раз разный, поэтому смысла в нормализации пока нет);
  • в заказ входят товары в каком-то количестве;
  • у товара есть неизменная цена с валютой;
  • заказ относится к определённому клиенту.

Обычный подход

Дальше почему-то происходит следующее. Вначале создаются таблицы, что-то вроде:

А потом для них — соответствующие entities. В зависимости от опыта разработчиков, в самом тяжелом случае получается такое.

@Entity
@Table(name = "orders")
public class OrderEntity {
   @Id
   private Long id;

   private LocalDateTime created;
   private String status; // see StatusConstants for available values
   private Integer express;

   private String addressCity;
   private String addressStreet;
   private String addressBuilding;

   private Long clientId;

   public static class StatusConstants {

       public static final String NEW = "N";
       public static final String PROCESSING = "P";
       public static final String COMPLETED = "C";
       public static final String DEFERRED = "D";

       public static final List<String> ALL = Arrays.asList(NEW, PROCESSING, COMPLETED);
       // oops, forgot to add "deferred" to the list
   }
}

@Entity
@Table(name = "items")
public class Item {
   @Id
   private Long id;

   private String name;
   private BigDecimal priceAmount;
   private String priceCurrency;
}

Это вполне рабочие entities, сделанные по принципу «поле в БД — такое же поле в классе», но далеко не лучшие. Из всех возможностей Hibernate мы используем только одну — маппинг между таблицами и классами. Фактически это работа в JDBC режиме.

Проблемы:

  • адрес и цена «размазаны» по нескольким полям;
  • целостность и операции с полем client делаются вручную;
  • легко ошибиться со значениями поля status;
  • нужно помнить, что флажок express представлен числом: 1 — true, 0 — false;

С некоторым опытом можно создавать более удобный маппинг, но сама идея — подгонять entities под таблицы — в случае JPA не верна.

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

  • база данных;
  • сложные запросы, написанные вручную со всеми возможными оптимизациями;
  • маппер в Java-классы типа MyBatis.

JPA же предлагает другой принцип: вначале Java-классы, потом их сохранение в БД.

Поэтому давайте отложим Hibernate в сторону и создадим объектную модель для исходной задачи.

Очевидно, что:

  • для статуса заказа удобно использовать Enum;
  • флажок «срочный» по самой своей сути — boolean;
  • для адреса будет отдельный класс;
  • для цены — тоже, причём уже есть готовый — Money из JavaMoney;
  • для товаров и их количества подойдёт Map;
  • клиент заказа — это поле типа Client, а не числовой указатель на него.

В результате получается следующее:

public class Order {
   private LocalDateTime created;
   private Status status;
   private boolean express;
   private Address address;
   private Map<Item, Integer> items;
   private Client client;
}

public class Address {
   private String city;
   private String street;
   private String building;
}

public class Item {
   private String name;
   private Money price;
}

public class Client {
   private String firstName;
   private String lastName;
}

Коллекции

Поскольку JPA умеет сам инициализировать коллекции, разработчики часто ограничиваются их объявлением, как в примере выше, а дальнейшие модификации осуществляют через геттер.

С учетом базового принципа «Класс должен уметь работать без JPA» лучше:

  • инициализировать коллекцию сразу: в месте объявления либо в конструкторе;
  • убрать сеттер;
  • в геттере возвращать не модифицируемую копию;
  • добавить модифицирующие методы.

Для нашего примера может быть так:

private Map<Item, Integer> items = new HashMap<>();

public Map<Item, Integer> getItems() {
   return Collections.unmodifiableMap(items);
}

public void addItem(Item item) {
   items.merge(item, 1, (v1, v2) -> v1 + v2);
}

public void removeItem(Item item) {
   items.computeIfPresent(item, (k, v) -> v > 1 ? v - 1 : null);
}

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

Подключаем JPA

Для этого требуются:

  • Если хотим, чтобы класс хранился в отдельной таблице — аннотации @Entity и @Id, если в таблице другого класса — @Embeddable.
  • Примитивы, числа, строки и даты Hibernate умеет сохранять сам.
  • Для Enum-ов указываем @Enumerated и тип: хранить либо порядковый номер — EnumType.ORDINAL, либо строковое представление — EnumType.STRING.
  • Ещё для Enum-ов и других объектов, которым достаточно одного поля в БД, удобно использовать AttributeConverter.
  • Классы из нескольких полей и не соответствующие конвенциям Java Bean — Money в нашем случае — требуют @Type с описанием преобразования аналогично AttributeConverter-у. Для Money нужный класс уже написан.
  • Для классов и коллекций указываются соответствующие отношения (ManyToOne, OneToMany, ElementCollection и т.д.).

После этого Hibernate вполне может создать необходимые таблицы.

Возможно, для кого-то эта информация будет новой, но @Table и различные @Column:

  • не обязательны и содержат лишь уточняющие DDL атрибуты;
  • не являются валидацией.

Ответ на вступительный вопрос.

@Column(name = "name", nullable = false, length = 32)
private String name;

... конвертируется в часть инструкции «create table»

name varchar(32) not null

В runtime Java никаких проверок на null и длину поля не происходит.

Поскольку при разработке приложения структура классов меняется, созданная JPA схема — это, скорее, заготовка для flyway/liquibase и/или in-memory БД.

Null

Null и в Java, и в базе данных следует использовать только тогда, когда нам действительно нужно значение «не определено». Во многих случаях такой необходимости нет. Ленясь инициализировать поля, мы либо подкладываем себе грабли в виде NPE, либо осыпаем код ненужными проверками на null.

Срочность заказа (поле express) на первый взгляд имеет три состояния — «да», «нет», «не указано». На практике, нам, скорее всего, будет достаточно двух — срочные и обычные заказы. Поэтому используйте примитивы (boolean) вместо классов (Boolean) там, где это возможно.

Важно помнить, что для поля id примитив использовать нельзя, поскольку для новых (transient) entities оно не определено. И, к сожалению, по техническим причинам (чтобы Hibernate мог создавать прокси) указывать модификатор final невозможно.

Именование полей

По умолчанию JPA использует такой naming convention для полей и классов fieldName (в java) -> field_name (в БД).

Поэтому указывать в @Column(name = «another_name») имеет смысл, если это не так. В нашем примере «Order» — служебное слово в SQL, поэтому я назвал таблицу «Orders», и остальные в множественном числе — для однообразия.

Ключи

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

Для всех entities (кроме Address) добавляем:

@Id
private Long id;

(Не)использование Id

При написании бизнес-логики объекты сравнивают между собой. Некоторые пишут так:

boolean equals = order.getId().equals(anotherOrder.getId());

... а принадлежность объекта к коллекции проверяют с помощью стрима

boolean contains = orders.stream().anyMatch(o -> o.getId().equals(someOrder.getId());

Это не компактно и не совсем верно. Лучше:

boolean equals = order.equals(anotherOrder);

boolean contains = orders.contains(someOrder);

Суррогатные ключи — IDs — не должны фигурировать ни в бизнес-логике, ни в запросах. Всегда работайте с объектами. Вместо параметра clientId

select o from Order o where o.client.id = :clientId

... должен быть объект

select o from Order o where o.client = :client

Позвольте JPA построить запрос самостоятельно.

Если в контексте нет объекта client и целиком он не нужен, достаточно использовать его reference. То есть вместо

Client client = em.find(Client.class, clientId);

... используем

Client client = em.getReference(Client.class, clientId);

Эквивалентность (методы equals/hashCode)

Чтобы сравнивать объекты, как было указано выше,

boolean equals = order.equals(anotherOrder);

... нужно определить методы equals и hashCode. Часто реализуют их через id:

@Override
public boolean equals(Object o) {
   if (this == o) return true;
   if (o == null || getClass() != o.getClass()) return false;
   Order that = (Order) o;
   return Objects.equals(id, that.id);
}

@Override
public int hashCode() {
   return Objects.hash(id);
}

... но это не вполне корректно.

Из нашего принципа — не привязываться к JPA — следует, что суррогатный id не должен фигурировать в equals/hashCode. Важно помнить, что для новых (transient) entities поле id еще не инициализировано (равно null). Тем не менее, эквивалентность должна работать корректно независимо от состояния entity.

Объекты должны сравниваться по бизнес-ключу, а не по id.

Для класса Order в качестве бизнес-ключа напрашивается пара полей дата-клиент.

@Override
public boolean equals(Object o) {
   if (this == o) return true;
   if (!(o instanceof Order)) return false;
   Order order = (Order) o;
   return Objects.equals(created, order.created)
           && Objects.equals(client, order.client);
}

@Override
public int hashCode() {
   return Objects.hash(created, client);
}

Обратите внимание, что Hibernate может создавать прокси-объекты и проверять класс следует не методом getClass(), а через instanceOf. К счастью, в Lombok этот момент учтен.

Только в исключительных случаях, если у класса нет ничего, что может быть использовано в качестве бизнес-ключа кроме поля id, equals() проверяем через тождество (==) и эквивалентность id, а hashCode() без полей вырождается в константу.

@Override
public boolean equals(Object o) {
   if (this == o) return true;
   if (!(o instanceof Foo)) return false;
   Foo that = (Foo) o;
   return Objects.equals(id, that.id);
}

@Override
public int hashCode() {
   return 31;
}

Более детально тему раскрывает Влад Михальча:

Cascades

JPA призвано всячески упрощать рутинные операции. Для сохранения и удаления сложных объектов нет необходимости «пробегать» по структуре и повторять операции для вложенных объектов, достаточно указать каскады.

В нашем примере при создании нового заказа для нового пользователя не нужно сохранять их по отдельности, это сделает каскад.

Очевидный нюанс: прежде чем указывать CascadeType.ALL, подумайте, нужен ли включенный в него CascadeType.REMOVE. Опять же, для нашего примера — нет, при удалении заказа, клиент не удаляется, поэтому ALL не применяем.

Entity и DTO

Если мы разрабатываем Web-приложение, нам не обойтись без передачи entities на Front и обратно. Теория учит, что для передачи следует использовать отдельные DTO классы. Часто, в случае простых entities типа Client и Address в нашем примере, поля ClientDto и AddressDto будут точно такие же. Возникает соблазн не создавать отдельные классы, а использовать существующие. Это неверный подход.

Могут появиться поля, нужные только для DTO. Приходится маскировать их от сохранения в БД с помощью @Transient. Возможны изменения значений полей перед отправкой на UI. Чтобы эти модификации не отразились в БД, начинаются вызовы entityManager.detach().

Коллекции по умолчанию работают в режиме lazy loading и уходят на UI пустыми. Изменение режима на FetchType.EAGER закладывает серьёзную мину под производительность. Загрузка элементов коллекции теперь будет происходить во всех вопросах и создавать N+1 проблему. Не делайте так. У Entity и DTO разная ответственность. Валидацию введенных пользователем данных — проверки @NotNull, @Size и т. д. — делает DTO.

Правильный подход — всегда создавать отдельные DTO классы. Чтобы сократить написание boilerplate кода по перекладыванию полей используйте MapStruct или аналоги.

FetchType.EAGER

Исторически Hibernate по умолчанию использует режим EAGER загрузки в отношении ManyToOne и OneToOne, а во всех остальных случаях — LAZY. Рекомендуется использовать LAZY во всех случаях. Указать в запросе делать join вместо нескольких select-ов всегда возможно, а обратно — отключить EAGER для определённых случаев — нет.

Опять же, слово Владу — «EAGER fetching is a code smell when using JPA and Hibernate».

EntityManager.flush() и clear()

Ещё один тревожный маркер — это многочисленные вызовы flush() и clear(). Почему-то вместо того, чтобы доверить управление entities фреймворку, разработчики начинают вмешиваться в этот процесс.

Навскидку приходят в голову только две исключительные ситуации, когда нужны эти методы:

  • обработка очень большого количества данных (репорты), переполнения кеша 1-го уровня;
  • вызов бизнес-логики из хранимых процедур в процессе транзакции, в таком случае нужен flush().

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

Project Lombok

Отличная штука, сокращает количество boilerplate кода. С JPA, однако, необходимо учитывать нюанс. @Data по умолчанию включает в себя @EqualAndHashCode и @ToString по всем полям, что в свою очередь может порождать каскад ненужных загрузок полей, игнорируя старательно указанный FetchType.LAZY, и зацикленные вызовы для bi-directional отношений.

Поэтому рекомендуется не использовать @Data, а в @EqualsAndHashCode и @ToString указывать только нужные поля.

Правильные entities

Применяя всё вышеизложенное к нашим классам, получаем:

@Getter
@EqualsAndHashCode(of = {"firstName", "lastName"})
@ToString(of = {"firstName", "lastName"})
@NoArgsConstructor
@Entity
@Table(name = "clients")
public class Client {
   @Id
   @GeneratedValue(strategy = GenerationType.IDENTITY)
   private Long id;

   @Column(length = 32, nullable = false)
   private String firstName;

   @Column(length = 32, nullable = false)
   private String lastName;

   public Client(String firstName, String lastName) {
       this.firstName = firstName;
       this.lastName = lastName;
   }
}

@Getter
@EqualsAndHashCode(of = "name")
@ToString(of = {"name", "price"})
@NoArgsConstructor
@Entity
@Table(name = "items")
public class Item {
   @Id
   @GeneratedValue(strategy = GenerationType.IDENTITY)
   private Long id;

   @Column(nullable = false, length = 32)
   private String name;

   @Columns(
           columns = {
                   @Column(name = "price_currency", length = 3, nullable = false),
                   @Column(name = "price_amount", precision = 7, scale = 2, nullable = false)
           }
   )
   @Type(type = "org.jadira.usertype.moneyandcurrency.moneta.PersistentMoneyAmountAndCurrency")
   private Money price;

   public Item(String name, Money price) {
       this.name = name;
       this.price = price;
   }
}

@Getter
@EqualsAndHashCode(of = {"city", "street", "building"})
@ToString(of = {"city", "street", "building"})
@NoArgsConstructor
@AllArgsConstructor
@Embeddable
public class Address {
   private String city;
   private String street;
   private String building;
}

@AllArgsConstructor
public enum Status {
   NEW("N"),
   PROCESSING("P"),
   COMPLETED("C"),
   DEFERRED("D");

   @Getter
   private final String code;
}

@Converter(autoApply = true)
public class StatusConverter implements AttributeConverter<Status, String> {
   @Override
   public String convertToDatabaseColumn(Status status) {
       return status.getCode();
   }

   @Override
   public Status convertToEntityAttribute(String code) {
       for (Status status : Status.values()) {
           if (status.getCode().equals(code)) {
               return status;
           }
       }
       throw new IllegalArgumentException("Unknown code " + code);
   }
}

@Getter
@EqualsAndHashCode(of = {"created", "client"})
@ToString(of = {"created", "address", "express", "status"})
@NoArgsConstructor
@Entity
@Table(name = "orders")
public class Order {
   @Id
   @GeneratedValue(strategy = GenerationType.IDENTITY)
   private Long id;

   @Column(nullable = false)
   private LocalDateTime created = LocalDateTime.now();

   @AttributeOverrides({
           @AttributeOverride(name = "city", column = @Column(name = "address_city", nullable = false, length = 32)),
           @AttributeOverride(name = "street", column = @Column(name = "address_street", nullable = false, length = 32)),
           @AttributeOverride(name = "building", column = @Column(name = "address_building", nullable = false, length = 32))
   })
   private Address address;

   @Setter
   private boolean express;

   @Column(length = 1, nullable = false)
   @Setter
   private Status status = Status.NEW;

   @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST, optional = false)
   private Client client;

   @ElementCollection
   @Column(name = "quantity", nullable = false)
   @MapKeyJoinColumn(name = "item_id")
   private Map<Item, Integer> items = new HashMap<>();

   public Order(Address address, Client client) {
       this.address = address;
       this.client = client;
   }

   public Map<Item, Integer> getItems() {
       return Collections.unmodifiableMap(items);
   }

   public void addItem(Item item) {
       items.merge(item, 1, (v1, v2) -> v1 + v2);
   }

   public void removeItem(Item item) {
       items.computeIfPresent(item, (k, v) -> v > 1 ? v - 1 : null);
   }
}

Многочисленные аннотации @Table и @Column присутствуют только для того, чтобы сгенерировать точно такую же схему, как на диаграмме. Пример учебный, на практике для генерации id лучше использовать sequence.

Внимательный читатель должен заметить, что использование поля client c lazy-загрузкой в Order.equals() противоречит рекомендациям раздела Lombok. Если бизнес-логика позволяет, лучше реализовать эквивалентность без него.

Заключение

JPA достаточно обширная тема, а Hibernate, к сожалению, содержит большое количество «gotchas», чтобы рассмотреть все нюансы в одной статье.

Много полезного и интересного в блоге Влада Михальча.

За кадром остались:

  • наследование;
  • uni- и bi-directional отношения;
  • стратегии работы с коллекциями;
  • criteriaBuilder;
  • batching;
  • QueryDSL;
  • и многие другие темы.

При встрече с ними я бы рекомендовал придерживаться основного посыла, который всячески старался проиллюстрировать — вначале полностью рабочая объектная модель, потом вопросы, как сохранить её в БД, не наоборот.

Резюмируя, составим check list потенциальных проблем, которые освещены в статье:

  • entities состоят из большого количества примитивных полей, а не из классов;
  • вместо enum-ов используются строковые константы;
  • методы equals/hashCode не определены;
  • в бизнес-логике и запросах фигурируют id;
  • происходит смешение Entity и Dto в одном классе;
  • использование FetchType.EAGER, в том числе по умолчанию в @ManyToOne;
  • ненужные вызовы flush() и clear();
  • неаккуратное использование Lombok.


За рецензию материала и дельные замечания благодарю Игоря Дмитриева.

Похожие статьи:
Саме представники HR-відділів здійснюють фільтрацію вхідного потоку резюме: ті листи, що відповідають потрібним критеріям,...
In a fast-moving, competitive world, being able to learn new skills is one of the keys to success. It’s not enough to be smart — you need to always be getting smarter. Heidi Grant Halvorson, a motivational...
[Про автора: Краковецький Олександр — CEO в DevRain Solutions, кандидат наук, спікер, Microsoft Regional Director, Microsoft MVP AI, CTO ДонорUA] Майже рік...
В выпуске: планы для scala-2.13, новые SIP, Scala language server для MS Visual Studio, байндинги для scala.js, обзор экосистемы и развития основных...
Honeycomb Software, яка має офіси у Львові та Рівному, перемогла на стартап-шоу CodeLaunch HOU2023 у Хʼюстоні. Про це DOU повідомили...
Яндекс.Метрика