Я не гуру разработки и не читал книжку по Hibernate на 800 страниц. Я просто любознательный малый, который решил немного хлебнуть из бездонной бочки знаний по разработке на Java. Эта статья рассчитана на Junior разработчиков, которые хотят заполнить пробелы по Hibernate. Если это будут читать более опытные разработчики: напишите замечания по техническому наполнению статьи. Буду вам очень признателен. Со вступлением все. Поехали)

MappedBy

Как мы знаем, Hibernate создает таблицы исходя из ваших сущностей: количество колонок будет совпадать с количеством полей в нашей сущности, но не всегда нам это нужно. Так, например, при связи сущностей @OneToOne нам обычно не нужно чтобы в каждой из таблиц была колонка-референс друг на друга. Так вот mappedBy сигнализирует hibernate, что колонку-референс можно не создавать.

Исходя из всего вышесказанного давайте проверим себя: создастся ли колонка person_id у сущности ниже?

Справа результат, сгенерированный hibernate
Справа результат, сгенерированный hibernate

Правильный ответ: НЕТ
Но у нас же указан @JoinColumn! Почему колонка не создалась?! Дело в том, что эта аннотация помогает проконтролировать как будет называться колонка В СЛУЧАЕ СОЗДАНИЯ. Получается, что эта аннотация скорее вспомогательная, нежели обязательная.


Создастся ли колонка person_id тут?

Справа результат, сгенерированный hibernate
Справа результат, сгенерированный hibernate

Ответ: ДА
Hibernate сам догадается как назвать столбцы и поэтому не обязательно использовать @JoinColumn, который говорит как будет называться колонка-референс, но если мы это хотим проконтролировать, то лучше указать.

Если вы все таки решите, что вам нужна двухсторонняя связность на уровне БД, то давайте посмотрим на ряд нюансов, которые нужно учесть.

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

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

Казалось бы, Hibernate догадался каскадно сохранить маму, но добавить ей референс на папу не догадался. И получится, что впоследствии доставая папу мы будем иметь привязанную к ней маму(колонка mama_id не null), а вот при попытке достать маму, окажется, что папа к ней не привязан(колонка papa_id пуста) 

Конечно, в методе можно не только папе назначать маму с помощью set, но и наоборот. 

Да, это поможет, но это лишний код. Именно поэтому мы чуть переделаем метод set для обеих сторон

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


@NaturalId

Все в один голос кричат о необходимости переопределять equals и hashCode у наших сущностей. Но чем их не устраивает стандартная реализация?! Давайте разбираться.

Представим такую ситуацию: вы решили кешировать ваши сущности в HashMam и внутри это работает так: мы смотрим в кэш, и если сущность, которую мы принесли, там уже есть, то мы ее перезаписываем, а если нет - добавляем ее.

************************************************************************************************

Напомню: HashMap — основан на хэш-таблицах и реализует интерфейс Map (что подразумевает хранение данных в виде пар ключ/значение). В качестве ключа используется hashCode, который по умолчанию вычисляется исходя из всех полей вашего объекта

************************************************************************************************

Если параметры ваших сущностей периодически изменяются, то есть шанс однажды упасть с OutOfMemory. Но почему?! 

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

Допустим пользователь зарегистрировался и вы его сохранили в кэш (мы вычислили hash и поместили сущность в нужный бакет кэша). После чего пользователь всего-лишь поменял фамилию в аккаунте, а hash его аккаунта-сущности полностью изменился, так как по умолчанию все поля участвуют в создании hash, а если он изменился, то и попасть в нужный бакет, чтобы перезаписать аккаунт-сущность, с еще не измененной фамилией, не получится и нам придется записать его уже в новый бакет. Так, у нас получается в кэше будут храниться все предыдущие состояния наших аккаунтов-сущностей. 
*если ничего не поняли - ничего страшного, просто перечитайте еще раз*

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

Идея же Natural Id в том, что еще на этапе конструктора мы присваиваем какую-то уникальную строку (или любым другим способом, но мне приглянулся этот), которая бы до конца жизни сущности оставалась неизменной. И тогда мы сможем правильно переопределить equals и hash, в котором будет участвовать только сам Natural Id. И как бы мы не меняли поля в сущности, HashCode у нее всегда будет один.

Один из вариантов реализации: 

@NaturalId
@Column(name = "natural_id", nullable = false, unique = true)
private Integer naturalId;

public ВашКонструктор(){
   naturalId = System.identityHashCode(this);//вычисляет hash
}

Неповторимый N+1

Чаще всего N+1 вылазит из-за Lazy-связей. Каково же было мое удивление, когда n+1 вылезла на Eager зависимости. 

Эта проблема актуальна при использовании методов findAll() и findAllById() от Spring Data JPA. Так, при попытке достать все аккаунты, к каждому из которых привязан реальный человек через @OneToOne и выставленным FetchType.Eager, мы все равно получаем проблему n+1.

