Clean Code et l'art de la gestion des exceptions

Publié: 2022-03-11

Les exceptions sont aussi anciennes que la programmation elle-même. À l'époque où la programmation se faisait en matériel ou via des langages de programmation de bas niveau, des exceptions étaient utilisées pour modifier le déroulement du programme et éviter les pannes matérielles. Aujourd'hui, Wikipedia définit les exceptions comme :

conditions anormales ou exceptionnelles nécessitant un traitement spécial - modifiant souvent le flux normal d'exécution du programme…

Et que les manipuler nécessite :

des constructions de langage de programmation spécialisées ou des mécanismes de matériel informatique.

Ainsi, les exceptions nécessitent un traitement spécial et une exception non gérée peut provoquer un comportement inattendu. Les résultats sont souvent spectaculaires. En 1996, le célèbre échec du lancement de la fusée Ariane 5 a été attribué à une exception de débordement non gérée. Les pires bogues logiciels de l'histoire contiennent d'autres bogues qui pourraient être attribués à des exceptions non gérées ou mal gérées.

Au fil du temps, ces erreurs, et d'innombrables autres (qui n'étaient peut-être pas aussi dramatiques, mais toujours catastrophiques pour les personnes impliquées) ont contribué à donner l'impression que les exceptions sont mauvaises .

Mais les exceptions sont un élément fondamental de la programmation moderne ; ils existent pour améliorer notre logiciel. Plutôt que de craindre les exceptions, nous devrions les accepter et apprendre à en tirer profit. Dans cet article, nous verrons comment gérer les exceptions avec élégance et les utiliser pour écrire du code propre et plus maintenable.

Gestion des exceptions : c'est une bonne chose

Avec l'essor de la programmation orientée objet (POO), la prise en charge des exceptions est devenue un élément crucial des langages de programmation modernes. Un système robuste de gestion des exceptions est intégré dans la plupart des langages, de nos jours. Par exemple, Ruby fournit le modèle typique suivant :

 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

Il n'y a rien de mal avec le code précédent. Mais l'utilisation excessive de ces modèles entraînera des odeurs de code et ne sera pas nécessairement bénéfique. De même, leur utilisation abusive peut en réalité nuire considérablement à votre base de code, la rendant fragile ou obscurcissant la cause des erreurs.

La stigmatisation entourant les exceptions fait souvent que les programmeurs se sentent perdus. C'est un fait de la vie que les exceptions ne peuvent être évitées, mais on nous enseigne souvent qu'elles doivent être traitées rapidement et de manière décisive. Comme nous le verrons, ce n'est pas nécessairement vrai. Nous devrions plutôt apprendre l'art de gérer les exceptions avec élégance, en les rendant harmonieuses avec le reste de notre code.

Voici quelques pratiques recommandées qui vous aideront à accepter les exceptions et à les utiliser, ainsi que leurs capacités, pour que votre code reste maintenable , extensible et lisible :

  • maintenabilité : nous permet de trouver et de corriger facilement de nouveaux bogues, sans craindre de casser les fonctionnalités actuelles, d'introduire d'autres bogues ou d'avoir à abandonner complètement le code en raison d'une complexité accrue au fil du temps.
  • extensibilité : nous permet d'ajouter facilement à notre base de code, en implémentant des exigences nouvelles ou modifiées sans casser les fonctionnalités existantes. L'extensibilité offre de la flexibilité et permet un haut niveau de réutilisation de notre base de code.
  • lisibilité : nous permet de lire facilement le code et de découvrir son objectif sans passer trop de temps à creuser. Ceci est essentiel pour découvrir efficacement les bogues et le code non testé.

Ces éléments sont les principaux facteurs de ce que nous pourrions appeler la propreté ou la qualité , qui n'est pas une mesure directe en soi, mais plutôt l'effet combiné des points précédents, comme le montre cette bande dessinée :

"WTFs/m" par Thom Holwerda, OSNews

Cela dit, plongeons dans ces pratiques et voyons comment chacune d'elles affecte ces trois mesures.

Remarque : Nous présenterons des exemples de Ruby, mais toutes les constructions présentées ici ont des équivalents dans les langages POO les plus courants.

Créez toujours votre propre hiérarchie ApplicationError

La plupart des langages sont livrés avec une variété de classes d'exception, organisées dans une hiérarchie d'héritage, comme toute autre classe OOP. Pour préserver la lisibilité, la maintenabilité et l'extensibilité de notre code, il est judicieux de créer notre propre sous-arborescence d'exceptions spécifiques à l'application qui étendent la classe d'exception de base. Investir du temps dans la structuration logique de cette hiérarchie peut être extrêmement bénéfique. Par exemple:

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

Exemple de hiérarchie d'exceptions d'application : StandardError est en haut. ApplicationError en hérite. ValidationError et ResponseError en héritent tous les deux. RequiredFieldError et UniqueFieldError héritent de ValidationError, tandis que BadRequestError et UnauthorizedError héritent de ResponseError.

Le fait de disposer d'un package d'exceptions extensible et complet pour notre application facilite grandement la gestion de ces situations spécifiques à l'application. Par exemple, nous pouvons décider quelles exceptions gérer de manière plus naturelle. Cela améliore non seulement la lisibilité de notre code, mais augmente également la maintenabilité de nos applications et bibliothèques (gems).

Du point de vue de la lisibilité, c'est beaucoup plus facile à lire :

 rescue ValidationError => e

Que de lire :

 rescue RequiredFieldError, UniqueFieldError, ... => e

Du point de vue de la maintenabilité, disons, par exemple, que nous implémentons une API JSON et que nous avons défini notre propre ClientError avec plusieurs sous-types, à utiliser lorsqu'un client envoie une mauvaise requête. Si l'un d'entre eux est déclenché, l'application doit rendre la représentation JSON de l'erreur dans sa réponse. Il sera plus facile de corriger ou d'ajouter une logique à un seul bloc qui gère ClientError plutôt que de boucler sur chaque erreur client possible et d'implémenter le même code de gestionnaire pour chacune. En termes d'extensibilité, si nous devons implémenter ultérieurement un autre type d'erreur client, nous pouvons être sûrs qu'il sera déjà correctement traité ici.

De plus, cela ne nous empêche pas d'implémenter une gestion spéciale supplémentaire pour des erreurs client spécifiques plus tôt dans la pile des appels, ou de modifier le même objet d'exception en cours de route :

 # 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

Comme vous pouvez le voir, la levée de cette exception spécifique ne nous a pas empêchés de pouvoir la gérer à différents niveaux, en la modifiant, en la relançant et en permettant au gestionnaire de classe parent de la résoudre.

