A arquitetura de uma situação excepcional: parte 2 de 4

Eu acho que um dos problemas mais importantes neste tópico é criar uma arquitetura de tratamento de exceções em seu aplicativo. Isso é interessante por várias razões. E a principal razão, eu acho, é uma aparente simplicidade, com a qual você nem sempre sabe o que fazer. Todas as construções básicas, como IEnumerable , IDisposable , IObservable , etc. tenha essa propriedade e use-a em qualquer lugar. Por um lado, sua simplicidade tenta usar essas construções em diferentes situações. Por outro lado, eles estão cheios de armadilhas que você pode não conseguir. É possível que, olhando a quantidade de informações que abordaremos, você tenha uma pergunta: o que há de tão especial em situações excepcionais?


No entanto, para tirar conclusões sobre a construção da arquitetura de classes de exceção, devemos aprender alguns detalhes sobre sua classificação. Como antes de criar um sistema de tipos que seria claro para o usuário do código, um programador deve determinar quando escolher o tipo de erro e quando capturar ou ignorar exceções. Então, vamos classificar as situações excepcionais (não os tipos de exceções) com base em vários recursos.


Com base em uma possibilidade teórica de capturar uma exceção futura.


Com base nesse recurso, podemos dividir as exceções entre aquelas que serão definitivamente capturadas e as que provavelmente não serão capturadas. Por que digo altamente provável ? Porque sempre há alguém que tentará capturar uma exceção enquanto isso é desnecessário.


Primeiro, vamos descrever o primeiro grupo de exceções - aquelas que devem ser capturadas.


No caso de tais exceções, por um lado, dizemos ao nosso subsistema que chegamos a um estado em que não há sentido em outras ações com nossos dados. Por outro lado, queremos dizer que nada de desastroso aconteceu e podemos encontrar a saída da situação simplesmente capturando a exceção. Essa propriedade é muito importante, pois define a criticidade de um erro e garante que, se capturarmos uma exceção e limparmos os recursos, podemos simplesmente prosseguir com o código.


O segundo grupo lida com exceções que, embora possam parecer estranhas, não precisam ser capturadas. Eles podem ser usados ​​apenas para registro de erros, mas não para corrigir uma situação. O exemplo mais simples é ArgumentException e NullReferenceException . De fato, em uma situação comum, você não precisa capturar, por exemplo, ArgumentNullException porque, nesse caso, a origem de um erro é exatamente você. Se você capturar essa exceção, admite que cometeu um erro e passou algo inaceitável para um método:


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

Neste método, tentamos capturar ArgumentNullException . Mas acho isso estranho, pois passar argumentos corretos para um método é inteiramente nossa preocupação. Reagir após o evento seria incorreto: a melhor coisa que você pode fazer em tal situação é verificar os dados passados ​​antes de chamar um método ou até mesmo construir esse código onde é impossível obter parâmetros errados.


Outro grupo de situações excepcionais são erros fatais. Se algum cache estiver com defeito e o trabalho de um subsistema estiver incorreto, será um erro fatal e o código mais próximo da pilha não o capturará com certeza:


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

CacheCorruptedException é uma exceção, significando que "o cache do disco rígido é inconsistente". Em seguida, se a causa de um erro desse tipo for fatal para o subsistema de cache (por exemplo, não há direitos de acesso ao arquivo de cache), o código a seguir não poderá recriar o cache usando a instrução RecreateCache e, portanto, capturar essa exceção será um erro.


Com base na área em que uma situação excepcional é realmente capturada


Outra questão é se devemos pegar algumas exceções ou passá-las para alguém que entende melhor a situação. Em outras palavras, devemos estabelecer áreas de responsabilidade. Vamos examinar o seguinte código:


 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) { } } 

Qual das duas estratégias é mais apropriada? A área de responsabilidade é muito importante. Inicialmente, pode parecer que o trabalho e a consistência do WildInvestment dependam totalmente do WildStrategy . Portanto, se o WildInvestment simplesmente ignorar essa exceção, ela irá para o nível superior e não devemos fazer nada. No entanto, observe que, em termos de arquitetura, o método Main captura uma exceção de um nível ao chamar o método de outro. Como fica em termos de uso? Bem, é assim que parece:


  • a responsabilidade por essa exceção foi entregue a nós;
  • o usuário desta classe não tem certeza de que essa exceção foi passada anteriormente por um conjunto de métodos de propósito;
  • começamos a criar novas dependências das quais nos livramos chamando uma camada intermediária.

