Mediator i MediatR

Implementacja CQRS w praktyce

Piotr Wandycz

Mediator to wzorzec projektowy, z kategorii wzorców zachowań. Odpowiada za komunikację między obiektami. Tworzymy tu centralny obiekt, który obsługuje nasze żądania (request) i ewentualnie zwraca odpowiedzi (response). Porównuje się go często do wieży kontroli lotów – samoloty nie  rozmawiają ze sobą bezpośrednio, to wieża kontroluje ich położenie i wydaje im odpowiednie komendy.

To jeden z niewielu wzorców używanych przeze mnie na co dzień. Dzięki niemu mogę łatwo realizować CQRS – do obiektu mediatora trafiają zapytania (query) i komendy (command). Jeśli było to query, to zostanie zwrócony odpowiedni response / read model. Nie byłoby to jednak możliwe bez użycia biblioteki MediatR – napisanej przez Jimmiego Bogarta (jest on także twórcą AutoMappera). Bardzo lubię takie NuGety, gdzie wystarczy je zainstalować, dopisać jedną linijkę kodu i działa.

Konfigurację pokażę standardowo – na projekcie typu AspNetCore. Będziemy potrzebować dwa NuGety – MediatR oraz MediatR.Extensions.Microsoft.DependencyInjection (istnieje też wersja pod Autofac). Później wchodzimy w Startup.cs i finalizujemy rejestrację:

using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace MediatorTest
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllers();
            services.AddMediatR(typeof(Startup).Assembly);
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseHttpsRedirection();
            app.UseRouting();
            app.UseAuthorization();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllers();
            });
        }
    }
}

Jedna linijka kodu i wszystkie zależności będą rozwiązywały się automagicznie.

Przykładowy kontroler

Jedną z największych zalet mediatora jest odseparowanie kawałków kodu od siebie w prosty sposób. Dzięki temu w kontrolerze mogę trzymać jedynie rzeczy związane typowo z infrastrukturą projektu webowego, a całą resztę logiki wypchać gdzieś na zewnątrz. Przeważnie wygląda to tak, że mam wstrzyknięty jedynie obiekt mediatora do konstruktora.

Dodatkową zaletą tej biblioteki jest silnie typowana odpowiedź. W przeszłości sam implementowałem Query/Command Dispatcher i kończyłem z rzutowaniem zwrotki. Tu zwracamy konkretny typ, dlatego kod upraszcza się jeszcze bardziej.

using Microsoft.AspNetCore.Mvc;
using MediatR;
using MediatorTest.Query;
using System.Threading.Tasks;
using MediatorTest.Command;

namespace MediatorTest.Controllers
{
    [ApiController]
    [Route("Api/Users")]
    public class UsersController : ControllerBase
    {
        private readonly IMediator _mediator;

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

        [HttpGet("GetAll")]
        public async Task<GetUsersResponse> GetAll()
        {
            return await _mediator.Send(new GetUsersQuery());
        }

        [HttpPost("Create")]
        public async Task Create(string name)
        {
            await _mediator.Send(new CreateUserCommand(name));
        }
    }
}

Przykładowa kwerenda

Query składa się z trzech części. Pierwszą z nich jest publiczna klasa, która wskazuje jakiej odpowiedzi żąda (IRequest). Czasami jest pusta, czasami dopisujemy tu właściwości filtrujące zapytanie, np. id szukanego użytkownika.

using MediatR;

namespace MediatorTest.Query
{
    public class GetUsersQuery : IRequest<GetUsersResponse>
    {
        // TODO: add optional filter parameters
    }
}

Elementem wiążącym całość są handlery. Nie ma problemu, żeby były prywatne, nawet jeśli znajdują się w innym projekcie. Do utworzenia tej klasy wystarczy podziedziczyć IRequestHandler i wskazać mu co jest na wejściu, a co na wyjściu. Następnie kliknąć “zaimplementuj interfejs”, aby nie zapamiętywać składni. Możemy tu wstrzykiwać inne serwisy, aby zebrać komplet potrzebnych przez nas danych. Mamy tez pod ręką Query z możliwymi parametrami filtrującymi.

using MediatR;
using System.Threading;
using System.Threading.Tasks;

namespace MediatorTest.Query
{
    class GetUsersQueryHandler : IRequestHandler<GetUsersQuery, GetUsersResponse>
    {
        public async Task<GetUsersResponse> Handle(GetUsersQuery request, CancellationToken cancellationToken)
        {
            // TODO: get users from database
            return new GetUsersResponse();
        }
    }
}

Response nie wymaga niczego szczególnego. To najprostszy Data Transfer Object, zwracający nam wynikowe dane.

namespace MediatorTest.Query
{
    public class GetUsersResponse
    {
        // TODO: return data, this is a Dto
    }
}

Przykładowa komenda

Command z natury nic nie zwraca. Dlatego nie podajemy typu zwracanego przy dziedziczeniu po IRequest. Warto tu robić pola get-only przypisywane w konstruktorze, żeby mieć pewność, że nie zmienimy przez przypadek ich wartości w handlerze.

using MediatR;

namespace MediatorTest.Command
{
    public class CreateUserCommand : IRequest
    {
        public string Name { get; }

        public CreateUserCommand(string name)
        {
            this.Name = name;
        }
    }
}

Przy CommandHandler sprawa robi się trochę ciekawsza. Implementując interfejs, zostanie zwrócony typ Unit. Jest to drobny hack, aby łatwo było zrobić obiekt mediatora. Trzeba się przyzwyczaić, że istnieje i go ignorować. Przytoczę tutaj jedno zdanie z dokumentacji:

To simplify the execution pipeline, IRequest inherits IRequest<Unit> where Unit represents a terminal/ignored return type.

using MediatR;
using System.Threading;
using System.Threading.Tasks;

namespace MediatorTest.Command
{
    class CreateUserCommandHandler : IRequestHandler<CreateUserCommand>
    {
        public async Task<Unit> Handle(CreateUserCommand request, CancellationToken cancellationToken)
        {
            // TODO: add user to the database
            return Unit.Value;
        }
    }
}

MediatR ma dużo większe możliwości niż te tutaj zaprezentowane. Możemy robić obsługę zdarzeń (event handlery) poprzez implementowanie INotification, obsługiwać wyjątki czy robić pipeline’y (dekoratory). Z tych dwóch ostatnich funkcji korzystałem i postaram się je kiedyś opisać. Standardowo zachęcam do zapoznania się z pełną dokumentacją.

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *