Super Schema Architecture

от автора

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

Описанный подход работает независимо от используемого на проекте стека технологий и особенно полезен в гетерогенных системах. Поэтому я стараюсь приводить примеры из разных языков программирования и технологий: Java, Python, TypeScript, REST, GraphQL, protobuf.

Введение

Всякая программа оперирует какими-то данными. Обычно в проекте содержится множество декларативных описаний данных. Это могут быть определения классов в ООП, схемы таблиц базы данных, схемы GraphQL, protobuf и так далее. Каждое такое описание решает свою техническую задачу.

Схема таблицы в PostgreSQL нужна, чтобы СУБД могла хранить данные, а описание этой же таблицы для ORM-библиотеки нужно, чтобы работать с БД в коде. Несмотря на то, что они описывают одну и ту же информацию, нельзя сказать, что эти описания эквивалентны и взаимозаменяемы. ORM-схема может, например, не уметь задавать constraint, но при этом иметь какую-то свою вспомогательную информацию, не содержащуюся в самой БД. Но сильная логическая связь между этими схемами очевидна. Как минимум имена и типы столбцов должны соответствовать друг другу.

Пример. Схема ORM Prisma:

model users {  id        String   @id @db.Uuid @default(uuid(7))  email     String   @unique  birth_date DateTime @db.Date}

Схема PostgreSQL:

CREATE TABLE users (    id UUID PRIMARY KEY,    email TEXT NOT NULL UNIQUE,    birth_date DATE NOT NULL,    CONSTRAINT birth_date_in_past        CHECK (birth_date < CURRENT_DATE));

Prisma-схема не умеет описывать contraint. Однако она содержит дополнительное дефолтное значение столбца id, используемое ORM. Остальную информацию схемы, по сути, дублируют.

Похожие рассуждения можно применить и к слабее связанным друг с другом метаданным. Представим себе OpenAPI-спецификацию в том же проекте. Сущности в API могут не иметь ничего общего с моделями базы данных. Связь между БД и API есть, она описывается кодом реализующим эндпоинт, но код этот может быть самым разнообразным. Однако в случаях, когда код лишь передаёт данные без преобразований, схемы API и базы данных могут так же дублировать метаинформацию.

OpenAPI:

paths:  /users/{id}:    get:      parameters:        - name: id          in: path          required: true          schema:            type: string            format: uuid      responses:        "200":          description: User          content:            application/json:              schema:                type: object                required:                  - id                  - email                  - birth_date                properties:                  id:                    type: string                    format: uuid                  email:                    type: string                  birth_date:                    type: string                    format: date

В отличие от примера с ORM, эта схема описывает самостоятельную сущность. Но её нельзя назвать и полностью независимой от модели БД. Поскольку в нашем упрощенном примере данные передаются без преобразований, то любая метаинформация, применимая к модели БД, будет применима и к ответу эндпоинта. С одной стороны, мы необязательно захотим всю эту информацию фиксировать в контракте. Но если возникнет необходимость дублировать эту информацию, то это может стать проблемой. Например, мы можем иметь подробную документацию для колонки в БД, которую необходимо предоставить и поддерживать в актуальном состоянии пользователям API.

Я подвожу к тому, что практика полного разделения сущностей БД и API хоть и является корректной, но только потому что наши инструменты не всегда позволяют выразить более сложные отношения между этими сущностями. Мы вернёмся к этому вопросу позже, а пока давайте рассматривать только тривиальные CRUD-системы, в которых сущности на разных уровнях по сути дублируют друг друга.

Конечно, дублирование метаданных IT-сообщество не могло не замечать, поэтому породило самые разнообразные конвертеры и кодогенераторы: из OpenAPI/JSON-Schema в структуры любого языка программирования и наоборот; из XML Schema в код валидации XML-сообщения; даже из protobuf-схемы в SQL. Как мне кажется, все эти решения сфокусированы на мелких частных проблемах и не охватывают всей картины.

SSA 0-го уровня. Объединение метаданных.

Валидация, транспорт и БД

