Czysty kod i sztuka obsługi wyjątków

Opublikowany: 2022-03-11

Wyjątki są tak stare, jak samo programowanie. W czasach, gdy programowanie odbywało się na sprzęcie lub za pomocą języków programowania niskiego poziomu, wyjątki były używane do zmiany przepływu programu i uniknięcia awarii sprzętu. Dziś Wikipedia definiuje wyjątki jako:

anomalne lub wyjątkowe warunki wymagające specjalnego przetwarzania – często zmieniające normalny przebieg wykonywania programu…

A że obsługa ich wymaga:

specjalistyczne konstrukcje języka programowania lub mechanizmy sprzętu komputerowego.

Dlatego wyjątki wymagają specjalnego traktowania, a nieobsługiwany wyjątek może spowodować nieoczekiwane zachowanie. Wyniki są często spektakularne. W 1996 roku słynna awaria startu rakiety Ariane 5 została przypisana nierozpatrzonemu wyjątkowi przepełnienia. History's Worst Software Bugs zawiera kilka innych błędów, które można przypisać nieobsługiwanym lub niewłaściwie obsłużonym wyjątkom.

Z biegiem czasu te błędy i niezliczone inne (które być może nie były tak dramatyczne, ale wciąż katastrofalne dla zaangażowanych osób) przyczyniły się do powstania wrażenia, że ​​wyjątki są złe .

Ale wyjątki są podstawowym elementem współczesnego programowania; istnieją, aby ulepszać nasze oprogramowanie. Zamiast bać się wyjątków, powinniśmy je przyjąć i nauczyć się, jak z nich korzystać. W tym artykule omówimy, jak elegancko zarządzać wyjątkami i używać ich do pisania czystego kodu, który jest łatwiejszy w utrzymaniu.

Obsługa wyjątków: to dobra rzecz

Wraz z rozwojem programowania obiektowego (OOP) obsługa wyjątków stała się kluczowym elementem nowoczesnych języków programowania. Solidny system obsługi wyjątków jest obecnie wbudowany w większość języków. Na przykład Ruby zapewnia następujący typowy wzorzec:

 begin do_something_that_might_not_work! rescue SpecificError => e do_some_specific_error_clean_up retry if some_condition_met? ensure this_will_always_be_executed end

Nie ma nic złego w poprzednim kodzie. Ale nadużywanie tych wzorców spowoduje zapach kodu i niekoniecznie będzie korzystne. Podobnie, ich niewłaściwe użycie może w rzeczywistości wyrządzić wiele szkód w twoim kodzie, czyniąc go kruchym lub zaciemniając przyczynę błędów.

Piętno otaczające wyjątki często sprawia, że ​​programiści czują się zagubieni. Faktem jest, że nie da się uniknąć wyjątków, ale często uczy się nas, że należy się nimi zająć szybko i zdecydowanie. Jak zobaczymy, niekoniecznie jest to prawdą. Powinniśmy raczej nauczyć się sztuki obsługi wyjątków z wdziękiem, dzięki czemu będą one harmonijne z resztą naszego kodu.

Poniżej znajduje się kilka zalecanych praktyk, które pomogą Ci przyjąć wyjątki i wykorzystać je oraz ich możliwości, aby Twój kod był konserwowalny , rozszerzalny i czytelny :

  • pielęgnowalność : pozwala nam łatwo znajdować i naprawiać nowe błędy, bez obawy o złamanie bieżącej funkcjonalności, wprowadzenie kolejnych błędów lub konieczność całkowitego porzucenia kodu z powodu rosnącej złożoności w czasie.
  • rozszerzalność : Pozwala nam łatwo dodawać do naszej bazy kodu, wdrażając nowe lub zmienione wymagania bez naruszania istniejącej funkcjonalności. Rozszerzalność zapewnia elastyczność i umożliwia wysoki poziom ponownego wykorzystania naszej bazy kodu.
  • czytelność : Pozwala nam łatwo odczytać kod i odkryć jego cel bez poświęcania zbyt wiele czasu na kopanie. Ma to kluczowe znaczenie dla efektywnego wykrywania błędów i nieprzetestowanego kodu.

