O tym czemu generyczność to nie prostota
Cześć!
Zanim przejdę to właściwej treści to prośba z mojej strony. Paweł Klimczyk jest bardzo porządną i aktywną osobą w naszej programistycznej (szczególnie .NETowej) społeczności. Dawid, jego chrześniak ma 7 lat. Ma też guza mózgu. Jeśli masz jak dorzuć do składki (choćby 5 zł) i wesprzeć jego rodziców, to bardzo zachęcam: https://www.siepomaga.pl/dawid-stawiarski.
Najbliższy tydzień będzie dla mnie pracowity - trzy wystąpienia! Wspominałem w zeszłym tygodniu o prelekcji na 4Developers. Będę tam przedstawiał nowości z .NET 5 jak rekordy, nullowalne typy referencyjne i przede wszystkim Endpointy w kontekście CQRS.
Jutro będę miał próbę generalną na połączonych siłach grupy Wrocławskiej i Krakowskiej Grupy .NET - na którą Cię serdecznie zapraszam: https://www.meetup.com/wrocnet/events/278081384/.
W środę będę mówił razem z Andrzejem Ludwikowskim i Piotrem Wyczesanym o Event Sourcingu, czy to hit czy kit. Transmisja na Facebook na PGR: https://www.facebook.com/groups/programistycznagruparozwoju. Rónież zapraszam, po SegFaultowemu planujemy się w tańcu nie pier…
Chciałbym dzisiaj zaspoilerować nieco te wystąpienie, więc jak chcesz na nim być, to w tym momencie ostrzegam!
Jak wiesz, jestem zwolennikiem CQRSa. Uważam, że mylnie wzrozec jest uważany za skomplikowany. Według mnie, jest on w stanie pomóc nawet gdy mamy klasyczne CRUDowe podejście. Na przykład, jeśli wiemy, że dana metoda służy tylko do zwracania danych możemy wprowadzić optymalizację. Nawet w ORM Entity Framework - możemy oznaczyć, że nie będziemy śledzić zmian, przez co zapytania będą działać szybciej.
Drobne optymalizacje są fajne, ale więcej warte są te strategiczne zmiany. Często nazywam “Clean Architecture” jako “Onion Architecture”. Nie tylko z powodu nadmiaru warstw. Również z powodu specyficznego zapachu, który dookoła niej się unosi. Dzielimy w niej na poziome warstwy: API, Application layer, business layer, data layer, itd. itp. Porządna, “enterprise’owa” architektura. Dlaczego czepiam się jej zapachu?
Niezbyt ładnie mi pachną następujące rzeczy:
- jeśli zmienimy coś w danej warstwie, to najprawdopodobniej wpłynie to na nasze wszystkie funkcjonalności,
- próg wejścia i zrozumienia takiej architektury jest bardzo wysoki,
- zależności między warstwami i komponentami są trudne do odgadnięcia. Jeśli opakujemy wszystko w interfejsy to znalezienie ścieżki po jakiej idzie nasze wywołanie jest ciężkie. Nawet GPS z Hołkiem może nie pomóc,
- gdy zmieniamy dodajemy/zmieniamy funkcjonalność to musimy pamiętać o wielu rzeczach i zmienić w różnych warstawach.
To wszystko przekłada się na tzw. obciążenie poznawcze, a to z kolei wprost na koszt wytworzenia i wprowadzenia oprogramowania. Co więcej, zwiększa to ryzyko zmian. Jeśli mamy zmienić generyczne repozytorium, to ryzykujemy, że przez złego ifa możemy wszystko zepsuć. Często prowadzi to do zaniechania i unikania zmian lub co gorsza obejścia problemu “hackami”.
Wg tnąc nasz kod pionowo jesteśmy w stanie pisać bardziej dopasowany kod pod daną sytuację. Oczywiście nie mówię tutaj o metodzie Kopypejsta, ale o pisaniu prostego kodu. Co wcale nie jest proste, ale trudne (przeczytaj np. tutaj: https://hackernoon.com/why-senior-devs-write-dumb-code-and-how-to-spot-a-junior-from-a-mile-away-27fa263b101a).
Pracując nad wczorajszym wystąpieniem przygotowałem taki kod:
internal static class Route
{
internal static IEndpointRouteBuilder UseRegisterProductEndpoint(this IEndpointRouteBuilder endpoints)
{
endpoints.MapPost("api/products/", async context =>
{
var (sku, name, description) = await context.FromBody<RegisterProductRequest>();
var productId = Guid.NewGuid();
var command = RegisterProduct.Create(productId, sku, name, description);
await context.SendCommand(command);
await context.Created(productId);
});
return endpoints;
}
}
Razem z kilkoma prostymi rozszerzeniami:
public static class HttpExtensions
{
public static async Task<T> FromBody<T>(this HttpContext context)
{
return await context.Request.ReadFromJsonAsync<T>() ??
throw new ArgumentNullException("request");
}
public static Task Created<T>(this HttpContext context, T id, string? location = null)
{
context.Response.Headers[HeaderNames.Location] = location ?? $"{context.Request.Path}{id}";
return context.ReturnJSON(id, HttpStatusCode.Created);
}
public static async Task ReturnJSON<T>(this HttpContext context, T result,
HttpStatusCode statusCode = HttpStatusCode.OK)
{
context.Response.StatusCode = (int)statusCode;
if (result == null)
return;
await context.Response.WriteAsJsonAsync(result);
}
}
public static class CommandHandlerExtensions
{
public static ICommandHandler<T> GetCommandHandler<T>(this HttpContext context)
=> context.RequestServices.GetRequiredService<ICommandHandler<T>>();
public static ValueTask SendCommand<T>(this HttpContext context, T command)
=> context.GetCommandHandler<T>()
.Handle(command, context.RequestAborted);
}
Pozwala nam osiągąć wg mnie zupełnie prosty i czytelny kod. Dodatkowo mamy pełną kontrolę jeśli byśmy chcieli zrobić specyficzną obsługę dla danego endpointa (np. jakieś nagłówki itd.). Nie tracimy dużo, bo możemy pobrać nasz handler przez IoC. Przy pobraniu go opakować itd.
Zyskujemy też wydajność, bo nie robimy nadmiarowych mapowań, IFów itd.
Jednakże, przez lata wbijano nam do głowy zasadę DRY (Don’t Repeat Yourself). Możemy chcieć pójść dalej: jeszcze opakować to dodatkowo aby móc zrobić jednolinijkowca do rejestracji komend.
endpoints.MapCommand<RegisterProduct>(HttpMethod.Post, "/api/products", HttpStatusCode.Created)
Oczywiście możemy to zrobić:
internal static class EndpointsExtensions
{
internal static IEndpointRouteBuilder MapCommand<TRequest>(
this IEndpointRouteBuilder endpoints,
HttpMethod httpMethod,
string url,
HttpStatusCode statusCode = HttpStatusCode.OK)
{
endpoints.MapMethods(url, new []{httpMethod.ToString()} , async context =>
{
var command = await context.FromBody<TRequest>();
var commandResult = await context.SendCommand(command);
if (commandResult == CommandResult.None)
{
context.Response.StatusCode = (int)statusCode;
return;
}
await context.ReturnJSON(commandResult.Result, statusCode);
});
return endpoints;
}
}
Jednak wymusza to na nas od razu zmiany w interfejsie do komendy:
public interface ICommandHandler<in T>
{
ValueTask<CommandResult> Handle(T command, CancellationToken token);
}
public record CommandResult
{
public object? Result { get; }
private CommandResult(object? result = null)
=> Result = result;
public static CommandResult None => new();
public static CommandResult Of(object result) => new(result);
}
Dodatkowo, pojawia się problem:
- gdzie generować id? Przenieść do command handlera?
- HTTP status Created powinien zwracać nagłówek Location. Jak to zrobić? Dorzucić ifa w mapowaniu?
Co jak przyjdzie nam więcej takich wymagań? Albo będziemy musieli dorzucać jakieś (auto)mapowania, albo lambdy, albo refleksje. Co zyskujemy dzięki temu? Skrócenie handlerów o 10 linijek?
Tracimy też czytelność, bo nie widzimy wprost co się dzieje. Zataczamy też koło, i wróciliśmy do miejsca od którego chcieliśmy uciec. Zmiana w takim MapCommand może w skrajnym przypadku wywalić nam wszystkie endpointy.
Zatem pytanie do Ciebie, warto czy nie warto?
Ja od DRY wolę KISS - Keep It Simple, Stupid.
Zachęcam do zerknięcia do PRów:
- https://github.com/oskardudycz/EventSourcing.NetCore/pull/41 - właściwy,
- https://github.com/oskardudycz/EventSourcing.NetCore/pull/43 - z MapCommand.
Chętnie przyjmę komentarze.
Mam nadzieję, że widzimy się jutro lub pojutrze!
Pozdrawiam!
Oskar
P.S. Wrzuciłem na bloga “How to create a custom GitHub Action?”, który jest tłumaczeniem jednego z newsletterów. Jeśli jeszcze nie znasz, zachęcam do lektury. https://event-driven.io/en/how_to_create_a_custom_github_action/. Jeśli Ci się spodoba lub podesłania znajomym, będzie mi bardzo miło! Zerknij też do nowego Architecture Weekly: https://github.com/oskardudycz/ArchitectureWeekly#17th-may-2021.