Menu Zamknij

Jak dobrze obsługiwać wyjątki w Javie?

Gdy rozpoczynałem moją przygodę z Javą, straszenie irytowało mnie to, że ciągle muszę obsługiwać jakieś wyjątki. Oczywiście na początku moja obsługa wyjątków często wyglądała w ten sposób, że je po prostu „połykałem”, nie logując w żaden sposób informacji o ich wystąpieniu.

Jak zatem radzić sobie z wyjątkami?

Czym jest wyjątek?

Wyjątek to obiekt sygnalizujący w programie wystąpienie wyjątkowej sytuacji. Jest informacją dla klienta danej metody, o nieoczekiwanym zdarzeniu. Taka wyjątkowa sytuacja różni się jednak znacząco przepływem od standardowych zdarzeń. Wyrzucony wyjątek niszczy wszystko na swojej drodze. Nie obsłużony w odpowiedni sposób może być nieobliczalny w swoich konsekwencjach. Błędy w danych, strata czasu i pieniędzy, lub życie z poważnymi błędami w błogiej nieświadomości. Dlatego warto trochę lepiej je poznać i zapanować nad nimi.

Ważna rzecz to zdać sobie sprawę z tego, że wyrzucenie wyjątku przerywa standardowe wykonywanie kodu aż do miejsca, w którym zostaje on przechwycony w bloku catch. Warto przede wszystkim zadbać o to by razie wypadku „posprzątać” i nie generować większej ilości bałaganu. Najważniejsze porządki to zamykanie otwartych połączeń i uchwytów do pliku, ale warto również zadbać o spójność systemu i jego danych. Nie chcemy przecież posiadać w aplikacji stanu wynikającego z poczynań naszych wyjątków. Taka sytuacja mogłaby generować kolejne błędy w innych miejscach systemu, a w przypadku niespójności danych, problem może wyjść nawet po dłuższym czasie.

Bywają takie przypadki w kodzie, że stan aplikacji lub danych jest nieoczekiwany w danym miejscu przepływu programu. Trzeba na początek dobrze pomyśleć, czy taki stan jest naprawdę sytuacją wyjątkową. Jeżeli tak, to nie obsługujmy tego zwracaniem null’a, pustych kolekcji, wartościami typu -1. Bo często się okazuje, że takie rozwiązanie, może okazać się zwykłym odkładaniem wystąpienia wyjątku w późniejszej fazie działania procesu. Dużo lepiej jawnie określić jaki wyjątek może wystąpić, niech klient naszej metody zabezpieczy się przed tym sam.

Grzech śmiertelny w obsłudze wyjątków

Połykanie wyjątków to najgorsza, wręcz patologiczna praktyka. To takie zamiatanie brudu pod dywan, a raczej istny trup w szafie. Czuć, że coś tu śmierdzi mimo, że w logach jest tak czysto. Trudno w takich przypadkach znaleźć przyczynę prawdziwych problemów. Na pierwszy rzut oka, program może nie wykazywać  żadnych oznak złego funkcjonowania, przez co można przez długi czas żyć w przeświadczeniu, że wszystko jest OK. Później dorabia się do tego różnego rodzaju szamanizmy — To tak już jest drogi kliencie! Raz to działa, raz nie działa. Jeżeli zobaczycie coś takiego, naprawcie to, zwróćcie uwagę koledze, co tak robi, napiszcie anonimowy donos, zgłoście na policje, cokolwiek. W takich przypadkach dużo lepszą opcją jest już opakowanie w RuntimeException lub po prostu zalogowanie wyjątku.

Obsłużone i połykane wyjątki

Co, gdzie i dlaczego?

Staraj się nie zwracać typów ogólnych wyjątków jak Exception, trudno dobrać odpowiednią strategię obsługiwania takich wyjątków. Ktoś, kto wywołuje taką metodę nie wie czego się tak naprawdę ma spodziewać ani jak reagować na taki wyjątek. Taka informacja nie wiele nam mówi i jedyne co można z tym zrobić to wrzucić w logi. Może z tych logów coś się kiedyś dowiem. Poprostu Exception to słaba informacja. To jak postawić. Przed wejściem znak z napisem „Uwaga!”. Uwaga, ale na co mam uważać ?

public void foo()throws Exception {  // Hej drogi kliencie! Mogą się tu wydarzyć się wyjątkowe sytuacje.
   // Tu zdarzają się wyjątkowe sytuacje
}

