Écrire des applications Web robustes l'art perdu du traitement des exceptions

En tant que développeurs, nous souhaitons que les applications que nous construisons soient résilientes en cas d’échec, mais comment réalisez-vous cet objectif? Si vous croyez que le battage publicitaire, les micro-services et un protocole de communication intelligent sont la réponse à tous vos problèmes, ou peut-être un basculement DNS automatique. Alors que ce genre de choses a sa place et fait une présentation intéressante de la conférence, la vérité un peu moins glamour est que faire une application robuste commence avec votre code. Cependant, même les applications bien conçues et testées manquent souvent d'un composant essentiel du code résilient - la gestion des exceptions.

Contenu sponsorisé

Ce contenu a été commandé par Engine Yard et a été écrit et / ou édité par l'équipe de Tuts +. Notre objectif avec le contenu sponsorisé est de publier des tutoriels pertinents et objectifs, des études de cas et des interviews inspirantes qui offrent une véritable valeur éducative à nos lecteurs et nous permettent de financer la création de contenu plus utile..

Je suis toujours étonné de constater à quel point la gestion des exceptions est sous-utilisée, même au sein de bases de code matures. Regardons un exemple.


Ce qui peut éventuellement aller mal?

Supposons que nous ayons une application Rails et qu'une des choses que nous puissions faire à l'aide de cette application est de chercher une liste des derniers tweets pour un utilisateur, en fonction de son descripteur. Notre TweetsController pourrait ressembler à ceci:

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

Et le La personne Le modèle que nous avons utilisé pourrait être semblable au suivant:

classe personne < 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

Ce code semble parfaitement raisonnable, il y a des dizaines d'applications qui ont un code comme celui-ci en production, mais regardons de plus près..

  • find_or_create_by est une méthode Rails, ce n'est pas une méthode "bang", elle ne devrait donc pas renvoyer d'exceptions, mais si nous regardons la documentation, nous pouvons voir qu'en raison du fonctionnement de cette méthode, elle peut générer une ActiveRecord :: RecordNotUnique Erreur. Cela n'arrivera pas souvent, mais si notre application a un volume de trafic décent, il se produira plus que prévu (je l'ai déjà vu plusieurs fois).
  • Tant que nous sommes sur le sujet, toute bibliothèque que vous utilisez peut générer des erreurs inattendues dues à des bugs au sein de la bibliothèque elle-même et Rails ne fait pas exception. Selon notre niveau de paranoïa, nous pourrions nous attendre à notre find_or_create_by lancer n'importe quel type d'erreur inattendue à tout moment (un niveau de paranoïa sain est une bonne chose lorsqu'il s'agit de créer un logiciel robuste). Si nous n'avons pas de moyen global de gérer les erreurs inattendues (nous en discuterons ci-dessous), nous voudrons peut-être les gérer individuellement..
  • Ensuite il y a person.fetch_tweets qui instancie un client Twitter et tente de récupérer des tweets. Ce sera un appel réseau et est sujet à toutes sortes d'échecs. Nous voudrons peut-être lire la documentation pour comprendre quelles sont les erreurs possibles que nous pourrions espérer, mais nous savons que les erreurs sont non seulement possibles ici, mais très probablement (par exemple, l’API de Twitter peut être en panne, une personne avec ce descripteur peut pas exister etc.). Ne pas mettre en place une logique de traitement des exceptions autour des appels réseau, c'est poser des problèmes.

Notre petite quantité de code a quelques problèmes sérieux, essayons de le rendre meilleur.


La bonne quantité de traitement des exceptions

Nous allons envelopper notre find_or_create_by et le pousser dans le La personne modèle:

classe personne < 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 "Une erreur est survenue lors de la recherche ou de la création de Person pour: # handle, # e.message # e.backtrace.join (" \ n ")" nil end end fin fin

Nous avons géré le ActiveRecord :: RecordNotUnique selon la documentation et nous savons maintenant que nous aurons soit un La personne objet ou néant si quelque chose ne va pas. Ce code est maintenant solide, mais qu'en est-il de récupérer nos tweets:

classe personne < ActiveRecord::Base def fetch_tweets client.user_timeline(handle).map|tweet| tweet.text rescue => e Rails.logger.error "Erreur lors de la récupération des tweets pour: # handle, # e.message # e.backtrace.join (" \ n ")" nil fin client privé @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.est

Nous poussons l'instanciation du client Twitter dans sa propre méthode privée et comme nous ne savions pas ce qui pouvait mal tourner lorsque nous récupérons les tweets, nous sauvons tout.

Vous avez peut-être entendu quelque part que vous devriez toujours détecter des erreurs spécifiques. C’est un objectif louable, mais les gens l’interprètent souvent de la façon suivante: "si je ne peux pas saisir quelque chose de spécifique, je ne prendrai rien". En réalité, si vous ne pouvez pas attraper quelque chose de spécifique, vous devriez tout attraper! De cette façon, vous avez au moins l'occasion de faire quelque chose, même s'il ne s'agit que de vous connecter et de relancer l'erreur..

Un côté sur OO Design

Afin de rendre notre code plus robuste, nous avons été obligés de refactoriser et maintenant notre code est sans doute meilleur qu'avant. Vous pouvez utiliser votre désir d'un code plus résistant pour éclairer vos décisions de conception..

Un côté sur les tests

Chaque fois que vous ajoutez une logique de traitement des exceptions à une méthode, il s'agit également d'un chemin supplémentaire à utiliser par cette méthode, qui doit être testé. Il est essentiel de tester le chemin exceptionnel, peut-être plus que de tester le chemin heureux. Si quelque chose ne va pas sur le bon chemin, vous avez maintenant l'assurance supplémentaire du porter secours bloquer pour empêcher votre application de tomber. Cependant, aucune logique à l’intérieur du bloc de sauvetage n’a cette assurance. Testez bien votre chemin exceptionnel afin que des choses stupides telles que la saisie d’un nom de variable dans le porter secours ne faites pas exploser votre application (cela m'est arrivé si souvent - sérieusement, il suffit de tester votre porter secours blocs).


Que faire avec les erreurs que nous capturons

J'ai vu ce genre de code d'innombrables fois au fil des ans:

commencez widgetron.create rescue # n'avez rien à faire à la fin

Nous sauvons une exception et ne faisons rien avec elle. C'est presque toujours une mauvaise idée. Dans six mois, lorsque vous déboguez un problème de production et que vous essayez de comprendre pourquoi votre "widgetron" n'apparaît pas dans la base de données, vous ne vous souviendrez pas que des commentaires innocents et des heures de frustration suivront.

N'avalez pas les exceptions! À tout le moins, vous devriez enregistrer toute exception que vous attrapez, par exemple:

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

De cette façon, nous pouvons parcourir les journaux et nous aurons la cause et la trace de la pile de l'erreur à examiner.

Mieux encore, vous pouvez utiliser un service de surveillance des erreurs, tel que Rollbar, qui est très pratique. Cela présente de nombreux avantages:

  • Vos messages d'erreur ne sont pas entrecoupés d'autres messages de journal.
  • Vous obtiendrez des statistiques sur la fréquence de la même erreur (vous pourrez donc déterminer s'il s'agit d'un problème grave ou non).
  • Vous pouvez envoyer des informations supplémentaires avec l'erreur pour vous aider à diagnostiquer le problème.
  • Vous pouvez recevoir des notifications (par courrier électronique, pagerduty, etc.) lorsque des erreurs se produisent dans votre application.
  • Vous pouvez suivre les déploiements pour voir quand des erreurs particulières ont été introduites ou corrigées
  • etc.
begin foo.bar rescue => e Rails.logger.error "# e.message # e.backtrace.join (" \ n ")" Rollbar.report_exception (e) fin

Vous pouvez bien sûr vous connecter et utiliser un service de surveillance comme ci-dessus..

Si ton porter secours block est la dernière chose dans une méthode, je recommande d'avoir un retour explicite:

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

Vous ne voudrez peut-être pas toujours revenir néant, Parfois, il vaut mieux utiliser un objet null ou ce qui a du sens dans le contexte de votre application. L'utilisation constante de valeurs de retour explicites évitera à tout le monde beaucoup de confusion.

Vous pouvez également relancer la même erreur ou en générer une autre à l’intérieur de votre ordinateur. porter secours bloc. Un motif que je trouve souvent utile consiste à envelopper l’exception existante dans une nouvelle et à la lever pour ne pas perdre la trace de pile originale (j’ai même écrit une gemme à ce sujet car Ruby ne fournit pas cette fonctionnalité immédiatement. ). Plus tard dans l'article, lorsque nous parlerons de services externes, je vous montrerai pourquoi cela peut être utile.


Traitement des erreurs à l'échelle mondiale

Rails vous permet de spécifier comment gérer les demandes de ressources d’un certain format (HTML, XML, JSON) en utilisant répondre à et répondre avec. Je vois rarement des applications qui utilisent correctement cette fonctionnalité, après tout si vous n'utilisez pas répondre à tout bloquer fonctionne correctement et Rails rend votre modèle correctement. Nous avons frappé notre contrôleur de tweets via / tweets / yukihiro_matz et obtenez une page HTML contenant les derniers tweets de Matzs. Ce que les gens oublient souvent, c’est qu’il est très facile d’essayer de demander un format différent de la même ressource, par exemple.. /tweets/yukihiro_matz.json. À ce stade, Rails tentera vaillamment de renvoyer une représentation JSON des tweets de Matzs, mais cela ne fonctionnera pas, car la vue n'existe pas. Un ActionView :: MissingTemplate erreur sera soulevée et notre application explose de façon spectaculaire. Et JSON est un format légitime. Dans une application à fort trafic, vous êtes tout aussi susceptible de recevoir une demande de /tweets/yukihiro_matz.foobar. Tuts + reçoit ce type de demandes tout le temps (probablement de la part de robots essayant d'être intelligents).

La leçon est la suivante: si vous ne prévoyez pas de renvoyer une réponse légitime pour un format particulier, empêchez vos contrôleurs d'essayer de répondre aux demandes de ces formats. Dans le cas de notre TweetsController:

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

Maintenant, lorsque nous aurons reçu des demandes de formats parasites, nous aurons une réponse plus pertinente. ActionController :: UnknownFormat Erreur. Nos contrôleurs se sentent un peu plus serrés, ce qui est une bonne chose lorsqu'il s'agit de les rendre plus robustes.

Gestion des erreurs à la manière des rails

Le problème que nous avons maintenant est que, malgré notre erreur sémantiquement agréable, notre application explose encore devant le visage de nos utilisateurs. C’est là que la gestion globale des exceptions entre en jeu. Parfois, notre application génère des erreurs auxquelles nous voulons réagir de manière cohérente, quelle que soit leur origine (comme notre ActionController :: UnknownFormat). Il y a aussi des erreurs qui peuvent être soulevées par le framework avant que tout notre code entre en jeu. Un exemple parfait de ceci est ActionController :: RoutingError. Quand quelqu'un demande une URL qui n'existe pas, comme / tweets2 / yukihiro_matz, nous n’avons nulle part où nous en prendre pour sauver cette erreur, en utilisant la gestion des exceptions traditionnelle. C'est là que Rails ' exceptions_app entre.

Vous pouvez configurer une application Rack dans application.rb être appelé lorsqu’une erreur que nous n’avons pas traitée est produite (comme notre ActionController :: RoutingError ou ActionController :: UnknownFormat). Vous verrez normalement ceci utilisé pour configurer votre application Routes en tant que exceptions_app, Ensuite, définissez les différentes routes pour les erreurs que vous voulez gérer et dirigez-les vers un contrôleur d'erreurs spécial que vous créez. Donc notre application.rb ressemblerait à ceci:

… Config.exceptions_app = self.routes… 

Notre routes.rb contiendra alors les éléments suivants:

… Match '/ 404' => 'erreurs # non_trouvées', via:: toutes les correspondances '/ 406' => 'erreurs # non_acceptables', via:: toutes les correspondances '/ 500' => 'erreurs # internal_server_error', via: :tout… 

Dans ce cas notre ActionController :: RoutingError serait ramassé par le 404 route et la ActionController :: UnknownFormat sera ramassé par le 406 route. Il existe de nombreuses erreurs possibles qui peuvent survenir. Mais tant que vous manipulez les plus communs (404, 500, 422 etc.) pour commencer, vous pouvez en ajouter d’autres si et quand ils se produisent.

Dans notre contrôleur d'erreurs, nous pouvons maintenant afficher les modèles appropriés pour chaque type d'erreur avec notre mise en page (si ce n'est pas un 500) afin de maintenir la stratégie de marque. Nous pouvons également enregistrer les erreurs et les envoyer à notre service de surveillance, bien que la plupart des services de surveillance se connectent automatiquement à ce processus afin que vous n'ayez pas à les envoyer vous-même. Désormais, lorsque notre application explose, elle le fait en douceur, avec le bon code d'état en fonction de l'erreur et une page où nous pouvons donner à l'utilisateur une idée de ce qui s'est passé et de ce qu'il peut faire (contact support) - une expérience infiniment meilleure. Plus important encore, notre application semblera (et sera en réalité) beaucoup plus solide.

Plusieurs erreurs du même type dans un contrôleur

Dans tout contrôleur Rails, nous pouvons définir des erreurs spécifiques à traiter globalement au sein de ce contrôleur (quelle que soit l'action dans laquelle elles sont produites) - nous le faisons via rescue_from. La question est quand utiliser sauvé de? Je trouve généralement qu’un bon modèle consiste à l’utiliser pour les erreurs pouvant survenir dans plusieurs actions (par exemple, la même erreur dans plusieurs actions). Si une erreur ne doit être produite que par une action, gérez-la via la méthode traditionnelle. commencer… secourir… terminer mécanisme, mais si nous sommes susceptibles d’obtenir la même erreur à plusieurs endroits et que nous voulons la traiter de la même manière, c’est un bon candidat pour un sauvé de. Disons notre TweetsController a aussi un créer action:

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

Disons aussi que ces deux actions peuvent rencontrer un TwitterError et s'ils le font, nous voulons dire à l'utilisateur que quelque chose ne va pas avec Twitter. C'est ici que sauvé de peut être vraiment pratique:

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

Maintenant, nous n'avons plus besoin de nous soucier de gérer cela dans nos actions. Elles auront l'air beaucoup plus propres et nous pouvons / devrions - bien sûr - enregistrer notre erreur et / ou notifier notre service de surveillance des erreurs au sein de la société. twitter_error méthode. Si tu utilises sauvé de correctement, il peut non seulement vous aider à rendre votre application plus robuste, mais également à rendre votre code plus propre. Cela facilitera la maintenance et le test de votre code, ce qui rendra votre application encore plus résistante..


Utilisation de services externes dans votre application

Il est difficile d'écrire une application importante de nos jours sans utiliser un certain nombre de services / API externes. Dans le cas de notre TweetsController, Twitter est entré en jeu via un joyau Ruby qui enveloppe l'API de Twitter. Dans l'idéal, nous ferions tous nos appels d'API externes de manière asynchrone, mais nous ne couvrons pas le traitement asynchrone dans cet article et de nombreuses applications permettent d'effectuer au moins certains appels d'API / réseau en cours de traitement..

Effectuer des appels réseau est une tâche extrêmement sujette aux erreurs et une bonne gestion des exceptions est indispensable. Vous pouvez obtenir des erreurs d'authentification, des problèmes de configuration et des erreurs de connectivité. La bibliothèque que vous utilisez peut générer un nombre illimité d’erreurs de code, puis les connexions sont lentes. Je passe sur ce point, mais c'est tellement crucial car vous ne pouvez pas gérer les connexions lentes via la gestion des exceptions. Vous devez configurer de manière appropriée les délais d'attente dans votre bibliothèque réseau ou, si vous utilisez un wrapper d'API, assurez-vous qu'il fournit des points d'ancrage pour configurer les délais d'attente. Pour un utilisateur, l'expérience n'est pas pire que d'être obligé de rester assis à attendre que votre application ne donne aucune indication de ce qui se passe. À peu près tout le monde oublie de configurer les délais d'attente de manière appropriée (je sais que j'ai), alors prenez garde.

Si vous utilisez un service externe à plusieurs endroits de votre application (plusieurs modèles, par exemple), vous exposez de grandes parties de votre application au paysage complet des erreurs pouvant être générées. Ce n'est pas une bonne situation. Ce que nous voulons faire, c’est limiter notre exposition. L’une des façons de le faire consiste à placer tous les accès à nos services externes derrière une façade, à éliminer toutes les erreurs et à relancer une erreur sémantiquement appropriée (soulignons que TwitterError dont nous avons parlé si des erreurs se produisent lorsque nous essayons de frapper l’API de Twitter). On peut alors utiliser facilement des techniques comme sauvé de pour traiter ces erreurs et nous n'exposons pas de grandes parties de notre application à un nombre inconnu d'erreurs provenant de sources externes.

Une idée encore meilleure pourrait être de faire de votre façade une API sans erreur. Renvoie toutes les réponses réussies telles quelles et nil ou null lorsque vous récupérez une erreur (nous devons néanmoins nous connecter / nous informer des erreurs via certaines des méthodes décrites précédemment). De cette façon, nous n’avons pas besoin de mélanger différents types de flux de contrôle (flux de contrôle d’exception vs si… autre), ce qui peut nous permettre d’obtenir un code nettement plus propre. Par exemple, encapsulons notre accès API Twitter dans un TwitterClient objet:

class TwitterClient attr_reader: client def initialize @client = Twitter :: REST :: Client.new do | config | fr.prog.consumer = carte | tweet | tweet.text rescue => e Rails.logger.error "# e.message # e.backtrace.join (" \ n ")" nil end end

Nous pouvons maintenant faire ceci: TwitterClient.new.latest_tweets ('yukihiro_matz'), n'importe où dans notre code et nous savons qu'il ne produira jamais d'erreur, ou plutôt qu'il ne propagera jamais l'erreur au-delà TwitterClient. Nous avons isolé un système externe pour nous assurer que les erreurs de ce système ne feraient pas tomber notre application principale..


Mais si j'ai une excellente couverture de test?

Si votre code est bien testé, je vous félicite de votre diligence, cela vous mènera loin vers une application plus robuste. Mais une bonne suite de tests peut souvent fournir un faux sentiment de sécurité. De bons tests peuvent vous aider à refactoriser en toute confiance et à vous protéger contre la régression. Mais, vous ne pouvez écrire que des tests pour des choses que vous prévoyez d'arriver. Les insectes sont, par nature, inattendus. Pour utiliser notre exemple de tweets, jusqu'à ce que nous choisissions d'écrire un test pour notre fetch_tweets méthode où client.user_timeline (handle) soulève une erreur nous obligeant ainsi à envelopper un porter secours bloquer autour du code, tous nos tests auront été verts et notre code serait resté sujet aux échecs.

Écrire des tests ne nous dispense pas de jeter un œil critique sur notre code pour déterminer comment ce code peut potentiellement casser. D'autre part, faire ce genre d'évaluation peut certainement nous aider à écrire des suites de tests meilleures et plus complètes..


Conclusion

Les systèmes résilients ne naissent pas complètement formés d'une session de piratage de fin de semaine. Rendre une application robuste est un processus continu. Vous découvrez des bogues, les corrigez et écrivez des tests pour vous assurer qu'ils ne reviendront pas. Lorsque votre application tombe en panne à cause d'une défaillance du système externe, vous isolez ce système pour vous assurer que la défaillance ne pourra plus jamais faire boule de neige. La gestion des exceptions est votre meilleur ami pour ce faire. Même l'application la plus sujette aux pannes peut être transformée en une application robuste si vous appliquez de bonnes pratiques de gestion des exceptions de manière cohérente et sur une longue période..

Bien entendu, la gestion des exceptions n'est pas le seul outil de votre arsenal pour rendre les applications plus résilientes. Dans les articles suivants, nous aborderons le traitement asynchrone, comment et quand l’appliquer et ce qu’il peut faire pour rendre votre application tolérante aux pannes. Nous examinerons également quelques astuces de déploiement et d’infrastructure qui peuvent avoir un impact significatif sans casser la banque en termes de temps et d’argent - restez à l’écoute -.