Перейти к содержанию

Принципы сервисной архитектуры: NestJS-гейтвей + gRPC-домены

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

Это дополнение к 03-architecture-draft.md, который описывает слои (ядро / API / фронт). Здесь описано, как эти слои разложены по процессам и по какому протоколу они разговаривают. Расхождения с черновиком — в разделе «Что меняется в других документах».

Откуда взято

Образец — внутренний монорепозиторий fairflow-crm/backend: NestJS 11 + Fastify + gRPC, 18 воркспейсов, единственная публичная точка входа gateway. Паттерн обкатан в проде, включая грабли, которых в учебниках нет (см. A-03, A-08). Берём структуру и инварианты, не берём объём: там мультиарендность, ABAC, реестр модулей, RBAC-каталог, Mongo + Postgres и шина на ~100 ключей маршрутизации — для 14 дней это мёртвый груз. Список «что не берём» — в конце.

Ссылки на образец даны как fairflow-crm/backend/<путь> — читать при реализации, не переписывать вслепую.


Принципы

Каждый принцип имеет ID A-xx, чтобы требования и код ссылались на него так же, как на R-xx из 00-brief/04-requirements-checklist.md (../00-brief/04-requirements-checklist.md).

A-01. Единственная публичная точка входа — gateway

Наружу (фронт, жюри, curl, QGroundControl) торчит один процесс: gateway на NestJS + Fastify, HTTP :3000. Он отдаёт REST /api/..., OpenAPI /docs, поток прогресса и файлы экспорта. Ни один доменный сервис не публикуется через ingress.

Следствие для k3s: в k3s/geoscan/ Ingress ведёт только на gateway. Всё остальное — ClusterIP.

A-02. Домены общаются только по gRPC; HTTP в домене — только ops-контракт

Доменный сервис отдаёт по HTTP ровно четыре маршрута и ничего больше:

Метод Путь Ответ
GET /healthz 200 {"status":"ok"}
GET /readyz 200 {"status":"ok"} / 503 {"status":"error","message":…}
GET /status 200 {service, version, commit, bootTime, uptimeSec, timestamp}
GET /metrics Prometheus text, text/plain; version=0.0.4

Бизнес-логика по HTTP в домене — запрещена. Не «нежелательна», а запрещена: как только у домена появляется REST-ручка, вокруг неё немедленно вырастает второй, неконтролируемый контур авторизации. Образец: fairflow-crm/backend/shared/src/ops-http-contract.ts — тело /status собирается одной функцией, чтобы форма была идентична во всех процессах.

A-03. .proto — источник правды контракта, опции загрузчика зафиксированы

