Sterowanie wyjątkami

1

Hej,

Chciałbym podyskutować o sterowaniu wyjątkami. Jeżeli ktoś wyraził o tym jakąś opinię na forum przy okazji innego tematu, to była ona przeciwna jej stosowania.

Wiadomo że zabawa z wyjątkami jest bardziej kosztowna niż inne rozwiązania, dlatego nie rozmawiajmy o przypadkach gdy wydajność jest krytyczna.

W ostatnich latach bardzo spopularyzowały się webserwisy. Nie tylko jako komunikacja między modułami w dużych aplikacjach, czy między zupełnie obcymi tworami, ale chociażby poprzez spopularyzowanie single page applications. Załóżmy więc że mamy następujący przypadek:
Tworzona jest aplikacja SPA a development frontendu i backendu jest robiony przez 2 inne teamy. Dobrym rozwiązaniem jest opracowanie jednego api dla błędów i stosowanie go wszędzie. Błędy z dowolnej usługi po stronie FO można obsłużyć w ten sam sposób. Załóżmy że zwrotka będzię w stylu:

{
    response: {} ,
    errorList: [{
        message:"",
        param1:{},
        param2:{}
    },{}]
}

Dostajemy albo prawidłowy response, albo listę błędów. W zależności co dostaniemy, możemy wrzucić response do jakiejś funkcji obsługującej lub wyświetlić listę errorów, podświetlić błędne pola etc.

Rozwiązanie 1: Bez wyjątków
Tworzymy obiekt przechowujący listę errorów oraz flagę, czy dalsze przetwarzanie ma sens.
Walidujemy request i obsługujemy w sposób -> 1) jeżeli posiada errory to dodajemy do listy. Jeżeli jakaś walidacja jest krytyczna, to ustawiamy flagę na true.
-> 2) Jeżeli !flaga, to możemy przetwarza dalej. Jeżeli nie, zwracamy listę.
Robimy dalsze obliczenia lub pobieramy coś z bazy.
Na nowych danych wywołujemy 1) a następnie ponownie 2).
Itd.

Zalety:
- Brak sterowania wyjątkami (zaleta czysto ideowa).
- Szybkie
- Możemy dodawać errory niekrytyczne (np. nieprawidłowy format email), do póki będziemy mieli wystarczająco prawidłowych danych do wykonania kolejnych kroków.

Wady:
- Masa spaghetti kodu obsługującego błędy. Duży stosunek kodu niebiznesowego do biznesowego. 

Rozwiązanie 2: Z wyjątkami
Każda faza walidacji składa się na:

  • Zebranie listy błędów z obecnego etapu.
  • Rzucenie wyjątku z tą listą

    Następnie w kontrolerze robimy przechwytywanie wyjątku i mapowanie na odpowiedni request. Dodatkowo np. Spring z javy posiada @ControllerAdvice który automatycznie przechwycił by wyjątek, czyli mapowanie wyjątku na response może zostać zrobione w jednym miejscu i wykorzystane w każdym konrolerze. Podejrzewam że każdy sensowny framework z dowolnego języka ma takie coś zaimplementowane.

    Zalety:

  • Czytelność. Jest bardzo niewiele kodu obsługującego błędy a i tak mamy pewność że obecny zestaw danych pozwala na dalsze przetwarzanie.
  • Posiadamy stos wywołań

    Wady:

  • Wolniejsze wykonanie.
  • Brak możliwości agregacji błędów dopóki nie wystąpi błąd krytyczny.

Rozwiązanie 3: Z AOP
Nie lubię AOP bo zaciemnia kod, więc jak ktoś chce to niech dopisze sam ;P.

Wrzućmy na tapetę zalety:
R1)

  • Pierwsza zaleta tylko dla purystów. Uważam że można wprowadzić wprowadzić byt o nazwie BusinessException i nie będzie to już wyjątek aplikacji, a wyjątek przetwarzania żądania.
  • Druga zaleta tak naprawdę nie do końca jest pewna. Jeżeli duża część requestów będzie poprawna, to się okazać że wcale nie jest wolniejsza. Dodatkowo, doliczając narzut samego wywołania przez http, to jaki procent tracimy?
  • Trzecia to tak naprawdę jedyna sensowna zaleta moim zdaniem. Chociaż z drugiej strony, nie jest to warte ceny bo w większości wypadków wystarcza walidacja etapami.

    R2) 
  • Pierwsza zaleta - wydaje mi się że to jest najważniejsze w dużych systemach. Eliminacja boilerplate code to podstawa.
  • Na pierwszy rzut oka stos może nam się wydać zbędny. Przecież jeżeli to użytkownik wprowadził błędne dane, to po co nam wiedzieć gdzie to poleciało? Otóż, czasem może się okazać że to my mamy babola, i prawidłowe dane potraktujemy jako błędne. Wtedy taki stos pozwala nam szybciej zlokalizować walidację.

Pora na wady:
R1) - Wada nr 1, bardzo krytyczna. Boilerplate code == zło. I to właściwie wyczerpuje temat.
R2) - Wolniejsze wykonie - tutaj za wytłumaczenie możemy przyjąć odwrotność drugiej zalety R1.

  • Faktyczny problem, chociaż w większości przypadków dodaje tylko ciut mniejszą wygodę.

Rozwiąznie AOP stworzyło by pewnie masę kodu który ogarnia dobrze jedna osoba która go napisała. Do tego jest dziurawy w kilku miejscach. Nie wierzę w to rozwiązanie, ale chętnie przyjmę naprostowanie jeżeli jestem w błędzie.

Prosiłbym o dyskusję dyskusję w małym stopniu powiązaną z konkretnym językiem. Wiem że w C nie ma wyjątków a w jakimś innym języku to może w ogóle jest super rozwiązanie z syntatic sugar które jest lekiem na całe zło. Tutaj głównym tematem jest sterowanie wyjątkami a coś poza tym może być dodane jako ciekawostka.

Co uważacie na ten temat? Może znacie jakieś lepsze rozwiązania od przedstawionych?

0
krzysiek050 napisał(a):

Rozwiązanie 1: Bez wyjątków
Tworzymy obiekt przechowujący listę errorów oraz flagę, czy dalsze przetwarzanie ma sens.
Walidujemy request i obsługujemy w sposób -> 1) jeżeli posiada errory to dodajemy do listy. Jeżeli jakaś walidacja jest krytyczna, to ustawiamy flagę na true.
-> 2) Jeżeli !flaga, to możemy przetwarza dalej. Jeżeli nie, zwracamy listę.
Robimy dalsze obliczenia lub pobieramy coś z bazy.
Na nowych danych wywołujemy 1) a następnie ponownie 2).
Itd.

Zalety:

  • Brak sterowania wyjątkami (zaleta czysto ideowa).
  • Szybkie
  • Możemy dodawać errory niekrytyczne (np. nieprawidłowy format email), dopóki będziemy mieli wystarczająco prawidłowych danych do wykonania kolejnych kroków.

    Wady:

  • Masa spaghetti kodu obsługującego błędy. Duży stosunek kodu niebiznesowego do biznesowego.

Ale czemu spaghetti kodu? Jak nie napiszesz spaghetti, to go nie będzie. Kod obsługujący błędy może być generyczny, jeden dla wszystkich żądań i odpowiedzi. Jest kilka możliwości, tego, co może pójść nie tak, których możemy być pewni: niedostępna baza danych, brak uprawnień do operacji, niepoprawne parametry wejściowe, natomiast cała reszta może być uznana za unknown exception.
Nie bardzo rozumiem, czemu w podejściu 2. chciałbyś przerzucać jakieś wyjątki, podczas gdy wystarczy przerwać proces i zwrócić obiekt w rodzaju OperationResult określający, że coś się nie powiodło z listą przyczyn tego niepowodzenia.
Jeśli zaś chodzi o walidację stanu obiektu, to od tego są biblioteki, które pozwalają na zdefiniowanie reguł i zwrócenie eleganckiej listy ich naruszeń, więc w ogóle nie ma co o tym dyskutować.

1

W Pythonie używanie wyjątków w taki sposób jest ogólnie uważane za dobre, patrz na przykład: https://jeffknupp.com/blog/20[...]leaner-python-use-exceptions/

0
somekind napisał(a):

Jeśli zaś chodzi o walidację stanu obiektu, to od tego są biblioteki, które pozwalają na zdefiniowanie reguł i zwrócenie eleganckiej listy ich naruszeń, więc w ogóle nie ma co o tym dyskutować.

