Codul curat și arta gestionării excepțiilor

Publicat: 2022-03-11

Excepțiile sunt la fel de vechi ca și programarea în sine. În vremurile când programarea se făcea în hardware sau prin limbaje de programare de nivel scăzut, excepțiile erau folosite pentru a modifica fluxul programului și pentru a evita defecțiunile hardware. Astăzi, Wikipedia definește excepțiile ca:

condiții anormale sau excepționale care necesită o prelucrare specială - adesea schimbând fluxul normal de execuție a programului...

Și că manipularea lor necesită:

constructii de limbaj de programare specializate sau mecanisme hardware de calculator.

Deci, excepțiile necesită un tratament special, iar o excepție netratată poate provoca un comportament neașteptat. Rezultatele sunt adesea spectaculoase. În 1996, faimosul eșec de lansare a rachetei Ariane 5 a fost atribuit unei excepții de depășire netratată. Cele mai grave erori software din istorie conține alte erori care ar putea fi atribuite excepțiilor negestionate sau gestionate greșit.

De-a lungul timpului, aceste erori și nenumărate altele (care nu au fost, poate, la fel de dramatice, dar totuși catastrofale pentru cei implicați) au contribuit la impresia că excepțiile sunt rele .

Dar excepțiile sunt un element fundamental al programării moderne; ele există pentru a face software-ul nostru mai bun. În loc să ne temem de excepții, ar trebui să le îmbrățișăm și să învățăm cum să beneficiem de ele. În acest articol, vom discuta despre cum să gestionăm excepțiile în mod elegant și să le folosim pentru a scrie cod curat, care este mai ușor de întreținut.

Gestionarea excepțiilor: este un lucru bun

Odată cu creșterea programării orientate pe obiecte (OOP), suportul pentru excepții a devenit un element crucial al limbajelor de programare moderne. Un sistem robust de gestionare a excepțiilor este încorporat în majoritatea limbilor, în zilele noastre. De exemplu, Ruby oferă următorul model tipic:

 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

Nu este nimic în neregulă cu codul anterior. Dar utilizarea excesivă a acestor modele va provoca mirosuri de cod și nu va fi neapărat benefică. De asemenea, folosirea greșită a acestora poate dăuna foarte mult bazei dvs. de cod, făcând-o fragilă sau ofucând cauza erorilor.

Stigmatul din jurul excepțiilor îi face adesea pe programatori să se simtă în pierdere. Este o realitate că excepțiile nu pot fi evitate, dar adesea suntem învățați că trebuie tratate rapid și hotărât. După cum vom vedea, acest lucru nu este neapărat adevărat. Mai degrabă, ar trebui să învățăm arta de a gestiona excepțiile cu grație, făcându-le armonioase cu restul codului nostru.

Următoarele sunt câteva practici recomandate care vă vor ajuta să acceptați excepțiile și să utilizați ele și abilitățile lor pentru a vă menține codul, extensibil și lizibil :

  • mentenabilitatea : Ne permite să găsim și să remediam cu ușurință noi erori, fără teama de a întrerupe funcționalitatea curentă, de a introduce erori suplimentare sau de a trebui să abandonăm codul din cauza complexității crescute în timp.
  • extensibilitate : Ne permite să adăugăm cu ușurință la baza noastră de cod, implementând cerințe noi sau modificate fără a întrerupe funcționalitatea existentă. Extensibilitatea oferă flexibilitate și permite un nivel ridicat de reutilizare pentru baza noastră de cod.
  • lizibilitate : ne permite să citim cu ușurință codul și să descoperim scopul său, fără a petrece prea mult timp să sapăm. Acest lucru este esențial pentru descoperirea eficientă a erorilor și a codului netestat.

Aceste elemente sunt factorii principali ai ceea ce am putea numi curățenie sau calitate , care nu este o măsură directă în sine, ci este efectul combinat al punctelor anterioare, așa cum se demonstrează în acest comic:

„WTFs/m” de Thom Holwerda, OSNews