No entanto, há outra conclusão resultante desta: devemos usar catch no método DoSomethingWild . E isso é um pouco estranho para nós: o WildInvestment quase não depende de algo. Quero dizer, se o PlayRussianRoulette não funcionou, o mesmo acontecerá com o DoSomethingWild : ele não tem códigos de retorno, mas precisa jogar a roleta. Então, o que podemos fazer em uma situação aparentemente sem esperança? A resposta é realmente simples: estar em outro nível DoSomethingWild deve lançar sua própria exceção que pertence a esse nível e agrupá-lo no InnerException como a fonte original de um problema:


 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) { } } 

Ao agrupar uma exceção em outra, transferimos o problema de um nível de aplicativo para outro e tornamos seu trabalho mais previsível em termos de consumidor dessa classe: o método Main .


Com base em problemas de reutilização


Frequentemente, sentimos preguiça de criar um novo tipo de exceção, mas quando decidimos fazer isso, nem sempre é claro em que tipo basear-se. Mas são precisamente essas decisões que definem toda a arquitetura de situações excepcionais. Vamos dar uma olhada em algumas soluções populares e tirar algumas conclusões.


Ao escolher o tipo de exceção, podemos usar uma solução criada anteriormente, ou seja, para encontrar uma exceção com o nome que tenha sentido semelhante e usá-la. Por exemplo, se obtivemos uma entidade por meio de um parâmetro e não gostamos dessa entidade, podemos lançar InvalidArgumentException , indicando a causa de um erro em Message. Esse cenário parece bom, especialmente porque InvalidArgumentException está no grupo de exceções que podem não ser capturadas. No entanto, a escolha de InvalidDataException estará errada se você trabalhar com alguns tipos de dados. É porque esse tipo está na área System.IO , o que provavelmente não é o que você lida. Assim, quase sempre será errado procurar um tipo existente em vez de desenvolver um por si mesmo. Quase não há exceções para uma gama geral de tarefas. Praticamente todos eles são para situações específicas e se você os reutilizar em outros casos, isso violará gravemente a arquitetura de situações excepcionais. Além disso, uma exceção de um tipo específico (por exemplo, System.IO.InvalidDataException ) pode confundir um usuário: por um lado, ele verá que a exceção pertence ao espaço para nome System.IO , enquanto, por outro lado, é lançada de um espaço para nome completamente diferente. Se esse usuário começar a pensar nas regras de lançamento dessa exceção, ele poderá ir para o sourcesource.microsoft.com e encontrar todos os locais onde é lançada :


  • internal class System.IO.Compression.Inflater

O usuário entenderá que alguém é todo polegar esse tipo de exceção o confundiu, pois o método que lançou essa exceção não lidava com a compactação.


Além disso, em termos de reutilização, você pode simplesmente criar uma exceção e declarar o campo ErrorCode nela. Parece uma boa ideia. Você apenas lança a mesma exceção, definindo o código e usa apenas uma catch para lidar com exceções, aumentando a estabilidade de um aplicativo e nada mais. No entanto, acredito que você deve repensar essa posição. Obviamente, essa abordagem facilita a vida, por um lado. No entanto, por outro lado, você descarta a possibilidade de capturar um subgrupo de exceções que possuem algum recurso em comum. Por exemplo, ArgumentException que une várias exceções por herança. Outra séria desvantagem é um código excessivamente grande e ilegível que deve organizar a filtragem baseada em código de erro. No entanto, a introdução de um tipo abrangente com um código de erro será mais apropriada quando um usuário não precisar se preocupar em especificar um erro.


 public class ParserException { 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); 

O código que protege a chamada do analisador não se importa por que a análise falhou: está interessado no erro como tal. No entanto, se a causa da falha se tornar importante, o usuário sempre poderá obter o código de erro da propriedade ErrorCode . E você realmente não precisa procurar as palavras necessárias em uma subcadeia de Message .


Se não optarmos por reutilizar, podemos criar um tipo de exceção para todas as situações. Por um lado, parece lógico: um tipo de erro - um tipo de exceção. No entanto, não exagere: ter muitos tipos de exceções causará o problema de capturá-las, pois o código de um método de chamada será sobrecarregado com blocos de catch . Porque ele precisa processar todos os tipos de exceções que você deseja passar para ele. Outra desvantagem é puramente arquitetônica. Se você não usa exceções, confunde quem as usará: elas podem ter muitas coisas em comum, mas serão capturadas separadamente.


No entanto, existem ótimos cenários para introduzir tipos separados para situações específicas. Por exemplo, quando o erro afeta não uma entidade inteira, mas um método específico. Então esse tipo de erro deve ocupar um lugar na hierarquia da herança que ninguém jamais pensaria em juntar-se a outra coisa: por exemplo, através de um ramo separado da herança.


