- PVSM.RU - https://www.pvsm.ru -

«Истина в последней инстанции» или зачем нужен Database First Design

В этой весьма запоздалой статье я объясню почему, по моему мнению, в большинстве случаев при разработке модели данных приложения необходимо придерживаться подхода "database first". Вместо "Java[любой другой язык] first" подхода, который выведет вас на длинную дорожку, полную боли и страданий, как только проект начнет расти.

image
"Слишком занят, чтобы стать лучше" Licensed CC [1] by Alan O’Rourke / Audience Stack [2]. Оригинальное изображение [3]

Эта статья вдохновлена недавним вопросом на StackOverflow [4].

Интересные reddit-обсуждения /r/java [5] и /r/programming [6].

Кодогенерация

К моему удивлению, одна небольшая группа пользователей похоже была потрясена тем фактом, что jOOQ [7] сильно "завязан" на генерации исходного кода.

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

for (Record2<String, String> 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-миграций [8], которые также могут запускаться как вручную, так и автоматически.

Генерация исходного кода

Существуют разные философии, преимущества и недостатки в отношении этих подходов к кодогенерации, которые я не хочу обсуждать в этой статье. Но по сути, смысл сгенерированного кода заключается в том, что он является Java-представлением того, что мы считаем неким "эталоном" (как внутри, так и снаружи нашей системы). В некотором роде компиляторы делают то же самое, когда генерируют байт-код, машинный код или какой-либо другой исходный код из исходников — в итоге мы получаем представление о нашем "эталоне" на другом специфичном языке.

Существует достаточно много таких генераторов кода. Например, XJC может генерировать Java-код из файлов XSD или WSDL [9]. Принцип всегда один и тот же:

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

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

"Type providers" и обработка аннотаций

Примечательно, что еще один, более современный, подход к генерации кода в jOOQ — это Type Providers, (как он сделан в F# [10]), где код генерируется компилятором при компиляции и никогда не существует в исходной форме. Аналогичный (но менее сложный) инструмент в Java — это обработчики аннотаций, например, Lombok [11].

В обоих случаях происходит все тоже самое что и в обычной кодогенерации, кроме:

  • Вы не видите сгенерированный код (возможно, для многих это уже большой плюс?)
  • Вы должны обеспечить доступность вашего "эталона" при каждой компиляции. Это не доставляет никаких проблем в случае с 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 [8]. При этом, что происходит с вашими клиентскими классами? Вы можете либо адаптировать их вручную (что приведет к двойной работе), либо попросить Hibernate генерировать их (но насколько велики шансы того, что результат такой генерации будет соответствовать ожиданиям?). В итоге вас могут ожидать большие проблемы.

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

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

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

Вперед к "Database First"

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

Это старая мудрость, ничего нового. Первичные и уникальные ключи хороши. Внешние ключи прекрасны. Проверка ограничений на стороне БД замечательна. Assertion (когда они окончательно реализованы) [12] великолепны.

И это еще далеко не все. Например, если вы используете 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() [13] конкретного JDBC-драйвера, либо в модуле jOOQ-meta.

Из результатов таких запросов относительно легко создать любое клиентское представление модели БД, независимо от того, какая именно технология доступа к данным используется.

  • Если вы используете JDBC или Spring, вы можете создать группу String-констант
  • Если используете JPA, можете сами создавать объекты
  • Если используете jOOQ, можете создать метамодели jOOQ

В зависимости от количества функций, предлагаемых вашим API доступа к данным (jOOQ, JPA или что-то еще), сгенерированная метамодель может быть действительно богатой и полной. Как пример, функция неявного соединения в jOOQ 3.11, которая опирается на метаинформацию о взаимоотношениях внешних ключей между вашими таблицами [14].

Теперь любое изменение схемы базы данных автоматически приведет к обновлению клиентского кода.

Представьте, что нужно переименовать колонку в таблице:

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 [15]

  • 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 [16], т.к. у вас нет четко определенной схемы. Также как и нет уверенности, что RDBMS в данном случае это верный выбор.

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

Автор: mgramin

Источник [17]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/java/283360

Ссылки в тексте:

[1] Licensed CC: https://creativecommons.org/licenses/by/2.0/

[2] Alan O’Rourke / Audience Stack: http://audiencestack.com/static/blog.html

[3] Оригинальное изображение: https://www.flickr.com/photos/toddle_email_newsletters/15596940251/in/photostream

[4] Эта статья вдохновлена недавним вопросом на StackOverflow: https://stackoverflow.com/q/50706556/521799

[5] /r/java: https://redd.it/8p0uj3

[6] /r/programming: https://redd.it/8p0qle

[7] jOOQ: https://www.jooq.org

[8] такая генерация может происходить сразу же после установки Flyway-миграций: https://blog.jooq.org/2014/06/25/flyway-and-jooq-for-unbeatable-sql-development-productivity

[9] XJC может генерировать Java-код из файлов XSD или WSDL: https://docs.oracle.com/javase/8/docs/technotes/tools/unix/xjc.html

[10] как он сделан в F#: https://docs.microsoft.com/en-us/dotnet/fsharp/tutorials/type-providers

[11] Lombok: https://projectlombok.org

[12] Assertion (когда они окончательно реализованы): https://community.oracle.com/ideas/13028

[13] DatabaseMetaData.getTables(): https://docs.oracle.com/javase/10/docs/api/java/sql/DatabaseMetaData.html#getTables(java.lang.String,java.lang.String,java.lang.String,java.lang.String%5B%5D)

[14] функция неявного соединения в jOOQ 3.11, которая опирается на метаинформацию о взаимоотношениях внешних ключей между вашими таблицами: https://blog.jooq.org/2018/02/20/type-safe-implicit-join-through-path-navigation-in-jooq-3-11

[15] https://www.jooq.org/xsd/jooq-meta-3.10.0.xsd: https://www.jooq.org/xsd/jooq-meta-3.10.0.xsd

[16] паттерна Entity-attribute-value: https://en.wikipedia.org/wiki/Entity%E2%80%93attribute%E2%80%93value_model

[17] Источник: https://habr.com/post/413597/?utm_campaign=413597