Ниже вы можете видеть 3 запроса вместо одного: Hibernate сначала достает все аккаунты, а потом по одному достает Потребителей(consumer). Меня честно это впечатлило, потому что я думал, что проблема n+1 может быть только у Lazy связей.

Безобразие
Безобразие

Чтобы такого безобразия не было, нам придется переопределить findAll в нашем репозитории и после запуска теста у нас вместо кучи запросов останется один, поздравляю!


Entity Graph

Entity Graph не подвержен проблеме n+1 и очень удобный в использовании. Мы можем сделать сколь угодно много этих графов для одной и той же сущности и использовать по мере надобности, не переживая, что вы забыли где-то прописать “fetch join”, который предотвращает проблему лишних запросов в базу. Они задаются прямо в сущностях, и все, что вам нужно сделать, это указать имена полей, которые вы хотите достать.

Внимательно изучив графы ниже, вы можете увидеть два графа "dev-graph" и "manager-graph"

В "manager-graph" я попросил доставать поле consumer

А в "dev-graph" я попросил доставать List<WebSite>

Но что делать, если поле, которое мы указываем в графе тоже является сущностью со своими Lazy и Eager связями?! В нашем графе такое поле присутствует: у сущности WebSite есть свои связи и мы их тоже можем продекларировать, что мы собственно и сделали в графе.

С первой частью разобрались: мы смогли создать два графа, но как их использовать?!
Я буду показывать на примере с EntityManager

Ниже javaDoc того самого метода, который мы будем использовать

В качестве параметров для метода мы должны предоставить класс-сущность которую хотим получить, id и мапу с пропертями.

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

Есть два вида графов: fetch и load, которые в коде выглядят как "javax.persistence.fetchgraph" и "javax.persistence.loadgraph" соответственно.

Разница в том, что режим load достает не только то, что мы указали в графе, но и все поля, которые были fetchType.Eager, а fetch достает исключительно то, что прописано в графе, игнорируя даже те поля, которые проставлены как Eager.


Магия Lazy

Представим Lazy зависимость @OneToOne между School и Director. Мы с помощью EntityManager достаем School и hibernate ожидаемо  делает запрос в базу за школой. Если после этого мы вызовем school.getDirector(), то фреймворк не сделает дополнительный трип в базу за директором. Но почему? Давайте разбираться.

Что по своей сути делает Hibernate, когда у нас Lazy связь? 

Если мы просим достать нам какую-то сущность, то hibernate смотрит какие у нее поля Lazy и вместо них подкладывает Proxy для обычных сущностей, и PersistentBag для коллекций. Эти подкладыши ждут, пока вы позовете связанную сущность и исходя из того, что вы хотите сделать с ней, будут решать: подгружать ее или нет. Но почему он просто не может по запросу get ее подгрузить?

Для того, чтобы HIbernate полез в базу за Lazy-сущностью мало будет просто сделать school.getDirector(), ведь мы можем запросить директора не для изменения полей, а просто чтобы поместить его в List с лучшими директорами, к примеру. Для этого нам по факту нужно только знать Id директора.

Чтобы Hibernate полез в БД, нам нужно: либо Hibernate.initialize();, либо запросить любое поле, кроме id.

Что же касаемо Lazy-списков, то тут дело в том, что Hibernate не глупый, и понимает, что мы можем достать связанный Lazy-список, чтобы в него что-то добавить и вернуть обратно в базу. И это будет глупостью, если бы Hibernate для такой задачи подгрузит весь список с данными. А если нам все-же нужен список с данными, то нужно либо явно инициализировать сущность Hibernate.initialize();, либо что то сделать с list, например:  .size() или .get(int index)

Подведем итоги про lazy-подкладыши: когда работаете с Lazy-связями, стоит всегда держать в голове, что вы работаете с Proxy даже после явной инициализации.

Но почему это так важно?
Когда вы достали Lazy-коллекцию школьников и решили ее отправить другому микросервису, предварительно сериализовав, возникает проблема, потому что на этом микросервисе-получателе не оказывается Hibernate, и получается пренеприятная ситуация, когда вы не можете десериализовать объекты на другой стороне, ввиду того, то это коллекция Hibernate, а не реализация List из коробки. И чтобы ее десериализовать нужен hibernate. Будьте аккуратнее. 

Чтобы получить оригинальный объект, фреймворк предлагает статический метод Hibernate.unproxy(), который к большому сожалению не работает с proxy-коллекциями от Hibernate.


@BatchSize(int size)

BatchSize работает только с коллекциями. Логика проста: мы достали 100 школ разом, и хотим проитерироваться  с  помощью forEach(), чтобы найти васю пупкина. А теперь представьте, что в каждой школе по 500 человек. Если бы мы не установили Batch Size, то hibernate за раз выберет 5000 полей со школьниками. Лучше сделать по другому. Выставляем @BatchSize на 50. И пока 50 первых школьников не обработается, то за вторыми 50 hibernate не полезет в бд.