Podobna sprawa jest z przechwytywaniem wyjątków, nie powinniśmy przechwytywać wyjątków w typie ogólnym Exception, ponieważ, możemy w ten sposób złapać również te wyjątki, których nie powinniśmy raczej łapać np. RuntimeException lub będziemy też obsługiwać wyjątki, których się nie spodziewaliśmy, w sposób, który nie jest do tego przeznaczony. Lepszym pomysłem będzie już multi-catch, przynajmniej wskazuje jakich wyjątków się spodziewamy. Łatwiej też zauważymy, jeżeli dostawca jakiegoś API dorzuci nowe wyjątki. Starajmy się podchodzić do wszystkich klas wyjątków indywidualnie.

try{
  doSomething();
}catch(NullPointerException | NumberFormatException e){
   doSomethingDifferent();
}

Przyczyna i źródło problemu

Czasem zdarza się tak, że mamy w kodzie miejsce, które wyrzuca wyjątek, który tak naprawdę nic nam nie mówi. Wtedy dobrym pomysłem może być utworzenie własnej klasy wyjątku, która może nieść ze sobą dużo więcej informacji, wskazywać na nieoczekiwane zdarzenie związane z konkretnym procesem biznesowym, którego dotyczy i lepiej tłumaczyć okoliczności zajścia takiej sytuacji. We własne wyjątki powinniśmy zawsze dodać informacje o naszym „małomównym” wyjątku. W ten sposób zachowamy również dostęp do informacji o pierwotnej przyczynie całego zajścia. Poznamy wtedy przyczynę wyjątku w ujęciu różnych poziomów abstrakcji.

Pamiętajmy również, aby dodać odpowiednią wiadomość do wyjątku. Niech jasno mówi, o przyczynach powstania problemu oraz definiuje jego źródło. Przyczyny można opisać poprzez poinformowanie o tym jakie założenia zostały złamane. Źródło problemu można zdefiniować przez dodanie identyfikatora obiektu biznesowego lub wartość danych, jeżeli nie są to dane wrażliwe — Pamiętajcie o RODO 🙂

try{
 //Logika biznesowa
}catch(IllegalArgumentException e){
  throw new MyBussinesException("Sorry, the title is not unique.",e);
}

Pamiętajmy by we wszystkim zachować umiar. Nie powinniśmy przesadzać i opakowywać wyjątki wielokrotnie, należy się zastanowić, czy taki zabieg daje nam realne korzyści. Nie powinniśmy przesadzać z nadmierną ilością typów wyjątków, Starajmy się w nazwie klasy wyjątku zawrzeć ogólne zjawisko, a konkretny przypadek opiszemy w polu message. W większości miejsc mogą się nadać standardowe wyjątki z Java takie jak IllegalArgumentException, IllegalStateException, IOExcepion itd. Odpowiedni opis i takie rozwiązanie powinno być wystarczające.

Log & throw

Czasem spotykam się w kodzie z miejscami, gdzie ktoś przechwytuje wyjątek, loguje informacje o nim, a później i tak wyrzuca go dalej. I wtedy zastanawiam się — Dlaczego? No rozumiem, zdarzyła się sytuacja wyjątkowa, zapisujemy informacje o tym wydarzeniu do logów. Jednak po co to pchać dalej ?

Takie zwyczaje prowadzą do wielokrotnego logowania takich wyjątków na wielu poziomach, co prowadzi do dezinformacji w logach. Trudno wtedy określić ile razy wystąpiło dane zdarzenie. Powinniśmy zdecydować się na jedną rzecz albo loguje do logów, albo przekazuje wyjątek dalej. Pewnie może, zdarzyć się sytuacja, w której trzeba przechwycić wyjątek, trochę posprzątać i przesłać go dalej. Jednak raczej nie idzie to w parze z logowaniem tego wyjątku.

Co zrobić na granicach systemu? Przekazać informacje do obcych o wystąpieniu wyjątku, czy zalogować ją u siebie w logach. Może być przypadek, w którym nasza aplikacja będzie siała jakimiś błędami. I powiedzą nam — Naprawcie, coś jest nie tak. Może warto zachować wtedy jakąś informacje u siebie ? Intencje klienta naszej usługi i obsługiwaniu wyjątków, mogą być różne. Fajnie byłoby umieć mu pomóc w takiej sytuacji. Być może się mylę, ale takie mam zdanie na tę chwilę. Napiszcie, co o tym sądzicie.

Podejście do wyjątków typu RuntimeException

Wyjątki RuntimeException wyrzucaj tylko wtedy gdy nie chcesz zmuszać klienta do ich obsługiwania. Oczywiście możemy złapać takie wyjątki i je obsługiwać, ale czy powinniśmy ?