Nie do końca. Mogę zwalidować email, IBAN, pesel i wiele innych. Ale to jest tylko walidacja wstępna żądania. Załóżmy że do wyboru jest opcja A,B,C, ale po pobraniu dodatkowych danych z bazy wyjdzie że dla tego przypadku A, nie jest dostępna i można wybrać tylko spośród B i C. Nie zrobimy tego na samym początku, bo dodatkowe dane trzeba pobrać z bazy. Biblioteka też nam tego nie załatwi bo to problem typowy tylko dla naszego przypadku.

somekind napisał(a):

Jest kilka możliwości, tego, co może pójść nie tak, których możemy być pewni: niedostępna baza danych, brak uprawnień do operacji, niepoprawne parametry wejściowe, natomiast cała reszta może być uznana za unknown exception.

To jest naprawdę sytuacja wyjątkowa i nie ma tutaj mowy o sterowaniu wyjątkami. W takich przypadkach oczywiście rzucę wyjątek, użytkownikowi pokażę błąd, ale on nic z tym nie zrobi. Mi chodzi o przypadek w którym żądanie zwraca błąd, np. Chcesz kupić 9Kg buraków i 5 Kg marchwi, ale maksymalna liczba warzyw jaką możesz kupić naraz to 10Kg. Dodatkowo ta reguła 10Kg jest zaszyta w bazie danych i konfigurowalna. Jest to sytuacja na którą użytkownik może zareagować, np. kupić tylko 5 Kg buraków.

somekind napisał(a):

Ale czemu spaghetti kodu? Jak nie napiszesz spaghetti, to go nie będzie. Kod obsługujący błędy może być generyczny, jeden dla wszystkich żądań i odpowiedzi. (...)
Nie bardzo rozumiem, czemu w podejściu 2. chciałbyś przerzucać jakieś wyjątki, podczas gdy wystarczy przerwać proces i zwrócić obiekt w rodzaju OperationResult określający, że coś się nie powiodło z listą przyczyn tego niepowodzenia.

Kontynuując poprzednią historię o burakach, załóżmy że nasz moduł po otrzymaniu 5Kg buraków i 5Kg marchwii, strzela do zewnętrznego serwisu który sprawdza czy dany użytkownik ma kartki na 5 Kg marchewek. Serwis może zwrócić odpowiedź pozytywną i pozwalamy na zakup, albo negatywną, bo klient wykorzystał już wszystkie kartki na marchewki w tym miesiącu.
Mamy zatem 3 punkty walidacji.

  1. Wstępny sprawdzający np. czy klient podał nazwisko i imię,
  2. Sprawdzający czy zakup zgadza się z regułami zakupowymi w bazie danych
  3. Sprawdzanie czy klient może dokonać tego zakupu w zewnętrznym serwisie.

Jeżeli przyjmiemy rozwiązanie 1, to po każdej walidacji musimy sprawdzić czy możemy kontynuować. Jeżeli tak to spoko, ale jeżeli nie, to musimy powrócić przez cały stos do kontrolera, w każdym miejscu sprawdzając czy result==failed. Stworzy się tutaj jednak sporo spagetti kodu.
Jeżeli rzucimy wyjątek po każdej walidacji i złapiemy go w kontrolerze, czy nawet jakimś interoceptorze zakładanym na każdy kontroller, to obsługę mamy załatwioną beż żadnego dodatkowego kodu.

1
krzysiek050 napisał(a):

Nie do końca. Mogę zwalidować email, IBAN, pesel i wiele innych. Ale to jest tylko walidacja wstępna żądania. Załóżmy że do wyboru jest opcja A,B,C, ale po pobraniu dodatkowych danych z bazy wyjdzie że dla tego przypadku A, nie jest dostępna i można wybrać tylko spośród B i C. Nie zrobimy tego na samym początku, bo dodatkowe dane trzeba pobrać z bazy.

Nie widzę problemu w zebraniu danych potrzebnych do wykonania walidacji przed jej wykonaniem.

Biblioteka też nam tego nie załatwi bo to problem typowy tylko dla naszego przypadku.

Dla mnie biblioteka do walidacji to coś, co pozwala definiować walidatory dla danej klasy za pomocą typowych przypadków (pusty tekst, zakres liczb, regex), dostarcza spójny interfejs (np. metodę Validate zwracającą ValidationResult posiadający pola: Valid: bool oraz ErrorList: string[]) oraz pozwala na rozszerzanie, np. poprzez wstrzyknięcie do naszej walidatora źródła danych, z którego w odpowiednim momencie walidator pobierze dane.

