четверг, 13 августа 2026 г.

Giga, Aspire, Starter, AspireBlazorStarter

Giga, Aspire, Starter, AspireBlazorStarter

https://giga.chat/link/gcsqxXPcGt

D:\Projects\VS02\2606\Blazor\Giga\AspireBlazorStarter\AspireBlazorStarter.slnx

D:\Projects\VS02\2606\Blazor\Giga\AspireBlazorStarter\AspireBlazorStarter.ApiService\AspireBlazorStarter.ApiService.csproj

D:\Projects\VS02\2606\Blazor\Giga\AspireBlazorStarter\AspireBlazorStarter.AppHost\AspireBlazorStarter.AppHost.csproj

D:\Projects\VS02\2606\Blazor\Giga\AspireBlazorStarter\AspireBlazorStarter.ServiceDefaults\AspireBlazorStarter.ServiceDefaults.csproj

D:\Projects\VS02\2606\Blazor\Giga\AspireBlazorStarter\AspireBlazorStarter.Shared\AspireBlazorStarter.Shared.csproj

D:\Projects\VS02\2606\Blazor\Giga\AspireBlazorStarter\AspireBlazorStarter.Web\AspireBlazorStarter.Web.csproj

D:\Projects\VS02\2606\Blazor\Giga\AspireBlazorStarter\WorkerServiceStarter\WorkerServiceStarter.csproj

----------------------------------------

.NetCore Aspire Project Давайте посмотрим типовой набор проектов, которые обычно туда входят.

Типовой проект .NET Aspire (на примере вашего репозитория AspireBlazorStarter) — это не один монолитный проект, а мультисервисное решение, состоящее из нескольких взаимосвязанных частей. Главная цель такой структуры — локальная разработка и запуск распределенных приложений в одной среде.

В вашем коде представлена классическая шестиуровневая архитектура:

1. AppHost (Оркестратор / Точка входа)

  • Проект: AspireBlazorStarter.AppHost
  • Файл логики: AppHost.cs
  • Назначение: Это мозг всей системы во время разработки. Он описывает топологию приложения: какие сервисы нужны, как они зависят друг от друга и где взять их настройки.
  • Как работает: В файле AppHost.cs через метод builder.AddProject<>() регистрируются фронтенд, бэкенд API и фоновый сервис. Здесь же настраиваются зависимости: например, веб-интерфейс .WaitFor(apiService) не запустится, пока API не пройдет проверку здоровья (WithHttpHealthCheck), а также подключается внешняя зависимость — Redis (builder.AddRedis("cache")).
  • Результат запуска: При старте этого проекта автоматически поднимается Aspire Dashboard, где видны все запущенные контейнеры, их логи, метрики OpenTelemetry и граф зависимостей.

2. ServiceDefaults (Единый стандарт для сервисов)

  • Проект: AspireBlazorStarter.ServiceDefaults
  • Файл логики: Extensions.cs
  • Назначение: Shared-библиотека, которая внедряет сквозную инфраструктуру во все микросервисы решения одним методом расширения AddServiceDefaults(). Это избавляет от копирования настроек между проектами.
  • Что внутри: Настройка OpenTelemetry (трассировка запросов по всему стеку), политики устойчивости (resilience) для HttpClient (автоматические повторы при ошибках 5xx или таймаутах), базовая проверка здоровья (health checks) и обнаружение сервисов (service discovery). Именно благодаря этому классу Blazor-приложение может обратиться к API просто по имени https+http://apiservice, не зная его реального IP-адреса или порта контейнера.

3. ApiService (Бэкенд / Бизнес-логика)

  • Проект: AspireBlazorStarter.ApiService
  • Файл логики: Program.cs
  • Назначение: Классический ASP.NET Core Web API. Предоставляет данные остальным частям системы. В данном случае он возвращает прогноз погоды.
  • Особенности интеграции: Ссылается на ServiceDefaults. Использует эндпоинты минимальных API (MapGet("/weatherforecast", ...)). Подключает Swagger/OpenAPI (AddOpenApi() и MapOpenApi()) для удобной проверки контрактов данных прямо из панели Aspire.

