Эссе о разработке игр, мышлении и книгах

Бесконечность схем данных

Программист думает о путях, которыми ходят байты. [Демон сидящий](https://ru.wikipedia.org/wiki/Демон_сидящий) (с) [Врубель](https://ru.wikipedia.org/wiki/Врубель,_Михаил_Александрович)

Программист думает о путях, которыми ходят байты. Демон сидящий (с) Врубель

Расскажу об одной боли при разработке и проектировании ПО — преобразованиях данных между их схемами. Буду говорить о серверах, как наиболее наглядном и знакомом мне примере, но соображения можно распространить на весь софт.

Для демонстрационных целей местами может случиться некоторое преувеличение.

Рассмотрим простейший проект, этакий минимальный набор:

  • один тип клиентов;
  • один сервис;
  • одно хранилище.

Данные, соответственно, ходят в обе стороны:

  • между клиентом и сервисом;
  • между сервисом и хранилищем.

Сколько схем данных вы тут видите?

Посчитаем

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

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

Сервис задаёт API, по которому с ним взаимодействуют клиенты. Хранилище задаёт API, по которому с ним взаимодействуют сервисы.

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

Мы нашли ещё две схемы данных:

  • API между клиентом и сервисом;
  • API между сервисом и хранилищем.

Теперь схем — пять.

Когда мы работаем с данными, у нас возникает два уровня представления информации:

  1. Прагматический — формат описания данных на конкретном языке: JSON, Protobuf, YAML, Python, С++, Lisp.
  2. Семантический — конкретное значение данных: координаты, счёт, документ.

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

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

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

  1. Семантика: сложные структуры данных мы представляем в виде плоских данных, специфичных для ЯП клиента.
  2. Прагматика: плоские данные клиента преобразуются в схему API и передаются на сервер.
  3. Прагматика: сервер из схемы API получает плоские данные в своём формате.
  4. Семантика: сервер по плоским данным восстанавливает высокоуровневые структуры.

Итого семь схем данных для простейшей серверной архитектуры:

  1. Схема клиента;
  2. Схема семантики API клиент-сервис;
  3. Схема прагматики API клиент-сервис;
  4. Схема сервиса;
  5. Схема семантики API сервис-хранилище;
  6. Схема прагматики API  сервис-хранилище;
  7. Схема хранилища.

Между этими схемами надо делать шесть преобразований:

  1. Схема клиента <-> семантика API клиент-сервис;
  2. Семантика API клиент-сервис <-> прагматика API клиент-сервис;
  3. Семантика API клиент-сервис <-> схема сервиса;
  4. Схема сервиса <-> семантика API сервис-хранилище;
  5. Семантика API сервис-хранилище <-> прагматика API сервис-хранилище;
  6. Семантика API сервис-хранилище <-> схема хранилища;

Можно добавить:

  1. Схему хранения данных хранилищем.
  2. Схемы промежуточных DSL на сервере. Например FastAPI использует Pydantic для описания структур данных в API.

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

В итоге:

  1. Львиная доля усилий, времени и кода посвящена сугубо преобразованию данных между схемами.
  2. Изменение на одном конце цепочки, особенно в начале разработки, легко инициирует последовательность изменений вплоть до другого её конца.

А теперь добавим в нашу систему ещё несколько клиентов, несколько сервисов и несколько хранилищ… В один прекрасный момент мозги начинают закипать.

Боремся со сложностью

Справляться с проблемами можно разными способами:

  • разделять ответственность;
  • редуцировать или удалять лишние схемы / домены;
  • унифицировать схемы / домены.

Изоляция сущностей за интерфейсами

Если у вас достаточно людей, можно разделить ответственность между ними, увеличив жёсткость интерфейсов.

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

Распространённый и эффективный способ. Работает тем лучше, чем больше у вас людей. Не факт, что сработатет, если вы — единственный разработчик: всё равно придётся держать в голове полную картину.

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

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

Объединение реализаций схем

Если обе сущности будут пользовать общей реализацией схемы, то пропадёт одно из преобразований: схема клиента/сервера <-> семантика 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) но не трогать семантику.