Введение в микросервисы C# + шаблон

от автора

Многие компании, использующие C# для разработки, стремятся к микросервисной архитектуре. Этот подход обеспечивает гибкость, отказоустойчивость и масштабируемость.

В этой статье описанны основные принципы построения микросервисов, популярные библиотеки и подходы. Расчитана на новичков.

Шаблон:

Если хочешь быстро запустить свой первый микросервис на .NET без лишней возни — загляни в Template.Net.Microservice после прочтения статьи.

Готовая структура, поддержка описываемых в статье паттернов — просто ставь шаблон и сразу кодь свой сервис. Минимум настройки, максимум пользы 🔥

Что такое микросервисы?

Микросервисы — это подход к архитектуре системы, при котором приложение разбивается на набор небольших, автономных сервисов, каждый из которых реализует отдельную бизнес-функциональность. Каждый сервис обладает собственной базой данных, своим циклом разработки, развёртывания и масштабирования. Такой подход позволяет:

  • Независимо развивать отдельные части системы.

  • Улучшать отказоустойчивость: сбой одного сервиса не приводит к остановке всей системы.

  • Гибко масштабировать компоненты в зависимости от нагрузки.

  • Легкое внедрение новых технологий и обновление версий библиотек без ущерба основной системы инструментов.

Разделение по слоям

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

  • Domain (Домен): Содержит основные сущности, агрегаты, бизнес-правила и доменные события. Здесь происходит моделирование предметной области с помощью DDD.

  • Application (Приложение): Реализует бизнес-логику, команды, запросы и сервисы, объединяя функциональность домена и инфраструктуры. Здесь часто применяется паттерн CQRS.

  • Infrastructure (Инфраструктура): Отвечает за взаимодействие с внешними ресурсами: базы данных, очереди сообщений, сторонние API, файловые системы.

  • UI/Presentation (Пользовательский интерфейс): Реализует интерфейсы взаимодействия (например, REST API, Minimal API, GraphQL).

Polyglot Persistence

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

  • Реляционные базы данных: PostgreSQL, SQL Server — для хранения структурированных данных с транзакционной целостностью.

  • NoSQL решения: MongoDB, Cassandra — для масштабируемости и работы с неструктурированными данными.

  • Кэширование и быстрый доступ: Redis — для кэширования, хранения сессий и временных данных.

Такой подход, называемый «Database per Service», позволяет избежать жёстких связей между сервисами и оптимизировать каждую часть системы под её конкретную задачу.

CQRS и MediatR: Разделение обязанностей чтения и записи

Command Query Responsibility Segregation (CQRS) разделяет операции записи (команды) и чтения (запросы) в приложении. Это позволяет оптимизировать каждую часть системы отдельно, снижая сложность и повышая производительность.

Преимущества CQRS:

  • Масштабируемость: Можно оптимизировать производительность операций чтения и записи независимо.

  • Упрощение логики: Каждая команда или запрос отвечает только за свою часть функциональности.

  • Лёгкость тестирования: Изолированное тестирование команд и запросов.

  • Единое место обработки сообщения: У вас могут быть разные виды API, но все они будут идти в общий обработчик.

Использование MediatR

Библиотека MediatR позволяет реализовать паттерн CQRS, объеденяя команды и запросы в общее понятие request и создавая обработчики к request.

Добавление в DI:

var assemblies = AppDomain.CurrentDomain.GetAssemblies(); services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(assemblies));

Команды или запросы должны реализовывать интерфейс IRequest<out TResponse>. Где T это тип, который вернет этот request при обработке

// Определение команды создания заказа public record OrderCreateCommand(decimal Price) : IRequest<bool>; //Обычно команды возвращают флаг статуса выполнения команды  // Определение запроса для получения заказа по идентификатору public record OrderGetByIdRequest(Guid OrderId) : IRequest<OrderDto>;

