Avertissement : Je ne connais aucune théorie sur la gestion des erreurs, j'ai cependant pensé de manière répétitive à ce sujet en explorant divers langages et paradigmes de programmation, ainsi qu'en jouant avec des conceptions de langages de programmation (et en les discutant). Ce qui suit, donc, est un résumé de mon expérience jusqu'à présent ; avec des arguments objectifs.
Note : cela devrait couvrir toutes les questions, mais je n'ai même pas essayé de les aborder dans l'ordre, préférant une présentation structurée. À la fin de chaque section, je présente une réponse succincte aux questions auxquelles elle a répondu, pour plus de clarté.
Introduction
En guise de prémisse, je voudrais noter que, quel que soit le sujet de la discussion, certains paramètres doivent être gardés à l'esprit lors de la conception d'une bibliothèque (ou d'un code réutilisable).
L'auteur ne peut espérer deviner comment cette bibliothèque sera utilisée et doit donc éviter les stratégies qui rendent l'intégration plus difficile qu'elle ne le devrait. Le défaut le plus flagrant serait de s'appuyer sur un état partagé au niveau mondial ; un état partagé au niveau du thread peut également être un cauchemar pour les interactions avec les coroutines/green-threads. L'utilisation de ces coroutines et threads met également en évidence le fait que la synchronisation doit être laissée à l'utilisateur. Dans un code monofilaire, elle sera inexistante (meilleures performances), tandis que dans les coroutines et les green-threads, l'utilisateur est le mieux placé pour implémenter (ou utiliser des implémentations existantes) des mécanismes de synchronisation dédiés.
Ceci étant dit, lorsque la bibliothèque est à usage interne uniquement, les variables globales ou thread-local pourrait ne sont pas pratiques ; si elles sont utilisées, elles doivent être clairement documentées comme une limitation technique.
Enregistrement
Il existe plusieurs façons d'enregistrer des messages :
- avec des informations supplémentaires telles que l'horodatage, l'ID du processus, l'ID du fil, le nom/IP du serveur, ...
- via des appels synchrones ou avec un mécanisme asynchrone (et un mécanisme de gestion des débordements)
- dans des fichiers, des bases de données, des bases de données distribuées, des serveurs de journaux dédiés, ...
En tant qu'auteur d'une bibliothèque, les journaux doivent être intégrés à l'infrastructure du client (ou désactivés). La meilleure façon d'y parvenir est de permettre au client de fournir des hooks afin de gérer lui-même les logs, ma recommandation est la suivante :
- pour fournir 2 hooks : un pour décider si l'on doit loguer ou non, et un pour loguer réellement (le message étant formaté et le dernier hook appelé seulement lorsque le client a décidé de loguer)
- pour fournir, en plus du message : une gravité (aka niveau), le nom du fichier, de la ligne et de la fonction si c'est un logiciel libre ou sinon l'adresse de l'utilisateur. module logique (si plusieurs)
- pour, par défaut, écrire dans
stdout
y stderr
(en fonction de la gravité), jusqu'à ce que le client dise explicitement de ne pas enregistrer
Je tiens à souligner que, conformément aux directives énoncées dans l'introduction, la synchronisation est laissée au client.
En ce qui concerne l'enregistrement des erreurs : n'enregistrez pas (en tant qu'erreurs) ce que vous signalez déjà par ailleurs via votre API ; vous pouvez toutefois continuer à enregistrer les détails à un niveau de gravité moindre. Le client peut décider de signaler ou non l'erreur lorsqu'il la traite, et par exemple choisir de ne pas la signaler s'il ne s'agissait que d'un appel spéculatif.
Remarque : certaines informations ne doivent pas figurer dans les journaux et il est préférable de masquer d'autres éléments. Par exemple, les mots de passe ne doivent pas être enregistrés, et les numéros de carte de crédit, de passeport ou de sécurité sociale doivent être masqués (au moins partiellement). Dans une bibliothèque conçue pour ces informations sensibles, cela peut être fait pendant la journalisation ; sinon, l'application doit s'en charger.
La journalisation doit-elle être effectuée uniquement dans le code de l'application ? Ou est-il possible de faire de la journalisation à partir du code de la bibliothèque ?
Le code d'application doit décider de la politique à suivre. Le fait qu'une bibliothèque se connecte ou non dépend de sa nécessité.
Poursuivre après une erreur ?
Avant de parler du signalement des erreurs, la première question à se poser est de savoir si l'erreur doit être signalée (pour être traitée) ou si les choses vont tellement mal que l'abandon du processus en cours est clairement la meilleure politique.
Il s'agit certainement d'un sujet délicat. En général, je conseillerais de concevoir le système de manière à ce que la poursuite du processus soit une option, avec une purge/réinitialisation si nécessaire. Si cela ne peut être réalisé dans certains cas, alors ces cas devraient provoquer l'interruption du processus.
Note : sur certains systèmes, il est possible d'obtenir un vidage de la mémoire du processus. Si une application manipule des données sensibles (mot de passe, cartes de crédit, passeports, ...), il est préférable de la désactiver en production (mais elle peut être utilisée pendant le développement).
Note : il peut être intéressant d'avoir un interrupteur de débogage qui transforme une partie des appels de signalement d'erreur en avortements avec un vidage de la mémoire pour aider au débogage pendant le développement.
Signaler une erreur
L'apparition d'une erreur signifie que le contrat d'une fonction/interface n'a pas pu être rempli. Cela a plusieurs conséquences :
- le client doit être averti, c'est pourquoi l'erreur doit être signalée
- aucune donnée partiellement correcte ne doit s'échapper dans la nature
Ce dernier point sera traité plus loin ; pour l'instant, concentrons-nous sur le signalement de l'erreur. Le client ne devrait jamais être en mesure de accidentellement ignorer ce rapport. C'est pourquoi l'utilisation de codes d'erreur est une telle abomination (dans les langues où les valeurs de retour peuvent être ignorées) :
ErrorStatus_t doit(Input const* input, Output* output);
Je connais deux systèmes qui nécessitent une action explicite de la part du client :
- exceptions
- les types de résultats (
optional<T>
, either<T, U>
, ...)
Le premier est bien connu, le second est très utilisé dans les langages fonctionnels et a été introduit dans C++11 sous le nom de std::future<T>
bien que d'autres implémentations existent.
Je conseille de préférer la seconde solution, lorsque c'est possible, car elle est plus facile à comprendre, mais de revenir aux exceptions lorsqu'aucun résultat n'est attendu. Contraste :
Option<Value&> find(Key const&);
void updateName(Client::Id id, Client::Name name);
Dans le cas d'opérations "en écriture seule" telles que updateName
le client n'a pas besoin d'un résultat. Il podría être introduit, mais il serait facile d'oublier le contrôle.
Le retour aux exceptions se produit également lorsqu'un type de résultat est peu pratique ou insuffisant pour transmettre les détails :
Option<Value&> compute(RepositoryInterface&, Details...);
Dans un tel cas de callback défini de manière externe, il existe une liste presque infinie de défaillances potentielles. L'implémentation pourrait utiliser le réseau, une base de données, le système de fichiers, ... dans ce cas, et afin de rapporter les erreurs avec précision :
- la fonction de rappel définie en externe doit être censée signaler les erreurs par le biais d'exceptions lorsque l'interface est insuffisante (ou peu pratique) pour transmettre tous les détails de l'erreur.
- les fonctions basées sur ce abstrait Le callback doit être transparent pour ces exceptions (les laisser passer, sans les modifier).
L'objectif est de laisser cette exception remonter jusqu'à la couche où l'implémentation de l'interface a été décidée (au moins), car ce n'est qu'à ce niveau qu'il est possible d'interpréter correctement l'exception levée.
Remarque : le callback défini en externe n'est pas obligé d'utiliser des exceptions, nous devons simplement nous attendre à ce qu'il en utilise.
Utilisation d'une erreur
Afin d'utiliser un rapport d'erreur, le client doit disposer d'informations suffisantes pour prendre une décision. Les informations structurées, comme les codes d'erreur ou les types d'exception, doivent être privilégiées (pour les actions automatiques) et des informations supplémentaires (message, pile, ...) peuvent être fournies de manière non structurée (pour que les humains puissent enquêter).
Il serait préférable qu'une fonction documente clairement tous les modes d'échec possibles : quand ils se produisent et comment ils sont signalés. Cependant, surtout en cas d'exécution d'un code arbitraire, le client doit être prêt à faire face à des codes/exceptions inconnus.
Une exception notable est, bien sûr, les types de résultats : boost::variant<Output, Error0, Error1, ...>
fournit une liste exhaustive, vérifiée par le compilateur, des modes de défaillance connus... bien qu'une fonction retournant ce type puisse toujours être rejetée, bien sûr.
Comment choisir entre consigner une erreur ou l'afficher sous forme de message d'erreur à l'utilisateur ?
L'utilisateur doit toujours être averti lorsque sa commande n'a pu être honorée, mais un message convivial (compréhensible) doit être affiché. Si possible, des conseils ou des solutions de contournement doivent également être présentés. Les détails sont pour les équipes de recherche.
Récupérer une erreur ?
Enfin, et ce n'est certainement pas le moins important, vient la partie vraiment effrayante des erreurs : la récupération.
C'est une chose pour laquelle les bases de données (les vraies) sont si bien faites : la sémantique des transactions. Si quelque chose d'inattendu se produit, la transaction est interrompue comme si rien ne s'était passé.
Dans le monde réel, les choses ne sont pas simples. L'exemple simple de l'annulation d'un e-mail envoyé me vient à l'esprit : trop tard. Des protocoles peuvent exister, en fonction de votre domaine d'application, mais cela n'entre pas dans le cadre de cette discussion. La première étape, cependant, est la capacité de récupérer un courrier électronique sain. en mémoire et cela est loin d'être simple dans la plupart des langages (et les STM ne peuvent pas tout faire aujourd'hui).
Tout d'abord, une illustration du défi :
void update(Client& client, Client::Name name, Client::Address address) {
client.update(std::move(name));
client.update(std::move(address)); // Throws
}
Maintenant, après que la mise à jour de l'adresse ait échoué, je me retrouve avec une adresse à moitié mise à jour. client
. Qu'est-ce que je peux faire ?
- essayer d'annuler toutes les mises à jour qui ont eu lieu est presque impossible (l'annulation peut échouer).
- la copie de l'état avant l'exécution de chaque mise à jour est un gouffre de performance (en supposant que l'on puisse même le rechanger de manière sûre).
Dans tous les cas, la comptabilité requise est telle que des erreurs se glissent.
Et le pire de tout : il n'y a aucune hypothèse sûre qui puisse être faite quant à l'étendue de la corruption (sauf que client
est maintenant bâclée). Ou du moins, aucune hypothèse qui résistera au temps (et aux changements de code).
Comme souvent, le seul moyen de gagner est de ne pas jouer.
Une solution possible : Transactions
Dans la mesure du possible, l'idée maîtresse est de définir macro qui soit échouera, soit produira le résultat attendu. Ce sont nos transactions . Et leur forme est invariante :
Either<Output, Error> doit(Input const&);
// or
Output doit(Input const&); // throw in case of error
Une transaction ne modifie pas d'état externe, donc si elle ne produit pas de résultat :
- le monde extérieur n'a pas changé (rien à rétablir)
- il n'y a pas de résultat partiel à observer
Toute fonction qui n'est pas une transaction doit être considérée comme ayant corrompu tout ce qu'elle a touché, et donc la seule façon de s'en sortir. sain d'esprit La meilleure façon de traiter une erreur provenant de fonctions non transactionnelles est de la laisser s'accumuler jusqu'à ce qu'une couche transactionnelle soit atteinte. Toute tentative de traiter l'erreur avant est, en fin de compte, vouée à l'échec.
Comment décider si une erreur doit être traitée localement ou propagée à un code de niveau supérieur ?
En cas d'exceptions, où faut-il généralement les attraper ? Dans le code de bas niveau ou de niveau supérieur ?
Traitez-les à chaque fois que c'est sûr de le faire et il y a de l'intérêt à le faire. En particulier, il est normal d'attraper une erreur, de vérifier si elle peut être traitée localement, puis de la traiter ou de la laisser passer.
Devriez-vous vous efforcer d'appliquer une stratégie unifiée de traitement des erreurs à toutes les couches du code, ou essayer de développer un système capable de s'adapter à diverses stratégies de traitement des erreurs (afin de pouvoir traiter les erreurs provenant de bibliothèques tierces).
Je n'ai pas abordé cette question précédemment, mais je crois qu'il est clair que l'approche que j'ai mise en évidence est déjà double puisqu'elle comprend à la fois des types de résultats et des exceptions. En tant que tel, le traitement des bibliothèques tierces devrait être un jeu d'enfant, bien que je conseille de les envelopper quand même pour d'autres raisons (le code tiers est mieux isolé au-delà d'une interface orientée métier chargée de l'adaptation de l'impédance).