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

Привет, Хабр!

Мы продолжаем исследовать тему Java и Spring, в том числе, на уровне баз данных. Сегодня предлагаем почитать о том, почему при проектировании больших приложений именно структура базы данных, а не код Java, должна иметь определяющее значение, как это делается, и какие исключения есть из этого правила.

В этой довольно запоздалой статье я объясню, почему считаю, что практически во всех случаях модель данных в приложении должна проектироваться «исходя из базы данных», а не «исходя из возможностей Java» (или другого клиентского языка, с которым вы работаете). Выбирая второй подход, вы вступаете на долгий путь боли и страданий, как только ваш проект начинает расти.

Статья написана по мотивам одного вопроса, заданного на Stack Overflow.

Интересные обсуждения на reddit в разделах /r/java и /r/programming.

Генерация кода

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

	for (Record2<String, String> record : DSL.using(configuration)
//   ^^^^^^^^^^^^^^^^^^^^^^^ Информация о типах выведена на 
//   основании сгенерированного кода, на который ссылается приведенное
// ниже условие SELECT 
 
       .select(ACTOR.FIRST_NAME, ACTOR.LAST_NAME)
//           vvvvv ^^^^^^^^^^^^  ^^^^^^^^^^^^^^^ сгенерированные имена
       .from(ACTOR)
       .orderBy(1, 2)) {
    // ...
}

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

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

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

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

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

Причем генерировать такое представление почти всегда бывает целесообразно – чтобы избежать избыточности.

Провайдеры типов и обработка аннотаций

На заметку: еще один, более современный и специфичный подход к генерации кода для jOOQ связан с использованием провайдеров типов, в таком виде, как они реализованы в F#. В таком случае код генерируется компилятором, собственно на этапе компиляции. В виде исходников такой код в принципе не существует. В Java существуют схожие, хотя и не столь изящные инструменты – это процессоры аннотаций, например, Lombok.

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

  • Вы не видите сгенерированного кода (возможно, такая ситуация кому-то кажется не столь отталкивающей?)
  • Вы должны гарантировать, что типы могут предоставляться, то есть, «истина» всегда должна быть доступна. Это легко в случае Lombok, который аннотирует “истину”. Чуть сложнее с моделями баз данных, работа которых зависит от постоянно доступного живого соединения.

В чем проблема с генерацией кода?

Кроме хитрого вопроса о том, каким образом лучше запускать генерацию кода – вручную или автоматически, приходится упомянуть и о том, что есть люди, считающие, что генерация кода вообще не нужна. Обоснование такой точки зрения, которое попадалось мне наиболее часто – в том, что тогда сложно настроить конвейер сборки. Да, действительно сложно. Возникают дополнительные инфраструктурные издержки. Если вы только начинаете работать с определенным продуктом (будь то jOOQ, или JAXB, или Hibernate, т.д.), на настройку рабочей среды уходит время, которое вы хотели бы потратить на изучение самого API, чтобы потом извлекать из него ценность.

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

Многие скажут, что у них нет времени всем этим заниматься. У них горят сроки сдачи по их Супер-Продукту. Когда-нибудь потом причешем конвейеры сборки, успеется. Я им отвечу:

Истина прежде всего, или почему систему нужно проектировать, исходя из устройства базы данных
Оригинал, Алан О’Рурк, Audience Stack

Но в Hibernate / JPA так просто писать код «под Java».

Действительно. Для Hibernate и его пользователей это одновременно и благо, и проклятье. В 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 действительно применит определение этого именованного первичного ключа?
  • А Hibernate создаст индекс в TITLE? – я точно знаю, он нам понадобится.
  • A Hibernate точно сделает этот ключ идентифицирующим в Identity Specification?

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

	@Entity
@Table(name = "book", indexes = {
  @Index(name = "i_book_title", columnList = "title")
})
class Book {
  @Id
  @GeneratedValue(strategy = IDENTITY)
  int id;
  String title;
}

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

Но впоследствии за это придется заплатить

Рано или поздно придется выходить в продакшен. Именно тогда такая модель перестанет работать. Потому что:

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

Отныне и навсегда вам придется писать миграционные скрипты DDL, например, при помощи Flyway. А что в таком случае произойдет с вашими сущностями? Вы сможете либо адаптировать их вручную (и так удвоите себе объем работы), либо прикажете Hibernate заново сгенерировать их для вас (насколько велики шансы, что сгенерированный таким образом будет соответствовать вашим ожиданиям?) Вы в любом случае проигрываете.

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

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

Сначала база данных

