Skip to content

Doc Architecture

TravelAgency Team Bot edited this page Oct 17, 2026 · 5 revisions

Архитектура

Сайт состоит из фронтенда (SPA на React), API Gateway и пяти сервисов на .NET 10. У каждого сервиса своя база данных. Снаружи виден только веб-сервер nginx, всё остальное во внутренней сети Docker.

Простой пример: путь одного запроса

Пользователь открывает страницу тура.

  1. Браузер загружает SPA с nginx и делает запрос GET /api/v1/catalog/tours/{id}.
  2. nginx передаёт всё, что начинается с /api/, в Gateway.
  3. Gateway находит маршрут catalog-public-route (GET без входа), убирает префикс /api/v1 и проксирует запрос в Catalog как GET /catalog/tours/{id}.
  4. Catalog читает тур из своей базы и возвращает JSON. Вместо адресов картинок в ответе только id файлов.
  5. Браузер строит адреса картинок вида /api/v1/media/files/{id}/w800, и эти запросы тем же путём идут в Media.

Схема

flowchart LR
    B[Браузер] -->|HTTPS| NG[nginx + SPA]
    NG -->|/api/*| GW[Gateway]
    GW --> ID[Identity]
    GW --> CAT[Catalog]
    GW --> BKG[Booking]
    GW --> CH[Chat]
    GW --> MED[Media]

    BKG -- gRPC --> CAT
    BKG -- gRPC --> ID
    CH -- gRPC --> BKG
    CAT -- gRPC --> MED

    ID --> PG[(PostgreSQL)]
    CAT --> PG
    BKG --> PG
    CH --> PG
    MED --> PG
    GW -.-> R[(Redis)]
    ID -.-> R
    CH -.-> R
    MED --> S3[(MinIO)]
Loading

Сплошные стрелки к хранилищам: обязательная зависимость. Пунктир: Redis используется, если задана строка подключения, иначе сервис работает без него.

Принципы

  1. Сервис на предметную область. Пользователи, каталог, заявки, чат и файлы живут в разных сервисах.
  2. База на сервис. Один экземпляр PostgreSQL, пять баз: travel_identity, travel_catalog, travel_booking, travel_chat, travel_media (создаются скриптом docker/init-db.sql). Сервис не читает чужую базу, а ссылается на чужие сущности только по id.
  3. Gateway без бизнес-логики. В нём маршрутизация, проверка JWT и ролей, лимиты запросов, CORS и перевод cookie в заголовок авторизации.
  4. Внешний API через один префикс /api/v1. Внутренние адреса сервисов наружу не публикуются.
  5. Синхронные вызовы между сервисами по gRPC. Брокера сообщений нет.

Сервисы

Сервис Отвечает за REST (внутри) gRPC-сервер
Gateway Единая точка входа /api/v1/*, /health/* нет
Identity Регистрация, вход, токены, профиль, роли /identity/* GetUserSummary
Catalog Туры, направления, конструктор, публикация /catalog/* GetTourSnapshot, GetTourSnapshotForExistingBooking
Booking Заявки, предложения, статусы, избранное /bookings/*, /favorites/* ValidateBookingAccess
Chat Сообщения по заявке, realtime через SignalR /chat/*, хаб /hubs/chat нет
Media Загрузка файлов, превью, отдача картинок /media/* GetMediaFiles, MarkMediaFilesPublic
Фронтенд SPA, раздаётся nginx нет нет

Как сервисы общаются

Кто вызывает Кого Зачем
Gateway все сервисы, HTTP проксирование внешних запросов
Booking Catalog, gRPC снимок тура при создании заявки, избранного и предложения
Booking Identity, gRPC имя, email и роль пользователя (карточка заявки для менеджера, проверка доступа к чату)
Chat Booking, gRPC есть ли у пользователя доступ к заявке
Catalog Media, gRPC проверка владельца и ширины фото, пометка фото публичными при публикации тура

Внутри каждого контейнера два порта: 8080 для REST и 8081 для gRPC (HTTP/2). Вызовы gRPC сопровождаются заголовком x-internal-auth с общим сервисным токеном; без него сервер отвечает отказом. Подробно: Межсервисные контракты.

Хранилища

Хранилище Кто использует Для чего
PostgreSQL все сервисы, кроме Gateway основные данные; миграции EF Core применяются при старте (ASPNETCORE_RUN_MIGRATIONS=true)
Redis Gateway общий счётчик лимитов запросов (если Redis недоступен, запросы пропускаются)
Redis Identity счётчик неудачных входов и блокировка (lockout)
Redis Chat только проверка готовности (/health/ready)
MinIO (S3) Media файлы и превью; bucket закрыт, наружу файлы отдаёт только Media

Устройство сервиса

Каждый сервис разбит на слои:

src/Services/<Имя>/
├── TravelAgency.<Имя>.Domain/          сущности и правила
├── TravelAgency.<Имя>.Application/     команды и запросы (MediatR), DTO, валидация (FluentValidation)
├── TravelAgency.<Имя>.Infrastructure/  EF Core, репозитории, gRPC-клиенты и серверы, хранилище
├── TravelAgency.<Имя>.API/             контроллеры, middleware, Program.cs
└── тесты (UnitTests, IntegrationTests)

Общий код лежит в src/Shared/:

Проект Что внутри
TravelAgency.Shared.Contracts роли (Client, Manager, Admin), имена политик доступа, общие абстракции
TravelAgency.Shared.Infrastructure проверка JWT, correlation id, глобальная обработка ошибок в ProblemDetails, логирование, gRPC-перехватчик сервисного токена
TravelAgency.Contracts.Grpc файлы .proto и сгенерированный код

Сквозные механизмы

  • Ошибки возвращаются в формате ProblemDetails (RFC 7807): type, title, status, detail. Как сбой запроса пишется в лог: Логирование ошибок.
  • Correlation id: заголовок X-Correlation-Id читается или создаётся, попадает в логи и в ответ.
  • Логи: Serilog, структурированные, в консоль контейнера. Уровни для ошибок запросов: Логирование ошибок.
  • Трассировка: OpenTelemetry во всех сервисах. Экспорт по OTLP настроен только у Gateway (Otel:Endpoint, по умолчанию http://otel-collector:4317), но сервиса otel-collector в compose нет; остальные сервисы экспортёра не имеют. Куда трейсы попадают на dev, требует проверки, задача #166.
  • Здоровье: у каждого сервиса /health/live (жив ли процесс) и /health/ready (доступны ли зависимости).

Логирование ошибок

Простой пример. Клиент вводит неверный пароль: Identity отвечает 401, в лог уходит одна строка Warning без стектрейса: POST /identity/login 401 UnauthorizedException: Invalid credentials. traceId=.... Непредвиденное исключение даёт ответ 500 и одну строку Error вместе с исключением и стектрейсом.

Правило общее для всех сервисов. GlobalExceptionHandlerMiddleware (TravelAgency.Shared.Infrastructure) подключён в Identity, Catalog, Booking, Chat, Media и Gateway. Он логирует сбой запроса один раз, уже после того как исключение переведено в код ответа:

Код ответа Уровень Что в записи
4xx: 401, 403, 404, 400 при ошибке валидации и остальные коды от 400 до 499 Warning метод, путь, код, тип и текст исключения, traceId; объект исключения не передаётся, стектрейса нет
5xx и исключения без перевода в код Error то же и объект исключения со стектрейсом
  • Свои исключения сервис переводит в код через IExceptionMapper. Без маппера обработчик сам отдаёт 400 на ValidationException (FluentValidation) и ArgumentException, 401 на UnauthorizedAccessException, на всё остальное 500.
  • У Gateway своего маппера нет: необработанное исключение, кроме трёх перечисленных типов, даёт 500 и Error.
  • Если ответ уже начал отправляться, код не меняется, исключение пишется как Error.
  • LoggingBehavior и ValidationBehavior (MediatR) исключение не логируют, только пробрасывают, поэтому второй записи о том же сбое нет.

Строка access-лога Serilog (UseTravelAgencyRequestLogging, уровень выбирает RequestLoggingExtensions.GetLevel):

  • 5xx: Error.
  • 4xx: Information, даже если к запросу прикреплено исключение.
  • Прикреплённое исключение при коде ниже 400: Error.
  • /health и /healthz с кодом ниже 500: Debug, остальное Information.

Во всех сервисах UseTravelAgencyRequestLogging подключён снаружи GlobalExceptionHandlerMiddleware. Обработчик перехватывает исключение и дальше его не пробрасывает, поэтому строка access-лога получает уже готовый код ответа без исключения: для 401 это Information, для 500 Error. Подробности исключения есть только в записи обработчика.

См. также: Где и как работает · Публичный API · Стек и версии

Clone this wiki locally