Обработчики к request`ам должны реализовывать интерфейс IRequestHandler<in TRequest, out TResponse>

// Обработчик команды public class OrderCreateCommandHandler(IOrderRepository orderRepository) : IRequestHandler<OrderCreateCommand, bool> {     public Task<bool> Handle(OrderCreateCommand request, CancellationToken cancellationToken)     {         var order = new Order(request.Price);         await orderRepository.InsertAsync(order);         return true;     } }  // Обработчик запроса public class OrderGetByIdRequestHandler(IOrderRepository orderRepository, IMapper mapper) : IRequestHandler<OrderGetByIdRequest, OrderDto> {     public async Task<OrderDto> Handle(OrderGetByIdRequest request, CancellationToken cancellationToken)     {         var order = await orderRepository.GetByIdAsync(request.OrderId);         return mapper.Map<OrderDto>(order);     } }

Очереди сообщений: RabbitMQ и MassTransit

RabbitMQ — популярный брокер сообщений, который позволяет организовать очередь сообщений. MassTransit — библиотека для упрощения работы с брокерами (поддерживает RabbitMQ, Azure Service Bus и др.).

Добавление MassTransit в DI:

services.AddMassTransit(x => {     //Добавление всех слушателей Message Consumer по Assembly     var assembly = typeof(Application).Assembly;     x.AddConsumers(assembly);       //При использовании RabbitMq необходимо скачать дополнительный пакет MassTransit.RabbitMQ     x.UsingRabbitMq((context, cfg) =>     {         cfg.Host("rabbitmq://localhost", h => {             h.Username("user");             h.Password("password");          });                  cfg.ConfigureEndpoints(context); //Автоматическая конфигурация endpoint     }); });

Message Consumer совместно с CQRS

Представим, что в сервисе заказов есть модель запроса (OrderGetByIdRequest) и её обработчик (OrderGetByIdRequestHandler). Чтобы другие сервисы могли ходить в обработчик запроса нужно описать слушателя для очереди сообщения:

public class OrderGetByIdRequestConsumer(IMediator mediator) : IConsumer<OrderGetByIdRequest> {     //Метод вызовется, когда в очередь придет сообщение OrderGetByIdRequest     public async Task Consume(ConsumeContext<OrderGetByIdRequest> context)       {         //Перенаправление сообщение в обработчик         var result = await mediator.Send(context.Message, context.CancellationToken);         await context.RespondAsync(result); //Ответ от обработчка вернуть в очередь     } }

В другом сервисе можно обратиться к этому слушателю с помощью клиента:

private readonly IRequestClient<OrderGetByIdRequest> _orderGetByIdRequestClient;   try {     var response = await _profilePaymentOrderCreateClient.GetResponse<OrderDto>(     new OrderGetByIdRequest(Guid.Empty), cancellationToken, RequestTimeout.Default);     //В response.Message находится ответ из очереди сообщений } catch (RequestException e) {     //Обработка исключения при ошибке в очереди сообщений }

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

services.AddMassTransit(x => { x.AddRequestClient<OrderGetByIdRequest>(); });

Отправка и обработка событий (Publisher/Subscriber)

Отправка события из сервиса заказов:

private readonly IPublishEndpoint _publishEndpoint  await _publishEndpoint.Publish(new OrderCreatedEvent(order.Id, order.ProductName, order.Price)); 

Потребитель, обрабатывающий событие (например, сервис уведомлений):

public class OrderCreatedConsumer : IConsumer<OrderCreatedEvent> {     public async Task Consume(ConsumeContext<OrderCreatedEvent> context)     {         // Логика обработки события: отправка уведомления, обновление статистики и т.д.         Console.WriteLine($"Получено событие о создании заказа: {context.Message.OrderId}");     } }

При Publisher/Subscriber подходе необходимо чтобы каждый подписчик имел свою очередь иначе ивент будет приходить не всем подписчикам сразу, а по очереди:

services.AddMassTransit(x => {     x.UsingRabbitMq((context, cfg) =>     {         //Обязательно указывать разное имя очереди в разных сервисах при Publisher/Subscriber         cfg.ReceiveEndpoint("order-created-queue", e =>          {             e.ConfigureConsumer<OrderCreatedConsumer>(context);         });     }); });

Контракт-First подход

Contract First — это подход к разработке микросервисов, при котором API определяется заранее, а затем на его основе пишется сервис. Для определения контрактов можно использовать как спецификации (например, Avro, Protobuf, AsyncAPI), так и общий пакет с моделями. Рассмотрим последний вариант.

Общий пакет (проект) контрактов может храниться в solution, если вы используете Mono-Repo, или же храниться в отдельном nuget-пакете при Multi-Repo подходе.

Что хранить в пакете контракта?

На самом деле хранить можно все что может помочь при интеграции с сервисов. В рабочих проектах я использую подобную струткуру:

  • Model — Внешние модели сервиса, например, viewmodel, dto и т.п.

  • Enum — Enum, которые нужны для моделей контракта.

  • Validator — Правила валидации для моделей контрактов.

  • Event — Ивенты для паттерна Publisher/Subscriber.

  • Request или Query/Command — CQRS модели для Message Consumer паттерна.

  • SagaEvent — Ивенты для паттерна SagaStateMashine.

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

Например, есть сервис интернет магазина (Shop), в нем есть модель корзины (Cart) и команда по записи корзины в базу (c). Если в каком-то другом сервисе будут модели с такими же названиями в контрактах, то это вызовет проблемы при разработке. В таком случае можно добавить префикс перед названиями моделей: ShopCart, ShopCartInsertCommand.

Minimal API

Minimal API позволяет создать легковесное и быстрое HTTP API без необходимости писать лишний шаблонный код. Это особенно актуально для микросервисов, где важно быстрое прототипирование и простота.

Пример Minimal API с CQRS

var builder = WebApplication.CreateBuilder(args); builder.Services.AddMediatR(typeof(Program)); builder.Services.AddEndpointsApiExplorer();  var app = builder.Build();  //Если EndPoint`ов в сервисе много, то стоит разнести их по файлам app.MapPost("/orders", async ([FromBody] OrderCreateCommand command, [FromServices] IMediator mediator, HttpContext context) => {     var result = await mediator.Send(command, context.RequestAborted)     return result ? Results.Ok() : Results.BadRequest(); });  app.Run();

Запрос идет в тот же обработчик что используется в очереди сообщений.

Документирование API