Te elementy są głównymi czynnikami tego, co moglibyśmy nazwać czystością lub jakością , która sama w sobie nie jest bezpośrednią miarą, ale jest połączonym efektem poprzednich punktów, jak pokazano w tym komiksie:

„WTF/m” autorstwa Thoma Holwerdy, OSNews

Mając to na uwadze, przyjrzyjmy się tym praktykom i zobaczmy, jak każda z nich wpływa na te trzy miary.

Uwaga: Zaprezentujemy przykłady z Rubiego, ale wszystkie przedstawione tutaj konstrukcje mają odpowiedniki w najpopularniejszych językach OOP.

Zawsze twórz własną hierarchię ApplicationError

Większość języków zawiera różne klasy wyjątków, zorganizowane w hierarchię dziedziczenia, jak każda inna klasa OOP. Aby zachować czytelność, łatwość konserwacji i rozszerzalność naszego kodu, dobrym pomysłem jest utworzenie własnego poddrzewa wyjątków specyficznych dla aplikacji, które rozszerzają podstawową klasę wyjątków. Zainwestowanie trochę czasu w logiczną strukturę tej hierarchii może być niezwykle korzystne. Na przykład:

 class ApplicationError < StandardError; end # Validation Errors class ValidationError < ApplicationError; end class RequiredFieldError < ValidationError; end class UniqueFieldError < ValidationError; end # HTTP 4XX Response Errors class ResponseError < ApplicationError; end class BadRequestError < ResponseError; end class UnauthorizedError < ResponseError; end # ... 

Przykład hierarchii wyjątków aplikacji: StandardError znajduje się na górze. ApplicationError dziedziczy po nim. ValidationError i ResponseError dziedziczą z tego. RequiredFieldError i UniqueFieldError dziedziczą z ValidationError, podczas gdy BadRequestError i UnauthorizedError dziedziczą z ResponseError.

Posiadanie rozszerzalnego, kompleksowego pakietu wyjątków dla naszej aplikacji znacznie ułatwia obsługę tych specyficznych dla aplikacji sytuacji. Na przykład możemy zdecydować, które wyjątki obsługiwać w bardziej naturalny sposób. To nie tylko zwiększa czytelność naszego kodu, ale także zwiększa łatwość utrzymania naszych aplikacji i bibliotek (gemów).

Z punktu widzenia czytelności znacznie łatwiej jest czytać:

 rescue ValidationError => e

Niż przeczytać:

 rescue RequiredFieldError, UniqueFieldError, ... => e

Z perspektywy pielęgnowalności powiedzmy na przykład, że implementujemy API JSON i zdefiniowaliśmy własny ClientError z kilkoma podtypami, które mają być używane, gdy klient wyśle ​​złe żądanie. Jeśli którykolwiek z nich zostanie zgłoszony, aplikacja powinna renderować reprezentację JSON błędu w swojej odpowiedzi. Łatwiej będzie naprawić lub dodać logikę do pojedynczego bloku, który obsługuje ClientError , zamiast zapętlać każdy możliwy błąd klienta i implementować ten sam kod obsługi dla każdego. Jeśli chodzi o rozszerzalność, jeśli później będziemy musieli zaimplementować inny rodzaj błędu klienta, możemy ufać, że zostanie on już tutaj prawidłowo obsłużony.

Co więcej, nie przeszkadza nam to we wcześniejszej implementacji dodatkowej specjalnej obsługi konkretnych błędów klienta w stosie wywołań lub zmianie tego samego obiektu wyjątku po drodze:

 # app/controller/pseudo_controller.rb def authenticate_user! fail AuthenticationError if token_invalid? || token_expired? User.find_by(authentication_token: token) rescue AuthenticationError => e report_suspicious_activity if token_invalid? raise e end def show authenticate_user! show_private_stuff!(params[:id]) rescue ClientError => e render_error(e) end

Jak widać, zgłoszenie tego konkretnego wyjątku nie przeszkodziło nam w obsłudze go na różnych poziomach, modyfikowaniu go, ponownym zgłaszaniu i umożliwianiu obsłudze klasy nadrzędnej, aby go rozwiązać.

Należy zwrócić uwagę na dwie rzeczy:

  • Nie wszystkie języki obsługują zgłaszanie wyjątków z poziomu obsługi wyjątków.
  • W większości języków zgłoszenie nowego wyjątku z modułu obsługi spowoduje utratę oryginalnego wyjątku na zawsze, więc lepiej jest ponownie zgłosić ten sam obiekt wyjątku (jak w powyższym przykładzie), aby uniknąć utraty oryginalnej przyczyny błąd. (Chyba że robisz to celowo).

Nigdy nie rescue Exception

Oznacza to, że nigdy nie próbuj implementować procedury obsługi typu catch-all dla podstawowego typu wyjątku. Hurtowe ratowanie lub łapanie wszystkich wyjątków nigdy nie jest dobrym pomysłem w żadnym języku, niezależnie od tego, czy jest to globalnie na podstawowym poziomie aplikacji, czy w małej ukrytej metodzie używanej tylko raz. Nie chcemy ratować Exception , ponieważ zaciemni to, co naprawdę się wydarzyło, niszcząc zarówno łatwość utrzymania, jak i rozszerzalność. Możemy zmarnować ogromną ilość czasu na debugowanie rzeczywistego problemu, podczas gdy może to być tak proste, jak błąd składni:

 # main.rb def bad_example i_might_raise_exception! rescue Exception nah_i_will_always_be_here_for_you end # elsewhere.rb def i_might_raise_exception! retrun do_a_lot_of_work! end

Być może zauważyłeś błąd w poprzednim przykładzie; return jest błędnie wpisany. Chociaż współczesne edytory zapewniają pewną ochronę przed tym konkretnym rodzajem błędu składni, ten przykład ilustruje, w jaki sposób rescue Exception szkodzi naszemu kodowi. W żadnym momencie nie jest adresowany faktyczny typ wyjątku (w tym przypadku NoMethodError ), ani nie jest on nigdy ujawniany deweloperowi, co może spowodować, że stracimy dużo czasu na bieganie w kółko.

Nigdy nie rescue więcej wyjątków, niż potrzebujesz

Poprzedni punkt dotyczy konkretnego przypadku tej reguły: zawsze powinniśmy uważać, aby nie przesadzać z naszymi procedurami obsługi wyjątków. Powody są takie same; za każdym razem, gdy ratujemy więcej wyjątków niż powinniśmy, w końcu ukrywamy części logiki aplikacji przed wyższymi poziomami aplikacji, nie wspominając już o blokowaniu zdolności programisty do samodzielnej obsługi wyjątku. To poważnie wpływa na rozszerzalność i łatwość utrzymania kodu.

Jeśli spróbujemy obsłużyć różne podtypy wyjątków w tej samej procedurze obsługi, wprowadzimy bloki kodu grubego, które mają zbyt wiele obowiązków. Na przykład, jeśli budujemy bibliotekę korzystającą ze zdalnego interfejsu API, obsługa MethodNotAllowedError (HTTP 405) zwykle różni się od obsługi błędu UnauthorizedError (HTTP 401), mimo że oba są błędami ResponseError .

Jak zobaczymy, często istnieje inna część aplikacji, która lepiej nadaje się do obsługi określonych wyjątków w bardziej SUCHY sposób.

