Привет, Хабр! Около месяца назад я рассказывал о Requestum — CQRS-библиотеке для .NET, созданной как бесплатная альтернатива MediatR.

После той публикации в комментариях многие справедливо спрашивали: «Зачем нужна ещё одна CQRS-библиотека? Есть же Mediator, LiteBus и другие проверенные решения. Чем твоя альтернатива лучше?»

Честный ответ на тот момент был: «Пока, наверное, ничем особенным — разве что MIT лицензией и чуть лучшей производительностью».

Но за этот месяц я постарался это исправить. Этот пост — мой ответ на те вопросы и рассказ о фичах, которые делают Requestum достойным внимания, а не просто «ещё одной библиотекой в списке».

Что нового

С момента первого релиза Requestum получил несколько важных фич:

  • ?️ Теги для запросов и обработчиков — динамическая маршрутизация без if-else

  • ? Атрибут [Retry] — автоматические повторные попытки при сбоях

  • ⏱️ Атрибут [Timeout] — ограничение времени выполнения обработчиков

  • ? Встроенное логирование — отслеживание производительности из коробки

  • ? Типизированные middleware — отдельные пайплайны для команд и запросов

Давайте разберём каждую фичу подробнее.


Теги: маршрутизация без if-else

Представьте ситуацию: у вас есть команда ProcessPaymentCommand, но логика обработки платежей отличается для разных платёжных систем, или для разных тарифов пользователей, или для разных окружений. Как решить это в MediatR? Обычно через if-else в обработчике или через фабрику.

В Requestum это решается через теги:

// Определяем команду с тегами
public record ProcessPaymentCommand(decimal Amount, string Provider) : ICommand, ITaggedRequest
{
    // Теги определяются динамически в рантайме
    public string[] Tags => [Provider]; // "stripe", "paypal", etc.
}

// Обработчик для Stripe
[HandlerTag("stripe")]
public class StripePaymentHandler : IAsyncCommandHandler
{
    private readonly IStripeClient _stripe;
    
    public async Task ExecuteAsync(ProcessPaymentCommand command, CancellationToken ct = default)
    {
        await _stripe.ChargeAsync(command.Amount, ct);
    }
}

// Обработчик для PayPal
[HandlerTag("paypal")]
public class PayPalPaymentHandler : IAsyncCommandHandler
{
    private readonly IPayPalClient _paypal;
    
    public async Task ExecuteAsync(ProcessPaymentCommand command, CancellationToken ct = default)
    {
        await _paypal.CreatePaymentAsync(command.Amount, ct);
    }
}

// Fallback обработчик (без тега)
public class DefaultPaymentHandler : IAsyncCommandHandler
{
    public async Task ExecuteAsync(ProcessPaymentCommand command, CancellationToken ct = default)
    {
        throw new NotSupportedException($"Provider {command.Provider} is not supported");
    }
}

Теперь вызов прост:

// Автоматически выберется StripePaymentHandler
await requestum.ExecuteAsync(new ProcessPaymentCommand(100, "stripe"));

// Автоматически выберется PayPalPaymentHandler
await requestum.ExecuteAsync(new ProcessPaymentCommand(50, "paypal"));

// Выберется DefaultPaymentHandler
await requestum.ExecuteAsync(new ProcessPaymentCommand(25, "unknown"));

Правила выбора обработчиков

Система тегов работает по чётким правилам:

Компонент

Поведение

Команды/Запросы

Выполняется ОДИН подходящий обработчик. Если нет совпадения — fallback на обработчик без тега

События

Выполняются ВСЕ подходящие обработчики

Middleware

Выполняются ВСЕ с подходящим тегом ПЛЮС все без тегов

Мультитенантность через теги

Теги идеально подходят для мультитенантных приложений:

public record CreateOrderCommand(int TenantId, OrderData Data) : ICommand, ITaggedRequest
{
    public string[] Tags =>; [$"tenant-{TenantId}"];
}

[HandlerTag("tenant-1")]
public class Tenant1OrderHandler : IAsyncCommandHandler
{
    // Специфичная логика для tenant-1
}

[HandlerTag("tenant-2")]
public class Tenant2OrderHandler : IAsyncCommandHandler
{
    // Специфичная логика для tenant-2
}

Глобальные теги

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

