[Перевод] «Истина в последней инстанции» или зачем нужен Database First Design
В этой весьма запоздалой статье я объясню почему, по моему мнению, в большинстве случаев при разработке модели данных приложения необходимо придерживаться подхода «database first». Вместо «Java[любой другой язык] first» подхода, который выведет вас на длинную дорожку, полную боли и страданий, как только проект начнет расти.
«Слишком занят, чтобы стать лучше» Licensed CC by Alan O«Rourke / Audience Stack. Оригинальное изображение
Эта статья вдохновлена недавним вопросом на StackOverflow.
Интересные reddit-обсуждения /r/java и /r/programming.
Кодогенерация
К моему удивлению, одна небольшая группа пользователей похоже была потрясена тем фактом, что jOOQ сильно «завязан» на генерации исходного кода.
При том, что вы можете использовать jOOQ именно так как вам удобно, предпочтительным способ (согласно документации) является начать именно с уже существующей схемы БД, затем сгенерировать необходимые клиентские классы (соответствующие вашим таблицам) с помощью jOOQ, а после этого уже спокойно писать типобезопасные запросы для этих таблиц:
for (Record2 record : DSL.using(configuration)
// ^^^^^^^^^^^^^^^^^^^^^^^ Type information derived from the
// generated code referenced from the below SELECT clause
.select(ACTOR.FIRST_NAME, ACTOR.LAST_NAME)
// vvvvv ^^^^^^^^^^^^ ^^^^^^^^^^^^^^^ Generated names
.from(ACTOR)
.orderBy(1, 2)) {
// ...
}
Код может генерироваться либо вручную вне сборки, либо автоматически с каждой сборкой. Например, такая генерация может происходить сразу же после установки Flyway-миграций, которые также могут запускаться как вручную, так и автоматически.
Генерация исходного кода
Существуют разные философии, преимущества и недостатки в отношении этих подходов к кодогенерации, которые я не хочу обсуждать в этой статье. Но по сути, смысл сгенерированного кода заключается в том, что он является Java-представлением того, что мы считаем неким «эталоном» (как внутри, так и снаружи нашей системы). В некотором роде компиляторы делают то же самое, когда генерируют байт-код, машинный код или какой-либо другой исходный код из исходников — в итоге мы получаем представление о нашем «эталоне» на другом специфичном языке.
Существует достаточно много таких генераторов кода. Например, XJC может генерировать Java-код из файлов XSD или WSDL. Принцип всегда один и тот же:
- Существует некий эталон (внешний или внутренний), такой как спецификация, модель данных и пр.
- Необходимо получить собственное представление об этом эталоне на нашем привычном языке программирования.
И почти всегда имеет смысл именно генерировать это представление, чтобы избежать лишней работы и лишних ошибок.
«Type providers» и обработка аннотаций
Примечательно, что еще один, более современный, подход к генерации кода в jOOQ — это Type Providers, (как он сделан в F#), где код генерируется компилятором при компиляции и никогда не существует в исходной форме. Аналогичный (но менее сложный) инструмент в Java — это обработчики аннотаций, например, Lombok.
В обоих случаях происходит все тоже самое что и в обычной кодогенерации, кроме:
- Вы не видите сгенерированный код (возможно, для многих это уже большой плюс?)
- Вы должны обеспечить доступность вашего «эталона» при каждой компиляции. Это не доставляет никаких проблем в случае с Lombok, который непосредственно аннотирует сам исходный код, который и является «эталоном» в данном случае. Немного сложнее с моделями баз данных, которые полагаются на всегда доступное «живое» соединение.
В чем проблема с генерацией кода?
Помимо каверзного вопроса, нужно ли генерировать код вручную или автоматически, некоторые люди считают, что код вообще не нужно генерировать. Причина, которую я слышу чаще всего — что такую генерацию сложно реализовать в CI/CD pipeline. И да, это правда, т.к. мы получаем накладные расходы на создание и поддержку дополнительной инфраструктуры, тем более если вы новичок в используемых инструментах (jOOQ, JAXB, Hibernate и др.).
Если накладные расходы на изучение работы кодогенератор слишком высоки, то действительно пользы будет мало. Но это единственный аргумент против. В большинстве остальных случаев совершенно не имеет никакого смысла вручную писать код, который является обычным представлением модели чего-либо.
Многие люди утверждают, что у них нет времени на это, т.к. именно сейчас нужно как можно скорее «выкатить» очередной MVP. А доработать свой CI/CD pipeline они смогут когда-нибудь потом. В таких случаях я обычно говорю: «Ты слишком занят, чтобы стать лучше».
«Но ведь Hibernate/JPA делает Java first разработку гораздо проще»
Да, это правда. Это одновременно и радость, и боль для пользователей Hibernate. С помощью него вы можете просто написать несколько объектов, вида:
@Entity
class Book {
@Id
int id;
String title;
}
И все, почти готово. Далее Hibernate возьмет на себя всю рутину по поводу того, как определить этот объект в DDL и на нужном SQL-диалекте:
CREATE TABLE book (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
title VARCHAR(50),
CONSTRAINT pk_book PRIMARY KEY (id)
);
CREATE INDEX i_book_title ON book (title);
Это действительно отличный способ для быстрого старта разработки — остается только запустить приложение.
Но не все так радужно. Еще остается множество вопросов:
- Сгенерирует ли Hibernate нужное мне имя для первичного ключа?
- Создаст ли необходимый мне индекс на поле TITLE?
- Будет ли генерироваться уникальное значение ID для каждой записи?
Похоже, что нет. Но пока проект находится в стадии разработки, вы всегда можете выбросить свою текущую базу данных и сгенерировать все с нуля, добавив нужные аннотации к модели.
Итак, класс Book в конечном виде будет выглядеть примерно так:
@Entity
@Table(name = "book", indexes = {
@Index(name = "i_book_title", columnList = "title")
})
class Book {
@Id
@GeneratedValue(strategy = IDENTITY)
int id;
String title;
}
Но вы заплатите за это, чуть позже
Рано или поздно ваше приложение попадает в production, и описанная схема перестанет работать:
В живой и настоящей системе вы больше не сможете просто так взять и выбросить свою базу данных, т.к. данные в ней используются и могут стоить кучу денег.
С этого момента вам необходимо писать скрипты миграций на каждое изменение в модели данных, например, используя Flyway. При этом, что происходит с вашими клиентскими классами? Вы можете либо адаптировать их вручную (что приведет к двойной работе), либо попросить Hibernate генерировать их (но насколько велики шансы того, что результат такой генерации будет соответствовать ожиданиям?). В итоге вас могут ожидать большие проблемы.
Как только код попадает в production, в него почти сразу необходимо вносить исправления, причем как можно быстрее.
И т.к. установка миграций БД не встроена в ваш сборочный конвейер, придется устанавливать такие патчи вручную на свой страх и риск. Чтобы вернуться назад и сделать все правильно уже не хватит времени. Его хватит только на то, чтобы винить Hibernate во всех своих бедах.
Вместо этого вы могли бы поступить совершенно иначе с самого начала. А именно использовать круглые колеса вместо квадратных.
Вперед к «Database First»
Эталон схемы данных и контроль над ней находится в ведомстве вашей СУБД. База данных — это единственное место, где определена схема, и все клиенты имеют копию этой схемы, но не наоборот. Данные находятся в вашей базе данных, а не в вашем клиенте, поэтому имеет смысл обеспечить контроль схемы и ее целостности именно там, где находятся данные.
Это старая мудрость, ничего нового. Первичные и уникальные ключи хороши. Внешние ключи прекрасны. Проверка ограничений на стороне БД замечательна. Assertion (когда они окончательно реализованы) великолепны.
И это еще далеко не все. Например, если вы используете Oracle, вы можете указать:
- В каком табличном пространстве находится ваша таблица
- Какое значение PCTFREE она имеет
- Каков размер кэша последовательности (sequence)
Возможно все это не имеет значения в небольших системах, зато в более крупных системах вам не придется идти по пути «больших данных», пока вы не выжмите все соки из своего текущего хранилища. Ни одна ORM, которую я когда-либо видел (в том числе jOOQ) не позволит вам использовать полный набор параметров DDL, которые предоставляет ваша СУБД. ORM предлагают только некоторые инструменты, которые помогут написать DDL.
В конечном счете, хорошо продуманная схема должна быть написана только вручную с помощью СУБД-специфичного DDL. Весь автоматически сгенерированный DDL являются лишь приближением к этому.
Что насчет клиентской модели?
Как уже упоминалось ранее, вам понадобится некое представление схемы БД на стороне клиента. Излишне говорить, что это представление должно быть синхронизировано с реальной моделью. Как это сделать? Конечно же используя генераторы кода.
Все базы данных предоставляют доступ к своей метаинформации посредством старого доброго SQL. Вот так, например, можно получить список всех таблиц из разных баз данных:
-- H2, HSQLDB, MySQL, PostgreSQL, SQL Server
SELECT table_schema, table_name
FROM information_schema.tables
-- DB2
SELECT tabschema, tabname
FROM syscat.tables
-- Oracle
SELECT owner, table_name
FROM all_tables
-- SQLite
SELECT name
FROM sqlite_master
-- Teradata
SELECT databasename, tablename
FROM dbc.tables
Именно такие запросы (а также аналогичные запросы для представлений, материализованных представлений и табличных функций) выполняются при вызове метода DatabaseMetaData.getTables () конкретного JDBC-драйвера, либо в модуле jOOQ-meta.
Из результатов таких запросов относительно легко создать любое клиентское представление модели БД, независимо от того, какая именно технология доступа к данным используется.
- Если вы используете JDBC или Spring, вы можете создать группу String-констант
- Если используете JPA, можете сами создавать объекты
- Если используете jOOQ, можете создать метамодели jOOQ
В зависимости от количества функций, предлагаемых вашим API доступа к данным (jOOQ, JPA или что-то еще), сгенерированная метамодель может быть действительно богатой и полной. Как пример, функция неявного соединения в jOOQ 3.11, которая опирается на метаинформацию о взаимоотношениях внешних ключей между вашими таблицами.
Теперь любое изменение схемы базы данных автоматически приведет к обновлению клиентского кода.
Представьте, что нужно переименовать колонку в таблице:
ALTER TABLE book RENAME COLUMN title TO book_title;
Вы уверены, что хотите выполнить эту работу дважды? Ни за что. Просто закомитьте этот DDL, запустите сборку и наслаждайтесь обновленным объектом:
@Entity
@Table(name = "book", indexes = {
// Would you have thought of this?
@Index(name = "i_book_title", columnList = "book_title")
})
class Book {
@Id
@GeneratedValue(strategy = IDENTITY)
int id;
@Column("book_title")
String **bookTitle**;
}
Так же полученный клиентский нет необходимости компилировать каждый раз (как минимум до следующего изменения в схеме БД), что уже может быть большим плюсом!
Большинство изменений DDL также являются семантическими изменениями, а не только синтаксическими. Таким образом, здорово видеть в сгенерированном коде клиента на что именно повлияли последние изменения в БД.
Правда всегда одна
Независимо от того, какую технологию вы используете, всегда должна быть только одна модель, которая и является эталоном для подсистемы. Или, как минимум, мы должны стремиться к этому и избегать неразберихи в бизнесе, где «эталон» есть везде и нигде одновременно. Это делает все намного проще. К примеру, если вы обмениваетесь XML-файлами с какой-либо другой системой, вы наверняка используете XSD. Как метамодель INFORMATION_SCHEMA jOOQ в формате XML: https://www.jooq.org/xsd/jooq-meta-3.10.0.xsd
- XSD хорошо понятен
- XSD отлично описывает XML-контент и позволяет осуществлять валидацию на всех клиентских языках
- XSD позволяет легко управлять версиями и сохранять обратную совместимость
- XSD можно превратить в Java-код с помощью XJC
Обратим отдельное внимание на последний пункт. При общении с внешней системой через XML-сообщения мы должны быть уверены в валидности сообщений. И это действительно очень легко сделать с помощью таких вещей как JAXB, XJC и XSD. Было бы сумасшествием думать об уместности Java-first подхода в данном случае. Генерируемый на основе объектов XML получится низкого качества, будет плохо задокументирован и трудно расширяем. И если на такое взаимодействие есть SLA, то вы будете разочарованы.
Честно говоря, это похоже на то, что сейчас происходит с различными API для JSON, но это уже совершенна другая история…
Чем базы данных хуже?
При работе с БД тут все тоже самое. База данных владеет данными, и она так же должна быть хозяином схемы этих данных. Все модификации схемы должны быть выполнены посредством DDL напрямую, чтобы обновить эталон.
После обновления эталона все клиенты должны обновить свои представления о модели. Некоторые клиенты могут быть написаны на Java, используя либо jOOQ и/или Hibernate, либо JDBC. Другие клиенты могут быть написаны на Perl (удачи им) или даже на C#. Это не имеет никакого значения. Основная модель находится в базе данных. Тогда как модели, созданные с помощью ORM, имеют низкое качество, недостаточно хорошо документированы и трудно расширяемы.
Поэтому не делайте этого, причем с самого начала разработки. Вместо этого начните с базы данных. Создайте автоматизированный CI/CD конвейер. Используйте в нем генерацию кода, чтобы автоматически генерировать модель базы данных для клиентов при каждой сборке. И перестаньте волноваться, все будет хорошо. Все что требуется — немного первоначальных усилий по настройке инфраструктуры, но в результате вы получите выигрыш в процессе разработки для остальной части вашего проекта на годы вперед.
Не надо благодарностей.
Пояснения
Для закрепления: эта статья никоим образом не утверждает, что модель базы данных должна распространяться на всю вашу систему (на предметную область, бизнес-логику и пр.). Мои заявления заключается лишь в том, что клиентский код, взаимодействующий с базой данных, должен быть лишь представлением схемы БД, но никак не определять и не формировать ее.
В двухуровневых архитектурах, которые по-прежнему имеют место быть, схема БД может быть единственным источником информации о модели вашей системы. Однако в большинстве систем я рассматриваю уровень доступа к данным как «подсистему», которая инкапсулирует модель базы данных. Как-то так.
Исключения
Как и в любом другом хорошем правиле, в нашем тоже есть свои исключения (и я уже предупреждал, что подход database first и генерация кода не всегда является правильным выбором). Вот эти исключения (возможно, список не полный):
- Когда схема неизвестна заранее и ее необходимо исследовать. Например, вы поставщик инструмента, помогающего пользователям осуществлять навигацию по любой схеме. Само собой, тут не может быть никакой генерации кода. Но в любом случае, придется иметь дело с самой БД напрямую и ее схемой.
- Когда для какой-то задачи необходимо создать схему «на лету». Это может быть похоже на одну из вариаций паттерна Entity-attribute-value, т.к. у вас нет четко определенной схемы. Также как и нет уверенности, что RDBMS в данном случае это верный выбор.
Особенность этих исключений в том, что они довольно редко всречаются в живой природе. В большинстве же случаев при использовании реляционных БД схема заранее известна и является «эталоном» вашей модели, а клиенты должны работать с копией этой модели, сгенерированной с использованием генераторов кода.