Exceções [DotNetBook]: arquitetura de sistema de tipos

Com este artigo, continuo publicando uma série de artigos, cujo resultado será um livro sobre o trabalho do .NET CLR e .NET em geral. Para links - bem-vindo ao gato.


Arquitetura de exceção


Provavelmente, um dos problemas mais importantes em relação ao tópico de exceções é o problema da construção de uma arquitetura de exceções em seu aplicativo. Esta questão é interessante por várias razões. Quanto a mim, o principal é a aparente simplicidade com a qual nem sempre é óbvio o que fazer. Essa propriedade é inerente a todas as construções básicas usadas em qualquer lugar: é IEnumerable , IDisposable e IObservable entre outras. Por um lado, eles acenam pela sua simplicidade, envolvendo-se no uso de si mesmos em várias situações. E, por outro lado, eles estão cheios de banheiras de hidromassagem e vaus, dos quais, sem saber às vezes nem sair. E, talvez, olhando para o volume futuro, sua pergunta tenha amadurecido: então, o que é isso em situações excepcionais?


Nota


O capítulo publicado em Habré não é atualizado e, provavelmente, já está um pouco desatualizado. E, portanto, consulte o original para obter textos mais recentes:



Mas, para chegar a algumas conclusões sobre a construção da arquitetura de classes de situações excepcionais, precisamos acumular alguma experiência com você em relação à sua classificação. Afinal, apenas tendo entendido com o que lidaremos, como e em que situações o programador deve escolher o tipo de erro e em que - faça a escolha em relação à captura ou ignição de exceções, é possível entender como é possível criar um sistema de tipos de maneira que fique óbvio para o seu código. Portanto, tentaremos classificar situações excepcionais (não os tipos de exceções em si, mas precisamente as situações) de acordo com vários critérios.


De acordo com a possibilidade teórica de capturar a exceção projetada


Em termos de interceptação teórica, as exceções podem ser facilmente divididas em dois tipos: aquelas que interceptam com precisão e aquelas com alta probabilidade de interceptação. Por que com um alto grau de probabilidade ? Porque sempre haverá alguém que tentará interceptar, embora isso não tenha que ser completamente feito.


Vamos primeiro revelar as características do primeiro grupo: exceções que devem e serão capturadas.


Quando introduzimos uma exceção desse tipo, por um lado, informamos ao subsistema externo que estamos em uma posição em que outras ações em nossos dados não fazem sentido. Por outro lado, queremos dizer que nada global foi quebrado e, se formos removidos, nada mudará e, portanto, essa exceção pode ser facilmente interceptada para melhorar a situação. Essa propriedade é muito importante: determina a criticidade do erro e a crença de que, se você capturar a exceção e apenas limpar os recursos, poderá executar o código com segurança.


O segundo grupo, por mais estranho que pareça, é responsável por exceções que não precisam ser capturadas. Eles só podem ser usados ​​para gravar no log de erros, mas não para corrigir a situação. O exemplo mais simples são as exceções de grupo ArgumentException e NullReferenceException . De fato, em uma situação normal, você não deve, por exemplo, capturar a exceção ArgumentNullException porque a origem do problema aqui será você e não mais ninguém. Se você capturar essa exceção, presume que cometeu um erro e forneceu o método ao qual não poderia:


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

Nesse método, tentamos capturar uma ArgumentNullException . Mas, na minha opinião, sua interceptação parece muito estranha: lançar os argumentos corretos para o método é inteiramente nossa preocupação. Não seria correto reagir após o fato: em tal situação, a coisa mais correta a ser feita é verificar os dados transmitidos antecipadamente, antes de chamar o método, ou melhor ainda, construir o código de tal maneira que simplesmente não seja possível receber parâmetros incorretos.


Outro grupo é a eliminação de erros fatais. Se um determinado cache estiver quebrado e a operação do subsistema não estiver correta em nenhum caso? Então, este é um erro fatal e o código mais próximo da pilha não será garantido para interceptá-lo:


 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(); } } 

