Наследование в Hibernate: выбор стратегии

Наследование в Hibernate: выбор стратегии

Главное противоречие между объектно-ориентированной и реляционной моделями заключается в том, объектная модель поддерживает два вида отношений («is a» — “является”, и «has a» — “имеет”), а модели, основанные на SQL, поддерживают только отношения «has a».

Иными словами, SQL не понимает наследование типов и не поддерживает его.

Поэтому на этапе построения сущностей и схемы БД одной из главных задач разработчика будет выбор оптимальной стратегии представления иерархии наследования.

Всего таких стратегий 4:

1) Использовать одну таблицу для каждого класса и полиморфное поведение по умолчанию.

2) Одна таблица для каждого конкретного класса, с полным исключением полиморфизма и отношений наследования из схемы SQL (для полиморфного поведения во время выполнения будут использоваться UNION-запросы)

3) Единая таблица для всей иерархии классов. Возможна только за счет денормализации схемы SQL. Определять суперкласс и подклассы будет возможно посредством различия строк.

4) Одна таблица для каждого подкласса, где отношение “is a” представлено в виде «has a», т.е. – связь по внешнему ключу с использованием JOIN.

Можно выделить 3 главных фактора, на которые повлияет выбранная вами стратегия:

1) Производительность (мы используем “hibernate_show_sql”, чтобы увидеть и оценить все выполняемые к БД запросы)

2) Нормализация схемы и гарантия целостности данных (не каждая стратегия гарантирует выполнение ограничения NOT NULL)

3) Возможность эволюции вашей схемы

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

Данная статья является выжимкой из книги «Java Persistance with Hibernate». Ее авторы — основатель проекта Hibernate Гэвин Кинг (Gavin King) и член команды разработчиков Hibernate Кристиан Баэур (Christian Bauer). Летом 2017 она была переведена и издана на русском языке.

Я постарался упростить изложение материала, а также работу с примерами. Испытывая сильную нелюбовь к примерам, с которыми для запуска нужно возиться час, я стремился сделать работу с ними в этой статье максимально удобной: — Весь Java-код вы можете просто скопировать в свою IDE. Все изменения Java-кода при переходе от одной стратегии к другой указаны в спойлерах, поэтому при переходе к новой стратегии старый код класса можно просто удалить и скопировать новый. Классы Main и HibernateUtil останутся без изменений, и будут работать при рассмотрении всех примеров.

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

Код написан с использованием Java 1.7, Hibernate5 и PostgreSQL9

Стратегия 1

Одна таблица для каждого класса

Мы решили затмить славу eBay и создаем для этой цели свое приложение интернет-аукциона. Каждый User может делать ставки, и в том случае если его ставка оказалась самой крупной – совершить оплату онлайн.

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

Диаграмма классов представлена ниже:

Класс Main с методом main():

Классы BankAccount и CreditCard наследуются от общего абстрактного предка BillingDetails. Как видно из схемы, несмотря на похожий функционал, их состояния существенно отличаются: для карты нам важны номер и срок действия, а для банковского счета – поля реквизитов.

Родительский класс хранит только общую для всех потомков информацию о владельце. Кроме того, туда можно вынести, например, поле Id вместе с типом генерации (в данном случае мы обошлись без этого).

Схема нашей БД для первой стратегии будет выглядеть так:

Запросы для создания таблиц:

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

ВАЖНО! Свойства суперкласса по умолчанию будут проигнорированы. Чтобы сохранить их в таблицу конкретного подкласса, необходимо использовать аннотацию @MappedSuperClass.

Отображение подклассов не содержит ничего необычного. Единственное, на что следует обратить внимание – возможно, незнакомая для некоторых аннотация @AttributeOverride. Она используется для переименования столбца в таблице подкласса, в том случае если названия у предка и таблицы потомка не совпадают (в нашем случае – чтобы «owner» из BillingDetails маппился на CC_OWNER в таблице CREDIT_CARD).

Главная проблема при использовании данной стратегии заключается в том, что использовать полиморфные ассоциации в полной мере будет невозможно: обычно они представлены в БД в виде доступа по внешнему ключу, а у нас попросту нет таблицы BILLING_DETAILS. А поскольку каждый объект BillingDetails будет в приложении связан с конкретным объектом User, то каждой из таблиц-«потомков» нужен будет внешний ключ, ссылающийся на таблицу USERS.

Кроме того, проблемой также будут и полиморфные запросы.

