Бесконечность схем данных
Расскажу об одной боли при разработке и проектировании ПО — преобразованиях данных между их схемами. Буду говорить о серверах, как наиболее наглядном и знакомом мне примере, но соображения можно распространить на весь софт.
Для демонстрационных целей местами может случиться некоторое преувеличение.
Рассмотрим простейший проект, этакий минимальный набор:
- один тип клиентов;
- один сервис;
- одно хранилище.
Данные, соответственно, ходят в обе стороны:
- между клиентом и сервисом;
- между сервисом и хранилищем.
Сколько схем данных вы тут видите?
Посчитаем
Начнём с трёх. Каждая сущность: клиент, сервис, хранилище — работает в контексте своего домена, а значит оперирует собственными схемами данных.
Между сущностями необходимо передавать информацию. Для этого мы фиксируем интерфейсы взаимодействия между ними.
Сервис задаёт API, по которому с ним взаимодействуют клиенты. Хранилище задаёт API, по которому с ним взаимодействуют сервисы.
Данные передаются через общую среду (например, цепочку серверов в интернете), поэтому их необходимо преобразовать в удобный для передачи вид, а потом преобразовать обратно.
Мы нашли ещё две схемы данных:
- API между клиентом и сервисом;
- API между сервисом и хранилищем.
Теперь схем — пять.
Когда мы работаем с данными, у нас возникает два уровня представления информации:
- Прагматический — формат описания данных на конкретном языке: JSON, Protobuf, YAML, Python, С++, Lisp.
- Семантический — конкретное значение данных: координаты, счёт, документ.
Например, пара длинных целых чисел будет по-разному представлена в JSON и Python, но будет иметь одинаковую семантику координат. Аналогично, одна и та же последовательность байт может нести разную семантическую нагрузку.
Когда мы работаем с языками программирования, прагматика и семантика обычно сливаются в одну схему данных за счёт высокоуровневых механизмов языка и того, что логика работы с данными написана на нём же. Для проформы их можно было бы разделить, но это будет действительно редкий случай для моей практики. Возможно, в драйверах и встроенных устройствах такое и встречается, не знаю.
Когда мы представляем данные на языке описания данных, нам приходится работать уже с двумя частями схемы:
- Семантика: сложные структуры данных мы представляем в виде плоских данных, специфичных для ЯП клиента.
- Прагматика: плоские данные клиента преобразуются в схему API и передаются на сервер.
- Прагматика: сервер из схемы API получает плоские данные в своём формате.
- Семантика: сервер по плоским данным восстанавливает высокоуровневые структуры.
Итого семь схем данных для простейшей серверной архитектуры:
- Схема клиента;
- Схема семантики API клиент-сервис;
- Схема прагматики API клиент-сервис;
- Схема сервиса;
- Схема семантики API сервис-хранилище;
- Схема прагматики API сервис-хранилище;
- Схема хранилища.
Между этими схемами надо делать шесть преобразований:
- Схема клиента <-> семантика API клиент-сервис;
- Семантика API клиент-сервис <-> прагматика API клиент-сервис;
- Семантика API клиент-сервис <-> схема сервиса;
- Схема сервиса <-> семантика API сервис-хранилище;
- Семантика API сервис-хранилище <-> прагматика API сервис-хранилище;
- Семантика API сервис-хранилище <-> схема хранилища;
Можно добавить:
- Схему хранения данных хранилищем.
- Схемы промежуточных DSL на сервере. Например FastAPI использует Pydantic для описания структур данных в API.
Нет необходимости реализовывать всё с нуля, но в любом случае эти преобразования кто-то должен держать в голове.
В итоге:
- Львиная доля усилий, времени и кода посвящена сугубо преобразованию данных между схемами.
- Изменение на одном конце цепочки, особенно в начале разработки, легко инициирует последовательность изменений вплоть до другого её конца.
А теперь добавим в нашу систему ещё несколько клиентов, несколько сервисов и несколько хранилищ… В один прекрасный момент мозги начинают закипать.
Боремся со сложностью
Справляться с проблемами можно разными способами:
- разделять ответственность;
- редуцировать или удалять лишние схемы / домены;
- унифицировать схемы / домены.
Изоляция сущностей за интерфейсами
Если у вас достаточно людей, можно разделить ответственность между ними, увеличив жёсткость интерфейсов.
Например, выделить по команде на сущность, обязать команды поддерживать стабильность интерфейсов, но разрешить менять что угодно за ними.
Распространённый и эффективный способ. Работает тем лучше, чем больше у вас людей. Не факт, что сработатет, если вы — единственный разработчик: всё равно придётся держать в голове полную картину.
Для одиночки подход может сыграть злую шутку: время на поддержание совместимости изменений будет тратиться, а голова очищаться от информации не будет.
Подход слабо поможет в начале разработки, когда потоком идут крупные изменения.
Объединение реализаций схем
Если обе сущности будут пользовать общей реализацией схемы, то пропадёт одно из преобразований: схема клиента/сервера <-> семантика API.
Естественно, для этого надо вынести часть кода в общую библиотеку, клиент и сервер делать на одной технологии.
А вот генерация логики API по схеме, а-ля OpenAPI или GraphQL, не поможет. Она решает проблему написания логики преобразований, но сами преобразования не убирает.
Объединение сущностей
Нет API — нет промежуточных преобразований. Можно:
- Сделать клиент «плагином» сервера.
- Объединить сервер с хранилищем и получить application server. Для примера посмотрите на Tarantool.
Тут тоже необходимо, чтобы логика сущностей была реализована на одной технологии. К тому же, остаётся преобразование между доменными моделями — устраняются только посредники.
Крайний случай подхода — монолитный клиентский софт.
Объединение технологий сущностей и API
Каноничные примеры:
- Семейство JavaScript и JSON.
- Python и YAML.
- Lisp :-D
Если сущность оперирует данными в том же формате, который используется на прагматическом уровне API, то преобразование семантики данных не требуется, равно как и преобразование из семантики в прагматику.
Однако в этом случае для бизнес логики могут остаться только низкоуровневые средства языка.
Для JavaScript это не страшно, а вот для Python будет существенной проблемой.
Унификация прагматики API
Если разные API используют один и тот же язык описания данных, например JSON, то для сущности можно поддерживать единственную логику преобразования семантики в прагматику.
К сожалению, если для клиентов это ещё можно сделать, то каждое хранилище имеет свои изюминки даже при работе с JSON.
Унификация семантики API
Слабо представляю когда это применимо, но для полноты картины рассмотрим и вариант, когда все сущности обмениваются данными в одном домене.
В контексте серверной архитектуры это может выражаться в том, что вся логика будет работать с данными из домена базы данных. То есть с низкоуровневым описанием данных: таблицами и базовыми типами в случае реляционных баз данных.
Возможно, подход применим для аналитики, когда в логике важны сами числа, а не объекты, которые те описывают.
Устранение или редукция сущностей в proxy
Иногда можно убрать доменную логику из промежуточных сущностей — превратить их в proxy или убрать полностью.
Например, свести роль сервиса до контроллера доступа и балансировщика, а бизнес-логику размазать между клиентом и базой. В таком случае сервис будет передавать данные между хранилищем и клиентом без преобразования.
Это направление сейчас относительно популярно и выражается в появлении:
- баз, изначально умеющих в «серверное API», аля GraphQL;
- плагинов для олдскульных баз, делающих то же.
Предполагаю, возможны промежуточные варианты. Например, сервис может преобразовывать прагматику (из JSON в YAML) но не трогать семантику.