Поиск  
Always will be ready notify the world about expectations as easy as possible: job change page
24 марта

Зачем нужен MediatR?

Зачем нужен MediatR?
Автор:
Иван Якимов
Источник:
Просмотров:
1736

MediatR

Недавно я натолкнулся в нашем коде на использование пакета MediatR. Это заинтересовало меня. Почему я должен использовать MediatR? Какие преимущества он мне предоставляет? Здесь я собираюсь рассмотреть эти вопросы.

Как пользоваться MediatR

На базовом уровне использование MediatR очень просто. Сначала вы устанавливаете NuGet пакет MediatR. В вашем приложении будет несколько описаний той работы, которую ему нужно выполнять (например, создать запись ToDo, изменить имя пользователя и т. д.). Эти описания в MediatR называются запросами (requests). Это обычные классы, реализующие интерфейс IRequest. Он является маркер-интерфейсом без всяких членов.

class CreateToDoItem : IRequest
{
    public string ToDoItemText { get; set; }
}

Эти классы могут не содержать никакой логики. Они представляют собой просто контейнеры данных, необходимых для выполнения операций.

Но что означает параметр T в интерфейсе IRequest? Видите ли, ваши операции могут возвращать некоторые результаты. Например, при создании записи ToDo вам может потребовать получить ID этой записи. Именно для этого и служит параметр T. В нашем случае, мы хотим получить целочисленный идентификатор записи ToDo.

Теперь нам нужен код, который, собственно, и будет выполнять нашу операцию. В MediatR этот код называется обработчиком запроса (request handler). Обработчики запроса должны реализовывать интерфейс IRequestHandler, где TRequest должен быть IRequest:

class CreateToDoItemHandler : IRequestHandler
{
    public Task Handle(CreateToDoItem request, CancellationToken cancellationToken)
    {
        ...
    }
}

Как видите, этот интерфейс требует реализовать единственный метод Handle, который асинхронно исполняет требуемую операцию и возвращает нужный результат.

Всё, что осталось нам сделать, это соединить запрос с соответствующим обработчиком. MediatR использует для этого контейнер зависимостей. Если вы разрабатываете приложение ASP.NET Core, вы можете воспользоваться пакетом MediatR.Extensions.Microsoft.DependencyInjection. Но MediatR поддерживает и массу других контейнеров.

services.AddMediatR(typeof(Startup));

Здесь services - экземпляр интерфейса IServiceCollection, который обычно доступен вам в методе ConfigureServices класса Startup. Данная команда сканирует сборку, в которой определён класс Startup, и находит там все обработчики запросов.

Теперь вы можете выполнять ваши запросы. Для этого вам потребуется получить экземпляр интерфейса IMediator. Он регистрируется в вашем контейнере зависимостей той же командой AddMediatR.

var toDoItemId = await mediator.Send(createToDoItemRequest);

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

И здесь мы переходим к главному вопросу.

Зачем мне нужен MediatR?

Давайте представим себе, что у нас есть ASP.NET Core контроллер, который поддерживает операции работы с записями ToDo. Мы сравним, как можно реализовать создание такой записи с использованием MediatR и без него. Вот как выглядит код без MediatR:

[ApiController]
public class ToDoController : ControllerBase
{
    private readonly IToDoService _service;

    public ToDoController(IToDoService service)
    {
        _service = service;
    }

    [HttpPost]
    public async Task CreateToDoItem([FromBody] CreateToDoItem createToDoItemRequest)
    {
        var toDoItemId = await _service.CreateToDoItem(createToDoItemRequest);

        return Ok(toDoItemId);
    }
}

А вот так выглядит реализация, использующая MediatR:

[ApiController]
public class ToDoController : ControllerBase
{
    private readonly IMediator _mediator;

    public ToDoController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpPost]
    public async Task CreateToDoItem([FromBody] CreateToDoItem createToDoItemRequest)
    {
        var toDoItemId = await _mediator.Send(createToDoItemRequest);

        return Ok(toDoItemId);
    }
}

Вы видите здесь какие-нибудь серьёзные преимущества MediatR? Я - нет. На самом деле мне кажется, что версия с MediatR чуть менее читабельна. Она использует обобщённый метод Send вместо более осмысленного CreateToDoItem.

