Привет, Хабр! Около месяца назад я рассказывал о 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;
}
}
Почему это важно
Семантическая корректность: транзакции имеют смысл для команд, кэширование — для запросов
Производительность: middleware не вызывается для неподходящих типов запросов
Читаемость: код ясно показывает намерения
Сравнение с MediatR
Давайте честно сравним, что есть в Requestum и чего ��ет в MediatR (и наоборот):
Что есть в Requestum, но нет в MediatR
Фича |
Requestum |
MediatR |
|---|---|---|
Явное разделение Command/Query/Event |
✅ |
❌ (всё через |
Настоящие синхронные обработчики |
✅ |
❌ (только async) |
Встроенные |
✅ |
❌ (нужен Polly) |
Теги для динамической маршрутизации |
✅ |
❌ |
Глобальные теги |
✅ |
❌ |
Типизированные middleware |
✅ |
❌ |
Встроенное логирование с таймингами |
✅ |
❌ |
Бесплатная MIT лицензия навсегда |
✅ |
⚠️ (планы на коммерциализацию) |
Что есть в MediatR, но нет в Requestum
Фича |
MediatR |
Requestum |
|---|---|---|
Notification (broadcast всем) |
✅ |
✅ (через |
Stream requests |
✅ |
❌ |
Pre/Post processors |
✅ |
❌ (используйте middleware) |
Более зрелая экосистема |
✅ |
? (развивается) |
Установка
dotnet add package Requestum
Пакет поддерживает .NET 8, .NET 9 и .NET 10.
Ссылки
NuGet: Requestum
GitHub: PogovorovDaniil/Requestum
Wiki: Документация
Лицензия: MIT
Заключение
Requestum продолжает развиваться как полноценная альтернатива MediatR. Новые фичи — теги, политики устойчивости, типизированные middleware и встроенное логирование — делают библиотеку ещё более полезной для production-проектов.
Если вы:
Хотите явное разделение CQRS через систему типов
Нуждаетесь в динамической маршрутизации без if-else
Цените декларативный подход к retry/timeout
Ищете бесплатную и открытую альтернативу MediatR
...попробуйте Requestum. Буду рад обратной связи и PR!
P.S. Если у вас есть идеи по улучшению или вы нашли баг — создайте issue на GitHub. Все предложения рассматриваются.