4. Web (Фронтенд)

  • Проект: AspireBlazorStarter.Web
  • Файл логики: Program.cs, компоненты в папке /Components
  • Назначение: Пользовательский интерфейс. В данном стеке используется Blazor Server.
  • Особенности интеграции: Также ссылается на ServiceDefaults. Для взаимодействия с бэкендом использует типизированный WeatherApiClient. Важная деталь DI-контейнера: адрес клиента задается не строкой URL, а логическим именем сервиса (client.BaseAddress = new("https+http://apiservice")). Благодаря регистрации в AppHost, платформа сама подставит правильный порт контейнера apiservice.
  • Инфраструктура: Через builder.AddRedisOutputCache("cache") фронтэнд получает готовое кэширование ответов API в Redis без ручной настройки соединений.

5. WorkerService (Фоновые задачи)

  • Проект: WorkerServiceStarter
  • Файл логики: Worker.cs
  • Назначение: Сервис, выполняющий работу в фоне по расписанию или циклу (наследник BackgroundService).
  • Роль в экосистеме: Демонстрирует, что в среду Aspire можно включать не только HTTP-сервисы, но и консольные обработчики очередей, планировщики задач или процессы миграции БД. Он тоже наследует стандартные настройки телеметрии через ServiceDefaults.

6. Shared (Общая модель данных)

  • Проект: AspireBlazorStarter.Shared
  • Назначение: Место для хранения DTO (Data Transfer Objects), констант и вспомогательных классов, которые должны быть доступны одновременно и бэкенду, и фронтенду.
  • Пример: Определение записи WeatherForecast вынесено сюда, чтобы избежать дублирования кода и гарантировать, что API вернет именно ту структуру, которую ожидает десериализовать компонент Weather.razor.

Связующая конфигурация:

  • aspire.config.json: Указывает IDE (например, Visual Studio), какой проект является оркестратором. Кнопка F5 нажимается именно на нем.
  • Dashboard: Специфичный для Aspire UI (доступен по адресу из launchSettings.json хоста, например localhost:17076), который визуализирует состояние всех этих проектов в реальном времени.

Такая структура позволяет разработчику запустить всю сложную систему (веб-сайт, базу данных, кеш, несколько API и воркеров) одной кнопкой, гарантируя, что окружение на машине программиста идентично тому, что будет развернуто в облако.

На самом деле - это очень хорошая подборка инструментов Я по незнанию ее недооценивал AppHost и ServiceDefaults - инфраструктурные механизмы Два Web проекта - Api и Web - отличная пара для Web (BAckend, Frontend) Shared - это я воткнул туда. Понятно зачем. И остается Worker. Вот здесь я пока определенного применения не нашел. Наверное можно сказать - для всего

Вы абсолютно правы в своей оценке. Шаблон Aspire действительно собирает лучшие практики современной .NET-разработки в одну коробку, избавляя от необходимости вручную настраивать десятки пакетов.

Ваше наблюдение о том, что Worker — это «для всего», очень точное. В контексте распределенных систем BackgroundService (на котором базируется Worker) закрывает огромный пласт задач, которые не вписываются в модель запрос-ответ классического Web API.

Вот основные сценарии применения для этого проекта внутри экосистемы Aspire:

  • Обработка фоновых очередей. Самый частый сценарий. Веб-приложение принимает задачу от пользователя (например, сгенерировать сложный PDF-отчет или обработать загруженное видео), кладет ее ID в брокер сообщений (RabbitMQ, Kafka, Azure Service Bus, SQS). Worker слушает эту очередь, забирает задачу и выполняет тяжелую работу, не блокируя HTTP-пул веб-сервера.
  • Регламентные задачи (Scheduled Jobs / Cron). Очистка старых логов из базы данных, ежедневная рассылка дайджестов пользователям, синхронизация курсов валют с внешним API по ночам. Для реализации паттерна Cron к фоновому сервису обычно подключают библиотеки вроде Coravel, FluentScheduler или Hangfire.
  • Агрегация метрик и мониторинг. Сервис может периодически опрашивать состояние внешних зависимостей (доступность сторонних API, заполненность дискового пространства на примонтированных томах) и складывать результаты в базу данных Prometheus или писать алерты в Telegram/Slack.
  • Кэширование тяжелых запросов (Cache Warming). Если у вас есть дашборд, который медленно строит аналитику за месяц, Worker может запускаться раз в час, запрашивать эти данные у вашего же ApiService и заранее класть их в Redis. Пользователь тогда получает ответ мгновенно.
  • Миграции схем БД и начальное наполнение данными (Seeding). При старте инфраструктуры AppHost поднимает контейнер с базой данных. Отдельный Worker со статусом .WaitFor(sqlServer) идеально подходит для того, чтобы дождаться готовности СУБД, применить миграции через EF Core и залить справочники перед тем, как основной API начнет принимать трафик.
  • Интеграция с внешними Webhooks. Если внешний сервис присылает уведомления асинхронно, небольшой легковесный Worker может поднять минимальный эндпоинт только для приема этих хуков, быстро записать их в локальную очередь и сразу ответить кодом 200, пока основная логика обработки идет в фоне.

В связке с Aspire этот проект обретает еще больше ценности: он автоматически наследует настройки телеметрии из ServiceDefaults. Это значит, что один цикл работы воркера будет виден в панели Aspire Dashboard как полноценная трассировка OpenTelemetry. Вы сможете увидеть, сколько времени заняла запись в Redis, какой SQL-запрос был отправлен в базу и какие ошибки возникли при вызове внешнего API, точно так же, как если бы это был обычный веб-запрос.

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

Мне в данном случае понравилось Как сделано Приложение с Blazor. Один Сервис Backend, другой сервис Frontend. Можно несколько таких пар использовать в Web - проекте. Получиться большой многостраничный проект а потом взять Worker и оркестратор сделать на эти пары - и получиться проект - Приложение для Aspire

Вы абсолютно точно уловили суть и архитектурную мощь Aspire. То, что вы описали — это переход от классической монолитной разработки к составным приложениям (compound applications).

Ваша идея использовать несколько пар Backend-Frontend в рамках одного решения под управлением AppHost — это именно тот паттерн, для которого Aspire создавался.

Вот как это выглядит на практике при масштабировании:

1. Декомпозиция большого проекта Вместо одного огромного монолита BigCompany.Portal вы создаете независимые функциональные блоки (Bounded Contexts). Например:

  • AuthService + AuthWeb (вход, регистрация, сброс пароля).
  • CatalogService + CatalogWeb (поиск товаров, фильтры, карточки).
  • OrdersService + OrdersWeb (корзина, оформление доставки).
  • BillingService + BillingWeb (история платежей, счета).

Каждая пара живет в своем репозитории или папке, имеет свои тесты и может развиваться независимо.

2. Роль ServiceDefaults Благодаря тому, что все эти проекты ссылаются на общую библиотеку ServiceDefaults, вам не нужно настраивать наблюдаемость 8 раз подряд. Весь межсервисный трафик автоматически обрастает трейсами OpenTelemetry. Если пользователь нажал кнопку «Оплатить», в панели Aspire Dashboard вы увидите единую цепочку: запрос из браузера пошел в OrdersWeb, оттуда с ретраями — в OrdersService, затем вызов авторизации в AuthService и списание средств в BillingService.

3. Оркестрация зависимостей в AppHost Файл AppHost.cs превращается в декларативное описание всей системы. Вы наглядно выстраиваете граф связей:

