Scrivere applicazioni Web robuste l'arte perduta di gestione delle eccezioni

Come sviluppatori, vogliamo che le applicazioni che creiamo siano resistenti quando si tratta di errori, ma come raggiungere questo obiettivo? Se credi che l'hype, i microservizi e un protocollo di comunicazione intelligente siano la risposta a tutti i tuoi problemi, o magari un failover DNS automatico. Mentre quel genere di cose ha il suo posto e rende interessante una presentazione della conferenza, la verità un po 'meno affascinante è che la creazione di un'applicazione robusta inizia con il tuo codice. Ma anche le applicazioni ben progettate e ben collaudate mancano spesso di un componente vitale del codice resiliente: la gestione delle eccezioni.

Contenuto sponsorizzato

Questo contenuto è stato commissionato da Engine Yard ed è stato scritto e / o modificato dal team Tuts +. Il nostro obiettivo con i contenuti sponsorizzati è quello di pubblicare tutorial pertinenti e obiettivi, case study e interviste ispiratrici che offrono un vero valore educativo ai nostri lettori e ci consentono di finanziare la creazione di contenuti più utili.

Non mi manca mai di stupirmi per il modo in cui la gestione delle eccezioni sottoutilizzate tende ad essere anche all'interno di codebase mature. Diamo un'occhiata a un esempio.


Cosa potrebbe andare storto?

Supponiamo di avere un'app Rails e una delle cose che possiamo fare usando questa app è quella di recuperare un elenco dei tweet più recenti per un utente, dato il loro handle. Nostro TweetsController potrebbe assomigliare a questo:

classe TweetsController < ApplicationController def show person = Person.find_or_create_by(handle: params[:handle]) if person.persisted? @tweets = person.fetch_tweets else flash[:error] = "Unable to create person with handle: #person.handle" end end end

E il Persona il modello che abbiamo usato potrebbe essere simile al seguente:

classe Persona < ActiveRecord::Base def fetch_tweets client = Twitter::REST::Client.new do |config| config.consumer_key = configatron.twitter.consumer_key config.consumer_secret = configatron.twitter.consumer_secret config.access_token = configatron.twitter.access_token config.access_token_secret = configatron.twitter.access_token_secret end client.user_timeline(handle).map|tweet| tweet.text end end

Questo codice sembra perfettamente ragionevole, ci sono dozzine di app che hanno il codice come questo in produzione, ma guardiamo un po 'più da vicino.

  • find_or_create_by è un metodo Rails, non è un metodo "bang", quindi non dovrebbe generare eccezioni, ma se osserviamo la documentazione possiamo vedere che, a causa del modo in cui questo metodo funziona, può generare un ActiveRecord :: RecordNotUnique errore. Questo non succederà spesso, ma se la nostra applicazione ha una quantità di traffico decente, è più probabile di quanto ci si possa aspettare (l'ho vista succedere molte volte).
  • Mentre siamo sull'argomento, qualsiasi libreria che usi può generare errori inaspettati a causa di bug all'interno della libreria stessa e Rails non fa eccezione. A seconda del nostro livello di paranoia, potremmo aspettarci il nostro find_or_create_by lanciare qualsiasi tipo di errore imprevisto in qualsiasi momento (un buon livello di paranoia è una buona cosa quando si tratta di costruire un software robusto). Se non abbiamo un modo globale di gestire errori imprevisti (ne discuteremo più avanti), potremmo volerli trattare singolarmente.
  • Poi c'è person.fetch_tweets che istanzia un client Twitter e cerca di recuperare alcuni tweet. Questa sarà una chiamata di rete ed è incline a tutti i tipi di errore. Potremmo voler leggere la documentazione per capire quali possibili errori possiamo aspettarci, ma sappiamo che gli errori non sono solo possibili qui, ma molto probabilmente (ad esempio, l'API di Twitter potrebbe essere inattiva, una persona con quella maniglia potrebbe non esiste ecc.). Non mettere un po 'di logica di gestione delle eccezioni intorno alle chiamate di rete è un problema.

La nostra piccola quantità di codice ha alcuni problemi seri, proviamo a migliorarla.


La giusta quantità di gestione delle eccezioni

Ci avvolgeremo find_or_create_by e spingilo giù nel Persona modello:

classe Persona < ActiveRecord::Base class << self def find_or_create_by_handle(handle) begin Person.find_or_create_by(handle: handle) rescue ActiveRecord::RecordNotUnique Rails.logger.warn  "Encountered a non-fatal RecordNotUnique error for: #handle"  retry rescue => e Rails.logger.error "Incontrato un errore durante il tentativo di trovare o creare Person per: # handle, # e.message # e.backtrace.join (" \ n ")" nil end end fine fine

Abbiamo gestito il ActiveRecord :: RecordNotUnique secondo la documentazione e ora sappiamo per certo che avremo o Persona oggetto o zero se qualcosa va storto Questo codice è ora solido, ma per quanto riguarda il recupero dei nostri tweet:

classe Persona < ActiveRecord::Base def fetch_tweets client.user_timeline(handle).map|tweet| tweet.text rescue => e Rails.logger.error "Errore durante il recupero dei tweet per: # handle, # e.message # e.backtrace.join (" \ n ")" nil end client def privato @client || = Twitter :: REST :: Client.new do | config | config.consumer_key = configatron.twitter.consumer_key config.consumer_secret = configatron.twitter.consumer_secret config.access_token = configatron.twitter.access_token config.access_token_secret = configatron.twitter.access_token_secret end end end

Spingiamo a istanziare il client Twitter nel suo metodo privato e dato che non sappiamo cosa potrebbe andare storto quando recuperiamo i tweet, salviamo tutto.

Potresti aver sentito da qualche parte che dovresti sempre rilevare errori specifici. Questo è un obiettivo lodevole, ma le persone spesso interpretano male come "se non riesco a cogliere qualcosa di specifico, non catturerò nulla". In realtà, se non riesci a catturare qualcosa di specifico, dovresti prendere tutto! In questo modo, almeno, hai l'opportunità di fare qualcosa anche se si tratta solo di loggare e controrilanciare l'errore.

A parte il progetto OO

Per rendere il nostro codice più solido, siamo stati costretti a refactoring e ora il nostro codice è probabilmente migliore di prima. Puoi usare il tuo desiderio per un codice più elastico per informare le tue decisioni di progettazione.

A parte il test

Ogni volta che aggiungi una logica di gestione delle eccezioni a un metodo, è anche un percorso extra attraverso quel metodo e deve essere testato. È fondamentale testare il percorso eccezionale, forse più che testare il percorso felice. Se qualcosa va storto sulla felice strada ora hai l'assicurazione extra del salvare blocco per evitare che la tua app cada. Tuttavia, qualsiasi logica all'interno del blocco di salvataggio non ha tale assicurazione. Metti alla prova il tuo percorso eccezionale, in modo che le cose stupide come digitazione di un nome variabile all'interno del salvare il blocco non fa esplodere la tua applicazione (mi è successo così tante volte - seriamente, prova solo la tua salvare blocchi).


Cosa fare con gli errori che intercettiamo

Ho visto questo tipo di codice infinite volte nel corso degli anni:

iniziare widgetron.create rescue # non è necessario che termini qualcosa

Salviamo un'eccezione e non facciamo nulla con essa. Questa è quasi sempre una cattiva idea. Quando esegui il debug di un problema di produzione tra sei mesi, cercando di capire perché il tuo 'widgetron' non viene mostrato nel database, non ti ricorderai che un commento innocente e ore di frustrazioni seguiranno.

Non ingoiare le eccezioni! Per lo meno è necessario registrare qualsiasi eccezione che si cattura, ad esempio:

begin foo.bar rescue => e Rails.logger.error "# e.message # e.backtrace.join (" \ n ")" fine

In questo modo possiamo eseguire il trapping dei log e avremo la causa e la traccia dello stack dell'errore da esaminare.

Ancora meglio, puoi usare un servizio di monitoraggio degli errori, come Rollbar, che è molto carino. Ci sono molti vantaggi a questo:

  • I tuoi messaggi di errore non sono intercalati con altri messaggi di registro
  • Riceverai le statistiche su quante volte è successo lo stesso errore (così puoi capire se si tratta di un problema serio o no)
  • Puoi inviare ulteriori informazioni insieme all'errore per aiutarti a diagnosticare il problema
  • Puoi ricevere notifiche (via email, pagerduty ecc.) Quando si verificano errori nella tua app
  • È possibile tenere traccia delle distribuzioni per vedere quando sono stati introdotti o corretti errori particolari
  • eccetera.
begin foo.bar rescue => e Rails.logger.error "# e.message # e.backtrace.join (" \ n ")" Rollbar.report_exception (e) end

È possibile, naturalmente, registrare e utilizzare un servizio di monitoraggio come sopra.

Se tuo salvare il blocco è l'ultima cosa in un metodo, consiglio di avere un ritorno esplicito:

def my_method begin foo.bar rescue => e Rails.logger.error "# e.message # e.backtrace.join (" \ n ")" Rollbar.report_exception (e) nil end end

Potresti non voler sempre tornare zero, a volte potresti stare meglio con un oggetto nullo o qualsiasi altra cosa ha senso nel contesto della tua applicazione. Usare coerentemente valori di ritorno espliciti farà risparmiare a tutti molta confusione.

Puoi anche controrilanciare lo stesso errore o alzarne uno diverso all'interno salvare bloccare. Uno schema che trovo spesso utile è quello di avvolgere l'eccezione esistente in una nuova e rilanciarla in modo da non perdere la traccia dello stack originale (ho persino scritto una gemma per questo dato che Ruby non fornisce questa funzionalità fuori dalla scatola ). Più avanti nell'articolo quando parliamo di servizi esterni, ti mostrerò perché questo può essere utile.


Gestione degli errori a livello globale

Rails consente di specificare come gestire le richieste di risorse di un determinato formato (HTML, XML, JSON) utilizzando rispondere a e respond_with. Raramente vedo le app che usano correttamente questa funzionalità, dopotutto se non ne usi una rispondere a Blocca tutto funziona correttamente e Rails rende il tuo modello correttamente. Abbiamo colpito il nostro controller tweets via / Tweets / yukihiro_matz e ottieni una pagina HTML piena degli ultimi tweet di Matz. Ciò che le persone spesso dimenticano è che è molto facile provare e richiedere un formato diverso della stessa risorsa, ad es. /tweets/yukihiro_matz.json. A questo punto Rails tenterà coraggiosamente di restituire una rappresentazione JSON dei tweet di Matzs, ma non andrà bene dato che la vista non esiste. Un ActionView :: MissingTemplate l'errore verrà sollevato e la nostra app esploderà in modo spettacolare. E JSON è un formato legittimo, in un'applicazione ad alto traffico che è probabile che richieda una richiesta /tweets/yukihiro_matz.foobar. Tuts + riceve continuamente questo tipo di richieste (probabilmente dai bot che cercano di essere intelligenti).

La lezione è questa, se non hai intenzione di restituire una risposta legittima per un particolare formato, limita i controller a cercare di soddisfare le richieste di tali formati. Nel caso del nostro TweetsController:

classe TweetsController < ApplicationController respond_to :html def show… respond_to do |format| format.html end end end

Ora, quando riceviamo richieste per formati spuri, diventeremo più pertinenti ActionController :: UnknownFormat errore. I nostri controller si sentono un po 'più stretti, il che è una grande cosa quando si tratta di renderli più robusti.

Gestione degli errori nel modo Rails

Il problema che abbiamo ora è che nonostante il nostro errore semanticamente piacevole, la nostra applicazione sta ancora esplodendo nel volto dei nostri utenti. È qui che entra in gioco la gestione globale delle eccezioni. A volte la nostra applicazione produce errori a cui vogliamo rispondere in modo coerente, indipendentemente da dove provengano (come il nostro ActionController :: UnknownFormat). Ci sono anche errori che possono essere sollevati dal framework prima che tutto il nostro codice entri in gioco. Un perfetto esempio di questo è ActionController :: RoutingError. Quando qualcuno richiede un URL che non esiste, come / Tweets2 / yukihiro_matz, non c'è nessun posto per noi da collegare per salvare questo errore, usando la gestione delle eccezioni tradizionale. Questo è dove Rails ' exceptions_app entra.

Puoi configurare un'app Rack in application.rb essere chiamato quando viene prodotto un errore che non abbiamo gestito (come il nostro ActionController :: RoutingError o ActionController :: UnknownFormat). Il modo in cui normalmente verrà utilizzato questo è configurare l'app per i percorsi come exceptions_app, quindi definire i vari percorsi per gli errori che si desidera gestire e indirizzarli a un controller di errori speciali creato. Quindi il nostro application.rb sarebbe simile a questo:

... config.exceptions_app = self.routes ... 

Nostro routes.rb conterrà quindi quanto segue:

... match '/ 404' => 'errors # not_found', via:: all match '/ 406' => 'errors # not_acceptable', tramite:: all match '/ 500' => 'errors # internal_server_error', tramite: :tutti… 

In questo caso il nostro ActionController :: RoutingError sarebbe raccolto dal 404 percorso e il ActionController :: UnknownFormat sarà raccolto dal 406 itinerario. Ci sono molti errori che possono sorgere. Ma finché gestisci quelli comuni (404, 500, 422 ecc.) per cominciare, puoi aggiungerne altri se e quando accadono.

All'interno del nostro controller degli errori possiamo ora rendere i modelli rilevanti per ogni tipo di errore insieme al nostro layout (se non è un 500) per mantenere il marchio. Possiamo anche registrare gli errori e inviarli al nostro servizio di monitoraggio, sebbene la maggior parte dei servizi di monitoraggio si colleghino automaticamente a questo processo in modo da non dover inviare personalmente gli errori. Ora quando la nostra applicazione esplode lo fa con delicatezza, con il giusto codice di stato a seconda dell'errore e una pagina in cui possiamo dare all'utente qualche idea su cosa è successo e cosa possono fare (contattare il supporto) - un'esperienza infinitamente migliore. Ancora più importante, la nostra app sembrerà (e sarà effettivamente) molto più solida.

Errori multipli dello stesso tipo in un controller

In qualsiasi controller di Rails possiamo definire errori specifici da gestire globalmente all'interno di quel controller (indipendentemente dall'azione in cui vengono prodotti) - lo facciamo tramite rescue_from. La domanda è quando usare rescue_from? Di solito trovo che un buon pattern lo si possa usare per errori che possono verificarsi in più azioni (ad esempio, lo stesso errore in più di un'azione). Se un errore verrà prodotto solo da un'azione, gestirlo tramite il tradizionale inizio ... salvataggio ... fine meccanismo, ma se è probabile che si verifichi lo stesso errore in più punti e vogliamo gestirlo allo stesso modo - è un buon candidato per un rescue_from. Diciamo il nostro TweetsController ha anche un creare azione:

classe TweetsController < ApplicationController respond_to :html def show… respond_to do |format| format.html end end def create… end end

Diciamo anche che entrambe queste azioni possono incontrare a TwitterError e se lo fanno vogliamo dire all'utente che qualcosa non va in Twitter. Qui è dove rescue_from può essere davvero utile:

classe TweetsController < ApplicationController respond_to :html rescue_from TwitterError, with: twitter_error private def twitter_error render :twitter_error end end

Ora non dobbiamo preoccuparci di gestirlo nelle nostre azioni e appariranno molto più puliti e possiamo / dovremmo - naturalmente - registrare il nostro errore e / o notificare il nostro servizio di monitoraggio degli errori all'interno del twitter_error metodo. Se usi rescue_from correttamente non solo può aiutarti a rendere l'applicazione più robusta, ma può anche rendere più pulito il codice del tuo controller. Ciò renderà più semplice mantenere e testare il codice, rendendo la tua applicazione ancora più resistente.


Utilizzo di servizi esterni nella tua applicazione

È difficile scrivere un'applicazione significativa in questi giorni senza utilizzare un numero di servizi / API esterni. Nel caso del nostro TweetsController, Twitter è entrato in gioco tramite una gemma Ruby che avvolge l'API di Twitter. Idealmente, faremmo tutte le chiamate API esterne in modo asincrono, ma non tratteremo l'elaborazione asincrona in questo articolo e ci sono un sacco di applicazioni là fuori che fanno almeno alcune chiamate API / di rete in-process.

Effettuare chiamate di rete è un'attività estremamente soggetta a errori e una buona gestione delle eccezioni è un must. È possibile ottenere errori di autenticazione, problemi di configurazione e errori di connettività. La libreria che usi può produrre un numero qualsiasi di errori di codice e quindi c'è una questione di connessioni lente. Sto sorvolando questo punto, ma è così cruciale dal momento che non è possibile gestire connessioni lente tramite la gestione delle eccezioni. È necessario configurare in modo appropriato i timeout nella libreria di rete oppure, se si utilizza un wrapper API, assicurarsi che fornisca hook per configurare i timeout. Non c'è esperienza peggiore per un utente che dover sedersi lì in attesa senza che la tua applicazione dia alcuna indicazione su ciò che sta accadendo. Quasi tutti dimenticano di configurare i timeout in modo appropriato (lo so che ho), quindi fate attenzione.

Se si utilizza un servizio esterno in più posizioni all'interno dell'applicazione (ad esempio più modelli), vengono esposte ampie parti dell'applicazione al panorama completo degli errori che possono essere generati. Questa non è una buona situazione. Quello che vogliamo fare è limitare la nostra esposizione e un modo in cui possiamo farlo è mettere tutto l'accesso ai nostri servizi esterni dietro una facciata, salvando tutti gli errori lì e rilanciare un errore semanticamente appropriato (sollevare quello TwitterError di cui abbiamo parlato se si verificano errori quando proviamo a colpire l'API di Twitter). Possiamo quindi facilmente utilizzare tecniche come rescue_from per gestire questi errori e non esporremo gran parte della nostra applicazione a un numero sconosciuto di errori da fonti esterne.

Un'idea ancora migliore potrebbe essere quella di rendere la tua facciata un'API priva di errori. Restituisci tutte le risposte riuscite così come sono e restituisci nils o oggetti nulli quando salvi qualsiasi tipo di errore (dobbiamo comunque registrarci / notificare gli errori tramite alcuni dei metodi sopra descritti). In questo modo non abbiamo bisogno di mescolare diversi tipi di flusso di controllo (flusso di controllo delle eccezioni vs if ... else) che potrebbe farci ottenere un codice significativamente più pulito. Ad esempio, avvolgiamo l'accesso alle API di Twitter in a TwitterClient oggetto:

classe TwitterClient attr_reader: client def initialize @client = Twitter :: REST :: Client.new do | config | config.consumer_key = configatron.twitter.consumer_key config.consumer_secret = configatron.twitter.consumer_secret config.access_token = configatron.twitter.access_token config.access_token_secret = configatron.twitter.access_token_secret end end def latest_tweets (handle) client.user_timeline (handle). map | Tweet | tweet.text rescue => e Rails.logger.error "# e.message # e.backtrace.join (" \ n ")" nil end end

Ora possiamo fare questo: TwitterClient.new.latest_tweets ( 'yukihiro_matz'), ovunque nel nostro codice e sappiamo che non produrrà mai un errore, o piuttosto non propagherà mai l'errore oltre TwitterClient. Abbiamo isolato un sistema esterno per assicurarci che glitch in quel sistema non riducano la nostra applicazione principale.


Ma cosa succede se ho una copertura di prova eccellente?

Se possiedi un codice ben collaudato, ti raccomando sulla tua diligenza, ci vorrà molto per avere un'applicazione più solida. Ma una buona suite di test può spesso fornire un falso senso di sicurezza. Buoni test possono aiutarti a rifattarti con fiducia e proteggerti dalla regressione. Ma puoi scrivere solo test per le cose che ti aspetti che succedano. Gli insetti sono, per loro stessa natura, inaspettati. Per usare il nostro esempio di tweet, finché non decidiamo di scrivere un test per il nostro fetch_tweets metodo dove client.user_timeline (maniglia) solleva un errore in tal modo costringendoci a avvolgere a salvare bloccare il codice, tutti i nostri test saranno stati verdi e il nostro codice sarebbe rimasto incline al fallimento.

Scrivere prove, non ci assolve dalla responsabilità di guardare con occhio critico il nostro codice per capire come questo codice possa potenzialmente rompersi. D'altra parte, fare questo tipo di valutazione può sicuramente aiutarci a scrivere suite di test migliori e più complete.


Conclusione

I sistemi resilienti non emergono completamente formati da una sessione di hacking del fine settimana. Rendere solida un'applicazione, è un processo in corso. Scopri bug, li correggi e scrivi test per assicurarti che non tornino. Quando l'applicazione si interrompe a causa di un errore di sistema esterno, si isola quel sistema per assicurarsi che l'errore non riesca a farla nuovamente. La gestione delle eccezioni è il tuo migliore amico quando si tratta di farlo. Anche l'applicazione più soggetta a errori può essere trasformata in robusta se si applicano costantemente buone pratiche di gestione delle eccezioni, nel tempo.

Ovviamente, la gestione delle eccezioni non è l'unico strumento nel vostro arsenale quando si tratta di rendere le applicazioni più resistenti. Negli articoli successivi parleremo dell'elaborazione asincrona, di come e quando applicarla e di cosa può fare in termini di tolleranza all'applicazione. Analizzeremo anche alcuni suggerimenti di implementazione e infrastruttura che possono avere un impatto significativo senza rompere la banca in termini di tempo e denaro - restate sintonizzati.