services.AddRequestum(cfg =>
{
    cfg.Default(typeof(Program).Assembly);
    
    // Эти теги будут применяться ко ВСЕМ запросам
    cfg.GlobalTags = ["production", "region-eu"];
});

Это полезно для:

  • Разделения окружений (dev/staging/production)

  • Региональной маршрутизации

  • A/B тестирования на уровне инфраструктуры

Теги для middleware

Middleware тоже поддерживают теги:

[MiddlewareTag("premium")]
public class PremiumLoggingMiddleware 
    : IAsyncRequestMiddleware
{
    public async Task InvokeAsync(
        TRequest request,
        AsyncRequestNextDelegate next,
        CancellationToken ct = default)
    {
        // Детальное логирование только для premium пользователей
        _logger.LogInformation("Premium request: {Request}", request);
        return await next.InvokeAsync(request);
    }
}

Политики устойчивости: Retry и Timeout

Вместо того чтобы писать try-catch-retry в каждом обработчике или подключать Polly, Requestum предлагает декларативный подход через атрибуты.

Атрибут [Retry]

public record CallExternalApiCommand(string Endpoint) : ICommand;

[Retry(3)] // Повторить до 3 раз при любом исключении
public class CallExternalApiHandler : IAsyncCommandHandler
{
    private readonly HttpClient _httpClient;
    
    public async Task ExecuteAsync(CallExternalApiCommand command, CancellationToken ct = default)
    {
        var response = await _httpClient.GetAsync(command.Endpoint, ct);
        response.EnsureSuccessStatusCode();
    }
}

Как это работает:

  • При возникновении исключения обработчик вызывается повторно

  • Если все попытки исчерпаны — выбрасывается AggregateException со всеми исключениями

  • Каждая попытка получает тот же самый экземпляр запроса

Атрибут [Timeout]

public record GenerateReportCommand(int ReportId) : ICommand;

[Timeout(5_000)] // Таймаут 5 секунд (значение в миллисекундах)
public class GenerateReportHandler : IAsyncCommandHandler
{
    private readonly IReportGenerator _generator;
    
    public async Task ExecuteAsync(GenerateReportCommand command, CancellationToken ct = default)
    {
        // Если генерация занимает больше 5 секунд — TimeoutException
        await _generator.GenerateAsync(command.ReportId, ct);
    }
}

Как это работает:

  • Создаётся связанный CancellationToken, который отменяется по таймауту

  • При превышении времени выбрасывается TimeoutException

  • Важно: ваш код должен проверять CancellationToken!

Комбинирование политик

Атрибуты можно комбинировать:

public record NotifyWebhookCommand(string Url, object Payload) : ICommand;

[Retry(3)]        // Сначала retry
[Timeout(10_000)] // Каждая попытка ограничена 10 секундами
public class NotifyWebhookHandler : IAsyncCommandHandler
{
    private readonly HttpClient _httpClient;
    
    public async Task ExecuteAsync(NotifyWebhookCommand command, CancellationToken ct = default)
    {
        var content = JsonContent.Create(command.Payload);
        var response = await _httpClient.PostAsync(command.Url, content, ct);
        response.EnsureSuccessStatusCode();
    }
}

Активация политик

Не забудьте вызвать AutoRegisterRequestumPolicies() после построения сервис-провайдера:

var app = builder.Build();

// Регистрируем политики для всех обработчиков с атрибутами [Retry] и [Timeout]
app.Services.AutoRegisterRequestumPolicies();

app.Run();

Почему это лучше Polly?

Не поймите неправильно — Polly отличная библиотека. Но для типичных сценариев CQRS атрибуты Requestum проще:

Polly

Requestum

Нужно настраивать политики отдельно

Декларативно через атрибуты

Нужно оборачивать вызовы

Работает автоматически

Гибко, но многословно

Просто и достаточно для 80% случаев

Если вам нужны сложные сценарии (circuit breaker, bulkhead, fallback) — используйте Polly. Для простого retry и timeout — атрибуты Requestum.


Встроенное логирование

Логирование — это то, что нужно практически всегда. Но писать middleware для логирования в каждом проекте утомительно. Requestum теперь включает встроенное логирование:

services.AddRequestum(cfg =>
{
    cfg.Default(typeof(Program).Assembly);
    
    // Включаем встроенное логирование
    cfg.NeedLogging = true;
});

Одна строчка — и вы получаете:

Что логируется

info: Requestum.IRequestum[0]
      Handling CreateUserCommand
