Przez lata spędzone na programowaniu aplikacji webowych stworzyłem tysiące kontrolerów. Niektóre napisałem lepiej, inne gorzej... nadal pamiętam to uczucie, gdy po raz pierwszy przenosiłem kod kontrolera do "serwisu" i czułem, że tak powinno wyglądać programowanie. Teraz wiem, że po prostu przenosiłem śmietnik z jednego miejsca do drugiego ;) To, czy będzie nosił nazwę "XController", "XService", "XManager" jest bez znaczenia, o ile nadal występuje w naszym projekcie. W tym artykule zaprezentuje Ci w jaki sposób możesz wykorzystać bibliotekę ApiEndpoints aby pisać lepszy kod.
Jaki jest problem kontrolerów?
Zanim zagłębimy się w bibliotekę pozwól, że opiszę Tobie co mi się nie podoba w kwestii kontrolerów. Problemy są dwa.... a w zasadzie jeden, SRP i spójność kodu (cohesion). Zacznijmy od bardzo prostego przykładu:
[ApiController]
[Route("api/[controller]")]
public class SongsController : ControllerBase
{
private readonly ISongCreator _songCreator;
private readonly ISongRemoval _songRemoval;
private readonly ISongUpdater _songUpdater;
private readonly ISongReader _songReader;
public SongsController(ISongCreator songCreator,
ISongRemoval songRemoval,
ISongUpdater songUpdater,
ISongReader songReader)
{
_songCreator = songCreator;
_songRemoval = songRemoval;
_songUpdater = songUpdater;
_songReader = songReader;
}
[HttpPost]
public async Task<IActionResult> Create(SongDto dto)
{
var result = await _songCreator.Create(dto);
return Created($"api/songs/{result.Value}", null);
}
[HttpGet]
public async Task<IActionResult> Get(SongId id)
{
var result = await _songReader.Get(id);
return Ok(result);
}
[HttpDelete]
public async Task<IActionResult> Delete(SongId id)
{
await _songRemoval.Remove(id);
return Ok();
}
[HttpPut]
public async Task<IActionResult> Update(Song song)
{
await _songUpdater.Update(song);
return Ok();
}
}
Jak widzisz przedstawiam bardzo prosty przykład. Każdy kontroler ma tylko dwie linie kodu, do czego miałbym się przyczepić? Tak samo mamy wydzielone zachowania do oddzielnych interfejsów. W sumie to, co się pod nimi kryje, tak mocno nas nie interesuje. Może to być pojedynczy serwis zarejestrowany pod czterema interfejsami, mogą to być oddzielne serwisy... w tym przypadku nie ma większego znaczenia. Nawet nie pisana zasada "Chudy kontroler, gruby serwis" jest tutaj przestrzegana. Przeanalizujmy więc jakie problemy ma ten mały kawałek kodu.
Spójność kodu
Jak możesz zauważyć przez konstruktor są wstrzykiwane cztery zależności. Nie jest ich wiele, jednak to nie mało. W "prawdziwych" projektach zdarzyło mi się widzieć konstruktory kontrolerów, których liczba parametrów była zbliżona do stawki godzinowej programisty piszącej go. Wiele parametrów może być pierwszym sygnałem, że dzieje się coś złego. Jako, że to subiektywne odczucie, zostawmy to w spokoju. Przecież jesteśmy inżynierami, znajdźmy jakąś metrykę, która będzie nam opisywać spójność kodu.
Opis LCOM 4
Daleko nie szukając spójżmy na LCOM4, która jest stosowana nawet przez analizatory SonarQube. Jeżeli to dla Ciebie nowość, już spieszę z wyjaśnieniem. Polega ona na wyliczeniu "wskaźnika" spójności klasy bazując na relacjach pomiędzy jej polami i metodami. Zgodnie z definicją metody są ze sobą powiązane jeżeli:
- Obie odwołują się do tego samego pola w klasie
- Jedna metoda wywołuje drugą lub odwrotnie
Po utworzeniu takiego grafu zależności możemy otrzymać jeden z poniższych wyników
- LCOM4 = 1 jest to pożądany wynik, mówi nam że klasa jest spójna
- LCOM4 >= 2 jest to wynik, który wskazuje problem. Najprawdopodobniej taką klasę da się podzielić na dwa niezależne byty
- LCOM4 = 0 jest wtedy kiedy w klasie nie ma metod. Jeżeli jest to nasza klasa domenowa może wskazywać, że występuje tam problem, lub jest to jakiś bardzo prosty obiekt DTO
LCOM4 a Songs Controller
Skoro już wiemy, że taka metryka istnieje i możemy ją bez problemu wyliczyć, spróbujmy dokonać wizualizacji grafu zależności w klasie.
Jak widać na powyższej wizualizacji możemy z tej klasy wydzielić cztery niezależne od siebie kawałki kodu (LCOM4 = 4). Widzimy, że poziom spójności, lekko mówiąc, nie jest zbyt poprawny.
Jak to możemy poprawić? Pewnie przeszło ci przez myśl, że to i tak, na koniec, wyląduje w jakimś serwisie. Dlaczego by nie scalić ich do jednego "SongsService", spójność kontrolera będzie spełniona. Oczywiście jest to całkowicie poprawne założenie, tylko z jednym wyjątkiem. Klasa kontrolera rzeczywiście wyniesie LCOM4=1, jednak problem ze spójnością zostanie przeniesiony do tego uber serwisu, który będzie odpowiedzialny za wszystkie możliwe do wykonania akcje.
Jednym ze sposobów mitygacji tego zjawiska jest zastosowania wzorca Mediator. Proponuje skorzystać z moich ulubionych bibliotek MediatoR, aby w dość szybki sposób zmienić implementacje kontrolera na:
[ApiController]
[Route("api/[controller]")]
public class SongsController : ControllerBase
{
private readonly ISender _sender;
public SongsController(ISender sender)
=> _sender = sender;
[HttpPost]
public async Task<IActionResult> Create(SongDto dto)
{
var result = await _sender.Send(new CreateSongCommand(dto));
return Created($"api/songs/{result.Value}", null);
}
[HttpGet]
public async Task<IActionResult> Get(SongId id)
{
var result = await _sender.Send(new GetSongCommand(id));
return Ok(result);
}
[HttpDelete]
public async Task<IActionResult> Delete(SongId id)
{
await _sender.Send(new RemoveSongCommand(id));
return Ok();
}
[HttpPut]
public async Task<IActionResult> Update(Song song)
{
await _sender.Send(new UpdateSongCommand(song));
return Ok();
}
}
W ten sposób osiągnęliśmy wysoką spójność klasy kontrolera, jaki i dzięki implementacji odpowiednich handler-ów jako autonomiczne byty w naszej aplikacji, nie straciliśmy jej dalej. Widać, że idziemy z tym kodem w dobrą stronę.
Wcześniej jednak wspominałem, że spójność to nie jedyny problem kontrolera, jest jeszcze...
Zasada jednej odpowiedzialności
Poznajmy jedną z najczęściej powtarzanych zasad w programowaniu (oczywiście poza DRY, KISS, YAGNI). Zacznijmy od szybkiego przypomnienia teorii:
The single-responsibility principle (SRP) is a computer-programming principle that states that every module, class or function in a computer program should have responsibility over a single part of that program's functionality, and it should encapsulate that part. All of that module, class or function's services should be narrowly aligned with that responsibility
Wycinek z wikipedii nie mówi za wiele, ale warto się z nim zapoznać. Pozostaje co prawda trochę miejsca do interpretacji, dlatego dużo bardziej podoba mi się cytat Wujka Boba
A class should have only one reason to change.
Powód to wymaganie biznesowe, które dotyka jednego lub więcej interesariuszy z naszego systemu... Ok, zaleciało kursem Droga Nowoczesnego Architekta 😁 Z tego co pamiętam, w jednej sekcji, chłopaki mówili właśnie coś podobnego. Mniejsza o większość.
Jako, że nasz kontroler może być używany przez wielu interesariuszy, np.
- Metoda Get może być dostępna dla wszystkich użytkowników
- Metoda Create i Update może być dostępna dla użytkowników z rolą moderatora lub wyższą
- Metoda Delete może być dostępna tylko dla adminów
Taka klasa łamię zasadę SRP... to prawdopodobnie odwieczny problem kontroler-ów.
ApiEndpoints na ratunek
Jakiś czas temu trafiłem na bibliotekę stworzoną przez Steve-a Smith-a, bardziej znanego pod pseudonimem Ardalis. To prosta biblioteka, która nakłada ograniczenia na programistów i pozwala na implementacje endpoint-ów w bardzo podobny sposób jak handler-y w bibliotece MediatR. Aby lepiej to zobrazować zobaczmy na implementacje przykładowego endpoint-u
public class Create : BaseAsyncEndpoint
.WithRequest<CreateSongRequest>
.WithoutResponse
{
private readonly ISongsWriteRepository _repository;
public Create(ISongsWriteRepository repository)
=> _repository = repository;
[HttpPost("api/songs")]
[SwaggerOperation(
Summary = "Create Song",
Description = "Create Song",
OperationId = "Song.Create",
Tags = new [] { "SongsEndpoints" }
)]
public override async Task<ActionResult> HandleAsync(CreateSongRequest request, CancellationToken cancellationToken = new ())
{
var song = Song.From(request);
await _repository.Add(song);
return Created($"api/songs/{song.Id.Value}", null);
}
}
Ważnym elementem w tej implementacji jest wykorzystanie atrybutu SwaggerOperation aby pogrupować endpointy na potrzeby definicji OpenApi. Już miałem kończyć, a bym zapomniał o jednym ważnym elemencie, konfiguracji biblioteki. Rozpoczęcie korzystania będzie potrzebować dwóch paczek nuget-a
- Ardalis.ApiEndpoints
- Swashbuckle.AspeNetCore.Annotations
Oprócz tego w pliku Startup.cs
w sekcji dotyczącej konfiguracji Swagger-a musisz dodać odpowiednie wywołanie, aby Swashbuckle był w stanie wygenerować dokument opisujący api z samych anotacji:
services.AddSwaggerGen(c => {
c.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" });
c.EnableAnnotations();
});
Podsumowanie
Nie spodziewałem się, że artykuł urośnie tak bardzo. Jeżeli, mój drogi czytelniku, dotrwałeś do końca, to szacunek! Mam nadzieje, że było ciekawie. Jeżeli chcesz się dowiedzieć nieco więcej o samej bibliotece MediatR to bardzo zapraszam Cie do pobrania dokumentu, który przygotowałem. Link do niego znajdziesz pod sekcją referencji.
Do Następnego!