E permita que CacheCorreptedException seja uma exceção, significando que "o cache no disco rígido não é consistente". Acontece que, se a causa de um erro desse tipo for fatal para o subsistema de armazenamento em cache (por exemplo, não há permissões para o arquivo de cache), o código adicional se ele não puder recriar o cache com o comando RecreateCache e, portanto, o fato de capturar essa exceção será um erro.


Na interceptação real de uma exceção


Outra questão que interrompe nosso vôo de pensamento nos algoritmos de programação é o entendimento: vale a pena capturar essas ou outras exceções ou vale alguém que entende deixá-las passar por elas. Traduzindo para o idioma dos termos, a questão que precisamos resolver é distinguir entre áreas de responsabilidade. Vejamos 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 propostas está mais correta? A área de responsabilidade é muito importante. Inicialmente, pode parecer que, como o trabalho do WildInvestment e sua consistência depende inteiramente do WildStrategy , se o WildInvestment simplesmente ignorar essa exceção, ele WildInvestment para um nível mais alto e não será necessário fazer mais nada. No entanto, observe que há um problema puramente arquitetural: o método Main captura uma exceção de uma camada arquitetural, invocando um método arquiteturalmente diferente. Como é a aparência em termos de uso? Sim, em geral, é assim:


  • a preocupação com essa exceção foi simplesmente superada por nós;
  • o usuário desta classe não tem certeza de que essa exceção seja lançada por vários métodos antes de nós, especificamente
  • começamos a atrair vícios desnecessários, dos quais nos livramos, causando uma camada intermediária.

No entanto, outra conclusão se segue dessa conclusão: devemos definir catch no método DoSomethingWild . E isso é um tanto estranho para nós: o WildInvestment parece ser muito dependente de alguém. I.e. se o PlayRussianRoulette não funcionar, DoSomethingWild também: ele não possui códigos de retorno, mas deve jogar roleta. O que fazer em uma situação aparentemente sem esperança? A resposta é realmente simples: estando em outra camada, o DoSomethingWild deve lançar sua própria exceção, que se refere a essa camada e agrupar o original como a fonte original do problema - no 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) { } } 

Voltando a exceção para outra, basicamente transferimos os problemas de uma camada de aplicativo para outra, tornando seu trabalho mais previsível do ponto de vista do usuário dessa classe: o método Main .


Para problemas de reutilização


Muitas vezes enfrentamos uma tarefa difícil: por um lado, somos preguiçosos demais para criar um novo tipo de exceção e, quando decidimos, nem sempre é claro do que pressionar: de que tipo usar como base. Mas são precisamente essas decisões que determinam toda a arquitetura de situações excepcionais. Vamos examinar as soluções populares e tirar algumas conclusões.


Ao escolher o tipo de exceções, você pode tentar usar uma solução já existente: encontre uma exceção com um significado semelhante no nome e use-a. Por exemplo, se nos foi dada uma entidade através de um parâmetro que de alguma forma não nos convém, podemos lançar uma InvalidArgumentException , indicando a causa do erro em Message. Esse cenário parece bom, especialmente considerando que InvalidArgumentException está no grupo de exceções que não estão sujeitas a captura obrigatória. Mas escolher InvalidDataException será ruim se você estiver trabalhando com algum dado. Só porque esse tipo está na zona System.IO e dificilmente é isso que você faz. I.e. Acontece que encontrar o tipo existente porque fazer o seu com preguiça quase sempre será a abordagem errada. Quase não há exceções criadas para o círculo geral de tarefas. Quase todos eles são criados para situações específicas e sua reutilização será uma violação grave da arquitetura de situações excepcionais. Não apenas isso, tendo recebido uma exceção de um determinado tipo (por exemplo, o mesmo System.IO.InvalidDataException ), o usuário ficará confuso: por um lado, ele verá a fonte do problema no System.IO como um espaço para nome da exceção e, por outro, como um espaço para nome do ponto de lançamento completamente diferente. Além disso, pensando nas regras para lançar essa exceção, ela irá para o sourcesource.microsoft.com e encontra todos os locais onde é lançada :


  • internal class System.IO.Compression.Inflater

E ele vai entender isso apenas alguém tem mãos tortas a escolha do tipo de exceção o confundiu, pois o método que lançou a exceção não estava envolvido na compactação.


Além disso, para simplificar a reutilização, você pode simplesmente criar e criar uma única exceção declarando um campo ErrorCode com um código de erro e viver feliz para sempre. Parece: uma boa solução. Lance a mesma exceção em todos os lugares, definindo o código, captura apenas uma catch aumentando assim a estabilidade do aplicativo: e não há mais o que fazer. No entanto, discorde desta posição. Agindo dessa maneira durante todo o aplicativo, por um lado, é claro, você simplifica sua vida. Mas, por outro lado, você descarta a capacidade de capturar um subgrupo de exceções, unido por algum recurso comum. Como isso é feito, por exemplo, com ArgumentException , que por si só combina um grupo inteiro de exceções por herança. O segundo fator menos importante são folhas de código excessivamente grandes e ilegíveis que organizarão a filtragem por código de erro. Mas se você tomar uma situação diferente: quando a finalização do erro não deve ser importante para o usuário final, a introdução de um tipo de generalização mais um código de erro já parece um aplicativo muito mais correto:


 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); 

O código que protege a chamada do analisador é quase sempre indiferente pelo motivo pelo qual a análise foi bloqueada: o fato do erro em si é importante. No entanto, se isso se tornar importante, o usuário sempre poderá extrair o código de erro da ErrorCode . Para fazer isso, não é necessário procurar as palavras necessárias subseqüentemente na Message .


Se você começar a ignorar problemas de reutilização, poderá criar um tipo de exceção para cada situação. Por um lado, parece lógico: um tipo de erro é um tipo de exceção. No entanto, aqui, como em tudo, o principal é não exagerar: tendo operações excepcionais em cada ponto de liberação, você causa problemas de interceptação: o código do método de chamada será sobrecarregado com blocos de catch . Afinal, ele precisa lidar com todos os tipos de exceções que você deseja dar a ele. Outro sinal de menos é puramente arquitetônico. Se você não usa herança, desorienta o usuário dessas exceções: pode haver muito em comum entre elas e é necessário interceptá-las individualmente.


No entanto, existem bons cenários para a introdução de tipos específicos para situações específicas. Por exemplo, quando ocorre uma discriminação não para toda a entidade como um todo, mas para um método específico. Então esse tipo deve estar na hierarquia de herança em um local para que não haja a possibilidade de interceptá-lo junto com outra coisa: por exemplo, selecionando-o através de um ramo de herança separado.


Além disso, se você combinar essas duas abordagens, poderá obter uma caixa de ferramentas muito poderosa para trabalhar com um grupo de erros: você pode introduzir um tipo abstrato generalizado do qual herdar situações específicas específicas. A classe base (nosso tipo de generalização) deve estar equipada com uma propriedade abstrata que armazena o código de erro, e os herdeiros substituirão essa propriedade para especificar esse código de erro:


 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); 

Quais são as propriedades maravilhosas que obtemos com essa abordagem?


  • por um lado, mantivemos a captura de uma exceção pelo tipo básico;
  • por outro lado, capturando uma exceção pelo tipo básico, ainda era possível descobrir uma situação específica;
  • e mais a tudo o que é possível interceptar para um tipo específico, e não para um básico, sem usar a estrutura plana das classes.

Quanto a mim, esta é uma opção muito conveniente.


Em relação a um único grupo de situações comportamentais


Que conclusões podem ser tiradas com base no raciocínio descrito anteriormente? Vamos tentar formulá-los:


Para começar, vamos decidir o que se entende por situações. Quando falamos sobre classes e objetos, estamos principalmente acostumados a operar entidades com algum estado interno sobre o qual podemos realizar ações. Acontece que, ao fazer isso, encontramos o primeiro tipo de situação comportamental: ações em uma determinada entidade. Além disso, se você olhar para o gráfico de objetos como se fosse de fora, notará que ele é logicamente combinado em grupos funcionais: o primeiro lida com cache, o segundo lida com bancos de dados e o terceiro realiza cálculos matemáticos. As camadas podem passar por todos esses grupos funcionais: uma camada de log de vários estados internos, log de processos, rastreamento de chamadas de métodos. As camadas podem ser mais abrangentes: combinando vários grupos funcionais. Por exemplo, uma camada de modelo, uma camada de controlador, uma camada de apresentação. Esses grupos podem estar na mesma assembléia ou em grupos completamente diferentes, mas cada um deles pode criar suas próprias situações excepcionais.


Acontece que, se você argumentar dessa maneira, poderá criar uma hierarquia de tipos de situações excepcionais, com base no tipo pertencente a um grupo ou camada específico, criando a capacidade de capturar exceções ao código para facilitar a navegação semântica nessa hierarquia de tipos.


Vamos dar uma olhada no código:


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

Como é isso? Quanto a mim, os espaços para nome são uma grande oportunidade para agrupar naturalmente tipos de exceções de acordo com suas situações comportamentais: tudo o que pertence a determinados grupos deve estar lá, incluindo exceções. Além disso, quando você recebe uma certa exceção, além do nome de seu tipo, verá seu espaço para nome, que determinará claramente sua afiliação. Lembre-se do exemplo de reutilização InvalidDataException tipo InvalidDataException que é realmente definido no espaço para nome System.IO ? Sua pertença a esse espaço para nome significa que, em essência, uma exceção desse tipo pode ser lançada fora das classes localizadas no espaço para nome System.IO ou em uma mais aninhada. Mas a exceção em si foi lançada de um lugar completamente diferente, confundindo o pesquisador com o problema que surgiu. Ao focar os tipos de exceção nos mesmos espaços de nome que os tipos que lançam essas exceções, você mantém a arquitetura de tipos consistente, por um lado, e, por outro lado, facilita para o desenvolvedor final entender os motivos do que aconteceu.


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


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

Além disso, se no caso de entidades de aplicativo comuns, herança significa herança de comportamento e dados, combinando tipos por pertencer a um único grupo de entidades , então no caso de exceções, herança significa pertencer a um único grupo de situações , uma vez que a essência da exceção não é a essência, mas a problemática.


Combinando os dois métodos de agrupamento, podemos tirar algumas conclusões:


  • dentro da montagem ( Assembly ) deve estar presente o tipo básico de exceções que essa montagem lança. Esse tipo de exceção deve estar no espaço para nome raiz do assembly. Essa será a primeira camada do agrupamento;
  • Além disso, dentro da própria montagem, pode haver um ou mais namespaces diferentes. Cada um deles divide a montagem em algumas zonas funcionais, definindo assim os grupos de situações que surgem nessa montagem. Podem ser zonas de controladores, entidades de banco de dados, algoritmos de processamento de dados e outros. Para nós, esses namespaces são um agrupamento de tipos por afiliação funcional e, do ponto de vista de exceções, um agrupamento por zonas problemáticas do mesmo assembly;
  • a herança de exceções pode ir apenas de tipos no mesmo espaço para nome ou na raiz mais. Isso garante uma compreensão inequívoca da situação pelo usuário final e a ausência de interceptação de exceções deixadas ao interceptar de acordo com o tipo básico. : 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. I.e. ;
  • , : , catch ;
  • – . ;
  • , . : , , , . , - : , — , , , ;
  • , : ;
  • , Mixed Mode c ErrorCode.


. , , :


  • unsafe , . : , (, ) ;
  • , , , .. . , , . , , . — — InnerExcepton . — ;
  • Nosso próprio código que foi inserido aleatoriamente em um estado não consistente. A análise de texto é um bom exemplo. Não há dependências externas, não há retirada unsafe, mas há um erro de análise.

Link para o livro inteiro



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


All Articles