Настоящая «истина» в схеме вашей базы данных и «суверенитет» над ней кроется внутри базы данных. Схема определяется только в самой базе данных и нигде больше, и у каждого из клиентов есть копия этой схемы, поэтому совершенно целесообразно навязывать соблюдение схемы и ее целостности, делать это прямо в базе данных – там, где и хранится информация.
Это старая даже избитая мудрость. Первичные и уникальные ключи – это хорошо. Внешние ключи – хорошо. Проверка ограничений – хорошо. Утверждения – хорошо.

Причем, это еще не все. Например, используя Oracle, вы, вероятно, захотите указать:

  • В каком табличном пространстве находится ваша таблица
  • Какое у нее значение PCTFREE
  • Каков размер кэша в вашей последовательности (за идентификатором)

Возможно, все это и не важно в малых системах, но не обязательно дожидаться перехода в область «больших данных» — можно и гораздо раньше начать извлекать пользу от предоставляемых поставщиком оптимизаций хранения данных, таких, как упомянутые выше. Ни одна из ORM, какие мне доводилось видеть (в том числе, jOOQ) не обеспечивает доступа к полному набору опций DDL, которые вы, возможно, захотите использовать в вашей базе данных. ORM предлагают некоторые инструменты, которые помогают писать DDL.

Но, в конце концов, хорошо спроектированная схема вручную написана на DDL. Любой сгенерированный DDL является лишь ее аппроксимацией.

Что насчет клиентской модели?

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

Все базы данных предоставляют свою метаинформацию через SQL. Вот как получить из вашей базы данных все таблицы на разных диалектах 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.

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

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

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

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

ALTER TABLE book RENAME COLUMN title TO book_title;

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

@Entity
@Table(name = "book", indexes = {
 
  // Вы об этом задумывались?
  @Index(name = "i_book_title", columnList = "book_title")
})
class Book {
  @Id
  @GeneratedValue(strategy = IDENTITY)
  int id;
 
  @Column("book_title")
  String bookTitle;
}

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

Единственная истина

Независимо от того, какой технологией вы пользуетесь, всегда есть одна модель, которая является единственным источником истины для некоторой подсистемы – или, как минимум, мы должны к этому стремиться и избегать такой enterprise-путаницы, где «истина» сразу везде и нигде. Все может быть гораздо проще. Если вы всего лишь обмениваетесь 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», где мы делаем наши сообщения в виде объектов Java, их можно было бы как-то внятно отобразить на XML и отправить для потребления в другую систему. XML, сгенерированный таким образом, был бы очень плохого качества, не документирован, и его сложно было бы развивать. Если бы по такому интерфейсу существовало соглашение об уровне качества обслуживания (SLA), мы бы сразу его запороли.

Честно говоря, именно это постоянно и происходит с API на JSON, но это уже другая история, в следующий раз поругаюсь…

Базы данных: это одно и то же

Работая с базами данных, вы понимаете, что все они, в принципе, похожи. База владеет своими данными и должна руководить схемой. Любые модификации, вносимые в схему, должны реализовываться непосредственно на DDL, чтобы обновлялся единый источник истины.

Когда обновление источника произошло, все клиенты также должны обновить свои копии модели. Некоторые клиенты могут быть написаны на Java с использованием jOOQ и Hibernate или JDBC (или всех сразу). Другие клиенты могут быть написаны Perl (остается пожелать им удачи), третьи – на C#. Это не важно. Главная модель находится в базе данных. Модели, сгенерированные при помощи ORM, обычно плохого качества, плохо документированы, и их сложно развивать.

Поэтому не совершайте ошибок. С самого начала не совершайте ошибок. Работайте, исходя из базы данных. Постройте такой конвейер развертывания, который можно автоматизировать. Включите генераторы кода, чтобы удобно было копировать модель вашей базы данных и сбрасывать ее на клиенты. И прекратите беспокоиться о генераторах кода. Они хорошие. С ними вы станете продуктивнее. Нужно только с самого начала потратить немного времени на их настройку – и далее вас ждут годы повышенной производительности, из которых сложится история вашего проекта.

Пока не благодарите, потом.

Пояснение

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

В двухуровневых архитектурах, которые до сих пор кое-где сохранились, такая модель системы может быть единственно возможной. Однако в большинстве систем уровень доступа данных кажется мне «подсистемой», инкапсулирующей модель базы данных.

Исключения

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

  • Когда схема неизвестна, и ее необходимо открыть. Например, вы поставщик инструмента, помогающего пользователям сориентироваться в любой схеме. Уф. Тут без генерации кода. Но все равно – база данных прежде всего.
  • Когда схема должна генерироваться на лету для решения некоторой задачи. Этот пример кажется слегка вычурной версией паттерна entity attribute value, т.е., у вас в самом деле нет четко определенной схемы. В данном случае зачастую даже вообще нельзя быть уверенным, что вам подойдет РСУБД.

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

Источник: habr.com