Acestea fiind spuse, haideți să ne aprofundăm în aceste practici și să vedem cum fiecare dintre ele afectează aceste trei măsuri.

Notă: Vom prezenta exemple din Ruby, dar toate constructele demonstrate aici au echivalente în cele mai comune limbaje OOP.

Creați întotdeauna propria dvs. ierarhie ApplicationError

Majoritatea limbilor vin cu o varietate de clase de excepție, organizate într-o ierarhie de moștenire, ca orice altă clasă OOP. Pentru a păstra lizibilitatea, mentenabilitatea și extensibilitatea codului nostru, este o idee bună să ne creăm propriul subarboresc de excepții specifice aplicației care extind clasa de bază de excepție. Investirea unui timp în structurarea logică a acestei ierarhii poate fi extrem de benefică. De exemplu:

 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 # ... 

Exemplu de ierarhie a excepțiilor aplicației: StandardError este în partea de sus. ApplicationError moștenește de la acesta. ValidationError și ResponseError moștenesc ambele din asta. RequiredFieldError și UniqueFieldError moștenesc de la ValidationError, în timp ce BadRequestError și UnauthorizedError moștenesc de la ResponseError.

Având un pachet extensibil și cuprinzător de excepții pentru aplicația noastră, gestionarea acestor situații specifice aplicației este mult mai ușoară. De exemplu, putem decide ce excepții să gestionăm într-un mod mai natural. Acest lucru nu numai că sporește lizibilitatea codului nostru, dar crește și mentenabilitatea aplicațiilor și bibliotecilor noastre (gemuri).

Din perspectiva lizibilității, este mult mai ușor de citit:

 rescue ValidationError => e

Decat sa citesti:

 rescue RequiredFieldError, UniqueFieldError, ... => e

Din perspectiva mentenabilității, să spunem, de exemplu, implementăm un API JSON și ne-am definit propriul ClientError cu mai multe subtipuri, pentru a fi folosit atunci când un client trimite o solicitare greșită. Dacă se afișează oricare dintre acestea, aplicația ar trebui să ofere reprezentarea JSON a erorii în răspunsul său. Va fi mai ușor să remediați sau să adăugați logică la un singur bloc care se ocupă de ClientError , mai degrabă decât să treceți în buclă peste fiecare posibilă eroare de client și să implementați același cod de gestionare pentru fiecare. În ceea ce privește extensibilitatea, dacă mai târziu trebuie să implementăm un alt tip de eroare de client, putem avea încredere că va fi deja tratată corect aici.

Mai mult, acest lucru nu ne împiedică să implementăm o gestionare specială suplimentară pentru anumite erori de client mai devreme în stiva de apeluri sau să modificăm același obiect excepție pe parcurs:

 # 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

După cum puteți vedea, ridicarea acestei excepții specifice nu ne-a împiedicat să o putem gestiona la diferite niveluri, să o modificăm, să o ridicăm din nou și permițând handler-ului clasei părinte să o rezolve.

Două lucruri de remarcat aici:

  • Nu toate limbile acceptă ridicarea de excepții din cadrul unui handler de excepții.
  • În majoritatea limbilor, ridicarea unei noi excepții din cadrul unui handler va face ca excepția originală să fie pierdută pentru totdeauna, așa că este mai bine să ridicați din nou același obiect de excepție (ca în exemplul de mai sus) pentru a evita pierderea evidenței cauzei originale a eroare. (Dacă nu faci asta în mod intenționat).

Nu rescue Exception

Adică, nu încercați niciodată să implementați un handler catch-all pentru tipul de excepție de bază. Salvarea sau capturarea tuturor excepțiilor cu ridicata nu este niciodată o idee bună în nicio limbă, fie că este vorba la nivel global la nivel de aplicație de bază, fie într-o mică metodă îngropată folosită o singură dată. Nu vrem să salvăm Exception , deoarece va obstrucționa orice s-a întâmplat cu adevărat, dăunând atât mentenabilitatea, cât și extensibilitatea. Putem pierde o cantitate enormă de timp depanând care este problema reală, când ar putea fi la fel de simplă ca o eroare de sintaxă:

 # 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