Além disso, se você combinar essas duas abordagens, poderá obter um poderoso conjunto de instrumentos para trabalhar com um grupo de erros: você pode introduzir um tipo abstrato comum e herdar casos específicos dele. A classe base (nosso tipo comum) deve obter uma propriedade abstrata, projetada para armazenar um código de erro, enquanto os herdeiros especificarão esse código substituindo essa propriedade.


 public abstract class ParserException { 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); 

Usando esta abordagem, obtemos algumas propriedades maravilhosas:


  • por um lado, continuamos capturando exceções usando um tipo base (comum);
  • por outro lado, mesmo capturando exceções com esse tipo de base, ainda somos capazes de identificar uma situação específica;
  • além disso, podemos capturar exceções por meio de um tipo específico, em vez de um tipo base, sem usar a estrutura plana das classes.

Eu acho que é muito conveniente.


Baseado em pertencer a um grupo específico de situações comportamentais


Que conclusões podemos tirar com base no raciocínio anterior? Vamos tentar defini-los.


Primeiro de tudo, vamos decidir o que significa uma situação? Geralmente, falamos sobre classes e objetos em termos de entidades com algum estado interno e podemos executar ações nessas entidades. Assim, o primeiro tipo de situação comportamental inclui ações em alguma entidade. A seguir, se observarmos um gráfico de objeto de fora, veremos que ele é logicamente representado como uma combinação de grupos funcionais: o primeiro grupo lida com cache, o segundo trabalha com bancos de dados e o terceiro realiza cálculos matemáticos. Camadas diferentes podem passar por todos esses grupos, por exemplo, camadas de registro de estados internos, registro de processos e rastreamento de chamadas de método. Camadas podem abranger vários grupos funcionais. Por exemplo, pode haver uma camada de um modelo, uma camada de controladores e uma camada de apresentação. Esses grupos podem estar em uma montagem ou em grupos diferentes, mas cada grupo pode criar suas próprias situações excepcionais.


Portanto, podemos construir uma hierarquia para os tipos de situações excepcionais com base na pertença a esses tipos em um ou outro grupo ou camada. Assim, permitimos que um código de captura navegue facilmente entre esses tipos na hierarquia.


Vamos examinar o seguinte código:


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

Como é isso? Eu acho que o espaço para nome é uma maneira perfeita de agrupar naturalmente os tipos de exceções com base nas situações comportamentais: tudo o que pertence a grupos específicos deve permanecer lá, incluindo exceções. Além disso, quando você obtém uma exceção específica, verá o nome do seu tipo e também o espaço para nome que especificará um grupo ao qual ele pertence. Você se lembra da reutilização incorreta de InvalidDataException que é realmente definida no espaço para nome System.IO ? O fato de pertencer a esse espaço para nome significa que esse tipo de exceção pode ser lançado a partir de classes que estão no espaço para nome System.IO ou em uma mais aninhada. Mas a exceção real foi lançada de um espaço completamente diferente, confundindo uma pessoa que lida com o problema. No entanto, se você colocar os tipos de exceções e os tipos que as lançam nos mesmos namespaces, manterá a arquitetura dos tipos consistente e facilitará aos desenvolvedores o entendimento dos motivos do que acontece.


Qual é a segunda maneira de agrupar no nível do código? Herança:


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

Observe que, para entidades de aplicativos comuns, elas herdam comportamentos e tipos de dados e grupos que pertencem a um único grupo de entidades . No entanto, para exceções, eles herdam e são agrupados com base em um único grupo de situações , porque a essência de uma exceção não é uma entidade, mas um problema.