Zdefiniuj więc pojedynczą odpowiedzialność swojej klasy lub metody i obsługuj minimum wyjątków, które spełniają to wymaganie dotyczące odpowiedzialności . Na przykład, jeśli metoda jest odpowiedzialna za pobranie informacji giełdowych ze zdalnego interfejsu API, powinna obsługiwać wyjątki, które wynikają tylko z uzyskania tych informacji, a obsługę pozostałych błędów pozostawić innej metodzie zaprojektowanej specjalnie dla tych obowiązków:

 def get_info begin response = HTTP.get(STOCKS_URL + "#{@symbol}/info") fail AuthenticationError if response.code == 401 fail StockNotFoundError, @symbol if response.code == 404 return JSON.parse response.body rescue JSON::ParserError retry end end

Tutaj zdefiniowaliśmy kontrakt na tę metodę, aby uzyskać tylko informacje o stanie magazynowym. Obsługuje błędy specyficzne dla punktów końcowych , takie jak niekompletna lub źle sformułowana odpowiedź JSON. Nie obsługuje przypadków, gdy uwierzytelnianie nie powiedzie się lub wygaśnie, lub jeśli zapas nie istnieje. Są to czyjeś odpowiedzialność i są jawnie przekazywane do stosu wywołań, gdzie powinno być lepsze miejsce do obsługi tych błędów w sposób SUCHY.

Oprzyj się pokusie natychmiastowej obsługi wyjątków

To jest uzupełnienie ostatniego punktu. Wyjątek można obsłużyć w dowolnym punkcie stosu wywołań i dowolnym punkcie hierarchii klas, więc wiedza o tym, gdzie go obsłużyć, może być zadziwiająca. Aby rozwiązać tę zagadkę, wielu programistów decyduje się na obsługę każdego wyjątku natychmiast po jego pojawieniu się, ale poświęcenie czasu na przemyślenie tego zwykle skutkuje znalezieniem bardziej odpowiedniego miejsca do obsługi określonych wyjątków.

Jednym z powszechnych wzorców, które widzimy w aplikacjach Rails (zwłaszcza tych, które ujawniają API-tylko JSON) jest następująca metoda kontrolera:

 # app/controllers/client_controller.rb def create @client = Client.new(params[:client]) if @client.save render json: @client else render json: @client.errors end end

(Zauważ, że chociaż technicznie nie jest to procedura obsługi wyjątków, funkcjonalnie służy temu samemu celowi, ponieważ @client.save zwraca wartość false tylko wtedy, gdy napotka wyjątek.)

W tym przypadku jednak powtarzanie tej samej procedury obsługi błędów w każdej akcji kontrolera jest przeciwieństwem DRY i szkodzi łatwości konserwacji i rozszerzalności. Zamiast tego możemy skorzystać ze specjalnej natury propagacji wyjątków i obsłużyć je tylko raz, w klasie kontrolera nadrzędnego, ApplicationController :

 # app/controllers/client_controller.rb def create @client = Client.create!(params[:client]) render json: @client end
 # app/controller/application_controller.rb rescue_from ActiveRecord::RecordInvalid, with: :render_unprocessable_entity def render_unprocessable_entity(e) render \ json: { errors: e.record.errors }, status: 422 end

W ten sposób możemy zapewnić, że wszystkie błędy ActiveRecord::RecordInvalid są prawidłowo i sucho obsługiwane w jednym miejscu, na podstawowym poziomie ApplicationController . Daje nam to swobodę manipulowania nimi, jeśli chcemy zająć się konkretnymi przypadkami na niższym poziomie lub po prostu pozwolić im się z wdziękiem propagować.

Nie wszystkie wyjątki wymagają obsługi

Tworząc klejnot lub bibliotekę, wielu programistów będzie próbowało zawrzeć funkcjonalność i nie pozwolić na propagowanie żadnych wyjątków z biblioteki. Ale czasami nie jest oczywiste, jak obsłużyć wyjątek, dopóki konkretna aplikacja nie zostanie zaimplementowana.