Интеграция Swagger через пакет Swashbuckle.AspNetCore позволяет автоматически генерировать документацию для вашего API, что упрощает разработку и тестирование.

builder.Services.AddSwaggerGen();  if (app.Environment.IsDevelopment()) {     app.UseSwagger();     app.UseSwaggerUI(); }

Свагер автоматически сгенерирует ui со всеми http методами. Если необходимо дополнительно описать то нужно можно воспользоваться подобным синтаксисом:

app.MapPost("/orders", CreateOrder)     .WithTags("Orders")     .WithSummary("Краткое описание")     .WithDescription("Основное описание")     .WithOpenApi();  [ProducesResponseType(200, Type = typeof(bool))] [ProducesResponseType(400)] private async Task<IResult> CreateOrder(     [FromBody] OrderCreateCommand command,     [FromServices] IMediator mediator,     HttpContext context) {     var result = await mediator.Send(command, context.RequestAborted);     return (bool)result ? Results.Ok(true) : Results.BadRequest(); }

Лучше http методы всегда выносить отдельно, указывать все возвращаемые значения и описание. Вот как это будет выглядеть в свагере:

Безопасность: аутентификация и авторизация

Безопасность является критически важным аспектом любой системы. Рекомендуется использовать централизованный Identity-сервис, который может базироваться на таких решениях, как:

  • OpenIddict(IdentityServer4) / Duende IdentityServer: Обеспечивают поддержку протоколов OpenID Connect и OAuth 2.0.

  • ASP.NET Core Identity с JWT: Легковесное решение для аутентификации через JSON Web Token.

Или же вы можите использовать уже готовые opensource приложения по типу KeyСloak или ознакомиться с мои решением на основе OpenIddict.

Централизованный Identity-сервис позволяет:

  • Управлять пользователями, ролями и разрешениями.

  • Обеспечить единую точку аутентификации для всех микросервисов.

  • Гарантировать безопасность и соответствие стандартам.

Контейнеризация и структура

Структура директорий проекта

Всегда стоит использовать одну струтуру директорий проекта, чтобы упростить работу DevOps инженера и понимание другими разработчиками, особенно если вы используете Multi-Repo. Пример простой структуры:

  • .gitignore: Файл описывающий ограничение по сканированию git

  • .gitlab-ci.yml или .github/workflows/main.yml: CI/CD файлы

  • LICENSE: Лецензия по которой распространяется проект

  • README.md: Описание проета

  • src: Все файлы, которые непосредственно связанны с кодом проекта

    • docker-compose.yml: Помогает собрать образы, если есть несколько запускаемых приложений

    • .nuget/NuGet/NuGet.Config: Если используются закрытые nuget резитории

    • Проект/

Контейнеризация с Docker

Упаковка микросервисов в Docker-контейнеры позволяет обеспечить воспроизводимость среды и легкость развёртывания. Пример Dockerfile для микросервиса:

#Определение окружения контейнера FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base USER $APP_UID WORKDIR /app  #Рестор пакетов и билд проекта FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src COPY ["MyService/MyService.PL.Api/MyService.PL.Api.csproj", "MyService/MyService.PL.Api/"] COPY ["MyService/MyService.Application/MyService.Application.csproj", "MyService/MyService.Application/"] COPY ["MyService/MyService.Domain/MyService.Domain.csproj", "MyService/MyService.Domain/"] COPY ["MyService/MyService.Infrastructure/MyService.Infrastructure.csproj", "MyService/MyService.Infrastructure/"] RUN dotnet restore "MyService/MyService.PL.Api/MyService.PL.Api.csproj" COPY . . WORKDIR "/src/MyService/MyService.PL.Api" RUN dotnet build "MyService.PL.Api.csproj" -c $BUILD_CONFIGURATION -o /app/build  #Публикация FROM build AS publish ARG BUILD_CONFIGURATION=Release RUN dotnet publish "MyService.PL.Api.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false  #Финальная конфигурация запуска FROM base AS final ENV ASPNETCORE_URLS=https://+:10001;http://+:10000 #Можно отдельно указать http порты  WORKDIR /app COPY --from=publish /app/publish . ENTRYPOINT ["dotnet", "MyService.PL.Api.dll"]

Этот Dockerfile должен запускаться из контекста выше проекта т.е. над директорией проекта должна быть общая директория:

docker build  -t MyService:latest     -f src/MyService/MyService.UI.Api/Dockerfile     src/

🚀 Поддержать автора

Если статья оказалась полезной, можно поддержать меня:

  • USDT (TRC-20): TDDXvViV857dZuGTNeUTDBzAZW4iADqnwv

  • BTC: bc1qrl8rfe0qxw7q4mpnaw5tccu3xmusn42v0pgza9

  • ETH: 0xd20312EC530D859B5Bf527FbBB1ff127Fa05417F

  • TON: UQAWH4nlcIZbTWdhud3wOTpIa0XdColpF6WnCyDYaIIgZdgI

💡 Даже небольшая поддержка мотивирует писать больше полезных статей и гайдов!

❤️ Поддержать через Donation Alerts


ссылка на оригинал статьи https://habr.com/ru/articles/891382/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *