Miniatura artykułu

JavaScript - obsługa błędów

11 minut

Skopiuj link

Data publikacji: 9/17/2023, 7:30:41

Ostatnia aktualizacja: 4/1/2024

Ludzie nie są nieomylni - pośpiech, stres, nieuwaga i wiele innych czynników (także zewnętrznych) ma wpływ na jakość napisanego przez nas kodu.

Z drugiej strony, chcemy jak najszybciej dostarczać nowe funkcjonalności, a terminy wiecznie nas gonią. Nasuwa się zatem jedno pytanie...

Czy obsługa błędów jest potrzebna

Odpowiedzieć na to pytanie można jednym słowem - tak, i bynajmniej nie chodzi tu o błędy w składni kodu, bo wychwycenie ich powinno nastąpić do momentu kompilacji. Mowa o błędach, które mogą wystąpić w czasie działania (runtime) aplikacji czy też strony internetowej.

Jakie to błędy? Świetnym przykładem jest API, które zamiast oczekiwanych danych zwraca nam odpowiedź ze statusem 5xx (kody z grupy 5xx oznaczają błąd po stronie serwera). Nie mamy wpływu na to, jaką odpowiedź otrzymamy, a jedyne co możemy zrobić, to ubezpieczyć się na wypadek gdybyśmy dostali z powrotem coś innego niż poprawne dane niezbędne do działania aplikacji.

Oczywiście takich przykładów można przytoczyć znacznie więcej, ale z każdego płynie ten sam wniosek - obsługa błędów jest konieczna w każdej aplikacji.

Try i catch

W JavaScript (i wielu innych językach programowania) mamy do dyspozycji składnię try...catch.

Blok try służy do "opakowania" dowolnego kawałka kodu, którego działanie może zakończyć się błędem. Bezpośrednio po nim następuje blok catch, jego zadaniem jest "złapanie" błędu, który wystąpił w bloku try.

Oczywiście błąd nie musi wystąpić (jeżeli występuje zawsze, to z naszym kodem prawdopodobnie coś jest nie tak), a w takiej sytuacji catch nie zostanie w ogóle uruchomiony. Innymi słowy kod wewnątrz bloku catch wykona się tylko wtedy, gdy w bloku try wystąpi jakiś wyjątek.

Zanim przejdziemy do samej składni, to postaram się jeszcze lepiej zobrazować, kiedy może się to w ogóle przydać. Załóżmy, że z zewnętrznego serwera pobieramy dane użytkownika, jednak z jakiegoś powodu zamiast obiektu z informacjami otrzymaliśmy tym razem null.

Próba odczytania jakiejkolwiek właściwości zakończy się w tym przypadku błędem:

Rozwiązaniem tego problemu jest oczywiście zastosowanie try...catch. W przykładzie poniżej obsługę błędów umieściłem bezpośrednio w funkcji getPersonFullName, jednak nic nie stoi na przeszkodzie, żeby "opakować" jedynie wywołanie samej funkcji.

Czas na wytłumaczenie krok po kroku, co dokładnie dzieję się w tym przykładzie:

  1. deklarujemy funkcję getPersonFullName

  2. wewnątrz umieszczamy ten sam kod, co w poprzednim przykładzie, jednak tym razem jest on opakowany w blok try { ... }. Oznacza to, że wszystkie błędy, które wystąpią w kodzie wewnątrz zostaną przechwycone przez blok catch i nie zakończą działania programu. Wyjątkiem są tutaj błędy spowodowane przez funkcje asynchroniczne (poruszymy ten temat w dalszej części artykułu) oraz błędy składni.

  3. ostatni krok to utworzenie bloku catch(error) { ... }, który różni się tym, że jako argument otrzymuje błąd "rzucony" w bloku try { ... }. Jego zadaniem jest przechwycenie tego wyjątku i obsłużenie go tak, by nie spowodował zakończenia działania aplikacji.

Działanie można w łatwy sposób przedstawić na diagramie

Zazwyczaj argument, który trafi do bloku catch jest instancją obiektu Error i jest to zdecydowanie najlepsza praktyka. Problem polega na tym, że w JavaScript "rzucić" za pomocą słowa kluczowego throw można dowolną wartość, zatem nie możemy mieć 100% pewności, z jaką wartością mamy do czynienia dopóki się nie upewnimy:

Warto wspomnieć także, że nie zawsze musimy być zainteresowani samym błędem, lub inną wartością, która została "rzucona", czasami chcemy po prostu wykonać jakąś akcję, niezależnie od tego z jakim błędem mamy do czynienia. Możemy go wtedy pominąć i zapisać to w następujący sposób: catch { ... } - identycznie jak blok try (który nadal musi wystąpić przed blokiem catch).

Zanim przejdziemy dalej, wspomnę o bardzo istotnej kwestii, o której należy pamiętać podczas stosowania try...catch.

Otóż nie jest on w stanie wyłapać asynchronicznych błędów. Jest to niezwykle istotna kwestia, ponieważ opakowanie asynchronicznej funkcji w taki blok może nic nam nie dać, a błędy pozostaną nieobsłużone.

Dlaczego tak się dzieje?

Takie zachowanie wynika bezpośrednio z działania samego języka. Jeżeli nie "zaczekamy" na asynchroniczną funkcję z użyciem async/await lub metody then w przypadku promise'ów, to interpreter będzie nadal wykonywać kod. Zatem blok try zakończy się zanim zostanie zwrócony jakikolwiek błąd i tym samym catch nigdy nie zostanie uruchomiony.

Mimo, że wewnątrz funkcji getUser obsługuję błędy, to nie czekam na zakończenie działania setTimeout, więc błąd zostanie rzucony już po zakończeniu działania bloku try.

Finally