info: Requestum.IRequestum[0]
      Handled CreateUserCommand in 145 ms

При ошибках:

info: Requestum.IRequestum[0]
      Handling ProcessOrderCommand
fail: Requestum.IRequestum[0]
      Error handling ProcessOrderCommand after 78 ms
      System.InvalidOperationException: Order not found
         at OrderService.ProcessAsync(...) in OrderService.cs:line 42

Structured Logging

Логирование совместимо со structured logging провайдерами (Serilog, NLog, etc.):

{
  "Timestamp": "2024-01-15T10:30:45.1234567Z",
  "Level": "Information",
  "MessageTemplate": "Handled {RequestType} in {Elapsed} ms",
  "Properties": {
    "RequestType": "CreateUserCommand",
    "Elapsed": 145
  }
}

Это позволяет делать запросы вроде:

-- Найти самые медленные запросы
SELECT RequestType, AVG(Elapsed) as AvgMs, MAX(Elapsed) as MaxMs
FROM logs
WHERE MessageTemplate = 'Handled {RequestType} in {Elapsed} ms'
GROUP BY RequestType
ORDER BY AvgMs DESC

Типизированные middleware

В первой версии middleware применялись ко всем типам запросов. Теперь можно создавать middleware, специфичные для команд или запросов:

Command-specific middleware

public class TransactionMiddleware 
    : IAsyncCommandMiddleware
{
    private readonly IDbContext _dbContext;
    
    public async Task InvokeAsync(
        TCommand request,
        AsyncRequestNextDelegate next,
        CancellationToken ct = default)
    {
        await using var transaction = await _dbContext.BeginTransactionAsync(ct);
        
        try
        {
            var response = await next.InvokeAsync(request);
            await transaction.CommitAsync(ct);
            return response;
        }
        catch
        {
            await transaction.RollbackAsync(ct);
            throw;
        }
    }
}

Query-specific middleware

public class CachingMiddleware 
    : IAsyncQueryMiddleware
{
    private readonly ICache _cache;
    
    public async Task InvokeAsync(
        TQuery request,
        AsyncRequestNextDelegate next,
        CancellationToken ct = default)
    {
        var cacheKey = GetCacheKey(request);
        
        if (_cache.TryGetValue(cacheKey, out var cached))
            return cached!;
        
        var response = await next.InvokeAsync(request);
        _cache.Set(cacheKey, response, TimeSpan.FromMinutes(5));
        
        return response;
    }
}

Почему это важно

  1. Семантическая корректность: транзакции имеют смысл для команд, кэширование — для запросов

  2. Производительность: middleware не вызывается для неподходящих типов запросов

  3. Читаемость: код ясно показывает намерения


Сравнение с MediatR

Давайте честно сравним, что есть в Requestum и чего ��ет в MediatR (и наоборот):

Что есть в Requestum, но нет в MediatR

Фича

Requestum

MediatR

Явное разделение Command/Query/Event

❌ (всё через IRequest)

Настоящие синхронные обработчики

❌ (только async)

Встроенные [Retry] и [Timeout]

❌ (нужен Polly)

Теги для динамической маршрутизации

Глобальные теги

Типизированные middleware

Встроенное логирование с таймингами

Бесплатная MIT лицензия навсегда

⚠️ (планы на коммерциализацию)

Что есть в MediatR, но нет в Requestum

Фича

MediatR

Requestum

Notification (broadcast всем)

✅ (через IEventMessage)

Stream requests

Pre/Post processors

❌ (используйте middleware)

Более зрелая экосистема

? (развивается)


Установка

dotnet add package Requestum

Пакет поддерживает .NET 8, .NET 9 и .NET 10.


Ссылки


Заключение

Requestum продолжает развиваться как полноценная альтернатива MediatR. Новые фичи — теги, политики устойчивости, типизированные middleware и встроенное логирование — делают библиотеку ещё более полезной для production-проектов.

Если вы:

  • Хотите явное разделение CQRS через систему типов

  • Нуждаетесь в динамической маршрутизации без if-else

  • Цените декларативный подход к retry/timeout

  • Ищете бесплатную и открытую альтернативу MediatR

...попробуйте Requestum. Буду рад обратной связи и PR!


P.S. Если у вас есть идеи по улучшению или вы нашли баг — создайте issue на GitHub. Все предложения рассматриваются.

Комментарии (0)