Este posibil să fi observat eroarea din exemplul anterior; return este scris greșit. Deși editorii moderni oferă o anumită protecție împotriva acestui tip specific de eroare de sintaxă, acest exemplu ilustrează modul în care rescue Exception dăunează codului nostru. În niciun moment nu este abordat tipul real al excepției (în acest caz o NoMethodError ) și nici nu este expus vreodată dezvoltatorului, ceea ce ne poate face să pierdem mult timp alergând în cerc.

Nu rescue niciodată mai multe excepții decât aveți nevoie

Punctul anterior este un caz specific al acestei reguli: ar trebui să fim întotdeauna atenți să nu generalizăm excesiv gestionatorii noștri de excepții. Motivele sunt aceleași; ori de câte ori salvăm mai multe excepții decât ar trebui, ajungem să ascundem părți ale logicii aplicației de la nivelurile superioare ale aplicației, ca să nu mai vorbim de suprimarea capacității dezvoltatorului de a gestiona singur excepția. Acest lucru afectează grav extensibilitatea și mentenabilitatea codului.

Dacă încercăm să gestionăm diferite subtipuri de excepții în același handler, introducem blocuri de cod grase care au prea multe responsabilități. De exemplu, dacă construim o bibliotecă care consumă un API la distanță, gestionarea unui MethodNotAllowedError (HTTP 405), este de obicei diferită de gestionarea unei Eroare UnauthorizedError (HTTP 401), chiar dacă ambele sunt ResponseError .

După cum vom vedea, de multe ori există o parte diferită a aplicației care ar fi mai potrivită pentru a gestiona anumite excepții într-un mod mai USCAT.

Deci, definiți responsabilitatea unică a clasei sau metodei dvs. și gestionați minimul de excepții care îndeplinesc această cerință de responsabilitate . De exemplu, dacă o metodă este responsabilă pentru obținerea informațiilor stoc de la o API de la distanță, atunci ar trebui să se ocupe de excepțiile care apar doar din obținerea respectivelor informații și să lase gestionarea celorlalte erori pe seama unei metode diferite concepute special pentru aceste responsabilități:

 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

Aici am definit contractul pentru această metodă pentru a ne obține doar informații despre stoc. Se ocupă de erori specifice punctului final , cum ar fi un răspuns JSON incomplet sau incorect. Nu se ocupă de cazul când autentificarea eșuează sau expiră sau dacă stocul nu există. Acestea sunt responsabilitatea altcuiva și sunt trecute în mod explicit în stiva de apeluri, unde ar trebui să existe un loc mai bun pentru a gestiona aceste erori într-un mod DRY.

Rezistați impulsului de a gestiona excepțiile imediat

Acesta este complementul ultimului punct. O excepție poate fi gestionată în orice punct al stivei de apeluri și în orice punct al ierarhiei claselor, așa că a ști exact unde să o gestionezi poate fi încrezător. Pentru a rezolva această enigma, mulți dezvoltatori aleg să gestioneze orice excepție de îndată ce apare, dar investirea timpului în gândire va duce de obicei la găsirea unui loc mai potrivit pentru a gestiona anumite excepții.

Un model comun pe care îl vedem în aplicațiile Rails (în special cele care expun API-uri numai JSON) este următoarea metodă de controler:

 # 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

(Rețineți că, deși acesta nu este, din punct de vedere tehnic, un handler de excepție, din punct de vedere funcțional, servește același scop, deoarece @client.save returnează false doar atunci când întâlnește o excepție.)

În acest caz, totuși, repetarea aceluiași handler de erori în fiecare acțiune a controlerului este opusul DRY și dăunează mentenanței și extensibilității. În schimb, putem folosi natura specială a propagării excepțiilor și le putem gestiona o singură dată, în clasa controlerului părinte, 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