Deux choses à noter ici :

  • Tous les langages ne prennent pas en charge la génération d'exceptions à partir d'un gestionnaire d'exceptions.
  • Dans la plupart des langages, déclencher une nouvelle exception à partir d'un gestionnaire entraînera la perte définitive de l'exception d'origine, il est donc préférable de relancer le même objet d'exception (comme dans l'exemple ci-dessus) pour éviter de perdre la trace de la cause d'origine du Erreur. (Sauf si vous le faites intentionnellement).

Ne jamais rescue Exception

Autrement dit, n'essayez jamais d'implémenter un gestionnaire fourre-tout pour le type d'exception de base. Sauver ou attraper toutes les exceptions en gros n'est jamais une bonne idée dans n'importe quel langage, que ce soit globalement au niveau de l'application de base ou dans une petite méthode enterrée utilisée une seule fois. Nous ne voulons pas sauver Exception car cela masquera tout ce qui s'est réellement passé, nuisant à la fois à la maintenabilité et à l'extensibilité. Nous pouvons perdre énormément de temps à déboguer le véritable problème, alors qu'il pourrait s'agir d'une simple erreur de syntaxe :

 # 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

Vous avez peut-être remarqué l'erreur dans l'exemple précédent ; le return est mal orthographié. Bien que les éditeurs modernes offrent une certaine protection contre ce type spécifique d'erreur de syntaxe, cet exemple illustre comment rescue Exception nuit à notre code. À aucun moment le type réel de l'exception (dans ce cas, une NoMethodError ) n'est adressé, et il n'est jamais exposé au développeur, ce qui peut nous faire perdre beaucoup de temps à tourner en rond.

Ne rescue jamais plus d'exceptions que nécessaire

Le point précédent est un cas particulier de cette règle : nous devons toujours faire attention à ne pas trop généraliser nos gestionnaires d'exceptions. Les raisons sont les mêmes; chaque fois que nous récupérons plus d'exceptions que nous ne le devrions, nous finissons par cacher des parties de la logique de l'application aux niveaux supérieurs de l'application, sans parler de la suppression de la capacité du développeur à gérer lui-même l'exception. Cela affecte gravement l'extensibilité et la maintenabilité du code.

Si nous essayons de gérer différents sous-types d'exceptions dans le même gestionnaire, nous introduisons des blocs de code gras qui ont trop de responsabilités. Par exemple, si nous construisons une bibliothèque qui consomme une API distante, la gestion d'une MethodNotAllowedError (HTTP 405) est généralement différente de la gestion d'une UnauthorizedError (HTTP 401), même si ce sont toutes les deux des ResponseError .

Comme nous le verrons, il existe souvent une partie différente de l'application qui serait mieux adaptée pour gérer des exceptions spécifiques d'une manière plus DRY.

Définissez donc la responsabilité unique de votre classe ou méthode et gérez le strict minimum d'exceptions qui satisfont à cette exigence de responsabilité . Par exemple, si une méthode est responsable de l'obtention d'informations boursières à partir d'une API distante, elle doit gérer les exceptions qui résultent uniquement de l'obtention de ces informations et laisser la gestion des autres erreurs à une autre méthode conçue spécifiquement pour ces responsabilités :

 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

Ici, nous avons défini le contrat pour cette méthode afin de nous fournir uniquement les informations sur le stock. Il gère les erreurs spécifiques au point de terminaison , telles qu'une réponse JSON incomplète ou mal formée. Il ne gère pas le cas où l'authentification échoue ou expire, ou si le stock n'existe pas. Celles-ci relèvent de la responsabilité de quelqu'un d'autre et sont explicitement transmises à la pile d'appels où il devrait y avoir un meilleur endroit pour gérer ces erreurs de manière DRY.

Résistez à l'envie de gérer les exceptions immédiatement

C'est le complément du dernier point. Une exception peut être gérée à n'importe quel point de la pile d'appels et à n'importe quel point de la hiérarchie des classes, donc savoir exactement où la gérer peut être mystificateur. Pour résoudre cette énigme, de nombreux développeurs choisissent de gérer toute exception dès qu'elle survient, mais investir du temps dans la réflexion aboutira généralement à trouver un endroit plus approprié pour gérer des exceptions spécifiques.

Un modèle courant que nous voyons dans les applications Rails (en particulier celles qui exposent uniquement des API JSON) est la méthode de contrôleur suivante :

 # 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

(Notez que bien qu'il ne s'agisse pas techniquement d'un gestionnaire d'exceptions, il a le même objectif fonctionnel, puisque @client.save ne renvoie que false lorsqu'il rencontre une exception.)

Dans ce cas, cependant, répéter le même gestionnaire d'erreurs dans chaque action du contrôleur est le contraire de DRY et nuit à la maintenabilité et à l'extensibilité. Au lieu de cela, nous pouvons utiliser la nature particulière de la propagation des exceptions et les gérer une seule fois, dans la classe de contrôleur parent, 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

De cette façon, nous pouvons nous assurer que toutes les erreurs ActiveRecord::RecordInvalid sont correctement et DRY-ly traitées en un seul endroit, au niveau ApplicationController de base. Cela nous donne la liberté de les manipuler si nous voulons traiter des cas spécifiques au niveau inférieur, ou simplement les laisser se propager avec grâce.

Toutes les exceptions n'ont pas besoin d'être traitées

Lors du développement d'une gemme ou d'une bibliothèque, de nombreux développeurs essaieront d'encapsuler la fonctionnalité et de ne permettre à aucune exception de se propager hors de la bibliothèque. Mais parfois, il n'est pas évident de savoir comment gérer une exception tant que l'application spécifique n'est pas implémentée.

Prenons ActiveRecord comme exemple de solution idéale. La bibliothèque offre aux développeurs deux approches pour l'exhaustivité. La méthode save gère les exceptions sans les propager, renvoyant simplement false , tandis que save! déclenche une exception en cas d'échec. Cela donne aux développeurs la possibilité de gérer différemment des cas d'erreur spécifiques ou simplement de gérer tout échec de manière générale.

Mais que se passe-t-il si vous n'avez pas le temps ou les ressources pour fournir une implémentation aussi complète ? Dans ce cas, s'il y a une incertitude, il est préférable d' exposer l'exception et de la libérer dans la nature.

Voici pourquoi : nous travaillons presque tout le temps avec des exigences changeantes, et prendre la décision qu'une exception sera toujours gérée d'une manière spécifique pourrait en fait nuire à notre implémentation, nuire à l'extensibilité et à la maintenabilité, et potentiellement ajouter une énorme dette technique, en particulier lors du développement bibliothèques.

Prenons l'exemple précédent d'un consommateur d'API d'actions récupérant les cours des actions. Nous avons choisi de traiter la réponse incomplète et malformée sur place, et nous avons choisi de réessayer la même demande jusqu'à ce que nous obtenions une réponse valide. Mais plus tard, les exigences peuvent changer, de sorte que nous devons revenir aux données de stock historiques enregistrées, au lieu de réessayer la demande.

À ce stade, nous serons obligés de changer la bibliothèque elle-même, en mettant à jour la façon dont cette exception est gérée, car les projets dépendants ne géreront pas cette exception. (Comment auraient-ils pu ? Cela ne leur avait jamais été exposé auparavant.) Nous devrons également informer les propriétaires de projets qui dépendent de notre bibliothèque. Cela pourrait devenir un cauchemar s'il existe de nombreux projets de ce type, car ils ont probablement été construits sur l'hypothèse que cette erreur sera gérée d'une manière spécifique.

Maintenant, nous pouvons voir où nous nous dirigeons avec la gestion des dépendances. Les perspectives ne sont pas bonnes. Cette situation se produit assez souvent, et le plus souvent, elle dégrade l'utilité, l'extensibilité et la flexibilité de la bibliothèque.

Voici donc l'essentiel : si la manière dont une exception doit être gérée n'est pas claire, laissez-la se propager avec élégance . Il existe de nombreux cas où un espace clair existe pour gérer l'exception en interne, mais il existe de nombreux autres cas où il est préférable d'exposer l'exception. Donc, avant de choisir de gérer l'exception, réfléchissez-y à deux fois. Une bonne règle empirique consiste à insister uniquement sur la gestion des exceptions lorsque vous interagissez directement avec l'utilisateur final.

Suivez la convention

L'implémentation de Ruby, et plus encore de Rails, suit certaines conventions de nommage, telles que la distinction entre method_names et method_names! avec un "bang". Dans Ruby, le bang indique que la méthode modifiera l'objet qui l'a invoqué, et dans Rails, cela signifie que la méthode lèvera une exception si elle ne parvient pas à exécuter le comportement attendu. Essayez de respecter la même convention, surtout si vous allez ouvrir votre bibliothèque.

Si nous devions écrire une nouvelle method! avec un bang dans une application Rails, nous devons tenir compte de ces conventions. Rien ne nous oblige à lever une exception lorsque cette méthode échoue, mais en s'écartant de la convention, cette méthode peut induire les programmeurs en erreur en leur faisant croire qu'ils auront la possibilité de gérer eux-mêmes les exceptions, alors qu'en fait, ils ne le feront pas.

Une autre convention Ruby, attribuée à Jim Weirich, consiste à utiliser fail pour indiquer l'échec de la méthode et à n'utiliser raise que si vous relancez l'exception.

Un aparté, parce que j'utilise des exceptions pour indiquer les échecs, j'utilise presque toujours le mot-clé fail plutôt que le mot-clé raise dans Ruby. Fail et raise sont des synonymes donc il n'y a pas de différence sauf que fail communique plus clairement que la méthode a échoué. La seule fois où j'utilise raise, c'est quand j'attrape une exception et que je la relance, car ici je n'échoue pas, mais je lève explicitement et délibérément une exception. C'est une question de style que je suis, mais je doute que beaucoup d'autres le fassent.

De nombreuses autres communautés linguistiques ont adopté des conventions comme celles-ci sur la façon dont les exceptions sont traitées, et ignorer ces conventions nuira à la lisibilité et à la maintenabilité de notre code.

Logger.log (tout)

Cette pratique ne s'applique pas uniquement aux exceptions, bien sûr, mais s'il y a une chose qui doit toujours être enregistrée, c'est une exception.

La journalisation est extrêmement importante (assez importante pour que Ruby livre un enregistreur avec sa version standard). C'est le journal de nos applications, et encore plus important que de garder une trace de la réussite de nos applications, c'est d'enregistrer comment et quand elles échouent.

Les bibliothèques de journalisation ou les services basés sur les journaux et les modèles de conception ne manquent pas. Il est essentiel de garder une trace de nos exceptions afin que nous puissions examiner ce qui s'est passé et enquêter si quelque chose ne semble pas correct. Des messages de journal appropriés peuvent orienter directement les développeurs vers la cause d'un problème, leur faisant gagner un temps incommensurable.

Cette confiance dans le code propre

Une gestion des exceptions propre enverra la qualité de votre code sur la lune !
Tweeter

Les exceptions font partie intégrante de tout langage de programmation. Ils sont spéciaux et extrêmement puissants, et nous devons tirer parti de leur pouvoir pour élever la qualité de notre code au lieu de nous épuiser à nous battre avec eux.

Dans cet article, nous nous sommes plongés dans quelques bonnes pratiques pour structurer nos arborescences d'exceptions et comment il peut être bénéfique pour la lisibilité et la qualité de les structurer logiquement. Nous avons examiné différentes approches pour gérer les exceptions, soit à un seul endroit, soit à plusieurs niveaux.

Nous avons vu qu'il n'est pas bon de « les attraper tous », et qu'il n'y a pas de mal à les laisser flotter et bouillonner.

Nous avons examiné où gérer les exceptions de manière DRY et avons appris que nous ne sommes pas obligés de les gérer au moment ou à l'endroit où elles surviennent pour la première fois.

Nous avons discuté de quand exactement c'est une bonne idée de les gérer, quand c'est une mauvaise idée, et pourquoi, en cas de doute, c'est une bonne idée de les laisser se propager.

Enfin, nous avons discuté d'autres points qui peuvent aider à maximiser l'utilité des exceptions, comme le respect des conventions et la journalisation de tout.

Avec ces directives de base, nous pouvons nous sentir beaucoup plus à l'aise et confiants face aux cas d'erreur dans notre code et rendre nos exceptions vraiment exceptionnelles !

Un merci spécial à Avdi Grimm et à son discours génial Exceptional Ruby, qui a beaucoup aidé à la réalisation de cet article.

En relation : Conseils et meilleures pratiques pour les développeurs Ruby