Z reguły naszą logikę biznesową umieszczamy w klasach serwisowych. Czasem są one większe, a czasem mniejsze. Zastanówmy się, jak mógłby wyglądać nasz kod, gdybyśmy dla każdej metody z serwisu tworzyli osobną klasę. Wykorzystamy do tego bibliotekę MediatR.
Jeśli ktoś się zastanawia po co, odpowiedź jest prosta: aby zwiększyć spójność i zmniejszyć sprzężenie. Na razie nie będziemy się zagłębiać w takie pojęcia jak CQRS czy CQS. Naszym celem jest rozbicie klas na mniejsze i zmiana tego, jak je wołamy. Po tych zmianach nasze testy e2e nadal powinny działać dokładnie tak samo.
O różnych sposobach grupowania logiki w klasach pisałem tutaj a o tym, jak ważna jest duża spójność (cohesion) i małe sprzężenie (coupling) tutaj.
Kod bez użycia MediatR
Na początku załóżmy, że mamy dwa różne interfejsy, które są wykorzystywane przez nasz serwis. Dla uproszczenia przykładu nazwałem je IReadItemsRepository i IWriteItemsRepository.
public interface IReadItemsRepository
{
Task<Item> GetByIdAsync(Guid itemId);
}
public interface IWriteItemsRepository
{
Task<Guid> InsertAsync(Item item);
}
Nasz serwis może wyglądać następująco:
public interface IItemsService
{
Task<Item> GetByIdAsync(Guid itemId);
Task<Guid> InsertAsync(Item item);
}
public class ItemsService : IItemsService
{
private readonly IReadItemsRepository _readItemsRepository;
private readonly IWriteItemsRepository _writeItemsRepository;
public ItemsService(
IReadItemsRepository readItemsRepository,
IWriteItemsRepository writeItemsRepository
)
{
_readItemsRepository = readItemsRepository;
_writeItemsRepository = writeItemsRepository;
}
public async Task<Item> GetByIdAsync(Guid itemId)
{
return await _readItemsRepository.GetByIdAsync(itemId);
}
public async Task<Guid> InsertAsync(Item item)
{
return await _writeItemsRepository.InsertAsync(item);
}
}
Łatwo zauważyć, że na załączonym przykładzie nasz serwis ma 2 zależności, a każda z jego metod wykorzystuje tylko 1 z nich (czyli wykorzystuje 50% zależności).
Ważne jest jeszcze to, jak wołany jest nasz serwis:
public class ItemsController : ControllerBase
{
private readonly IItemsService _itemsService;
public ItemsController(IItemsService itemsService)
{
_itemsService = itemsService;
}
[HttpGet("{itemId}")]
public async Task<IActionResult> GetById(Guid itemId)
{
return Ok(await _itemsService.GetByIdAsync(itemId));
}
[HttpPost]
public async Task<IActionResult> Insert([FromBody]Item item)
{
return Ok(await _itemsService.InsertAsync(item));
}
}
Kod z użyciem MediatR
Gdy zaczniemy używać bibliotekę MediatR, nasze nowe klasy będą wołane w następujący sposób:
public class ItemsController : ControllerBase
{
private readonly IMediator _mediator;
public ItemsController(IMediator mediator)
{
_mediator = mediator;
}
[HttpGet("{itemId}")]
public async Task<IActionResult> GetById(Guid itemId)
{
return Ok(await _mediator.Send(new GetItemById(itemId)));
}
[HttpPost]
public async Task<IActionResult> Insert([FromBody]Item item)
{
return Ok(await _mediator.Send(new InsertItem(item)));
}
}
To jest duża zmiana. Musimy pamiętać, że takie podejście ma jedną ogromną wadę: dodając zależność do IMediator w naszym kontrolerze, powodujemy, że może on wywołać dowolny handler z dowolną logiką, a nie tylko z logiką odpowiednią dla danego kontrolera (kontekstu). Dlatego podczas przeglądu kodu musimy być szczególnie uważni i monitorować, czy przypadkiem nie umieściliśmy wywołania danego handlera w nieodpowiednim kontrolerze. Niemniej jednak gdy już to opanujemy, łatwiej będzie nam potem zastosować wzorzec CQRS, jeśli oczywiście będziemy chcieli.
Handlery dla naszych metod mogą wyglądać tak:
public class GetItemById : IRequest<Item>
{
public GetItemById(Guid itemId)
{
ItemId = itemId;
}
public Guid ItemId { get; }
}
public class GetItemByIdHandler : IRequestHandler<GetItemById, Item>
{
private readonly IReadItemsRepository _readItemsRepository;
public GetItemByIdHandler(IReadItemsRepository readItemsRepository)
{
_readItemsRepository = readItemsRepository;
}
public async Task<Item> Handle(GetItemById request, CancellationToken cancellationToken)
{
return await _readItemsRepository.GetByIdAsync(request.ItemId);
}
}
public class InsertItem : IRequest<Guid>
{
public InsertItem(Item item)
{
Item = item;
}
public Item Item { get; }
}
public class InsertItemHandler : IRequestHandler<InsertItem, Guid>
{
private readonly IWriteItemsRepository _writeItemsRepository;
public InsertItemHandler(IWriteItemsRepository writeItemsRepository)
{
_writeItemsRepository = writeItemsRepository;
}
public async Task<Guid> Handle(InsertItem request, CancellationToken cancellationToken)
{
return await _writeItemsRepository.InsertAsync(request.Item);
}
}
Po naszych zmianach każda z nowych klas ma po 1 zależności, więc metoda w danej klasie wykorzystuje je wszystkie (100%). Dzięki takiemu zabiegowi nie powinniśmy już mieć ogromnych klas z dużą ilością metod i zależności, gdzie tylko niektóre zależności są potrzebne dla danej metody.
Na koniec pozostaje nam jeszcze rejestracja nowych klas w kontenerze wstrzykiwania zależności. Mamy do tego specjalną metodę, która wyszukuje i rejestruje wszystkie klasy implementujące interfejs IRequestHandler:
public void ConfigureServices(IServiceCollection services)
{
// ...
services.AddMediatR(typeof(GetItemByIdHandler));
// ...
}
Metoda AddMediatR jako parametr przyjmuje kilka możliwości. Na nasze proste potrzeby najbardziej przydatnymi będą dwie: ta która akceptuje listę Assembly i ta która akceptuje listę Type (z których i tak jest potem tworzona lista Assembly). Dlatego bez problemu możemy nasze handlery mieć w wielu różnych projektach. Wystarczy wtedy do tej metody przekazać listę odpowiednich Assembly lub Type.
Podsumowanie
Po naszych zmianach kod powinien działać tak samo, ale ze względu na zwiększenie spójności i zmniejszenie sprężenia będzie łatwiejszy w utrzymaniu. Nasze klasy nie będą rozrastać się w nieskończoność, nie trzeba będzie ich dzielić na mniejsze, a dodatkowo znalezienie szukanej logiki też powinno być prostsze. Oczywiście prawdziwe przypadki są bardziej rozbudowane. Jednakże na tym prostym przykładzie zobaczyliśmy, jak w łatwy sposób możemy zwiększyć spójność i zmniejszyć sprzężenie.
Pingback: dotnetomaniak.pl
Możliwość komentowania została wyłączona.