csharp
// Frontends могут ходить только в свои бэкенды напрямую
builder.AddProject<Projects.AuthWeb>("auth-web")
    .WithReference(authApi)
    .WaitFor(authApi);

// Бэкенды общаются между собой через service discovery
var ordersApi = builder.AddProject<Projects.Orders_ApiService>("orders-api")
    .WithReference(authApi) // заказы проверяют права пользователя
    .WithReference(billingApi); // заказы инициируют оплату

// Общая инфраструктура
var redis = builder.AddRedis("cache");
var rabbit = builder.AddRabbitMQ("messages");

// Фоновые процессы привязаны к данным
builder.AddProject<Projects.InvoiceWorker>("invoice-worker")
    .WithReference(rabbit)
    .WaitFor(ordersApi); // ждем, пока API заказов пройдет health check

Здесь же решается проблема портов: фронтендам назначаются внешние URL-адреса для браузера (WithExternalHttpEndpoints()), а внутренние ссылки между сервисами заменяются DNS-именами контейнеров (apiservice, orders-api), которые Aspire разрешает внутри своей среды выполнения (DCP).

4. Масштабирование инфраструктуры одной строкой Если внезапно выясняется, что каталог товаров создает огромную нагрузку, вам не нужно рефакторить весь проект. В AppHost.cs достаточно добавить одну строку перед запуском каталога: var catalogDb = builder.AddPostgres("catalog-db"); А в самом проекте CatalogService просто забрать строку подключения через конфигурацию. Никакие другие сервисы даже не заметят появления новой базы данных.