Weźmy ActiveRecord jako przykład idealnego rozwiązania. Biblioteka zapewnia programistom dwa podejścia do kompletności. Metoda save obsługuje wyjątki bez ich propagacji, po prostu zwracając false , podczas gdy save! zgłasza wyjątek, gdy się nie powiedzie. Daje to programistom możliwość obsługi określonych przypadków błędów w inny sposób lub po prostu ogólnej obsługi wszelkich awarii.

Ale co, jeśli nie masz czasu lub zasobów, aby zapewnić tak kompletną implementację? W takim przypadku, jeśli jest jakaś niepewność, najlepiej ujawnić wyjątek i wypuścić go na wolność.

Oto dlaczego: prawie cały czas pracujemy z przenoszeniem wymagań, a podjęcie decyzji, że wyjątek zawsze będzie obsługiwany w określony sposób, może w rzeczywistości zaszkodzić naszej implementacji, niszcząc rozszerzalność i łatwość utrzymania oraz potencjalnie powodując ogromne zadłużenie techniczne, zwłaszcza podczas tworzenia biblioteki.

Weźmy wcześniejszy przykład giełdowego API konsumenta pobierającego ceny akcji. Zdecydowaliśmy się obsłużyć niekompletną i zniekształconą odpowiedź na miejscu i postanowiliśmy ponowić to samo żądanie, dopóki nie otrzymamy prawidłowej odpowiedzi. Ale później wymagania mogą się zmienić, tak że zamiast ponawiać żądanie, musimy wrócić do zapisanych historycznych danych giełdowych.

W tym momencie będziemy zmuszeni zmienić samą bibliotekę, aktualizując sposób obsługi tego wyjątku, ponieważ projekty zależne nie obsłużą tego wyjątku. (Jak mogli? Nigdy wcześniej nie był dla nich narażony.) Będziemy musieli również poinformować właścicieli projektów, które opierają się na naszej bibliotece. Może to stać się koszmarem, jeśli jest wiele takich projektów, ponieważ prawdopodobnie zostały one zbudowane przy założeniu, że ten błąd zostanie obsłużony w określony sposób.

Teraz widzimy, dokąd zmierzamy z zarządzaniem zależnościami. Perspektywy nie są dobre. Taka sytuacja zdarza się dość często i najczęściej degraduje użyteczność, rozszerzalność i elastyczność biblioteki.

Oto sedno sprawy: jeśli nie jest jasne, w jaki sposób należy obsłużyć wyjątek, niech się z wdziękiem propaguje . Istnieje wiele przypadków, w których istnieje jasne miejsce do obsługi wyjątku wewnętrznie, ale istnieje wiele innych przypadków, w których ujawnienie wyjątku jest lepsze. Więc zanim zdecydujesz się na obsługę wyjątku, po prostu zastanów się. Dobrą zasadą jest naleganie na obsługę wyjątków tylko podczas bezpośredniej interakcji z użytkownikiem końcowym.

Postępuj zgodnie z konwencją

Implementacja Ruby, a tym bardziej Railsów, jest zgodna z pewnymi konwencjami nazewnictwa, takimi jak rozróżnienie między method_names a method_names! z hukiem." W Ruby huk wskazuje, że metoda zmieni obiekt, który ją wywołał, a w Rails oznacza, że ​​metoda zgłosi wyjątek, jeśli nie wykona oczekiwanego zachowania. Staraj się przestrzegać tej samej konwencji, zwłaszcza jeśli zamierzasz udostępnić swoją bibliotekę na zasadach open source.

Gdybyśmy mieli napisać nową method! z hukiem w aplikacji Railsowej musimy wziąć te konwencje pod uwagę. Nic nie zmusza nas do zgłaszania wyjątku, gdy ta metoda zawiedzie, ale odejście od konwencji może wprowadzić programistów w błąd, by uwierzyli, że będą mieli możliwość samodzielnej obsługi wyjątków, podczas gdy w rzeczywistości nie będą.