În acest fel, ne putem asigura că toate erorile ActiveRecord::RecordInvalid sunt tratate corect și în mod USCAT într-un singur loc, la nivelul de bază ApplicationController . Acest lucru ne oferă libertatea de a le juca dacă vrem să ne ocupăm de cazuri specifice la nivelul inferior sau pur și simplu să le lăsăm să se propagă cu grație.

Nu toate excepțiile trebuie tratate

Când dezvoltă o bijuterie sau o bibliotecă, mulți dezvoltatori vor încerca să încapsuleze funcționalitatea și să nu permită nicio excepție să se propage în afara bibliotecii. Dar uneori, nu este evident cum să gestionezi o excepție până când aplicația specifică este implementată.

Să luăm ActiveRecord ca exemplu de soluție ideală. Biblioteca oferă dezvoltatorilor două abordări pentru completare. Metoda de save gestionează excepțiile fără a le propaga, pur și simplu returnând false , în timp ce save! ridică o excepție atunci când eșuează. Acest lucru le oferă dezvoltatorilor opțiunea de a gestiona diferite cazuri de eroare sau pur și simplu de a gestiona orice eșec într-un mod general.

Dar ce se întâmplă dacă nu aveți timpul sau resursele pentru a oferi o implementare atât de completă? În acest caz, dacă există vreo incertitudine, cel mai bine este să expuneți excepția și să o eliberați în sălbăticie.

Iată de ce: lucrăm cu cerințe de mutare aproape tot timpul și luarea deciziei că o excepție va fi întotdeauna tratată într-un mod specific ar putea afecta implementarea noastră, dăunând extensibilității și mentenabilității și adăugând potențial datorii tehnice uriașe, mai ales atunci când dezvoltăm biblioteci.

Luați exemplul mai devreme al unui consumator API de stoc care preia prețurile acțiunilor. Am ales să gestionăm răspunsul incomplet și malformat pe loc și am ales să reîncercăm aceeași cerere din nou până când am primit un răspuns valid. Dar mai târziu, cerințele s-ar putea schimba, astfel încât trebuie să ne întoarcem la datele stocurilor istorice salvate, în loc să încercăm din nou cererea.

În acest moment, vom fi forțați să schimbăm biblioteca în sine, actualizând modul în care este gestionată această excepție, deoarece proiectele dependente nu vor gestiona această excepție. (Cum au putut? Nu le-a fost niciodată expus până acum.) De asemenea, va trebui să informăm proprietarii proiectelor care se bazează pe biblioteca noastră. Acest lucru ar putea deveni un coșmar dacă există multe astfel de proiecte, deoarece este probabil să fi fost construite pe presupunerea că această eroare va fi gestionată într-un mod specific.

Acum, putem vedea încotro ne îndreptăm cu gestionarea dependențelor. Perspectivele nu sunt bune. Această situație se întâmplă destul de des și, de cele mai multe ori, degradează utilitatea, extensibilitatea și flexibilitatea bibliotecii.

Așadar, aici este concluzia: dacă nu este clar cum ar trebui gestionată o excepție, lăsați-o să se propage cu grație . Există multe cazuri în care există un loc clar pentru a gestiona excepția în interior, dar există multe alte cazuri în care expunerea excepției este mai bună. Așadar, înainte de a opta pentru gestionarea excepției, gândiți-vă a doua oară. O regulă generală bună este să insistați asupra gestionării excepțiilor numai atunci când interacționați direct cu utilizatorul final.

Urmați convenția

Implementarea lui Ruby și, cu atât mai mult, Rails, urmează unele convenții de denumire, cum ar fi diferența dintre method_names și method_names! cu un „buc”. În Ruby, bang-ul indică faptul că metoda va modifica obiectul care a invocat-o, iar în Rails, înseamnă că metoda va ridica o excepție dacă nu reușește să execute comportamentul așteptat. Încercați să respectați aceeași convenție, mai ales dacă aveți de gând să deschideți biblioteca dvs.