Для начала рассмотрим ту же CRUD-систему, но добавим к описываемому бэкенду ещё Web-интерфейс. Представим себе наиболее примитивный вариант с формой, которая тривиально добавляет данные в конечную БД. Какой путь проделывают данные в таком приложении? Давайте рассмотрим на примере одного поля для ввода даты рождения.

  1. Начинается всё с визуальной формы ввода. Пусть форма содержит виджет для выбора даты. Скрипт формы хранит выбираемое значение в виде JavaScript-объекта типа Date.

  2. Фронтенд может сразу же валидировать вводимые данные. Дата рождения всегда должна быть в прошлом.

  3. Затем данные преобразуются в формат, пригодный для передачи через API. Хранимое в памяти значение Date сериализуется, допустим, в строковое значение JSON.

  4. Бэкенд преобразует полученный JSON в более удобное представление в памяти. Дата из JSON-строки превращается в объект на языке программирования бэкенда.

  5. Данные валидируются.

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

Условный фрагмент кода на React:

const [birthDate, setBirthDate] = useState<Date>();const handleSubmit = () => {if (!birthDate || birthDate.valueOf() > new Date().valueOf()) {throw new Error("Birth date must be in the past");}await fetch(`/api/users/${userId}`, {method: 'PATCH',body: JSON.stringify({birth_date: birthDate.toISOString().slice(0, 10)})});}

JSON:

{ "birth_date": "1990-05-20" }

Java:

public record PatchUserRequest(  LocalDate birth_date) {}if (!request.birth_date().isBefore(LocalDate.now())) {  throw new ValidationException();}

PostgreSQL:

birth_date DATE NOT NULL

Сколько различных метаданных присутствует на этом пути?

  1. Схема хранения значений формы в памяти фронтенда (JS-объект Date).

  2. Правила валидации данных. Причём они могут отличаться на фронтенде и бэкенде.

  3. Представление данных в виде JSON (строка заданного формата).

  4. Представление данных в памяти бэкенда (Java-объект LocalDate).

  5. Схема БД (колонка типа DATE).

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

Как именно должны выглядеть такие супер-схемы? Например, это могут быть YAML-описания, специализированный DSL или просто код с декларациями на языке общего пользования. Чтобы дальнейшее описание сделать более наглядным, я буду использовать подобный императивный псевдо-язык:

User = Model({  key: 'id',}, {  id: Uuid({ autogenerate: 'v7' }),  email: Unique(Email()),  birth_date: PlainDate({ forbidFuture: true }),})

Конкретный формат супер-схемы непринципиален, важна полнота описания. Для нашего примера мы можем автоматически вывести тайпинги, правила валидации и сериализации в JSON для TypeScript, а также необходимые DTO и логику валидации для бэкенда. Причём благодаря полноте нашего описания мы слабее завязаны на конкретные технологии. Например, мы можем добавить генерацию схем Zod и начать использовать их для перечисленных задач на фронтенде.

Эти супер-схемы являются единым источником истины для всех производных метаданных. Они одновременно задают и согласуют представления данных в памяти, правила кодирования, правила валидации, и типы для хранения в БД. Таким образом мы обеспечиваем типобезопасность и согласованность метаинформации на всём пути данных.

Логика отображения

Помимо транспорта и хранения данных информация из супер-схемы может использоваться и на уровне отображения. Например, если мы знаем, что поле хранит телефонный номер, то при выводе его значения на экран можно автоматически применить форматирование (+1 234-567-890). А для поля ввода мы можем по умолчанию задать маску и запросить цифровую клавиатуру в мобильных браузерах. Реализовать это можно с помощью сопоставления полей данных с соответствующими View-компонентами.

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

Может возникнуть ощущение, что супер-схемы нарушают распространённые принципы разработки, например, SRP. Одна декларация описывает работу с данными на совершенно разных архитектурных слоях. Особенно неожиданно увидеть логику отображения, задаваемую схемой данных. На самом деле супер-схема не перемешивает архитектурные слои. Она лишь группирует куски логики относящиеся к одному типу данных, но каждый из них используется внутри своего архитектурного слоя.

Важно, что супер-схема максимально подробно описывает данные, а не то, как они применяются. Но зная природу данных, мы можем использовать эту информацию в самых разных задачах.

SSA 1-го уровня. Производные супер-схемы.

