Wstęp
To jest artykuł z cyklu „Kij w mrowisko” i jestem pewien, że duża część czytelników się ze mną nie zgodzi. I mają do tego prawo tak samo jak ja mam prawo do wyrażenia własnego zdania 😀
Zwracanie wyniku operacji z serwisu do kontrolera WebApi bywa czasem problematyczne. Zwłaszcza widzę to u młodych osób, ale nie tylko. W tym artykule przedstawię sposoby, które znam i sposób, który polecam.
ProblemDetails
Gwoli przypomnienia, od prawie 10 lat istnieje standard, który opisuje jak powinniśmy zwracać błędy z samego WebApi do klienta. To się nazywa ProblemDetails, o którym już pisałem w tym artykule. Ale ten tekst ma być o zwracaniu rezultatu z samego serwisu, a więc lećmy dalej.
Używanie wyjątków
To chyba sposób, który budzi najwięcej kontrowersji. Ja jestem mu zdecydowanie przeciwny, ale to też „zależy”. Zatem zobaczmy, jak mogłoby to wyglądać.
Spójrzmy na fragment przykładowego serwisu, gdzie dodajemy sobie do bazy danych jakiś TodoItem:
public async Task<ToDoItem> AddItem(string title, string description, DateTimeOffset expireDate)
{
if(_timeProvider.GetUtcNow() > expireDate)
{
throw new ArgumentException("Expire Date nie może być w przeszłości!");
}
//dalej dodajemy do bazy danych i zwracamy utworzony obiekt
return addedItem;
}
Od strony kontrolera mogłoby to wyglądać w skrócie tak:
public async Task<IActionResult> AddItem(ToDoDto item)
{
try
{
var result = await _service.AddItem(item.Title, item.Description, item.ExpireDate);
return Created(result);
}catch(ArgumentException ex)
{
return ProblemDetails(ex.Message);
}
}
Zwracam uwagę jeszcze na to, że zamiast rzucać i łapać ArgumentException, moglibyśmy stworzyć sobie jakiś ServiceException i łapać go na poziomie middleware zamiast bezpośrednio w kontrolerze. Takie podejście zdecydowanie jest ładniejsze, czystsze i dużo bardziej użyteczne. Natomiast chodzi tutaj o samo rzucanie wyjątków.
Czy wyjątki są wyjątkowe?
Tutaj wielu czytelników się ze mną nie zgodzi. Natomiast wyjątki powinny być stosowane do obsługi wyjątkowych sytuacji. Co to jest „wyjątkowa sytuacja”? Problem, którego nie jesteśmy w stanie przewidzieć albo jesteśmy w stanie przewidzieć, ale jego wystąpienie powoduje, że dalsze działanie aplikacji (czy też konkretnej akcji) nie może zostać ukończone ani w żaden sposób poprawione.
To teraz sobie odpowiedzmy na pytanie – czy nieprawidłowe dane podane przez użytkownika to jest wyjątkowa sytuacja? Zdecydowanie nie. To jest naturalne, że użytkownik poda nieprawidłowe dane (albo przypadkiem, albo ze względu na brak walidacji na froncie, albo po prostu próbuje atakować nasz system). Nie jest to zatem sytuacja wyjątkowa i w mojej ocenie nie powinna być obsługiwana za pomocą wyjątków.
Wyjątkową sytuacją w takim systemie mógłby być problem z dobiciem się do zewnętrznego API (jeśli używamy), problem z połączeniem z bazą danych, brak pamięci na dysku/w systemie itp. Sytuacje, w których nie możemy nic zrobić, żeby system zadziałał. Nie mamy na nie w ogóle wpływu.
A jeśli użytkownik podaje nieprawidłowe dane? Możemy go poprosić o prawidłowe.
Problemy z wyjątkami
To nie jest tylko kwestia „widzi mi się”. Nie raz słyszeliśmy, że obsługa wyjątków jest wolna. Owszem, pewnie w większości przypadków w normalnym użyciu systemu nikt z nas nie zauważy tego spowolnienia. Jednak tutaj problemem może być jeszcze jedna rzecz.
Wyjątki są wolne. Co oznacza, że w specyficznych sytuacjach mogą nam dać podatność na atak DDoS. Jeśli system jest publiczny (każdy może z niego korzystać za darmo lub za opłatą), wtedy rodzi się pewne niebezpieczeństwo, że ktoś będzie chciał nas hackować. A używanie wyjątków w celu walidacji danych daje hackerowi dodatkowe możliwości.
Kiedy używać?
Oczywiście tutaj też trzeba podejść do sprawy zdroworozsądkowo. Bo jednak użycie wyjątków w takiej sytuacji jest niesamowicie wygodne. Jeśli tworzysz system tylko do użytku wewnętrznego lub jest to coś niezbyt wartego uwagi, to śmiało. Nic złego raczej się nie stanie.
Jeśli jednak to system publiczny, a nie daj Boże jakiś SaaS, to zalecam jednak unikać takiego wykorzystania wyjątków. Możesz się tu ze mną kłócić. Historia nas oceni 😉
Używanie tupli
Innym często spotykanym rozwiązaniem jest użycie prostej tupli. Powyższy kod mógłby wyglądać tak (serwis):
public async Task<(ToDoItem? Result, string? Error)> AddItem(string title, string description, DateTimeOffset expireDate)
{
if(_timeProvider.GetUtcNow() > expireDate)
{
return new(null, "Expire Date nie może być w przeszłości!");
}
//dalej dodajemy do bazy danych i zwracamy utworzony obiekt
return new(addedItem, string.Empty);
}
A od strony kontrolera:
public async Task<IActionResult> AddItem(ToDoDto item)
{
var result = await _service.AddItem(item.Title, item.Description, item.ExpireDate);
if(string.IsNullOrEmpty(result.Error))
return Created(result.Result);
else
return ProblemDetails(result.Error);
}
Jest to proste rozwiązanie. Gdy idę w tym kierunku, zawsze jednak staram się tą Tuple zamieniać na zwykły rekord:
public record AddItemResult(ToDoItem? Item, string? Error);
Jest to nieco czystszym rozwiązaniem.
I o ile tutaj nie ma żadnych pozornych problemów, to nie radziłbym tego używać w większych systemach. Takie zwracanie rezultatu nie jest po prostu uniwersalne. Jednak w małych systemach działa całkiem elegancko.
Jeśli masz system, gdzie masz tylko kilka endpointów i to się nie będzie rozrastać jakoś szybko, to zdecydowanie jest to szybkie i skuteczne rozwiązanie. Ale można lepiej…
FluentResult
FluentResult to bardzo fajna biblioteka open source dostępna na Nuget. Pozwala zwrócić zarówno błędy (w liczbie mnogiej) jak i sam rezultat operacji. Jej użycie w systemie może być bardzo uniwersalne, eleganckie i wygodne.
W każdym razie powyższy kod mógłby wyglądać tak (serwis):
public async Task<Result<ToDoItem>> AddItem(string title, string description, DateTimeOffset expireDate)
{
if(_timeProvider.GetUtcNow() > expireDate)
{
return Result.Fail("Expire Date nie może być w przeszłości!");
}
//dalej dodajemy do bazy danych i zwracamy utworzony obiekt
return addedItem; //klasa Result sama ogarnie konwersję
}
No i od strony kontrolera:
public async Task<IActionResult> AddItem(ToDoDto item)
{
var result = await _service.AddItem(item.Title, item.Description, item.ExpireDate);
if(result.IsFailed)
return ProblemDetails(result.Reasons[0].Message);
else
return Created(result.Value);
}
FluentResult obsługuje zarówno wyjątki, jak i obiekty implementujące jego interfejs IError. Przykład, który widzisz wyżej (Result.Fail(„błąd”)) tak naprawdę pod spodem tworzy obiekt klasy Error (która implementuje IError) i przepisuje ten błąd do niej. Potem możesz to sobie odczytać.
FluentResult ma wiele użytecznych rzeczy out-of-the-box. Potrzebujesz błąd razem z jakimś jednoznacznym kodem?
Error err = new Error("Expire Date nie może być w przeszłości!")
.WithMetadata("ErrorCode", ValidationError);
return Result.Fail(err);
Przy czym ValidationError to object, czyli może być czymkolwiek. Intem, stringiem, kotkiem, czym sobie chcesz.
Po stronie kontrolera też to możesz odczytać na różne sposoby. Nie chcę się za bardzo rozpisywać na temat tej biblioteki, ponieważ dokumentacja jest wystarczająca.
Podsumuję tylko, że ja jej bardzo chętnie używam, a tam gdzie potrzebuję większej uniwersalności (np. konwersja błędów na ProblemDetails) wystarczy jedna mała extension method. Super też się sprawdza podczas testów jednostkowych.
To jest właśnie ten sposób, który ja polecam i sam używam.
A Wy czego używacie? Używacie wyjątków? Sprawiły Wam kiedykolwiek jakieś problemy? A może macie zupełnie inny sposób? Koniecznie podzielcie się w komentarzu.
Dzięki za przeczytanie artykułu. Jeśli coś jest niejasne albo znalazłeś gdzieś jakiś błąd, daj znać w komentarzu.
Obrazek wyróżniający: Obraz autorstwa freepik