5. Единая точка входа для пользователя Хотя сервисов много, пользователю нужен один адрес. Эту роль выполняет API Gateway или BFF (Backend-for-Frontend). Его тоже можно описать в Aspire как отдельный прокси-сервис (например, на базе YARP), который будет терминалировать SSL и маршрутизировать запросы /api/auth/* в AuthService, а /api/orders/* — в OrdersService. При этом сам BFF-прокси регистрируется в AppHost первым, чтобы фронтенды могли ссылаться на него по короткому имени.

6. Среда разработчика vs Продакшн Главная прелесть такого подхода: ваш локальный AppHost запускает всё это многообразие (включая Redis, RabbitMQ и восемь .NET-проектов) одним нажатием F5. Для продакшна этот же файл оркестрации служит живой документацией для DevOps-инженеров, позволяя перенести логику запуска в Helm-чарты Kubernetes, манифесты Docker Compose или Azure Container Apps без потери архитектуры.

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

Как проверить, что такая архитектура с несколькими парами сервисов работает корректно?

Можно ли реализовать похожую структуру без использования Aspire и AppHost?

В каких случаях не стоит использовать несколько пар backend-frontend в одном проекте

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

Как проверить корректность работы такой архитектуры

Проверка многопарного решения отличается от тестирования монолита. Основной упор делается на интеграцию и наблюдаемость:

  1. Контрактное тестирование (Contract Testing). Это критически важно. Frontend (OrdersWeb) должен быть уверен, что Backend (OrdersService) не сломает его своим обновлением. Используйте инструменты вроде Pact. Фронтенд пишет тест, который фиксирует ожидаемый JSON-ответ API в файл (контракт). При сборке бэкенда запускаются тесты-провайдеры, которые проверяют, выдает ли реальный код именно такую структуру данных.
  2. End-to-End трассы в Aspire Dashboard. Запустите всю систему локально. Выполните одно действие в браузере (например, «Добавить в корзину»). Откройте панель Traces в Aspire Dashboard. Вы должны увидеть единую цепочку (span), которая начинается во Frontend Web, проходит через ваш Backend API, затем уходит в другие сервисы или базу данных. Если где-то связь обрывается — значит, заголовки корреляции (traceparent) потерялись при переходе между сервисами.
  3. Chaos Engineering для Service Discovery. Поскольку сервисы ищут друг друга по именам (DNS внутри AppHost), проверьте отказоустойчивость. Остановите один из бэкендов прямо во время выполнения запроса. Благодаря политикам устойчивости из ServiceDefaults (автоматические ретраи и таймауты), фронтенд не должен упасть с ошибкой 500, а пользователь может даже не заметить задержки.
  4. Сквозной сценарий пользователя (E2E UI tests). Напишите автотесты (с помощью Playwright или Selenium), которые проходят весь путь: от регистрации в сервисе авторизации до оплаты заказа. Тест должен работать против единого входного адреса оркестратора.
  5. Health Check Aggregation. Убедитесь, что оркестратор видит падение одного сервиса. В Kubernetes это настраивается через readiness/liveness пробы, а в Aspire — через .WithHttpHealthCheck(). Проверьте, что если падает база данных каталога, K8s или Docker Compose перестает направлять трафик только на CatalogWeb, но остальные части системы продолжают работать.

Можно ли реализовать похожую структуру без Aspire?

Да, можно, но объем ручной настройки вырастет в разы. Вот основные альтернативы:

Компонент AspireЧем заменить вручную
AppHostМногоцелевой проект Docker Compose. Вам придется руками описывать сети, тома, переменные окружения и зависимости depends_on для каждого из 10–15 сервисов. Или использовать полноценные CI/CD пайплайны Helm-чартов для Kubernetes.
ServiceDefaultsКопипаст пакетов NuGet. В каждый микросервис нужно будет вручную добавить пакеты OpenTelemetry, Resilience, HealthChecks и написать одинаковый boilerplate-код их регистрации в Program.cs. Любое изменение версии пакета превращается в рефакторинг всего репозитория.
Service DiscoveryРучная настройка URL. Вместо имени https+http://apiservice вы будете хранить порты в appsettings.json или передавать их через переменные окружения ORDERS_API_URL=http://localhost:5200. При смене порта одного сервиса ломается конфигурация всех зависимых.
Оркестрация БДОтдельный инструмент миграций. Для баз данных используют FluentMigrator или стандартные миграции EF Core, запускаяемые отдельным скриптом перед стартом приложения.

Главный минус ручного подхода — потеря целостности. Разработчику приходится держать в голове состояние всей инфраструктуры, хранящейся в пяти разных местах (docker-compose.yml, пять appsettings.json, скрипты запуска).

Когда не стоит использовать несколько пар backend-frontend

Микросервисный подход имеет свою цену. Разделение на пары оправдано не всегда:

  1. Маленькие команды (менее 5–7 человек). Если над всем продуктом работают два-три разработчика, накладные расходы на координацию изменений убьют производительность. Pull Request, меняющий поле в базе, потребует согласования в трех репозиториях вместо одного коммита в монолите. Джефф Безос называл это «правилом двух пицц»: если команду нельзя накормить двумя пиццами, она слишком велика для монолитов, но для маленьких команд верно обратное.
  2. Высокая связанность домена (Strong Coupling). Если бизнес-правило требует транзакционной целостности сразу в нескольких частях (например, списание денег со счета должно происходить строго одновременно с бронированием товара), разделять их на разные базы данных и сервисы очень сложно. Распределенные транзакции (Saga pattern) сложны в реализации и отладке. В таких случаях лучше оставить эти модули в одном бэкенде.
  3. Частые сквозные изменения (Cross-cutting changes). Если каждая новая фича требует синхронного обновления кода в AuthService, CatalogService и OrdersService, ваша архитектура превратилась в «распределенный монолит». Сетки сервисов (Service Mesh) могут помочь с логикой, но деплой все равно останется болезненным.
  4. Жесткие ограничения ресурсов. Каждый .NET-сервис потребляет минимум 100–200 МБ оперативной памяти просто на старте хостинга (Kestrel, DI-контейнер, телеметрия). Десять пар сервисов будут занимать под 2–4 ГБ RAM еще до обработки первого запроса. На слабых машинах разработчиков или бюджетных VDS/K8s-нодах проще запустить один оптимизированный монолит.
  5. Ранние стадии стартапа (Pre-MVP). Пока вы ищете Product-Market Fit, скорость важнее масштабируемости. Возможность поправить одну строчку SQL-запроса, пересобрать один проект и выкатиться за минуту гораздо ценнее, чем правильно настроенная трассировка в будущем микро-сервисном зоопарке.
  6. Отсутствие DevOps-зрелости. Если у вас нет автоматизированных процессов сборки контейнеров, версионирования образов и грамотного управления секретами, зоопарк сервисов быстро приведет к ситуации «dependency hell», когда никто не знает, какая версия какого API сейчас работает в тестовой среде.

В свое время для многих проектов мы делали StateMachine и отслеживали состояния - это очень помогает в работе. В .Netcore - есть ли что-то подобное ?

Да, в экосистеме .NET Core (и современных версий .NET) существует несколько отличных способов реализации конечных автоматов (State Machine). Более того, с развитием микросервисов и распределенных систем этот паттерн переживает ренессанс.

Варианты реализации можно разделить на три уровня: от встроенных средств языка до мощных фреймворков.

1. State Machine как тип данных (Switch-выражения)

В C# 8.0 и новее появились рекордные типы (record types) и улучшенные конструкции switch. Это позволяет реализовать легковесный автомат без сторонних библиотек прямо в коде доменной модели.

Это идеальный вариант для управления состояниями сущностей (например, жизненным циклом заказа), где логика не требует фоновых задержек:

csharp
public abstract record OrderState;
public record Pending : OrderState;
public record Paid : OrderState;
public record Shipped : OrderState;
public record Delivered : OrderState;
public record Cancelled : OrderState;

public class Order
{
    public OrderState State { get; private set; } = new Pending();

    public void Pay() => State = State switch
    {
        Pending s => new Paid(),
        _ => throw new InvalidOperationException($"Нельзя оплатить заказ в состоянии {State}")
    };

    public void Ship() => State = State switch
    {
        Paid s => new Shipped(),
        _ => throw new InvalidOperationException("Сначала нужно оплатить")
    };
}

Плюсы: Нулевые зависимости, строгая типизация, компилятор проверяет полноту условий (switch). Минусы: Вся логика живет в памяти процесса. Если сервис упадет во время выполнения метода, состояние может потеряться или остаться несогласованным.

2. Специализированные библиотеки (In-memory)

Если состояний много, а переходы между ними сложные (с проверками прав доступа, вызовом внешних API или расчетом таймаутов), лучше использовать готовые движки:

  • App.Mathematics.StateMachine. Легковесная библиотека, которая хорошо интегрируется с DI-контейнером .NET. Позволяет описывать граф переходов декларативно.
  • Stateless. Одна из самых популярных библиотек (.NET Foundation). Очень простая, поддерживает иерархические состояния, внешние триггеры и активаторы (guard clauses).

Пример на Stateless для обработки документа:

csharp
var machine = new StateMachine<DocumentState, Trigger>(() => currentState, s => currentState = s);

machine.Configure(DocumentState.Draft)
    .Permit(Trigger.Submit, DocumentState.UnderReview);

machine.Configure(DocumentState.UnderReview)
    .PermitIf(Trigger.Approve, DocumentState.Published, () => user.IsEditor)
    .Permit(Trigger.Reject, DocumentState.Draft);

Плюсы: Читаемый код, автогенерация диаграмм Graphviz для документации, богатая функциональность. Минусы: Состояние хранится только пока работает процесс приложения.

3. Оркестраторы долгоживущих процессов (Sagas / Saga Orchestration)

Для Aspire-проектов с несколькими парами сервисов это самый подходящий промышленный уровень. Когда действие затрагивает несколько баз данных, вам нужен «длительный» (long-running) автомат. Он может жить днями или неделями, засыпать и просыпаться по событию.

Здесь классические in-memory State Machine бессильны, так как они умрут вместе с рестартом контейнера. Используются два подхода:

  • Библиотеки Sagas (NServiceBus, MassTransit): Эти фреймворки позволяют писать логику автомата как обычный C#-класс (Saga). Библиотека сама сохраняет текущее состояние автомата в специальную базу данных (обычно SQL Server, Postgres или MongoDB) после каждого шага. Если сервер перезагрузится, автомат продолжит работу с того же места. Логика строится на обработке сообщений: пришло сообщение PaymentReceived -> изменили статус -> отправили команду ShipOrder.
  • Durable Task Frameworks (Temporal.io, Dapr Workflows): Temporal — индустриальный стандарт для таких задач сегодня. Вы пишете обычную функцию на C#, но за кулисами её выполняет кластер Temporal. Функция может сказать await workflow.SleepAsync(TimeSpan.FromDays(7)) (уснуть на неделю) или await shippingActivity.DeliverAsync() (вызвать другой микросервис). Если бэкенд упал, Temporal автоматически перезапустит функцию ровно с той строки кода, где она прервалась, сохранив все локальные переменные. В контексте Aspire такой воркер подключается к AppHost как еще один проект через AddProject<Projects.TemporalWorker>().

Какой подход выбрать?

ЗадачаРекомендуемый инструмент
Жизненный цикл одной записи в БД (статус заявки)Switch-выражения на Record Types + EF Core Shadow Properties для аудита
Сложная бизнес-логика внутри одного сервиса (скидочный купон, верификация телефона)Библиотека Stateless или App.Mathematics.StateMachine
Транзакция, охватывающая 3 разных микросервиса (заказ еды: оплата, бронь повара, вызов курьера)Sagas (через MassTransit/NServiceBus) или Workflow Engine (Temporal.io, Dapr Workflows)

В связке с вашей архитектурой Aspire чаще всего используется комбинация: простые статусы заказов живут в базе данных (п. 1), сложная логика промокодов — в библиотеке (п. 2), а межсервисное бронирование столика, списание баллов лояльности и вызов такси оформляются как долговечные процессы-саги (п. 3).

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

Ваше желание использовать встроенные средства абсолютно понятно. Зависимость от внешних библиотек — это всегда риск: их могут забросить, они могут конфликтовать с другими пакетами или навязывать свою архитектуру.

Хорошая новость в том, что в самом .NET Core (и современных версиях .NET) уже есть мощный фундамент для построения конечных автоматов. Вам не нужно ничего устанавливать через NuGet, чтобы реализовать надежную машину состояний на уровне доменной логики.

Вот три уровня «встроенности», которые закрывают большинство потребностей:

1. Уровень языка и типов: Record Types + Switch Expressions

Как упоминалось ранее, комбинация record (из C# 9), switch-выражений (C# 8) и методов-инициализаторов создает типобезопасный автомат прямо из коробки. Компилятор сам предупредит вас, если вы забыли обработать какое-то состояние при добавлении нового типа записи.

Чтобы сделать его еще более надежным без стороннего кода, добавьте к нему два встроенных механизма:

  • Интерфейс IValidatableObject. Позволяет централизованно проверить бизнес-правила объекта перед сохранением в БД (context.ValidateEntity()). Например, запретить объекту находиться в состоянии Cancelled, если у него стоит флаг IsPaid = true.
  • Shadow Properties в EF Core. Вы можете добавить полям состояния метаданные прямо в OnModelCreating, не меняя саму модель: .Property<DateTime>("LastStateTransitionAt"). Это позволяет вести аудит переходов средствами самого фреймворка.

2. Уровень данных и транзакций: Outbox Pattern (Boxed with EF Core)

Главная проблема любого автомата — согласованность (consistency). Что если вы изменили статус заказа в памяти на Paid, но база данных упала до сохранения? Состояние в коде и в реальности разойдется. .NET дает инструменты для решения этой проблемы без дополнительных очередей сообщений:

  • EF Core Transaction SaveChanges: Оберните изменение состояния сущности и запись события в одну транзакцию базы данных.
  • Внутренняя таблица Outbox: Вместо того чтобы сразу отправлять сообщение в Kafka/RabbitMQ (что может упасть после коммита транзакции), запишите событие в таблицу OutboxMessages внутри той же транзакции, где меняется статус. Отдельный фоновый хост (тот самый WorkerService в Aspire) будет читать эту таблицу и надежно публиковать сообщения во внешний мир.

3. Уровень долгоживущих процессов: BackgroundService + Channels (.NET Channel API)

Если вам нужен именно оркестратор долгих процессов (Saga), который переживает перезагрузки за счет внешнего хранилища, связка этих двух инструментов справится с задачей:

  • Класс BackgroundService. Встроенный базовый класс для создания фоновых задач. Он идеально интегрируется с DI-контейнером вашего проекта Aspire.
  • Блокирующая коллекция Channel<T>. Высокопроизводительный потокобезопасный канал из пространства имен System.Threading.Channels. Ваш веб-эндпоинт пишет команду StartShippingProcess в канал, а BackgroundService читает её оттуда, выполняет шаги саги, сохраняет промежуточные результаты в базу данных (Postgres/SQL Server) и ждет следующего сообщения.

Пример такой реализации выглядит так:

csharp
// Внутри Program.cs воркера
builder.Services.AddSingleton(Channel.CreateUnbounded<ShippingCommand>());
builder.Services.AddHostedService<ShippingOrchestrator>();

public class ShippingOrchestrator : BackgroundService
{
    private readonly ChannelReader<ShippingCommand> _reader;
    public ShippingOrchestrator(Channel<ShippingCommand> channel) => _reader = channel.Reader;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var command in _reader.ReadAllAsync(stoppingToken))
        {
            // Загружаем текущее состояние Saga из SQL-базы
            var sagaState = await db.Sagas.FindAsync(command.CorrelationId);
            
            // Выполняем шаг FSM
            if (sagaState.Status == "AwaitingPayment" && command.Type == "PaymentReceived")
            {
                await CallCarrierApi();
                sagaState.Status = "Shipped";
                await db.SaveChangesAsync(); // Сохраняем прогресс ДО удаления из канала
            }
        }
    }
}

Этот подход использует только BCL (Base Class Library) и Entity Framework. Да, здесь нет красивой визуальной диаграммы графа состояний «из коробки», и вам придется вручную писать SQL-запросы для загрузки/сохранения прогресса саги. Но по надежности такая конструкция ничем не уступает внешним тяжелым оркестраторам, пока объемы нагрузки остаются умеренными.

Когда этого становится мало? Вам все-таки придется выйти за пределы встроенного набора, когда потребуется один из трех элементов промышленного масштаба:

  1. Масштабируемость: Один экземпляр BackgroundService станет узким местом, и вам понадобится распределенная блокировка (Distributed Lock), чтобы две копии сервиса не обрабатывали одну и ту же сагу одновременно.
  2. Точные таймеры: Если нужно отправить письмо-напоминание ровно через 48 часов, полагаться на цикл Task.Delay в воркере нельзя (сервис может уйти в сон). Нужен внешний диспетчер (Quartz.NET) или планировщик Temporal.
  3. Визуализация: Когда граф переходов превышает 15–20 узлов, поддерживать его в виде switch/case становится невозможно, и потребность в автоматической генерации документации (как в библиотеке Stateless) возрастает.

Для большинства корпоративных CRUD-приложений и сервисов среднего размера возможностей чистого .NET в сочетании с базой данных более чем достаточно.

Комментариев нет:

Отправить комментарий