Напомню, что до этого момента мы находились в рамках вырожденного случая, когда информация на всём пути просто транслируется из одного представления в другое. С данным ограничением SSA может быть полезен даже в больших и сложных проектах. Однажды я участвовал в разработке подсистемы логирования для стримингового сервиса Окко. Чтобы избавиться от потока багов, вызванных расхождением в схемах данных, мы из единого источника правды стали генерировать DTO для Java, Swift, Python и TypeScript, схемы Protobuf, а также схемы и миграции для ClickHouse и Impala.

Но давайте перейдём от частного случая к общему. В этом месте важно подчеркнуть: то, что одна супер-схема может использоваться для задач на всех уровнях приложения, не означает что на всех этапах должна использоваться одна и та же схема.

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

Как минимум необходимы следующие операции:

  1. Определить схему объекта, с заданным подмножеством полей из другой схемы.

  2. Объединение полей из двух объектов в новый объект.

С помощью этих операций мы можем описывать связи между разными моделями данных и избежать дублирования информации. Например, схема API может содержать подмножество полей из двух таблиц базы данных.

UserInApi = Compose(  Pick(User, ['id', 'email', 'birth_date']),  Pick(Profile, ['avatar']),  Object({ age: Integer }))

В данном примере мы задаём UserInApi на основе схем User и Profileи дополняем ещё одним полем age. Хоть все они являются супер-схемами и могут использоваться в разных задачах, ничто не мешает нам использовать UserInApi только в слое API, а User и Profile только для хранения в базе данных и, например, кэше.

SSA 2-го уровня. Контракты API.

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

userPatchEndpoint = createEndpoint({method: 'PATCH',path: '/users/:userId',params: { userId: Uuid() },body: Pick(User, ['birth_date']),result: UserInApi,})

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

  • добавить дополнительные преобразования при сериализации, если библиотека транспортного уровня не может их выполнять (те же преобразования дат в строки и обратно);

  • абстрагироваться от конкретного транспорта, обеспечить поддержку для нескольких протоколов;

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

SSA 3-го уровня. Семантические операции.

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

userListEndpoint = createListEndpoint(User)userUpdateEndpoint = createUpdateEndpoint(User)

Такие высокоуровневые абстракции уже могут использоваться, например, для автоматизации CRUDL-задач на всём пути данных. На фронтенде мы можем использовать эту информацию в рантайме, чтобы автоматически выполнять обновление кэша при запросах в update-эндпоинт или построить таблицу с серверной сортировкой, фильтрацией и паджинацией по list-эндпоинту:

<ListEndpointTable endpoint={userListEndpoint} />

Для простых случаев на бэкенде можно автоматически сгенерировать обработчик для эндпоинта:

app = FastAPI()handleListEndpoint(app, userListEndpoint)

При этом мы всё ещё можем опускаться на нижние уровени абстракции при необходимости. Т.е. list-эндпоинт может иметь полностью кастомную реализацию на бэкенде или использоваться в кастомной логике отображения на фронтенде. Мы получаем преимущества low-code решений, сохраняя полный контроль над логикой.

Трудности

На текущий момент мне неизвестна ни одна готовая технология для описания данных, без привязки к какой-то конкретной задаче. Большинство форматов метаданных поддерживают расширения (например, OpenAPI, Protobuf, GraphQL), но их фокус на конкретной технологии и её системе типов делает эти форматы неудобными для более абстрактных описаний и поддержке производных схем данных. Более подходящим решением может быть недавно выпущенный Microsoft TypeSpec. Но на данном этапе он тоже сильно сфокусирован на сетевом уровне.

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

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

Заключение

Я старался сделать эту статью наиболее приземлённой, сфокусированной на конкретных проблемах мейнстримных практик разработки. Мне кажется, эти проблемы часто воспринимаются как неизбежное зло, и поэтому их последствия недооцениваются.

Но также я старался показать, что большее использование декларативного подхода открывает возможности, о которых мы иначе даже не задумывались. Я постарался продемонстрировать это в описании SSA 3-его уровня, но некоторые идеи остались за рамками статьи.

Думаю, что доступность и полнота метаинформации — это важный шаг в наших практиках.

Know your data.


Я активно ищу позицию senior или lead software engineer в пределах евросоюза. Если вы считаете, что мой опыт может подойти вашей команде, буду рад обсудить возможное сотрудничество.

ссылка на оригинал статьи https://habr.com/ru/articles/1043948/