Combinando esses dois métodos de agrupamento, podemos tirar as seguintes conclusões:


  • deve haver um tipo básico de exceções dentro do Assembly que será lançado por esse assembly. Esse tipo de exceção deve estar em um espaço para nome raiz do assembly. Essa será a primeira camada de agrupamento.
  • Além disso, pode haver um ou vários namespaces dentro de um assembly. Cada um deles divide a montagem em zonas funcionais, definindo os grupos de situações que aparecem nessa montagem. Podem ser zonas de controladores, entidades de banco de dados, algoritmos de processamento de dados etc. Para nós, esses namespaces significam tipos de agrupamento com base em suas funções. No entanto, em termos de exceções, eles são agrupados com base em problemas dentro do mesmo assembly;
  • as exceções devem ser herdadas dos tipos no mesmo espaço para nome de nível superior. Isso garante que o usuário final compreenda de forma inequívoca as situações e não capture exceções baseadas em tipos errados . Admita, seria estranho capturar global::Finiki.Logistics.OhMyException por catch(global::Legacy.LoggerExeption exception) , enquanto o código a seguir parece absolutamente apropriado:

 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 is wrong in the parser } catch (FinancialPipeExceptionBase exception) { // Something else is wrong. Looks critical because we don't know the real reason } 

Aqui, o código do usuário chama um método de biblioteca que, como sabemos, pode XmlParserServiceException em alguma situação. E, como sabemos, essa exceção refere-se ao espaço de nomes herdado JetFinance.FinancialPipe.FinancialPipeExceptionBase , o que significa que pode haver outras exceções - desta vez o microsserviço XmlParserService cria apenas uma exceção, mas outras exceções podem aparecer no futuro. Como temos uma convenção para criar tipos de exceções, sabemos de que entidade essa nova exceção será herdada e colocamos uma catch abrangente com antecedência. Isso nos permite pular todas as coisas irrelevantes para nós.


Como construir uma hierarquia de tipos?


  • Primeiro de tudo, devemos criar uma classe base para um domínio. Vamos chamá-lo de uma classe base de domínio. Nesse caso, um domínio é uma palavra que abrange vários conjuntos, combinando-os com base em algum recurso: log, lógica de negócios, interface do usuário. Quero dizer zonas funcionais de um aplicativo que são tão grandes quanto possível.
  • Em seguida, devemos introduzir uma classe base adicional para exceções que devem ser capturadas: todas as exceções que serão capturadas usando a palavra-chave catch serão herdadas dessa classe base;
  • Todas as exceções que indicam erros fatais devem ser herdadas diretamente de uma classe base do domínio. Assim, nós os separaremos daqueles capturados no nível da arquitetura;
    - Divida o domínio em áreas funcionais com base em namespaces e declare o tipo básico de exceções que serão lançadas em cada área. Aqui é necessário usar o bom senso: se um aplicativo tiver um alto grau de aninhamento de namespace, você não deve fazer um tipo de base para cada nível de aninhamento. No entanto, se houver ramificação em um nível de aninhamento quando um grupo de exceções for para um espaço para nome e outro grupo for para outro espaço para nome, será necessário usar dois tipos de base para cada subgrupo;
  • Exceções especiais devem ser herdadas dos tipos de exceções pertencentes a áreas funcionais
  • Se um grupo de exceções especiais puder ser combinado, é necessário fazê-lo em mais um tipo de base: assim, você poderá identificá-las com mais facilidade;
  • Se você supõe que o grupo será capturado com mais frequência usando uma classe base, introduza o Modo misto com o ErrorCode.

Com base na fonte de um erro


A fonte de um erro pode ser outra base para combinar exceções em um grupo. Por exemplo, se você criar uma biblioteca de classes, o seguinte poderá formar grupos de fontes:


  • chamada de código insegura com erro. Essa situação pode ser resolvida agrupando uma exceção ou um código de erro em seu próprio tipo de exceção e salvando os dados retornados (por exemplo, o código de erro original) em uma propriedade pública da exceção;
  • uma chamada de código por dependências externas, que lançou exceções que não podem ser capturadas por nossa biblioteca, pois estão além de sua área de responsabilidade. Esse grupo pode incluir exceções dos métodos daquelas entidades que foram aceitas como parâmetros de um método atual ou exceções do construtor de uma classe cujo método chamou de dependência externa. Por exemplo, um método de nossa classe chamou um método de outra classe, cuja instância foi retornada por meio de parâmetros de outro método. Se uma exceção indicar que somos a fonte de um problema, devemos gerar nossa própria exceção, mantendo a original no InnerExcepton . No entanto, se entendermos que o problema foi causado por uma dependência externa, ignoraremos essa exceção como pertencendo a um grupo de dependências externas além do nosso controle;
  • nosso próprio código que foi acidentalmente colocado em um estado inconsistente. Um bom exemplo é a análise de texto - sem dependências externas, sem transferência para um mundo unsafe , mas ocorre um problema de análise.

Este capítulo foi traduzido do russo em conjunto pelo autor e por tradutores profissionais . Você pode nos ajudar com a tradução do russo ou do inglês para qualquer outro idioma, principalmente para chinês ou alemão.

Além disso, se você quiser nos agradecer, a melhor maneira de fazer isso é nos dar uma estrela no github ou no fork do repositório github / sidristij / dotnetbook .

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


All Articles