Істина перш за все, або чому систему потрібно проектувати, виходячи із пристрою бази даних

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

Ми продовжуємо досліджувати тему Java и весна, зокрема, лише на рівні баз даних. Сьогодні пропонуємо почитати про те, чому при проектуванні великих додатків саме структура бази даних, а не код Java повинна мати визначальне значення, як це робиться, і які винятки є з цього правила.

У цій досить запізнілій статті я поясню, чому вважаю, що практично у всіх випадках модель даних у додатку повинна проектуватися "виходячи з бази даних", а не "виходячи з можливостей Java" (або іншої клієнтської мови, з якою ви працюєте). Вибираючи другий підхід, ви вступаєте на довгий шлях болю та страждань, як тільки ваш проект починає зростати.

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

Цікаві обговорення на reddit у розділах /r/java и /r/програмування.

Генерація коду

Наскільки ж я здивувався, що існує такий невеликий прошарок користувачів, які, познайомившись з 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, який анотує “істину”. Трохи складніше з моделями баз даних, робота яких залежить від доступного живого з'єднання.

У чому проблема із генерацією коду?

Крім хитрого питання про те, як краще запускати генерацію коду – вручну чи автоматично, доводиться згадати і про те, що є люди, які вважають, що генерація коду взагалі не потрібна. Обґрунтування такої точки зору, яке траплялося мені найчастіше – у тому, що тоді складно налаштувати конвеєр збирання. Так, справді складно. Виникають додаткові інфраструктурні витрати. Якщо ви тільки починаєте працювати з певним продуктом (чи то 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

Додати коментар або відгук