Как стать автором
Обновить

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

Уровень сложностиПростой
Время на прочтение9 мин
Количество просмотров5K

Многие компании, использующие 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/

Теги:
Хабы:
Всего голосов 14: ↑8 и ↓6+2
Комментарии16

Публикации

Работа

Ближайшие события