W większości przypadków wyjątki typu RuntimeException to wyjątki, do których nie powinniśmy doprowadzić, powinniśmy sprawdzić coś wcześniej. Np. aby nie mieć problemów z wyjątkami typu NullPointerException lub ArrayIndexOutOfBoundsException, powinniśmy po prostu wiedzieć, co robimy i sprawdzać wcześniej dane, które przetwarzamy. Tak więc brak danych może, ale nie musi być wyjątkową sytuacją w systemie.

Jeżeli wychodzą jakieś błędy typu checked, ale nie chcesz, aby były obsługiwane, uważasz, że nie trzeba tego robić, bo np. sytuacja jest nie do odratowania lub klient używa metody niezgodnie z wytycznymi, to możesz opakować je w wyjątki typu RuntimeException.

Złą praktyką jest przepychanie wyjątków w górę stosu z metody na metodę. Wyjątek powinien być, w miarę szybko obsłużony, a jeżeli tak nie jest to może powinien być wyjątkiem typu RuntimeException.

Kolejność łapania i wyrzucania wyjątków

Problem polega na tym, że zostaje wykonany tylko pierwszy blok catch, który pasuje do wyjątku. Tak więc, jeśli najpierw złapiesz wyjątek IllegalArgumentException, nigdy nie osiągniesz bloku catch, który powinien obsłużyć bardziej specyficzny wyjątek NumberFormatException, ponieważ jest to podklasa wyjątku IllegalArgumentException.

try{
  //wyrzuca wyjątek NumberFormatException 
}catch(IllegalArgumentException){
  //złapie tylko raz
}catch(NumberFormatException e){ 
  //drugi raz już nie złapie
}

Uważaj na wyjątki wyrzucane w blokach catch i finally. Kiedy nowy wyjątek zostanie zgłoszony w bloku catch lub w finally, który będzie propagował z tego bloku, wówczas obecny wyjątek zostanie przerwany (i zapomniany), ponieważ nowy wyjątek jest propagowany na zewnątrz.

   throw new AlphaException();
}catch(AlphaException e){
   throw new BetaException(); // Wyjątek AlphaException zostaje zapomniany i wyrzucany zostaje BetaException
}finally{
   throw new GammaException(); // BetaException zostanie zapomniany, a wyrzucony zostanie GammaException 
}

Również w przypadkach bez bloku catch będzie podobnie.

try{ 
    throw new AlphaException(); 
}finally{ 
    throw new GammaException(); // AlphaException zostanie zapomniany, a wyrzucony zostanie GammaException 
}

Czytelność kodu

W przypadku gdy obsługa wyjątków silnie wpływa na czytelność kodu, możemy podzielić metodę na wewnętrzną bez obsługi wyjątków i zewnętrzną z wywołaniem tej wewnętrznej i obsługą wyjątków. W przypadku wielu typów wyjątków możemy wyekstrahować obsługę do osobnej metody, przekazując go jako parametr funkcji. Warto też zadbać, aby alternatywna ścieżka była osobną metodą.

try{
   doSomethingInternal() 
}catch(Exception e){ 
   handleExceptions(e); 
}

Wyjątki mogą jednak też pomagać w pisaniu czytelnego kodu. Dzięki odpowiedniemu podejściu nie będziemy musieli pisać już skompilowanego kodu obsługi dla każdego miejsca, gdzie, mogą wystąpić niestandardowe sytuacje. W większości przypadków możemy zająć się tym w innym miejscu niż logika domenowa. Pozwala to nie komplikować kodu bardziej, niż wymaga tego poziom trudności samego problemu biznesowy.

void handleExceptions(Exception e){
  try{
     throw e;
  }catch(SpecificException e)
     handleSpecificException(e);
  }catch(DifferentException e){
     handleDifferentException(e);
  }
}

Exception ID

Ciekawa idea na którą ostatnio natknąłem się w necie, to tak zwany ExceptionID. Coś w formie połączenia wyjątku z kodem błędu. Takie rozwiązanie, ma powodować niby szybsze znalezienie przyczyny problemu przez powiązanie identyfikatora wyjątku z miejscem jego występowania w kodzie

throw new IdentifiableException("54692e87","Zły format danych. Brak prawego nawiasu",e);

Więcej w tym temacie można posłuchać w nagraniu poniżej.

Please follow and like us:
Skuteczna refaktoryzacja w 10 krokach!

Odbierz Darmowy Poradnik o Refaktoryzacji!

Poznaj kilka prostych technik i wprowadź nową jakość w swoim projekcie.

Dzięki za dołączenie do mojej listy.

Coś poszło nie tak :( Spróbuj jeszcze raz.