Все контракты между процессами лежат в одном каталоге proto/geoscan/*.proto и версионируются вместе с кодом. Поля — snake_case. Клиенты и серверы для TS и Python генерируются из этих файлов, руками структуры не дублируются.

Опции @grpc/proto-loader не оставляются по умолчанию — фиксируются одной общей функцией:

{ keepCase: true, longs: Number, arrays: true }

Это не стилистика. В образце одна и та же ошибка прилетала четыре раза и каждый раз проявлялась как «работает, но пусто»:

  • без keepCase загрузчик camelCase-ит project_id → поле просто не доезжает, ошибки нет;
  • без longs: Number каждое int64 декодируется в объект Long {low, high, unsigned}, а TypeScript видит объявленный number и молчит: new Date(long) → Invalid Date, Number(long) → NaN;
  • без arrays: true пустой repeated приходит как undefined, и «нет галсов» неотличимо от «поле отсутствует».

Ни один тайпчекер этого не ловит. Ловит только pin-тест (A-13). Образец: fairflow-crm/backend/shared/src/grpc/loader-options.ts — там же разобран случай, когда longs: String легитимен.

A-04. Авторизация в два слоя, домены не видят пользовательский JWT

  • Клиент → gateway: JWT в Authorization: Bearer. Подпись проверяется на гейтвее.
  • Gateway → домен: сервисный ключ в gRPC-metadata (x-service-api-key, x-request-id, при необходимости x-user-id). Домен валидирует ключ и не парсит пользовательский токен.

Для хакатонного контура допустим упрощённый режим: один статический сервисный ключ из creds-store (project/geoscan), без сервиса auth и без пользовательских ролей. Но форма вызова остаётся той же — метаданные проставляются всегда, иначе потом это не вкрутить. Решение «нужна ли вообще аутентификация пользователей» — открытый вопрос (см. конец).

A-05. Сквозной контекст запроса передаётся явно

x-request-id рождается на гейтвее (или берётся из входящего заголовка), кладётся в metadata каждого gRPC-вызова, попадает в каждую строку лога и в каждый span. По нему и только по нему сшивается история одного расчёта: POST /api/plans → planner → validator → export.

Это то, что на защите превращает «у нас есть логи» в «покажите, что происходило с этим планом» за одну команду.

A-06. Единый контракт ошибок, маппинг gRPC → HTTP в одном месте

Домены бросают RpcException с кодом gRPC. Гейтвей переводит код в HTTP одной таблицей и отдаёт тело единой формы {errorCode, message, details?, errors?}. Таблица — ровно как в образце (shared/src/grpc/grpc-to-http.ts):

gRPC HTTP Наш случай
INVALID_ARGUMENT 400 сценарий не проходит схему
NOT_FOUND 404 нет плана с таким id
FAILED_PRECONDITION 422 нет борта, способного закрыть требуемый GSD; площадка вне разрешённого ВП
DEADLINE_EXCEEDED 504 решатель не уложился в time_limit_s
RESOURCE_EXHAUSTED 429 очередь расчётов заполнена
UNAVAILABLE 503 planner не поднят

Отдельно важное для нашей задачи: «задача нерешаема» — это не 500. Infeasible из ядра (03-architecture-draft (03-architecture-draft.md), altitude_for_gsd) доезжает до фронта как 422 с внятным details, а не как «что-то пошло не так».

A-07. Долгий расчёт — не синхронный вызов, а задача со статусом

Расчёт плана идёт секунды-минуты и должен показывать прогресс. Поэтому:

POST /api/plans            → 202 {planId, status: "queued"}
GET  /api/plans/{id}       → {status, metrics?, plan?}
GET  /api/plans/{id}/events→ SSE: прогресс решателя, промежуточная целевая функция
GET  /api/plans/{id}/export/{fmt}

Внутри: Planner.Planserver-streaming RPC, гейтвей ретранслирует поток в SSE. Синхронный ответ на POST /api/plans остаётся только для time_limit_s ≤ 5 (демо-сценарии, тесты) — это исключение, а не режим по умолчанию.

Почему это принцип, а не деталь: фронт (слайдер Парето, таймлайн) строится вокруг потока прогресса. Если решить это поздно, переписывать придётся и фронт, и контракт.

A-08. Асинхронные события — транзакционный outbox, одна топология шины

Применяется только если заводим шину (см. открытые вопросы). Если заводим — то по правилам образца, без самодеятельности:

  • запись в БД и строка outbox пишутся в одной транзакции; фоновой relay публикует и только после ack брокера помечает строку опубликованной;
  • доставка at-least-once, потребитель дедуплицирует по idempotencyKey ?? messageId;
  • топология (exchange, DLX, очереди, retry-очереди с x-message-ttl) объявляется одним общим модулем. В образце расхождение типов одного и того же exchange между двумя сервисами роняло бутстрап с 406 PRECONDITION_FAILED, а retry-очередь с dead-letter обратно в topic-exchange молча теряла сообщения.

Образцы: shared/src/outbox.ts, shared/src/bus-topology.ts.

Для нашего объёма честная оценка: RabbitMQ, скорее всего, избыточен. Прогресс расчёта решается gRPC-стримом (A-07), очередь задач — таблицей в Postgres. Шину заводим, только когда появится второй потребитель одного события.

A-09. Мутации идемпотентны по ключу

POST /api/plans с тем же Idempotency-Key не должен посчитать план дважды. Реестр ключей: {scope, key} → результат, первый запрос занимает ключ и сохраняет полный ответ, повтор отдаёт сохранённый байт-в-байт. Образец: shared/src/idempotency.ts.

Практический смысл на защите — двойной клик по «Рассчитать» не порождает второй прогон OR-Tools на 30 секунд.

A-10. У домена своя схема данных, кросс-домен — только через gRPC

Один Postgres (+PostGIS), но отдельная схема на сервис, никаких запросов в чужую схему и никаких JOIN через границу домена. Нужны данные соседа — вызов его RPC.

Правило из образца: имена каталогов миграций должны быть уникальны в пределах БД — два ..._init в разных схемах ломают применение второй.

A-11. Конфигурация — только из env, секреты — только из creds-store

Ни одного адреса, ключа или пароля в коде и в git. Всё через ConfigService, значения — из env, секреты кладутся в project/geoscan в creds-store (правило из корневого CLAUDE.md). У каждого сервиса — .env.example с полным перечнем переменных и дефолтами для локального запуска.

A-12. Наблюдаемость заводится вместе с сервисом, а не после

/metrics есть с первого коммита сервиса, scrape-target и дашборд — в тот же день (правило корневого CLAUDE.md). Минимальный набор метрик для нас:

  • geoscan_plan_duration_seconds (histogram, label objective) — сколько считает решатель;
  • geoscan_plan_total{status} — успех / infeasible / таймаут;
  • geoscan_validator_violations_total{kind}нарушения по видам, это прямой вход в демо;
  • стандартные gRPC-метрики по методам.

A-13. Инварианты держатся тестами-барьерами, а не договорённостями

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

  • pin-тест обходит все регистрации gRPC-клиентов и серверов в репозитории (читая исходники, не по списку) и падает, если где-то потеряны keepCase / longs (A-03);
  • тест проверяет, что ни один доменный сервис не объявляет HTTP-контроллеров кроме health/metrics (A-02);
  • тест проверяет, что validate/ не импортирует coverage/ и routing/ (03-architecture-draft (03-architecture-draft.md)).

Это самая дешёвая часть архитектуры и единственная, которая переживает спешку последней недели.

A-14. Новый сервис — из шаблона, по чеклисту

  1. Скопировать каркас существующего домена (main.ts, application.ts, health, metrics, config, grpc-модуль).
  2. Подключить проверку сервисного ключа.
  3. HTTP — только ops-контракт (A-02).
  4. Зарегистрировать клиента в gateway и завести BFF-контроллер.
  5. Прописать в docker-compose.yml, в k3s/geoscan/, в Prometheus.

A-15. Соразмерность: минимум процессов

14 дней и один ревьюер. Дробление на десяток сервисов убьёт проект вернее, чем монолит. Целевой контур — четыре процесса, и каждый новый требует обоснования, почему он не может быть модулем.


Разрез на сервисы

Сервис Язык HTTP gRPC Что внутри Хранилище
gateway TS / NestJS + Fastify 3000 (публичный) REST /api, OpenAPI, SSE-прогресс, отдача файлов экспорта, справочники fleet/payloads, реестр сценариев и планов Postgres, схема gateway
planner Python 3001 (ops) 5001 ядро: geometry/, coverage/, routing/ (OR-Tools), export/. Server-streaming прогресса без состояния
validator Python 3002 (ops) 5002 независимый счётчик метрик и нарушений без состояния
airspace TS 3003 (ops) 5003 зоны, ограничения ВП, справочники ФП-138 Postgres + PostGIS, схема airspace

Порты — по образцу: HTTP 300x, gRPC 500x, номер совпадает.

planner и validator — два разных процесса намеренно. Правило «валидатор не импортирует ядро» (03-architecture-draft (03-architecture-draft.md), 70-plan/01) в одном процессе держится на дисциплине, а разными процессами — на топологии. Заодно валидатор можно показать жюри как публичную ручку POST /api/validate: «проверьте наш план сами».

airspace заводится последним. Пока зоны лежат в статике — это модуль внутри gateway. Отдельный процесс появляется, когда появятся внешние источники данных о воздушном пространстве.

Минимальный контур, который должен подняться первым: gateway + planner. Валидатор в этот момент уже существует как CLI (он пишется раньше продукта) и оборачивается в gRPC-сервис вторым шагом.


Полиглотность: чем gRPC оправдан именно здесь

Обычный аргумент против gRPC внутри — «у нас всё на одном языке, хватило бы HTTP». У нас не один язык, и это не прихоть: OR-Tools, Shapely, pyproj — это Python, а гейтвей, фронт и весь операционный обвес — TypeScript. gRPC — то, что сшивает их по одному описанию контракта, без ручной синхронизации DTO на двух языках.

Правила для полиглотного контура:

  • один .proto на обе стороны, генерация в CI: @grpc/proto-loader / ts-proto для TS, grpcio-tools для Python. Сгенерированный код в git не коммитится (кроме дескрипторов для reflection);
  • snake_case в proto — по умолчанию для Python, и именно ради него на стороне TS обязателен keepCase (A-03);
  • ops-контракт (A-02) одинаковый на обоих языках — Prometheus и k8s-пробы не должны знать, чем написан сервис;
  • ядро на Python не знает ни про HTTP, ни про gRPC. gRPC-сервер — тонкий адаптер поверх тех же функций, что вызывает CLI. Это усиление принципа из черновика архитектуры, а не замена ему.

Что меняется в других документах

  1. 03-architecture-draft.md — FastAPI отпадает. Публичный HTTP теперь на NestJS-гейтвее; Python-часть выставляет gRPC. Слои («ядро не знает про web») и список модулей ядра остаются без изменений — меняется только то, чем ядро обёрнуто снаружи. Разделы «API» и «Фронтенд» черновика надо перечитать под A-07: ручки становятся асинхронными (202 + статус + SSE), а не «синхронно при time_limit_s ≤ 30».
  2. 40-formats/03-our-mission-schema.md — схема сценария и плана получает второе представление, .proto. Источник правды один; JSON-схема для REST и proto для внутреннего транспорта должны генерироваться из него или сверяться тестом.
  3. 00-brief/04-requirements-checklist.md (../00-brief/04-requirements-checklist.md) — требования к API переформулируются под асинхронную модель; добавляются строки на ops-контракт и метрики.
  4. src/backend/ — пустой. Раскладка заводится по этому документу: proto/, shared/, gateway/, planner/, validator/.

Что из образца сознательно не берём

Что Почему не берём
Мультиарендность (x-project-id как единица изоляции) у нас нет арендаторов; изоляция по сценарию — это ключ в БД, а не сквозной контур
ABAC / компилируемые предикаты доступа, RBAC-каталог разрешений недели работы ради модели прав, которой в ТЗ нет
Реестр модулей, манифесты, per-project включение модулей набор функций фиксирован
Mongo рядом с Postgres один Postgres + PostGIS. Два хранилища — двойной обвес на ровном месте
GraphQL в образце его как раз выпиливают; повторять нечего
Полноценный сервис auth с выдачей ключей и seed'ом статический сервисный ключ из creds-store (A-04)
RabbitMQ с ~100 ключами маршрутизации пока нет второго потребителя события — см. A-08

Что берём почти дословно: ops-http-contract, loader-options + его pin-тест, grpc-to-http, метаданные и x-request-id, контракт ошибок, idempotency.


Открытые вопросы (для следующего захода по требованиям)

  1. Нужна ли аутентификация пользователей вообще? ТЗ её не требует явно. Варианты: нет совсем / один демо-пользователь / полноценный JWT. От ответа зависит, появляется ли пятый процесс.
  2. Очередь расчётов: таблица в Postgres + воркер или сразу брокер? Связано с A-08.
  3. Хранение планов: Postgres с самого начала или файлы, как предложено в 01-libraries.md? Рекомендация — Postgres сразу, потому что реестр планов и идемпотентность (A-09) всё равно требуют таблицы.
  4. Где живёт export/ — внутри planner (сейчас так) или отдельным сервисом? Отдельный оправдан, только если экспорт понадобится для планов, посчитанных не нами.
  5. Нужен ли PostGIS на старте или geometry целиком в Shapely/pyproj в памяти.