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

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

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

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-пакет. Способность выражать любые операции через единый интерфейс и механизм поведений конвейера делают его очень привлекательным для моих проектов. Не могу сказать, что он представляет собой решение всех проблем, но определённые преимущества у него есть. Удачи вам в его использовании.

Похожее
Jul 7, 2021
Author: Changhui Xu
C# has a feature, String Interpolation, to format strings in a flexible and readable way. The following example demonstrates the way how we usually output a string when we have data beforehand then pass data to the template string. var...
Nov 11
Author: Jeslur Rahman
SOLID principles make it easy for a developer to write easily extendable code and avoid common coding errors. These principles were introduced by Robert C. Martin, and they have become a fundamental part of object-oriented programming. In the context of...
Dec 1, 2023
Author: Rico Fritzsche
The flight monitor case study: Applying Vertical Slices in a real-world scenario In my last articles I talked about Vertical Slice Architecture and how to organize code in a non-technical way. I showed some simple examples of how to do...
Feb 10, 2023
Author: Hr. N Nikitins
Design patterns are essential for creating maintainable and reusable code in .NET. Whether you’re a seasoned developer or just starting out, understanding and applying these patterns can greatly improve your coding efficiency and overall development process. In this post, we’ll...
Написать сообщение
Тип
Почта
Имя
*Сообщение