Wyjątkowe wyjątki i unhappy path

Patryk Woziński
6 min readJan 20, 2019

--

Wyjątki to chleb powszedni w codzienności każdego developera. Niestety mimo, że towarzyszą nam one od zawsze, to bardzo wiele osób miewa problemy z odpowiednim zachowaniem się w ich obliczu. Często tworzymy je bezmyślnie lub nie zwracamy ich w odpowiednim momencie. Postaram się w tym artykule opisać parę istotnych spraw i rozwiązań, o których warto pamiętać.

Generyczne wyjątki to zło

Zło, diabeł i problemy, które w przyszłości mogą być dla nas czymś, co zniszczy nasz projekt. Główny problem generycznych wyjątków, to fakt, że nie mamy nad nimi właściwie żadnej kontroli. Nie jesteśmy w stanie odseparować oczekiwanych w konkretnych momentach wyjątków załóżmy, że pochodzących z warstwy domenowej od tych, które wystąpiły całkowicie wbrew naszym planom. Spodziewamy się, że aplikacja w momencie, gdy spróbujemy dodać coś do przepełnionego magazynu zwróci wyjątek z odpowiednią informacją. Chcemy w takiej sytuacji móc łatwo zareagować i zaprogramować kolejne kroki. Co w takiej sytuacji, gdybyśmy rzucili w naszej aplikacji czysty wyjątek, którego przechwycenie w jakimkolwiek momencie byłoby niemożliwe? Powstałaby gigantyczna liczba instrukcji warunkowych, które zaćmiłyby uwypuklone procesy biznesowe. Musimy mieć możliwość odseparowania przewidzianych przez nas wyjątków, zbudować alternatywy, a zaś w sytuacji, gdy nastąpi błąd nieoczekiwany, który nie będzie obsłużony - powinniśmy zwrócić status 500 aplikacji.

Proszę wezwać sprzątaczkę, coś tutaj brzydko pachnie

Logika w catchu i łapaniu unhappy pathów to ewidentnie brzydki zapaszek w kodzie. Takie miejsca powinny być bardzo przejrzyste, proste i możliwe do zrozumienia podczas pierwszego spojrzenia na te kilka linijek. Jeżeli napotkacie tam jakieś instrukcje warunkowe, czy nie daj Boże pętle – wzywajcie serwis sprzątający. To nie miejsce na logikę z jakąkolwiek złożonością. Coś się stało? Okej, log, rollback, event, prosta reakcja systemu. W tym miejscu nie tworzymy kodu, który mógłby być dodatkowym miejscem do wysypania się podczas działania naszego systemu.

Nie nazywajmy szamba perfumerią

Załóżmy, że nie rzucamy już generycznych wyjątków, wszystkie są odpowiednio budowane dla konkretnych kontekstów. Wszystko brzmi fajnie, prawda? Pozostaje jednak kwestia wychwytywania wyjątków i stosowania w takich miejscach ogólników. Ostatnio na forum o pewnej technologii jedna z osób zadała parę pytań i przedstawiła swój kod, w którym łapane były wszystkie problemy z aplikacją i ubieranie w zgrabny status 400 Bad Request. Uważam, że to niepoprawne podejście do zarządzania unhappy path w naszych aplikacjach. Fajnie, że nie rzucamy czystej, brzydkiej strony informującej użytkownika o napotkanym błędzie, ale istnieją dużo lepsze i bezpieczniejsze metody na informowanie ostatecznego odbiorcy o problemach.

Postawmy sprawę jasno – wystąpił w systemie problem, na którego obsługę się nie przygotowaliśmy? Leci 500-tka, nie – nie opakowujemy gówna w ładne opakowanie i nie zwracamy tego udając, że nic się nie stało. Był problem, aplikacja nie była na to przygotowana – leci pięćsetka. Po pierwsze dzięki takiemu zachowaniu mamy zachowane poprawne funkcjonowanie logów wszelkich odchyleń od normy, które będą w należyty sposób zapisywane, po drugie klient dostaje prawdziwe informacje na twarz, może zgłosić się do supportu i poprosić o rozwiązanie problemu, lub zwyczajnie poinformować nas o sytuacji, która mu się przydarzyła. Nie oszukujmy go i nie mydlmy mu oczu przez przykładowy komunikat „Nieprawidłowe wyszukiwanie, spróbuj ponownie”. To bardzo nieprofesjonalne podejście.

Dodatkowo w momencie, gdy już poleci nasz nieszczęsny błąd 500 – możemy w wyższej warstwie wpiąć handler wyjątków i w trybie produkcyjnym aplikacji zwrócić end-userowi ładną, stosownie przygotowaną podstronę błędu, w której zawrzemy informacje o możliwych formach kontaktu. Tylko tryb developerski / debugowania będzie nam zwracał pełen stacktrace z przyczynami błędów. Nie mielibyśmy go gdybyśmy pozostali przy zwracaniu sztucznie generowanej 400tki i zapewne sami musielibyśmy zastanawiać się nad powodem takiego zachowania oprogramowania.

Re-Throw i warstwy aplikacji

