[DotNetBook] Exceptions: Type Architecture du système

Avec cet article, je continue de publier une série d'articles, dont le résultat sera un livre sur le travail du .NET CLR, et du .NET en général. Pour les liens - bienvenue au chat.


Architecture d'exception


L'un des problèmes les plus importants concernant le sujet des exceptions est probablement la création d'une architecture d'exception dans votre application. Cette question est intéressante pour plusieurs raisons. Pour moi, l'essentiel est l'apparente simplicité avec laquelle il n'est pas toujours évident de savoir quoi faire. Cette propriété est inhérente à toutes les constructions de base qui sont utilisées partout: elle est IEnumerable , et IDisposable et IObservable et d'autres. D'une part, ils font signe par leur simplicité, en s'impliquant dans leur utilisation dans diverses situations. Et d'autre part, ils sont pleins de tourbillons et de gués, d'où, ne sachant pas comment parfois ne pas sortir du tout. Et, peut-être qu'en regardant le futur volume, votre question a mûri: alors qu'est-ce que c'est dans des situations exceptionnelles?


Remarque


Le chapitre publié sur Habré n'est pas mis à jour et, probablement, est un peu dépassé. Et par conséquent, veuillez vous tourner vers l'original pour un texte plus récent:



Mais pour en arriver à des conclusions concernant la construction de l'architecture des classes de situations exceptionnelles, nous devons accumuler avec vous une certaine expérience concernant leur classement. Après tout, après avoir compris ce que nous allons traiter, comment et dans quelles situations le programmeur doit choisir le type d'erreur et dans lequel - faire le choix concernant la capture ou le saut d'exceptions, vous pouvez comprendre comment vous pouvez construire un système de type de telle manière qu'il devienne évident pour votre code. Par conséquent, nous essaierons de classer les situations exceptionnelles (non pas les types d'exceptions elles-mêmes, mais précisément les situations) selon divers critères.


Selon la possibilité théorique de rattraper l'exception projetée


En termes d'interception théorique, les exceptions peuvent facilement être divisées en deux types: celles qui intercepteront avec précision et celles qui sont très susceptibles d'intercepter. Pourquoi avec un degré de probabilité élevé ? Parce qu'il y aura toujours quelqu'un qui tentera d'intercepter, bien que cela n'ait pas dû être complètement fait.


Révélons d'abord les caractéristiques du premier groupe: les exceptions qui devraient et vont se bloquer.


Lorsque nous introduisons une exception de ce type, d'une part, nous informons le sous-système externe que nous sommes dans une position où d'autres actions au sein de nos données n'ont pas de sens. D'un autre côté, nous voulons dire que rien de global n'a été brisé et si nous sommes supprimés, rien ne changera, et donc cette exception peut être facilement interceptée pour améliorer la situation. Cette propriété est très importante: elle détermine la criticité de l'erreur et la conviction que si vous interceptez l'exception et effacez simplement les ressources, vous pouvez exécuter le code en toute sécurité.


Le deuxième groupe, aussi étrange que cela puisse paraître, est responsable des exceptions qui n'ont pas besoin d'être détectées. Ils ne peuvent être utilisés que pour écrire dans le journal des erreurs, mais pas pour corriger la situation. L'exemple le plus simple est les exceptions de groupe ArgumentException et NullReferenceException . En effet, dans une situation normale, vous ne devez pas, par exemple, intercepter l'exception ArgumentNullException car la source du problème ici sera vous, et personne d'autre. Si vous interceptez cette exception, vous supposez que vous avez fait une erreur et donné la méthode que vous ne pouviez pas lui donner:


 void SomeMethod(object argument) { try { AnotherMethod(argument); } catch (ArgumentNullException exception) { // Log it } } 

Dans cette méthode, nous essayons d'attraper une ArgumentNullException . Mais à mon avis, son interception semble très étrange: jeter les bons arguments à la méthode est entièrement notre préoccupation. Il ne serait pas correct de réagir après coup: dans une telle situation, la chose la plus correcte qui puisse être faite est de vérifier les données transmises à l'avance, avant d'appeler la méthode, ou mieux encore, de construire le code de telle manière que la réception de paramètres incorrects ne soit tout simplement pas possible.


Un autre groupe est l'élimination des erreurs fatales. Si un certain cache est cassé et que le fonctionnement du sous-système ne sera en aucun cas correct? Il s'agit alors d'une erreur fatale et le code le plus proche de la pile ne sera pas garanti pour l'intercepter:


 T GetFromCacheOrCalculate() { try { if(_cache.TryGetValue(Key, out var result)) { return result; } else { T res = Strategy(Key); _cache[Key] = res; return res; } } catch (CacheCorreptedException exception) { RecreateCache(); return GetFromCacheOrCalculate(); } } 

Et que CacheCorreptedException soit une exception signifiant que "le cache sur le disque dur n'est pas cohérent". Ensuite, il s'avère que si la cause d'une telle erreur est fatale pour le sous-système de mise en cache (par exemple, il n'y a pas d'autorisations sur le fichier de cache), alors le code supplémentaire s'il ne peut pas recréer le cache avec la commande RecreateCache , et donc le fait d'attraper cette exception est une erreur en soi.


Sur l'interception effective d'une exception


Une autre question qui arrête notre fuite de pensée dans les algorithmes de programmation est la compréhension: cela vaut-il la peine d'attraper ces exceptions ou d'autres ou vaut-il quelqu'un qui comprend de les laisser les traverser. En traduisant dans le langage des termes, la question que nous devons résoudre est de distinguer les domaines de responsabilité. Regardons le code suivant:


 namespace JetFinance.Strategies { public class WildStrategy : StrategyBase { private Random random = new Random(); public void PlayRussianRoulette() { if(DateTime.Now.Second == (random.Next() % 60)) { throw new StrategyException(); } } } public class StrategyException : Exception { /* .. */ } } namespace JetFinance.Investments { public class WildInvestment { WildStrategy _strategy; public WildInvestment(WildStrategy strategy) { _strategy = strategy; } public void DoSomethingWild() { ?try? { _strategy.PlayRussianRoulette(); } catch(StrategyException exception) { } } } } using JetFinance.Strategies; using JetFinance.Investments; void Main() { var foo = new WildStrategy(); var boo = new WildInvestment(foo); ?try? { boo.DoSomethingWild(); } catch(StrategyException exception) { } } 

Laquelle des deux stratégies proposées est la plus correcte? Le domaine de responsabilité est très important. Initialement, il peut sembler que, puisque le travail de WildInvestment et sa cohérence dépendent entièrement de WildStrategy , si WildInvestment ignore simplement cette exception, il passera à un niveau supérieur et il n'y a pas besoin de faire autre chose. Cependant, veuillez noter qu'il existe un problème purement architectural: la méthode Main intercepte une exception d'une couche architecturale en invoquant une méthode architecturale différente. À quoi ressemble-t-il en termes d'utilisation? Oui, en général, cela ressemble à ceci:


  • le souci de cette exception a été tout simplement dépassé par nous;
  • l'utilisateur de cette classe n'est pas sûr que cette exception soit levée à travers un certain nombre de méthodes devant nous spécifiquement
  • nous commençons à dessiner des dépendances inutiles, dont nous nous sommes débarrassés, provoquant une couche intermédiaire.

Cependant, une autre conclusion découle de cette conclusion: nous devons définir la catch dans la méthode DoSomethingWild . Et cela est quelque peu étrange pour nous: WildInvestment semble être très dépendant de quelqu'un. C'est-à-dire si PlayRussianRoulette ne pouvait pas fonctionner, alors DoSomethingWild aussi: il n'a pas de code retour, mais il doit jouer à la roulette. Que faire dans une situation aussi désespérée? La réponse est en fait simple: étant dans une autre couche, DoSomethingWild devrait DoSomethingWild sa propre exception, qui fait référence à cette couche et envelopper l'original comme source d'origine du problème - dans InnerException :


 namespace JetFinance.Strategies { pubilc class WildStrategy { private Random random = new Random(); public void PlayRussianRoulette() { if(DateTime.Now.Second == (random.Next() % 60)) { throw new StrategyException(); } } } public class StrategyException : Exception { /* .. */ } } namespace JetFinance.Investments { public class WildInvestment { WildStrategy _strategy; public WildInvestment(WildStrategy strategy) { _strategy = strategy; } public void DoSomethingWild() { try { _strategy.PlayRussianRoulette(); } catch(StrategyException exception) { throw new FailedInvestmentException("Oops", exception); } } } public class InvestmentException : Exception { /* .. */ } public class FailedInvestmentException : Exception { /* .. */ } } using JetFinance.Investments; void Main() { var foo = new WildStrategy(); var boo = new WildInvestment(foo); try { boo.DoSomethingWild(); } catch(FailedInvestmentException exception) { } } 

Passant l'exception à une autre, nous transférons essentiellement les problèmes d'une couche d'application à une autre, ce qui rend son travail plus prévisible du point de vue de l'utilisateur de cette classe: la méthode Main .


Pour les problèmes de réutilisation


Très souvent, nous sommes confrontés à une tâche difficile: d'une part, nous sommes trop paresseux pour créer un nouveau type d'exception, et lorsque nous décidons, il n'est pas toujours clair de quoi pousser: quel type prendre comme base comme base. Mais ce sont précisément ces décisions qui déterminent toute l'architecture des situations exceptionnelles. Passons en revue les solutions populaires et tirons quelques conclusions.


Lorsque vous choisissez le type d'exceptions, vous pouvez essayer de prendre une solution déjà existante: trouver une exception avec une signification similaire dans le nom et l'utiliser. Par exemple, si on nous a donné une entité via un paramètre qui ne nous convient pas, nous pouvons InvalidArgumentException une InvalidArgumentException , indiquant la cause de l'erreur dans Message. Ce scénario semble bon, surtout si l'on considère que InvalidArgumentException fait partie du groupe d'exceptions qui ne sont pas soumises à une capture obligatoire. Mais choisir InvalidDataException sera mauvais si vous travaillez avec des données. Tout simplement parce que ce type se trouve dans la zone System.IO , et ce n'est guère ce que vous faites. C'est-à-dire il s'avère que trouver le type existant parce que faire paresseusement le vôtre sera presque toujours la mauvaise approche. Il n'y a presque aucune exception créée pour le cercle général des tâches. Presque tous sont créés pour des situations spécifiques et leur réutilisation sera une violation flagrante de l'architecture des situations exceptionnelles. De plus, après avoir reçu une exception d'un certain type (par exemple, le même System.IO.InvalidDataException ), l'utilisateur sera confus: d'une part, il verra la source du problème dans System.IO comme un espace de noms d'exception, et de l'autre, un espace de noms complètement différent pour le point de lancement. De plus, en pensant aux règles pour lever cette exception, il ira à referencesource.microsoft.com et trouvera tous les endroits où il est levé :


  • internal class System.IO.Compression.Inflater

Et il comprendra que juste quelqu'un a les mains tordues le choix du type d'exception le déroutait, car la méthode qui lançait l'exception n'était pas impliquée dans la compression.


De plus, afin de simplifier la réutilisation, vous pouvez simplement prendre et créer une seule exception en déclarant un champ ErrorCode avec un code d'erreur et vivre heureux pour toujours. Il semblerait: une bonne solution. Jetez la même exception partout, définissez le code, interceptez un seul catch augmentant ainsi la stabilité de l'application: et il n'y a plus rien à faire. Cependant, veuillez ne pas être d'accord avec cette position. En agissant de cette manière tout au long de l'application, d'une part, bien sûr, vous simplifiez la vie. Mais d'un autre côté, vous vous débarrassez de la possibilité d'attraper un sous-groupe d'exceptions, uni par une caractéristique commune. Comment cela se fait, par exemple, avec ArgumentException , qui sous lui-même combine tout un groupe d'exceptions par héritage. Le deuxième inconvénient grave est des feuilles de code trop grandes et illisibles qui organiseront le filtrage par code d'erreur. Mais si vous prenez une situation différente: lorsque la finalisation de l'erreur ne devrait pas être importante pour l'utilisateur final, l'introduction d'un type généralisant plus un code d'erreur semble déjà une application beaucoup plus correcte:


 public class ParserException : Exception { public ParserError ErrorCode { get; } public ParserException(ParserError errorCode) { ErrorCode = errorCode; } public override string Message { get { return Resources.GetResource($"{nameof(ParserException)}{Enum.GetName(typeof(ParserError), ErrorCode)}"); } } } public enum ParserError { MissingModifier, MissingBracket, // ... } // Usage throw new ParserException(ParserError.MissingModifier); 

Le code qui protège l'appel de l'analyseur est presque toujours indifférent pour quelle raison l'analyse a été bloquée: le fait d'erreur lui-même est important pour lui. Cependant, si cela devient néanmoins important, l'utilisateur pourra toujours extraire le code d'erreur de la ErrorCode . Pour ce faire, il n'est pas du tout nécessaire de rechercher les mots nécessaires par sous-chaîne dans Message .


Si vous commencez par ignorer les problèmes de réutilisation, vous pouvez créer un type d'exception pour chaque situation. D'une part, cela semble logique: un type d'erreur est un type d'exception. Cependant, ici, comme dans tout, l'essentiel n'est pas d'en faire trop: avoir des opérations exceptionnelles sur chaque point de sortie, vous causez des problèmes d'interception: le code de la méthode appelante sera surchargé de blocs catch . Après tout, il doit gérer tous les types d'exceptions que vous souhaitez lui accorder. Un autre inconvénient est purement architectural. Si vous n'utilisez pas l'héritage, vous désorientez l'utilisateur de ces exceptions: il peut y avoir beaucoup de points communs entre elles et vous devez les intercepter individuellement.


Néanmoins, il existe de bons scénarios pour introduire des types particuliers pour des situations spécifiques. Par exemple, lorsqu'une panne se produit non pas pour l'entité dans son ensemble, mais pour une méthode spécifique. Ensuite, ce type devrait être dans la hiérarchie d'héritage dans un tel endroit afin qu'il ne soit pas pensé à l'intercepter avec autre chose: par exemple, le sélectionner via une branche d'héritage distincte.


De plus, si vous combinez ces deux approches, vous pouvez obtenir une boîte à outils très puissante pour travailler avec un groupe d'erreurs: vous pouvez introduire un type abstrait généralisant à partir duquel hériter des situations particulières spécifiques. La classe de base (notre type généralisant) doit être équipée d'une propriété abstraite qui stocke le code d'erreur, et les héritiers remplaceront cette propriété pour spécifier ce code d'erreur:


 public abstract class ParserException : Exception { public abstract ParserError ErrorCode { get; } public override string Message { get { return Resources.GetResource($"{nameof(ParserException)}{Enum.GetName(typeof(ParserError), ErrorCode)}"); } } } public enum ParserError { MissingModifier, MissingBracket } public class MissingModifierParserException : ParserException { public override ParserError ErrorCode { get; } => ParserError.MissingModifier; } public class MissingBracketParserException : ParserException { public override ParserError ErrorCode { get; } => ParserError.MissingBracket; } // Usage throw new MissingModifierParserException(ParserError.MissingModifier); 

Quelles sont les merveilleuses propriétés que nous obtenons avec cette approche?


  • d'une part, nous avons retenu la capture d'une exception par le type de base;
  • d'autre part, en faisant exception au type de base, il était encore possible de découvrir une situation spécifique;
  • et en plus de tout, il est possible d'intercepter pour un type spécifique, et non pour un type basique, sans utiliser la structure plate des classes.

Pour moi, c'est une option très pratique.


Par rapport à un seul groupe de situations comportementales


Quelles conclusions peut-on tirer sur la base du raisonnement décrit précédemment? Essayons de les formuler:


Pour commencer, décidons de ce que l'on entend par situations. Lorsque nous parlons de classes et d'objets, nous sommes principalement habitués à des entités opérationnelles avec un état interne sur lequel nous pouvons effectuer des actions. Il s'avère que ce faisant, nous avons trouvé le premier type de situation comportementale: les actions sur une certaine entité. De plus, si vous regardez le graphique des objets comme de l'extérieur, vous remarquerez qu'il est logiquement combiné en groupes fonctionnels: le premier traite de la mise en cache, le second des bases de données, le troisième effectue des calculs mathématiques. Les couches peuvent passer par tous ces groupes fonctionnels: une couche de journalisation de divers états internes, la journalisation des processus, la trace des appels de méthode. Les couches peuvent être plus englobantes: combinant plusieurs groupes fonctionnels. Par exemple, une couche modèle, une couche contrôleur, une couche présentation. Ces groupes peuvent être dans la même assemblée, ou dans des groupes complètement différents, mais chacun d'eux peut créer ses propres situations exceptionnelles.


Il s'avère que si vous argumentez de cette manière, vous pouvez créer une hiérarchie de types de situations exceptionnelles, basée sur le type appartenant à un groupe ou une couche particulier, créant ainsi la possibilité de détecter des exceptions au code pour une navigation sémantique facile dans cette hiérarchie de types.


Regardons le code:


 namespace JetFinance { namespace FinancialPipe { namespace Services { namespace XmlParserService { } namespace JsonCompilerService { } namespace TransactionalPostman { } } } namespace Accounting { /* ... */ } } 

À quoi ça ressemble? Quant à moi, les espaces de noms sont une excellente opportunité pour regrouper naturellement les types d'exceptions en fonction de leurs situations comportementales: tout ce qui appartient à certains groupes devrait être là, y compris les exceptions. De plus, lorsque vous recevez une certaine exception, en plus du nom de son type, vous verrez son espace de noms, qui déterminera clairement son affiliation. Vous vous souvenez du mauvais exemple de réutilisation du type InvalidDataException qui est réellement défini dans l'espace de noms System.IO ? Son appartenance à cet espace de noms signifie qu'en substance une exception de ce type peut être levée à partir des classes situées dans l'espace de noms System.IO ou dans un espace plus imbriqué. Mais l'exception elle-même a été rejetée d'un endroit complètement différent, déroutant le chercheur du problème qui s'est posé. En concentrant les types d'exception sur les mêmes espaces de noms que les types lançant ces exceptions, vous gardez l'architecture de type cohérente d'une part et, d'autre part, facilitez au développeur final la compréhension des raisons de ce qui s'est produit.


Quelle est la deuxième façon de regrouper au niveau du code? Héritage:


 public abstract class LoggerExceptionBase : Exception { protected LoggerExceptionBase(..); } public class IOLoggerException : LoggerExceptionBase { internal IOLoggerException(..); } public class ConfigLoggerException : LoggerExceptionBase { internal ConfigLoggerException(..); } 

De plus, si dans le cas d'entités d'application ordinaires, l'héritage signifie l'héritage de comportements et de données, combinant des types en appartenant à un seul groupe d'entités , alors dans le cas d'exceptions, l'héritage signifie appartenir à un seul groupe de situations , car l'essence de l'exception n'est pas l'essence, mais la problématique.


En combinant les deux méthodes de regroupement, nous pouvons tirer quelques conclusions:


  • à l'intérieur de l'assembly ( Assembly ) doit être présent le type de base d'exceptions que cet assembly lève. Ce type d'exception doit se trouver dans l'espace de noms racine de l'assembly. Ce sera la première couche du regroupement;
  • Plus à l'intérieur de l'assemblage lui-même, il peut y avoir un ou plusieurs espaces de noms différents. Chacun d'eux divise l'assemblage en quelques zones fonctionnelles, définissant ainsi les groupes de situations qui se présentent dans cet assemblage. Il peut s'agir de zones de contrôleurs, d'entités de base de données, d'algorithmes de traitement de données et autres. Pour nous, ces espaces de noms sont un regroupement de types par affiliation fonctionnelle, et du point de vue des exceptions, un regroupement par zones problématiques d'un même assemblage;
  • l'héritage des exceptions ne peut aller que de types dans le même espace de noms ou dans la racine. Cela garantit une compréhension sans ambiguïté de la situation par l'utilisateur final et l'absence d'interception des exceptions laissées lors de l'interception selon le type de base. : global::Finiki.Logistics.OhMyException , catch(global::Legacy.LoggerExeption exception) , :

 namespace JetFinance.FinancialPipe { namespace Services.XmlParserService { public class XmlParserServiceException : FinancialPipeExceptionBase { // .. } public class Parser { public void Parse(string input) { // .. } } } public abstract class FinancialPipeExceptionBase : Exception { } } using JetFinance.FinancialPipe; using JetFinance.FinancialPipe.Services.XmlParserService; var parser = new Parser(); try { parser.Parse(); } catch (XmlParserServiceException exception) { // Something wrong in parser } catch (FinancialPipeExceptionBase exception) { // Something else wrong. Looks critical because we don't know real reason } 

, : , , , XmlParserServiceException . , , , JetFinance.FinancialPipe.FinancialPipeExceptionBase , : XmlParserService , . , catch : .


?


  • . . — , : , -, UI. C'est-à-dire ;
  • , : , catch ;
  • – . ;
  • , . : , , , . , - : , — , , , ;
  • , : ;
  • , Mixed Mode c ErrorCode.


. , , :


  • unsafe , . : , (, ) ;
  • , , , .. . , , . , , . — — InnerExcepton . — ;
  • Notre propre code qui a été entré au hasard dans un état non cohérent. L'analyse du texte est un bon exemple. Il n'y a pas de dépendances externes, il n'y a pas de retrait unsafe, mais il y a une erreur d'analyse.

Lien vers le livre entier



Source: https://habr.com/ru/post/fr419927/


All Articles