Привет, Хабр! Около месяца назад я рассказывал о 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. Все предложения рассматриваются.