Inną konwencją Ruby, przypisywaną Jimowi Weirichowi, jest używanie fail do wskazania niepowodzenia metody i używanie raise tylko wtedy, gdy ponownie zgłaszasz wyjątek.

Poza tym, ponieważ używam wyjątków do wskazywania błędów, prawie zawsze używam słowa kluczowego fail zamiast słowa kluczowego raise w Ruby. Niepowodzenie i podbicie są synonimami, więc nie ma różnicy, z wyjątkiem tego, że niepowodzenie wyraźniej informuje o niepowodzeniu metody. Podbijam używam tylko wtedy, gdy łapię wyjątek i ponownie go podbijam, ponieważ tutaj nie zawodzię, ale wyraźnie i celowo podnoszę wyjątek. Jest to kwestia stylistyczna, którą śledzę, ale wątpię, czy robi to wiele innych osób.

Wiele innych społeczności językowych przyjęło takie konwencje dotyczące traktowania wyjątków, a ignorowanie tych konwencji zaszkodzi czytelności i utrzymaniu naszego kodu.

Logger.log(wszystko)

Ta praktyka oczywiście nie dotyczy wyłącznie wyjątków, ale jeśli jest jedna rzecz, która powinna być zawsze rejestrowana, jest to wyjątek.

Logowanie jest niezwykle ważne (wystarczająco ważne, aby Ruby dostarczał logger ze standardową wersją). To dziennik naszych aplikacji, a nawet ważniejsze niż prowadzenie rejestru sukcesów naszych aplikacji, jest rejestrowanie, jak i kiedy zawodzą.

Nie brakuje bibliotek rejestrowania lub usług opartych na dziennikach i wzorców projektowych. Bardzo ważne jest śledzenie naszych wyjątków, abyśmy mogli sprawdzić, co się stało i zbadać, czy coś nie wygląda dobrze. Właściwe komunikaty dziennika mogą wskazać programistom bezpośrednio przyczynę problemu, oszczędzając im niezmierzony czas.

Ta pewność czystego kodu

Czysta obsługa wyjątków wyśle ​​jakość Twojego kodu na księżyc!
Ćwierkać

Wyjątki są podstawową częścią każdego języka programowania. Są wyjątkowe i niezwykle potężne, a my musimy wykorzystać ich moc, aby podnieść jakość naszego kodu, zamiast męczyć się walcząc z nimi.

W tym artykule zagłębiliśmy się w kilka dobrych praktyk dotyczących strukturyzowania naszych drzew wyjątków oraz tego, w jaki sposób logiczna struktura ich może być korzystna dla czytelności i jakości. Przyjrzeliśmy się różnym podejściom do obsługi wyjątków, w jednym miejscu lub na wielu poziomach.

Widzieliśmy, że źle jest „złapać je wszystkie” i że dobrze jest pozwolić im unosić się i bulgotać.

Przyjrzeliśmy się, gdzie radzić sobie z wyjątkami na sucho i dowiedzieliśmy się, że nie jesteśmy zobowiązani do ich obsługi, kiedy i gdzie po raz pierwszy pojawią się.

Rozmawialiśmy, kiedy dokładnie jest to dobry pomysł, aby się nimi zająć, kiedy jest to zły pomysł i dlaczego, gdy masz wątpliwości, dobrym pomysłem jest pozwolić im się rozmnażać.

Na koniec omówiliśmy inne punkty, które mogą pomóc zmaksymalizować użyteczność wyjątków, takie jak przestrzeganie konwencji i rejestrowanie wszystkiego.

Dzięki tym podstawowym wskazówkom możemy czuć się znacznie bardziej komfortowo i pewnie, radząc sobie z przypadkami błędów w naszym kodzie i czyniąc nasze wyjątki naprawdę wyjątkowymi!

Specjalne podziękowania dla Avdi Grimm i jego niesamowitej przemowy Exceptional Ruby, która bardzo pomogła w tworzeniu tego artykułu.

Powiązane: Wskazówki i najlepsze praktyki dla programistów Ruby