Так зачем же мне использовать MediatR?

Ссылки

Прежде всего, MediatR отделяет обработчики запросов от самих запросов. В коде контроллера вы нигде не ссылаетесь на класс CreateToDoItemHandler. Это означает, что вы можете двигать этот класс в пределах одной сборки как вам угодно, и вам не потребуется ничего изменять в вашем контроллере.

Но лично я не вижу здесь большого преимущества. Да, вам будет удобнее выполнять некоторые изменения в вашем проекте. Но в то же время вы получите и ряд трудностей. Из кода вашего контроллера вы не можете видеть, кто в действительности выполняет ваш запрос. Чтобы найти обработчик для экземпляра CreateToDoItem, вы должны знать, что такое MediatR, и как он работает. Здесь нет ничего особенно сложного. В конце концов, IToDoService так же не является реализацией обработчика, вам приходится искать классы, реализующие данный интерфейс. Тем не менее у новичка уйдёт больше времени, чтобы понять, что здесь происходит.

Единственная ответственность

Следующее отличие более важно. Видите ли, ваш обработчик запросов - это класс. И весь этот класс ответственен за выполнение единственной операции. В случае же сервиса (например, IToDoService), за выполнение одной операции ответственен один метод. Это означает, что сервис может содержать множество различных методов, ответственных за разные операции. Всё это усложняет понимание кода сервиса. С другой стороны, весь класс обработчика запроса отвечает только за одну операцию. Это делает данный класс меньше и легче для понимания.

Всё это, конечно, здорово, но реальность несколько сложнее. Обычно вам нужно поддерживать несколько связанных операций (например, создать запись, обновить её, изменить статус записи, ...) Все эти операции могут требовать выполнения одинаковых кусков кода. В случае сервиса вы можете вынести этот код в приватный метод. Но обработчики запросов - отдельные классы. Конечно, мы можем использовать наследование и вынести всё, что нужно, в базовый класс. Но это возвращает нас к той же, если не худшей ситуации. В случае сервиса, у нас было множество методов внутри одного класса. Теперь у нас есть множество методов, раскиданных по нескольким классам. Не уверен, какой из вариантов лучше.

Другими словами, если вы собираетесь отстрелить себе ногу, у вас всё ещё есть масса вариантов, как сделать это.

Декораторы

Но существует ещё одно очень важное преимущество MediatR. Видите ли, все ваши обработчики запросов реализуют один и тот же интерфейс IRequestHandler. Это означает, что вы можете создавать декораторы, применимые ко всем ним. В ASP.NET Core вы можете использовать пакет Scrutor для поддержки декораторов. Например, вы можете написать декоратор для логирования так:

class LoggingDecorator : IRequestHandler
    where TRequest : IRequest
{
    private readonly IRequestHandler _handler;
    private readonly Logger _logger;

    public LoggingDecorator(IRequestHandler handler,
        Logger logger)
    {
        _handler = handler;
        _logger = logger;
    }

    public Task Handle(TRequest request, CancellationToken cancellationToken)
    {
        _logger.Log("Log something here.");

        return _handler.Handle(request, cancellationToken);
    }
}

Теперь зарегистрируйте его:

services.AddMediatR(typeof(Startup));
services.Decorate(typeof(IRequestHandler<,>), typeof(LoggingDecorator<,>));

Вот и всё. Вы применили логику логирования ко всем вашим обработчикам запросов. Вам не нужно создавать отдельный декоратор для каждого из ваших сервисов. Всё, что вам нужно, - это декорировать единственный интерфейс.

Но зачем вообще связываться со Scrutor? MediatR предоставляет вам ту же функциональность с поведениями конвейера (pipeline behaviors). Создайте класс, реализующий IPipelineBehavior:

class LoggingBehavior : IPipelineBehavior
{
    private readonly Logger _logger;

    public LoggingBehavior(Logger logger)
    {
        _logger = logger;
    }

    public async Task Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate next)
    {
        try
        {
            _logger.Log($"Before execution for {typeof(TRequest).Name}");

            return await next();
        }
        finally
        {
            _logger.Log($"After execution for {typeof(TRequest).Name}");
        }
    }
}