Dacă ar fi să scriem o nouă method! cu un bang într-o aplicație Rails, trebuie să luăm în considerare aceste convenții. Nimic nu ne obligă să ridicăm o excepție atunci când această metodă eșuează, dar, prin abaterea de la convenție, această metodă îi poate induce în eroare pe programatori, făcându-le să creadă că li se va oferi șansa de a gestiona ei înșiși excepțiile, când, de fapt, nu o vor face.

O altă convenție Ruby, atribuită lui Jim Weirich, este să folosiți fail pentru a indica eșecul metodei și numai să folosiți raise dacă re- ridicați excepția.

Deoparte, pentru că folosesc excepții pentru a indica eșecurile, aproape întotdeauna folosesc cuvântul cheie fail în loc de raise în Ruby. Eșuarea și creșterea sunt sinonime, așa că nu există nicio diferență, cu excepția faptului că eșuarea comunică mai clar că metoda a eșuat. Singura dată când folosesc raise este atunci când prind o excepție și o ridic din nou, pentru că aici nu reușesc, ci ridic o excepție în mod explicit și intenționat. Aceasta este o problemă stilistică pe care o urmăresc, dar mă îndoiesc de mulți alți oameni.

Multe alte comunități lingvistice au adoptat convenții ca acestea cu privire la modul în care sunt tratate excepțiile, iar ignorarea acestor convenții va afecta lizibilitatea și mentenabilitatea codului nostru.

Logger.log(totul)

Această practică nu se aplică numai excepțiilor, desigur, dar dacă există un lucru care ar trebui întotdeauna înregistrat, este o excepție.

Înregistrarea este extrem de importantă (suficient de important pentru ca Ruby să livreze un logger cu versiunea sa standard). Este jurnalul aplicațiilor noastre și, chiar mai important decât păstrarea unei evidențe a modului în care aplicațiile noastre reușesc, este înregistrarea cum și când eșuează.

Nu lipsesc bibliotecile de jurnalizare sau serviciile bazate pe jurnal și modelele de proiectare. Este esențial să urmărim excepțiile noastre, astfel încât să putem analiza ce sa întâmplat și să investigăm dacă ceva nu arată în regulă. Mesajele de jurnal adecvate pot indica dezvoltatorii direct la cauza unei probleme, economisindu-le timp nemăsurat.

Acea încredere în codul curat

Gestionarea curată a excepțiilor va trimite calitatea codului dvs. pe lună!
Tweet

Excepțiile sunt o parte fundamentală a fiecărui limbaj de programare. Sunt speciali și extrem de puternici și trebuie să le valorificăm puterea pentru a crește calitatea codului nostru, în loc să ne epuizăm luptând cu ei.

În acest articol, ne-am scufundat în câteva bune practici pentru structurarea arborilor noștri de excepții și cum poate fi benefic pentru lizibilitate și calitate să le structuram logic. Am analizat diferite abordări pentru gestionarea excepțiilor, fie într-un singur loc, fie pe mai multe niveluri.

Am văzut că e rău să îi „prindem pe toți” și că este în regulă să-i lași să plutească și să facă bule.

Am analizat unde să tratăm excepțiile într-o manieră USCATĂ și am învățat că nu suntem obligați să le gestionăm când sau unde apar pentru prima dată.

Am discutat exact când este o idee bună să le gestionăm, când este o idee proastă și de ce, atunci când aveți dubii, este o idee bună să le lăsați să se propage.

În cele din urmă, am discutat și alte puncte care pot ajuta la maximizarea utilității excepțiilor, cum ar fi respectarea convențiilor și înregistrarea în jurnal.

Cu aceste linii directoare de bază, ne putem simți mult mai confortabili și încrezători în gestionarea cazurilor de eroare din codul nostru și făcând excepțiile noastre cu adevărat excepționale!

Mulțumiri speciale lui Avdi Grimm și discursului său minunat Exceptional Ruby, care a ajutat foarte mult la realizarea acestui articol.

Înrudit: Sfaturi și cele mai bune practici pentru dezvoltatorii Ruby