Mi chodzi o przypadek w którym żądanie zwraca błąd, np. Chcesz kupić 9Kg buraków i 5 Kg marchwi, ale maksymalna liczba warzyw jaką możesz kupić naraz to 10Kg. Dodatkowo ta reguła 10Kg jest zaszyta w bazie danych i konfigurowalna. Jest to sytuacja na którą użytkownik może zareagować, np. kupić tylko 5 Kg buraków.

Ale to jest właśnie przypadek, w którym nie należy używać wyjątków. Wyjątki są od sytuacji wyjątkowych, czyli niespodziewanych. Tworząc aplikację spodziewamy się, że użytkownik będzie podawał nieprawidłowe dane, i system ma je walidować.
Przekierowania między widokami też byś zrobił na wyjątkach? Bo to ma tyle samo sensu.

Kontynuując poprzednią historię o burakach, załóżmy że nasz moduł po otrzymaniu 5Kg buraków i 5Kg marchwii, strzela do zewnętrznego serwisu który sprawdza czy dany użytkownik ma kartki na 5 Kg marchewek. Serwis może zwrócić odpowiedź pozytywną i pozwalamy na zakup, albo negatywną, bo klient wykorzystał już wszystkie kartki na marchewki w tym miesiącu.
Mamy zatem 3 punkty walidacji.

  1. Wstępny sprawdzający np. czy klient podał nazwisko i imię,
  2. Sprawdzający czy zakup zgadza się z regułami zakupowymi w bazie danych
  3. Sprawdzanie czy klient może dokonać tego zakupu w zewnętrznym serwisie.

Jeżeli przyjmiemy rozwiązanie 1, to po każdej walidacji musimy sprawdzić czy możemy kontynuować. Jeżeli tak to spoko, ale jeżeli nie, to musimy powrócić przez cały stos do kontrolera, w każdym miejscu sprawdzając czy result==failed. Stworzy się tutaj jednak sporo spagetti kodu.

No jeśli przepływem logiki biznesowej steruje kontroler, to faktycznie będzie to spaghetti, bo nie do tego służą kontrolery.
Przy prawidłowym podejściu, kontroler wysyła dane do serwisu, serwis czyta konfigurację z bazy, uruchamia walidator(y), a ten sprawdza, co trzeba. Jeśli mamy błędy, to zwracamy ich listę do kontrolera, ten je wyświetla. Jeśli wszystko jest ok, to serwis zatwierdza zakup w zewnętrznym systemie.

Jeżeli rzucimy wyjątek po każdej walidacji i złapiemy go w kontrolerze, czy nawet jakimś interoceptorze zakładanym na każdy kontroller, to obsługę mamy załatwioną beż żadnego dodatkowego kodu.

To samo osiągniemy, jeśli serwis dla każdego żądania OperationRequest<t> będzie zwracał generyczny OperationResult<t> zawierający nasz ValidationResult<t> z listą błędów.

0

Do OP: skąd Ci się wzięło, że mechanizm wyjątków jest powolniejszy od alternatywnych mechanizmów obsługi błędów?

W praktyce, prawidłowo używane wyjątki są szybsze niż kody błędów. Kody błędów powodują, że w każdym wywołaniu musisz dodatkowo sprawdzać kod błędu - czyli marnujesz cykle, nawet jeśli wszyskto jest ok, a przecież na ogół właśnie błędu nie ma. W mechanizmie wyjątków nie ma konieczności takiego sprawdzania.

Natomiast w kwestii używania wyjątków jako jedynego mechanizmu walidacji, to już jest jakaś pomyłka. Wyjątki służą do obsługi sytuacji wyjątkowych typu padł dysk, a ktoś odłączył kabelek sieciowy, a nie do rzeczy, które są prawdopodobne. Wyjątek może być użyty jako druga linia obrony, tj. jeśli np. warstwa walidująca przepuściła błędne dane (w wyniku błędu programisty) i próbuje wstawić do bazy danych jakieś śmieci.

W ogóle to co się da powinieneś walidować na kliencie na bieżąco po stronie przeglądarki, aby było szybko i sprawnie, a serwer tylko powinien powtórnie walidować, aby chronić przez złośliwcami. I tam wtedy spokojnie możesz mieć wyjątki i inne "brzydkie" sposoby ochrony przed błędami.

1 użytkowników online, w tym zalogowanych: 0, gości: 1, botów: 0