Зарегистрируйте его:

services.AddMediatR(typeof(Startup));
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));

И всё будет работать так же. Вам больше не нужны декораторы. Все зарегистрированные поведения конвейера будут выполнены для каждого обработчика запросов в том порядке, в котором они зарегистрированы.

Подход с поведениями даже лучше декораторов. Давайте посмотрим на следующий пример. Вам может требоваться обрабатывать некоторые запросы внутри транзакций. Чтобы пометить такие запросы, вы используете маркер-интерфейс ITransactional:

interface ITransactional { }

class CreateToDoItem : IRequest, ITransactional
    ...

Как применить поведение только к тем запросам, которые помечены ITransactional? Вы можете использовать ограничения на типы:

class TransactionalBehavior : IPipelineBehavior
    where TRequest : ITransactional
    ...

Проделать то же самое с декораторами Scrutor нельзя. Если вы реализуете декоратор вот так:

class TransactionalDecorator : IRequestHandler
    where TRequest : IRequest, ITransactional
    ...

вы не сможете использовать его, если у вас есть хотя бы один запрос, не реализующий ITransactional.

Создавая поведения конвейера, помните, что они выполняются при каждом вызове метода Send. Это может быть важно, если вы посылаете запросы изнутри обработчиков запросов:

class CommandHandler : IRequestHandler
{
    private readonly IMediator _mediator;

    public CommandHandler(IMediator mediator)
    {
        _mediator = mediator;
    }

    public async Task Handle(Command request, CancellationToken cancellationToken)
    {
        ...

        var result = await _mediator.Send(new AnotherCommand(), cancellationToken);

        ...
    }
}

Если и Command, и AnotherCommand помечены с помощью ITransactional, соответствующее поведение TransactionalBehavior будет выполнено дважды. Поэтому убедитесь, что вы не создаёте две отдельные транзакции.

Другая функциональность

MediatR предоставляет вам и другую функциональность. Так он поддерживает механизм уведомлений (notification). Он может оказаться очень полезным, если вы в вашей системе используете доменные события. Все классы событий должны реализовывать маркер-интерфейс INotification. И вы можете создать любое количество обработчиков для конкретного типа событий с помощью интерфейса INotificationHandler. Разница между запросами и уведомлениями следующая. Запрос передаётся только одному единственному обработчику. Уведомления передаются всем зарегистрированным обработчикам для данного типа уведомлений. Также от обработчика запроса вы можете получить некоторый результат его обработки. Уведомления не позволяют получить никакого результата. Используйте метод Publish для рассылки уведомлений.

MediatR также предоставляет механизм обработки исключений. Он довольно изощренный, и вы можете почитать о нём здесь.

Заключение

В завершение должен сказать, что для меня MediatR представляет собой очень интересный NuGet-пакет. Способность выражать любые операции через единый интерфейс и механизм поведений конвейера делают его очень привлекательным для моих проектов. Не могу сказать, что он представляет собой решение всех проблем, но определённые преимущества у него есть. Удачи вам в его использовании.

Похожее
Oct 3
Author: IT Illuminated
In the world of modern software development, the ability to perform multiple tasks simultaneously is important for building responsive and efficient applications. Have you ever wondered how your favorite apps handle multiple operations at once without slowing down? Or how...
May 30
Author: Erik Pourali
Imagine crafting a library app where users effortlessly find books by title, author, or genre. Traditional search methods drown you in code. But fear not! Dynamic Querying in C# saves the day. In our tale, crafting separate search methods for...
Jun 14
Author: codezone
Dependency injection is a powerful technique in software development that promotes loose coupling between components and improves testability and maintainability. When working with the HttpClient library in C#, integrating it with dependency injection can lead to cleaner and more manageable...
Jan 1, 2023
Author: Daniel Kreider
Here’s the simple step-by-step guide that will teach you how to build and code a generic repository. There are oodles of design patterns. Some of these design patterns are floating about on antique blogs full of mad logic. They’re ridiculous...
Написать сообщение
Тип
Почта
Имя
*Сообщение