ORM-маппинг в Hibernate
Положил новый объект в HashSet и проверил, что множество его находит. Находит. Потом сохранил объект в базу и проверил снова тем же самым объектом - НЕ находит. Ни одной строчки кода между этими проверками я не написал: id из null стал шестёркой, hashCode изменился, и объект потерялся в собственном множестве.
Так выглядит главная особенность работы с ORM (object-relational mapping, отображение объектов на таблицы): библиотека меняет твои объекты за спиной. Спрашивают на собесе именно это - разметку сущностей, кто владеет связью и почему equals с hashCode пишутся не как обычно.
// Формулировки: «как размечаешь связь один ко многим?», «кто владелец связи?», «как писать equals и hashCode для сущности?», «что такое каскад?».
Сущность, идентификатор и связи
Сущность (entity) - класс, отображённый на таблицу: аннотация @Entity, поле-идентификатор с @Id. Идентификатор обычно генерирует база: @GeneratedValue со стратегией IDENTITY означает автоинкремент в самой таблице.
Связи размечаются четырьмя аннотациями: @OneToMany, @ManyToOne, @OneToOne, @ManyToMany. У каждой есть режим загрузки. Ленивый (LAZY) - связанные объекты подтягиваются при первом обращении к ним. Жадный (EAGER) - сразу вместе с основным объектом. Для коллекций ленивый режим стоит по умолчанию, а для @ManyToOne и @OneToOne - ЖАДНЫЙ, и это регулярно оказывается неприятным сюрпризом: загрузил одну книгу, а вместе с ней приехал автор, издательство и всё, что было размечено связями.
// Практическое правило: ставь LAZY везде руками, включая @ManyToOne, а нужные связи подтягивай явно в запросе. Тогда ты решаешь, что грузится, а не разметка пятилетней давности.
- сущность
- класс, отображённый на таблицу базы
- LAZY / EAGER
- связь подгружается при обращении / сразу вместе с объектом
Владелец связи и каскады
В двусторонней связи одна сторона главная. Владелец - та, где лежит внешний ключ, обычно сторона @ManyToOne. Противоположная помечается mappedBy и говорит: «колонку в базе пишет не я». Отсюда классическая ошибка - добавить объект только в коллекцию на стороне mappedBy: в памяти всё выглядит правильно, в базе не меняется ничего, потому что владелец про изменение не узнал. Лечится методом-помощником, который проставляет обе стороны разом.
Каскад говорит, какие операции переходят с родителя на детей: CascadeType.ALL передаёт сохранение, обновление и удаление. Рядом живёт отдельная настройка orphanRemoval - «удаляй ребёнка, если его выкинули из коллекции».
Я это проверил. Загрузил автора, удалил одну книгу из его списка и НЕ звал никакого сохранения. В базе книга исчезла: в следующей транзакции у автора осталось две книги вместо трёх. Мощно и опасно - orphanRemoval на связи, которую вы «просто почистили в памяти», удаляет строки безвозвратно.
- владелец связи
- сторона с внешним ключом, только её изменения попадают в базу
- orphanRemoval
- объект убрали из коллекции - строку удаляют из базы
Почему equals и hashCode пишутся иначе
Вернёмся к замеру из начала. Я написал самый очевидный equals - сравнение по идентификатору - и такой же hashCode. Пока объект не сохранён, идентификатор равен null. После сохранения он стал шестёркой, hashCode пересчитался, и HashSet перестал находить объект: множество разложило его по корзине для старого значения хеша.
Отсюда два правила. Первое: hashCode сущности обязан быть ПОСТОЯННЫМ на всём жизненном пути объекта - проще всего вернуть константу от класса, тогда никакая генерация идентификатора его не сдвинет. Второе: equals стоит строить на бизнес-ключе - на том, что делает объект уникальным по смыслу (номер паспорта, артикул, электронная почта), а не на техническом идентификаторе.
// Есть и третья тонкость, из-за которой сравнение классов через getClass() ломается: ORM подсовывает вместо объекта прокси - подставной подкласс, который подгружает данные при первом обращении. У прокси другой класс, поэтому getClass() их не сравнит. Спасает проверка через instanceof, а в современной Java - через o instanceof Author a.
@Override public int hashCode() { return Objects.hash(id); } // ловушка
var fresh = new Author("Новый");
Set<Author> set = new HashSet<>(); set.add(fresh);
// до сохранения: id=null, set.contains(fresh) = true
// после сохранения: id=6, set.contains(fresh) = false- бизнес-ключ
- то, что делает объект уникальным по смыслу, а не по идентификатору
- прокси сущности
- подставной подкласс, подгружающий данные при первом обращении
Как отвечать: «Как писать equals и hashCode для сущности?»
Не так, как для обычного объекта. Главное требование - hashCode должен быть постоянным всю жизнь объекта, потому что идентификатор появляется только после сохранения. Я это проверял: положил несохранённый объект в HashSet, множество его находило, а после сохранения тот же самый объект перестало находить - идентификатор из null стал числом, и хеш уехал. Поэтому hashCode я делаю константным для класса, а equals строю на бизнес-ключе: на том, что уникально по смыслу - артикул, электронная почта, номер документа. Если бизнес-ключа нет, можно сравнивать по идентификатору, но с проверкой на null и с тем же константным hashCode. И сравнение типов пишу через instanceof, а не через getClass, потому что ORM подставляет прокси - подкласс, у которого getClass вернёт другое значение.
Почему это сильный ответ: названо требование постоянства хеша с объяснением, откуда оно берётся, дана рабочая схема и упомянут прокси - деталь, на которой ломается привычный шаблон equals.
На чём валят
- −hashCode по генерируемому идентификатору. После сохранения объект теряется в своём же множестве - я это замерил.
- −Сравнение типов через getClass() в equals. ORM подставляет прокси-подкласс, и сравнение проваливается; нужен instanceof.
- −Добавить объект только на сторону mappedBy. В памяти связь есть, в базе нет: колонку пишет владелец.
- −Оставить EAGER на @ManyToOne по умолчанию. Один запрос тянет за собой половину связанного графа.
- −orphanRemoval на связи, которую чистят «временно, в памяти». Строки удалятся из базы без всякого save.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 21, остальные разбираются в тренажёре.
- Что помечает аннотация @Entity на классе?A)Класс, экземпляры которого автоматически сериализуются в JSON при возврате из контроллера RESTB)Класс — сущность, отображаемая на таблицу БД и управляемая ORMC)Класс, который Hibernate делает неизменяемым: его поля не получится менять после создания объектаD)Класс-конфигурацию Hibernate, где перечисляют настройки подключения к базе данных приложения
показать ответ и разбор
+B)Класс — сущность, отображаемая на таблицу БД и управляемая ORM// разбор: @Entity помечает POJO как ПЕРСИСТЕНТНУЮ СУЩНОСТЬ: Hibernate отображает её на таблицу (имя = класс или @Table), поля — на колонки (@Column), а один @Id обязателен как первичный ключ. Экземпляры такой сущности могут управляться контекстом персистентности (сохраняться, отслеживаться, загружаться). Требования: непустой конструктор, не final. Часто путают роль @Entity (модель хранения) с DTO (модель передачи) — их обычно разделяют, чтобы не тащить особенности БД/ленивые связи в API.
- Чем стратегии @GeneratedValue IDENTITY и SEQUENCE отличаются на практике?A)IDENTITY генерирует UUID на стороне приложения, а SEQUENCE берёт целочисленный ключ из таблицы-счётчикаB)Они эквивалентны: обе позволяют Hibernate группировать вставки в один пакетный запросC)IDENTITY — авто-инкремент БД (мешает батч-вставке); SEQUENCE — последовательность (можно батчить)D)SEQUENCE работает только в MySQL, а IDENTITY — только в PostgreSQL, поэтому выбор зависит от СУБД
показать ответ и разбор
+C)IDENTITY — авто-инкремент БД (мешает батч-вставке); SEQUENCE — последовательность (можно батчить)// разбор: IDENTITY использует авто-инкрементный столбец БД: id становится известен только ПОСЛЕ выполнения INSERT, поэтому Hibernate вынужден вставлять строки по одной — БАТЧИНГ вставок отключается (проблема на массовых сохранениях). SEQUENCE берёт значения из последовательности БД ЗАРАНЕЕ (и может выбирать пачками через allocationSize), поэтому позволяет группировать INSERT'ы в батчи. Для PostgreSQL/Oracle SEQUENCE обычно предпочтительнее по производительности. AUTO отдаёт выбор провайдеру, TABLE — медленный переносимый вариант.
- Что означает mappedBy в двунаправленной связи @OneToMany(mappedBy = "...")?A)Эта сторона владеет связью и хранит внешний ключ, а mappedBy задаёт имя колонки этого ключа в таблицеB)MappedBy включает каскадное удаление: при удалении родителя все связанные дочерние строки удаляютсяC)MappedBy создаёт отдельную соединительную таблицу для хранения связи между двумя сущностямиD)Эта сторона — обратная (inverse); владелец связи и внешний ключ — на другой стороне
показать ответ и разбор
+D)Эта сторона — обратная (inverse); владелец связи и внешний ключ — на другой стороне// разбор: В двунаправленной связи ВЛАДЕЛЕЦ (owning side) — та сторона, что содержит внешний ключ; обычно это @ManyToOne с @JoinColumn. Противоположная @OneToMany-сторона помечается mappedBy="поле" и является INVERSE (обратной): она лишь отражает связь, но НЕ управляет её сохранением в БД. Частая ошибка: изменить только inverse-коллекцию (parent.getChildren().add(child)) и удивляться, что FK не записался — синхронизировать нужно OWNING-сторону (child.setParent(parent)). Забытый mappedBy приводит к лишней join-таблице и дублирующим апдейтам.
- Какой fetch-тип по умолчанию у @ManyToOne и @OneToMany?A)@ManyToOne — EAGER, @OneToMany — LAZYB)Обе связи по умолчанию EAGER: Hibernate сразу подгружает связанные сущности вместе с корнемC)Обе связи по умолчанию LAZY, поэтому связанные данные не грузятся, пока к ним явно не обратятсяD)Тип загрузки по умолчанию не задан спецификацией и зависит только от настроек конкретной СУБД
показать ответ и разбор
+A)@ManyToOne — EAGER, @OneToMany — LAZY// разбор: По спецификации JPA умолчания: связи «к одному» (@ManyToOne, @OneToOne) — EAGER (подгружаются сразу вместе с сущностью), связи «ко многим» (@OneToMany, @ManyToMany) — LAZY (грузятся при первом обращении). На практике EAGER у @ManyToOne часто вреден (тянет лишние джойны, порождает N+1 и неожиданные запросы), поэтому многие ЯВНО ставят fetch=LAZY даже на to-one и подгружают что нужно через JOIN FETCH/@EntityGraph. Понимание дефолтов — ключ к контролю над генерируемыми запросами.
- Как правильно смоделировать связь «многие-ко-многим» в Hibernate?A)Хранить в одной из сущностей список идентификаторов другой в виде строки через запятую в одной колонкеB)@ManyToMany с соединительной таблицей (@JoinTable) или отдельной entity-связкойC)Продублировать все поля одной сущности внутри другой, чтобы избежать создания дополнительных таблицD)Связь многие-ко-многим в реляционной БД не выходит — её приходится разбивать на уровне приложения вручную
показать ответ и разбор
+B)@ManyToMany с соединительной таблицей (@JoinTable) или отдельной entity-связкой// разбор: Многие-ко-многим в реляционной модели реализуется ПРОМЕЖУТОЧНОЙ (join) таблицей с двумя внешними ключами. В Hibernate это @ManyToMany с @JoinTable (указывают имя таблицы и колонки-ключи), одна сторона — владелец, другая — mappedBy. Важный нюанс: если у самой связи есть АТРИБУТЫ (дата, количество, роль), чистый @ManyToMany не подходит — join-таблицу превращают в отдельную @Entity с двумя @ManyToOne (и своим ключом). Также @ManyToMany требует аккуратности с каскадами и производительностью (легко получить лишние удаления/вставки всей коллекции).
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.