H2 - это удобно

Из коробки со SpringBoot идет БД H2, основными плюсами которой является скорость и ПОЛНАЯ готовность из коробки. Что я имею ввиду?

Если вы удалите все настройки, связанные с dataSource из application.yml, и добавите зависимость на h2 в pom, то ваше приложение запуститься и даже будут работать все sql/hql запросы, будут созданы таблички по вашим Entity и Jpa будьте полноценно функционировать. 

<dependency>
   <groupId>com.h2database</groupId>
   <artifactId>h2</artifactId>
   <scope>runtime</scope>
</dependency>

Мы можем при желании даже потрогать эту БД

spring:
 h2:
   console:
     enabled: true

Поздравляю, теперь когда поднимается ваше приложение можно перейти по URl http://localhost:8080/h2-console и посмотреть на БД.

Но при попытке подключиться он начнет ругаться

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

spring:
 datasource:
   url: jdbc:h2:mem:testDb
   username: sas
   password: da
   driver-class-name: org.h2.Driver

Перезапускаем приложение и вуаля, мы можем зайти и потыкать нашу БД


Итоги:

Во многих проектах узким горлышком как производительности, так и отказоустойчивости является БД, а Hibernate - прослойка между нашим приложением и той самой БД. Именно поэтому важно понимать как он работает. Надеюсь статья заполнила пробелы в ваших
знаниях. В любом случае буду рад получить обратную связь.

Полезные ссылки:

Hibernate: https://habr.com/ru/post/416851/
@Fetch: https://mkyong.com/hibernate/hibernate-fetching-strategies-examples/
Прокси в Hibernate https://habr.com/ru/company/otus/blog/578950/
EntityGraph https://sysout.ru/entity-graph/
Обратная сторона медали https://habr.com/ru/post/551902/
dirty Checking https://javatute.com/hibernate/dirty-checking-in-hibernate/

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


  1. alexdoublesmile
    29.12.2022 18:18

    Спасибо за статью. Пара имхо замечаний:

    1. генерировать схему БД с помощью hbm2ddl - плохая идея (создавать схему самому не дольше по времени, если есть навыки работы с БД, зато несравнимо лучше по качеству, эффективности и чистоте кода(нет нужды описывать кучу ненужных в сервисе констрейнтов на уровне классов модели)), но валидировать вполне.

    2. а описывать все скрипты по созданию схемы лучше сразу в виде миграции (Liquibase, Flyway, etc.)

    3. mappedBy свойство не имеет отношения к генерации схемы, оно указывает как работать с сущностями при ORM-маппинге. Если указано над entity, то его поле с указанным именем используется для связи с текущей entity при маппинге. А уже встроенный механизм hbm2ddl при его активации парсит эти свойства и применяет при генерации

    4. аналогично для @JoinColumn - ее свойство value лишь указывает на имя колонки в БД, на которую осуществляется маппинг текущего поля при работе с entity. Default namnig strategy также можно менять, но имя лучше явно указывать для наглядноси.

    5. двусторонняя связь на уровне БД не может быть нужна. Как правило связь указывается в зависимой сущности.

    6. непонятно, с какой целью в примере используется @Transactional - она не имеет реализованного функционала в чистом Hibernate, но даже при использовании, например, Spring реализаций, она ни к чему над этим методом.

    7. в качестве naturalId лучше использовать не синтетические поля, а любое поле сущности имеющее констрейнт unique (типа username etc.). Синтетические поля вообще дурная практика - они просто засоряют логику и понимание кода.

    8. деталь: над интерфейсом, наследующим JpaRepository нет смысла ставить @Repository

    9. деталь: чтобы IDEA понимала твои маппинги, стоит добавить связь с твоей БД (для sql это тоже делается - Alt+Enter -> Inject language)

    10. непонятно также, с какой целью автор описывал громоздкий именованный граф над сущностью и доставал сущности через find, если у него при этом очевидно используется Spring Data JPA на скрине выше, где к тому же очевидно используется свойство attrıbutePath, которое не работает с именованными графами. Т.е. как говорится, Вы либо крестик снимите, либо трусы наденьте.

    11. BatchSize использовать можно не только над коллекциями. Можно поставить над типом entity(тогда все запросы сущностей с которыми связаны помеченные сущности в единичном экземпляре будут подтягиваться батчем)

    12. использовать H2 - не лучшая практика, т.к. при тестировании луше использовать более схожую с боевой среду. Лучше использовать test containers


  1. breninsul
    30.12.2022 13:45

    Простите за грубость, но я очень огорчён предложением использовать автогенерацию таблиц и H2