Ten blok może wystąpić bezpośrednio po bloku try (try...finally) lub po bloku catch (try...catch...finally). Kod wewnątrz niego zostanie zawsze wykonany, niezależnie od tego, czy w bloku w bloku try wystąpi błąd, czy nie. Ilustruje to poniższy diagram:

Teoria za nami, czas na kilka przykładów. Zacznijmy od podstawowej składni:

Wewnątrz bloku finally powinien znaleźć się kod odpowiedzialny za "posprzątanie", może to być na przykład usunięcie aktywnych nasłuchiwaczy zdarzeń lub anulowanie oczekujących zapytań HTTP.

W tym przykładzie nie używam bloku catch, ponieważ nie chcę w żaden sposób obsłużyć błędu, chcę jedynie mieć pewność, że kod w bloku finally się wykona. Warto wspomnieć, że takie zastosowanie to rzadkość, zazwyczaj pomijany jest blok finally, a catch zdecydowanie rzadziej.

Jeżeli nie planujesz obsługiwać błędu, to znacznie lepiej jest całkowicie pominąć blok catch, niż pozostawić go pustym, bo może to skutkować cichymi błędami (silent errors), które są bardzo trudne do wykrycia, dlatego powinniśmy ich unikać za wszelką cenę.

Rethrowing

Czasami nie chcemy obsłużyć wszystkich możliwych wyjątków, które mogą wystąpić w bloku try. Właśnie wtedy przydaje się technika zwana rethrowing, czyli ponowne rzucenie błędu złapanego przez block catch.

W JavaScript (i niemal każdym innym języku programowania) mamy do dyspozycji kilka różnych obiektów błędu, a dodatkowo możemy tworzyć swoje własne (rozszerzając klasę Error). Jednak nie zawsze chcemy obsłużyć wszystkie opcje w jednym bloku. Czasami za obsłużenie błędów związanych np. z połączeniem do bazy danych powinna być odpowiedzialna zupełnie inna część kodu, ponieważ dotyczy on całej aplikacji, a nie tylko jednej, konkretnej funkcji.

Postaram się to przedstawić na przykładzie

Najpierw tworzymy nową klasę błędu DbConnectionError, która rozszerza klasę Error, a następnie w funkcji getUser symulujemy wystąpienie błędu (czyli po prostu rzucamy instancję klasy DbConnectionError). Oczywiście w prawdziwej aplikacja ten błąd wystąpiłby tylko w wyniku nieudanego połączenia, a nie za każdym razem, tak jak to jest w przykładzie powyżej.

Wywołanie funkcji jest opakowane w kolejny blok try...catch, który jest odpowiedzialny za obsługę błędów, które zostały ponownie rzucone z funkcji getUser, w naszym przypadku ponownie rzucany jest jedynie błąd związany z połączeniem do bazy danych.

Przykład ten ma na celu zobrazowanie, że nie musisz umieścić obsługi wszystkich możliwych błędów w jednym miejscu. Możesz wybrać, którymi chcesz zająć się tu i teraz, a które należy przekazać dalej.

Edit: 04.12.2023

Zanim przejdziemy do podsumowania, pokażę Ci jeszcze jedną sztuczkę. W poprzednim przykładzie zastosowaliśmy rethrowing, jednak straciliśmy dostęp do źródła oryginalnego błędu (tego, który został przechwycony przez blok catch).

Rozwiązaniem tego problemu jest wykorzystanie właściwości cause, którą można przekazać w konstruktorze nowego błędu. Więcej na ten temat możesz przeczytać tutaj.

Teraz, gdy ponownie przechwycimy ten błąd, możemy odwołać się do jego właściwości cause, żeby sprawdzić, skąd dokładnie pochodzi i co go spowodowało. Dzięki tej prostej zmianie, debugowanie będzie znacznie łatwiejsze 🙂

Alternatywnym sposobem przekazania wartości do cause, może być jej własnoręczne odtworzenie i dostosowanie do potrzeb oprogramowania odpowiedzialnego za logowanie błędów lub innego systemu (w tym także systemu obsługi błędów). W takim przypadku nie zależy nam na czytelnej dla człowieka wiadomości, zamiast tego chcemy przekazać taką wartość, którą zrozumie używany przez nas program.

Niestety jest to nowa właściwość, dlatego zanim zastosujesz to rozwiązanie w swoim projekcie, upewnij się, że wspiera on ES2022. Sprawdź także, czy wspierają ją wszystkie przeglądarki, które obsługuje Twoja aplikacja.

Podsumowanie

Obsługa błędów jest kluczowa we wszystkich aplikacjach - nie można po prostu liczyć na to, że nie wydarzy się nic nieprzewidzianego. Jeżeli do tej pory nie stosowałeś try...catch, to zdecydowanie najwyższa pora żeby zacząć to robić. Pamiętaj jednak o kilku rzeczach:

  • funkcje zawierające asynchroniczny kod wymagają "zaczekania" na zakończenie ich działania, inaczej błąd może nie zostać poprawnie obsłużony

  • za wszelką cenę unikaj cichych błędów - są one bardzo trudne do debugowania

  • jeden block catch nie musi być odpowiedzialny za obsługę wszystkich możliwych błędów (rethrowing)

Warto na sam koniec wspomnieć również o metodach catch oraz finally które dostępne są w instancjach Promise. Jeżeli pracujesz bezpośrednio z promise'ami, to zamiast opakowywać je w try...catch możesz stosować wspomniane metody w celu uniknięcia problemów z asynchronicznością.

Avatar: Wojciech Rygorowicz

Software Engineer / Fullstack developer

Wojciech Rygorowicz

wojciech.rygorowicz@gmail.com

Podziel się na

Dodaj komentarz

Komentarze (0)

Brak komentarzy