Попробуем выполнить запрос

Для этого (здесь и далее) просто запустите метод main().

В данном случае он будет выполнен следующим образом:

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

Другой важной проблемой при использовании данной стратегии будет сложность рефакторинга. Изменение названия полей в суперклассе вызовет необходимость изменения названий во многих таблицах и потребует ручного переименования (инструменты большинства IDE не учитывают @AttributeOverride). В случае, если в вашей схеме не 2 таблицы, а 50, это чревато большими временными затратами.

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

а) Полиморфизм не нужен (выборку для конкретного подкласса Hibernate будет выполнять в один запрос -> производительность будет высокой)

б) Изменения в суперклассе не предвидятся.

Для приложения, где запросы будут ссылаться на родительский класс BillingDetails эта стратегия не подойдет.

Стратегия 2

Одна таблица для каждого класса с объединениями (UNION)

В роли абстрактного класса вновь выступит BillingDetails. Схема БД также останется без почти без изменений.

Единственный момент – поле CC_OWNER в таблице CREDIT_CARD придется переименовать в OWNER, поскольку данная стратегия не поддерживает @AttributeOverride. Из документации: «The limitation of this approach is that if a property is mapped on the superclass, the column name must be the same on all subclass tables».

Новой также будет указанная над суперклассом аннотация @Inheritance с указанием выбранной стратегии TABLE_PER_CLASS.

ВАЖНО! В рамках данной стратегии наличие идентификатора в суперклассе является обязательным требованием (в первом примере мы обошлись без него).

ВАЖНО! Согласно стандарту JPA стратегия TABLE_PER_CLASS не является обязательной, поэтому другими реализациями может не поддерживаться.

Наша схема SQL по-прежнему ничего не знает о наследовании; между таблицами нет никаких отношений.

Главное преимущество данной стратегии можно увидеть, выполнив полиморфный запрос из предыдущего примера.

На сей раз он будет выполнен по-другому:

В данном случае Hibernate использует FROM, чтобы извлечь все экземпляры BillingDetails из всех таблиц подклассов. Таблицы объединяются с помощью UNION, а в промежуточный результат добавляются литералы (1 и 2). Литералы используются Hibernate для создания экземпляра правильного класса.

Объединение таблиц требует одинаковой структуры столбцов, поэтому вместо несуществующих столбцов были вставлены NULL (например, «null::varchar as bank_name» в credit_card – в таблице кредиток нет названия банка).

Другим важный преимуществом по сравнению с первой стратегией будет возможность использовать полиморфные ассоциации. Теперь можно будет без проблем отобразить ассоциации между классами User и BillingDetails.

Стратегия 3

Единая таблица для всей иерархии классов

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

Наша схема теперь выглядит вот так:

Для создания отображения с одной таблицей необходимо использовать стратегию наследования SINGLE_TABLE. Корневой класс будет отображен в таблицу BILLING_DETAILS. Для различения типов будет использован столбец селектора. Он не является полем сущности и создан только для нужд Hibernate. Его значением будут строки – “CC” или “BA”. ВАЖНО! Если не указать столбец селектора в суперклассе явно – он получит название по умолчанию DTYPE и тип VARCHAR.

Каждый класс иерархии может указать свое значение селектора с помощью аннотации @DiscriminatorValue. Не стоит пренебрегать явным указанием имени селектора: по умолчанию Hibernate будет использовать полное имя класса или имя сущности (зависит от того, используются ли файлы XML-Hibernate или xml-файлы JPA/аннотации).

Для проверки используем в методе main уже привычный запрос

В случае с единой таблицей этот запрос будет выполнен так:

Если же запрос выполняется к конкретному подклассу – будет просто добавлена строка «where BD_TYPE = “CC”».

Вот как будет выглядеть отображение в единую таблицу:

В случае, когда схема была унаследована, и добавить в нее столбец селектора невозможно, на помощь приходит аннотация @DiscriminatorFormula, которую необходимо добавить к родительскому классу. В нее необходимо передать выражение CASE. WHEN.

Главным плюсом данной стратегии является производительность. Запросы (как полиморфные, так и неполиморфные) выполняются очень быстро и могут быть легко написаны вручную. Не приходится использовать соединения и объединения. Эволюция схемы также производится очень просто.

Однако, проблемы, сопровождающие эту стратегию, часто будут перевешивать ее преимущества.