Kiedy stosować re-throw wyjątków i w czym on nam pomaga? Wyobraźmy sobie sytuację, gdzie stosujemy wielowarstwowe podejście do architektury. Nie mówię tutaj o smrodzie jakim jest MVC, ADR i podobne. Mówię tutaj o czystej architekturze, hexagonie czy podejściu znanym z Domain Driven Design. Mamy więc wiele różnych warstw systemu, a o każdej pokrótce postaram się napisać parę słów.

  • Warstwa aplikacyjna – miejsce, które to jest jedynym punktem dostępowym do konkretnych funkcjonalności naszego systemu. Niektórzy nazywają tę warstwę API systemu i jedyny punkt dostępu.
  • Warstwa domenowa – serce naszego systemu, to właśnie dla niego tworzymy naszą aplikację. To tutaj zawarte są wszystkie skomplikowane elementy procesów biznesowych.
  • Warstwa infrastruktury – czyli miejsce gdzie siedzą implementacje poszczególnych elementów oprogramowania. Klienty obsługi zewnętrznych systemów, repozytoria, loggery i inne ciekawostki.
  • Warstwa interfejsu użytkownika (UI) – wszystko to, z czym spotykamy się wchodząc do aplikacji. Punkt wejściowy, który wykorzystuje inne warstwy w celu spełnienia potrzeb użytkownika. Kontrolery, REST, CLI i inne.

Dobrze! Skoro już opisałem nasze cztery warstwy, to mogę zabrać się za temat re-throwów pomiędzy nimi. Bardzo ciekawą praktyką, o której dowiedziałem się od kolegi z zespołu – Łukasza Rynka (ciekawa prezentacja Nikola Poša) – jest to, aby w każdym module, czy tak jak tutaj – w każdej warstwie tworzyć paczki poziomów wyjątków. Przykładowo w Domain tworzymy wyjątki abstrakcyjne takie jak InvalidArgument, które to dziedziczą po natywnym wyjątku dostarczanym przez język (w moim przypadku PHP). Następnie tworzymy pod każdą ścieżkę uszczegółowione finalne klasy, takie jak przykładowo InvalidUsername w kontekście subdomeny użytkowników.

Warstwa domenowa rzuca w takim razie bardzo wiele różnych błędów, które mogą być związane z nieprawidłowymi argumentami. Zamiast wychwytywać pojedynczych wyjątków, możemy zebrać je w paczkę w warstwie aplikacyjnej, w konkretnym przypadku użycia (Command / Query lub Application Service), nadać bardziej ogólny kontekst i zrobić re-throw do najniższej warstwy jaką jest UI. Teraz już nie trzeba reagować na wszystkie błędy domenowej walidacji, a wystarczy złapanie ogólnika i napisanie zachowania aplikacji pod konkretne problemy. Oczywiście możemy też wyłapywać poszczególne błędy i próbować na nie reagować. Jest to również bardzo dobre rozwiązanie, ale zdarzają się przypadki, gdzie istnieją grupy wyjątków, na które reagujemy w jednolity sposób i dzięki temu możemy zrobić to o wiele prościej.

Named constructors

Niestety, wolałem odpuścić tłumaczenie tytułu tej części tekstu, bo brzmiałoby to co najmniej zabawnie. Mowa więc o statycznych konstruktorach wyjątków, zaznaczę na początku, że jestem mocnym przeciwnikiem statycznych metod, które zabierają nas w świat programowania proceduralnego i tworzenia worków na funkcje, ale… stosuje statyczne metody tylko w przypadku wyjątków, a dokładnie ich konstruktorów i uważam to za całkiem wygodne rozwiązanie.

Dobrze, czym więc są named constructors? Są to jak już wspomniałem statyczne metody klas wyjątków, które opisują nam sposób wystąpienia unhappy patha. Za przykład wezmę wyszukiwanie użytkownika i w przypadku jego nieodnalezienia wyjątek UserNotFound. Dobrze, możemy więc w odpowiednich miejscach systemu reagować na taką sytuację i nie potrzebujemy szczegółów związanych z kontekstem, w którym wystąpił wyjątek. Reagujemy na UserNotFound i nie jesteśmy zainteresowani powodem takiego stanu rzeczy. Obiekt możemy zbudować przykładowo za pomocą withName(string name): self, gdy nie odnaleźliśmy wyniku podczas wyszukiwania po nazwie, albo też withEmail(string email): self – z punktu widzenia sterowania aplikacji nie ma to większego znaczenia, ale w sytuacji, gdy mamy zamiar zalogować odpowiednią informację, to uzyskamy dodatkowy kontekst i więcej szczegółów. Oczywiście równie dobrze możemy jawnie przekazywać message związany z naszym wyjątkiem w momencie jego tworzenia – wybór należy do Was, ja osobiście bardzo lubię wygodę, którą uzyskałem wraz ze stosowaniem statycznych, nazwanych odpowiednio konstruktorów. Chętnie poznam Wasze opinie, także śmiało komentujcie!

Reasumując kontekst jest królem, daje nam kontrolę nad zachowaniem systemu jak i pozwala łatwo, bezpiecznie reagować na wszelkie unhappy path, które są tak samo ważne w kontekście pisania testów jak i procesów, oraz zachowań aplikacji. Pamiętajmy o tym, by myśleć nie tylko o poprawnych ścieżkach, ale mieć w głowie także miejsce na sytuacje wyjątkowe. ;) Moim zdaniem pomaga w pamiętaniu o nich event storming, to genialne narzędzie, dzięki któremu zapanujemy kompleksowo nad całym szeregiem procesów za które odpowiada nasze oprogramowanie.

Dziękuje za poświęcony czas na przeczytanie tego wpisu. Jeżeli znajdzie się chociaż jedna osoba, której to się przydało – będę miał potwierdzenie tego, że warto było przelać to wszystko z głowy na… klawiaturę telefonu.

--

--

Patryk Woziński
Patryk Woziński

Written by Patryk Woziński

Product Engineer with many years of experience in creating and designing web applications. #DDD freak