Главной из них является целостность данных. Столбцы тех свойств, которые объявлены в подклассах, могут содержать NULL. В результате простая программная ошибка может привести к тому, что в базе данных окажется кредитная карта без номера или без срока действия.

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

Стратегия 4

Одна таблица для каждого класса с использованием соединений (JOIN)

Схема наших классов останется неизменной:

А вот в схеме БД произошли некоторые изменения

В Java-коде для создания такого отображения необходимо использовать стратегию JOINED.

Теперь при сохранении, например, экземпляра CreditCard Hibernate вставит две записи. В таблицу BILLING_DETAILS попадут свойства, объявленные в полях суперкласса BillingDetails, а значения полей подкласса CreaditCard будут записаны в таблицу CREDIT_CARD. Эти записи будут объединены общим первичным ключом.

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

, мы увидим следующую картину:

Предложение CASE…WHEN позволяет Hibernate определить конкретный подкласс для каждой записи. В нем проверяется наличие либо отсутствие строк в таблицах подклассов CREDIR_CARD и BANK_ACCOUNT с помощью литералов.

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

Смешение стратегий отображения наследования

При работе со стратегиями TABLE_PER_CLASS, SINGLE_TABLE и JOINED значительным неудобством является тот факт, что между ними невозможно переключаться. Выбранной стратегии придется придерживаться до конца (либо полностью менять схему). Но есть приемы, с помощью которых можно переключить стратегию отображения для конкретного подкласса.

Например, отобразив иерархию классов в единственную таблицу (стратегия 3), можно выбрать для отдельного подкласса стратегию с отдельной таблицей и внешним ключом (стратегия 4).

Теперь мы можем отобразить подкласс CreditCard в отдельную таблицу. Для этого нам нужно будет применить стратегию InheritanceType.SINGLE_TABLE к суперклассу BillingDetails, а в работе с классом CreditCard нам поможет аннотация @SecondaryTable.

При помощи аннотаций @SecondaryTable и @Column мы переопределяем основную таблицу и ее столбцы, указывая Hibernate, откуда необходимо брать данные.

При выборе стратегии SINGLE_TABLE столбцы подклассов могут содержать NULL. Используя же данный прием, вы можете гарантировать целостность данных для конкретного подкласса (в нашем случае — CreditCard). Исполняя полиморфный запрос, Hibernate выполнит внешнее соединение для извлечения экземпляров BillingDetails и всех его подклассов.

Этот прием можно применить и к остальным классам иерархии, но для обширной иерархии он подойдет не слишком хорошо, поскольку внешнее соединение в таком случае станет проблемой. Для такой иерархии лучше подойдет стратегия, которая немедленно выполнит второй SQL-запрос вместо внешнего соединения.

Выбор стратегии

Каждая из перечисленных выше стратегий и приемов имеет свои преимущества и недостатки. Общие рекомендации по выбору конкретной стратегии будут выглядеть так:

— Стратегию №2 (TABLE_PER_CLASS на основе UNION), если полиморфные запросы и ассоциации не требуются. Если вы редко выполняете (или не выполняете вообще) «select bd from BillingDetails bd», и у вас нет классов, ссылающихся на BillingDetails, этот вариант будет лучшим (поскольку возможность добавления оптимизированных полиморфных запросов и ассоциаций сохранится).

— Стратегию №3 (SINGLE_TABLE) стоит использовать:

а) Только для простых задач. В ситуациях, когда нормализация и ограничение NOT NULL являются критическими – следует отдать предпочтение стратегии №4 (JOINED). Имеет смысл задуматься, не стоит ли в данном случае вообще отказаться от наследования и заменить его делегированием б) Если требуются полиморфные запросы и ассоциации, а также динамическое определение конкретного класса во время выполнения; при этом подклассы объявляют относительно мало новых полей и основная разница с суперклассом заключается в поведении. Ну и вдобавок к этому, Вам предстоит серьезный разговор с администратором БД.

— Стратегия №4 (JOINED) подойдет в случаях, когда требуются полиморфные запросы и ассоциации, но подклассы объявляют относительно много новых полей.

Здесь стоит оговориться: решение между JOINED и TABLE_PER_CLASS требует оценки планов выполнения запросов на реальных данных, поскольку ширина и глубина иерархии наследования могут сделать стоимость соединений (и, как следствие, производительность) неприемлемыми.

Отдельно стоит принять во внимание, что аннотации наследования невозможно применить к интерфейсам.

📎📎📎📎📎📎📎📎📎📎