La arquitectura de una situación excepcional: punto 2 de 4

Supongo que uno de los problemas más importantes en este tema es construir una arquitectura de manejo de excepciones en su aplicación. Esto es interesante por muchas razones. Y la razón principal, creo, es una aparente simplicidad, con la que no siempre sabes qué hacer. Todas las construcciones básicas como IEnumerable , IDisposable , IObservable , etc. tener esta propiedad y usarla en todas partes. Por un lado, su simplicidad tienta a usar estos constructos en diferentes situaciones. Por otro lado, están llenos de trampas que quizás no puedas sacar. Es posible que al observar la cantidad de información que cubrimos tenga una pregunta: ¿qué tiene de especial las situaciones excepcionales?


Sin embargo, para sacar conclusiones sobre la construcción de la arquitectura de las clases de excepción, debemos aprender algunos detalles sobre su clasificación. Porque antes de construir un sistema de tipos que sea claro para el usuario del código, un programador debe determinar cuándo elegir el tipo de error y cuándo detectar o saltear excepciones. Entonces, clasifiquemos las situaciones excepcionales (no los tipos de excepciones) en función de varias características.


Basado en una posibilidad teórica para atrapar una futura excepción.


Con base en esta característica, podemos dividir las excepciones en aquellas que definitivamente serán capturadas y aquellas que probablemente no serán capturadas. ¿Por qué digo altamente probable ? Porque siempre habrá alguien que tratará de atrapar una excepción mientras esto sea innecesario.


Primero, describamos el primer grupo de excepciones: aquellas que deberían detectarse.


En caso de tales excepciones, nosotros, por un lado, decimos a nuestro subsistema que llegamos a un estado en el que no tiene sentido realizar más acciones con nuestros datos. Por otro lado, queremos decir que no sucedió nada desastroso y podemos encontrar la salida de la situación simplemente atrapando la excepción. Esta propiedad es muy importante ya que define la criticidad de un error y da confianza de que si detectamos una excepción y eliminamos recursos, simplemente podemos proceder con el código.


El segundo grupo trata con excepciones que, aunque puede parecer extraño, no tienen que ser atrapadas. Se pueden usar solo para el registro de errores, pero no para corregir una situación. El ejemplo más simple es ArgumentException y NullReferenceException . De hecho, en una situación ordinaria no necesita atrapar, por ejemplo, ArgumentNullException porque en este caso la fuente de un error es exactamente usted. Si detecta una excepción, admite que cometió un error y pasó algo inaceptable a un método:


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

En este método intentamos atrapar ArgumentNullException . Pero creo que esto es extraño, ya que pasar los argumentos correctos a un método es totalmente nuestra preocupación. Reaccionar después del evento sería incorrecto: lo mejor que puede hacer en tal situación es verificar los datos pasados ​​por adelantado antes de llamar a un método o incluso construir dicho código donde es imposible obtener parámetros incorrectos.


Otro grupo de situaciones excepcionales son los errores fatales. Si algún caché está defectuoso y el trabajo de un subsistema es incorrecto, es un error fatal y el código más cercano en la pila no lo detectará con seguridad:


 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 es una excepción que significa que "el caché del disco duro es inconsistente". Luego, si la causa de tal error es fatal para el subsistema de caché (por ejemplo, no hay derechos de acceso a archivos de caché), el siguiente código no puede recrear el caché utilizando la instrucción RecreateCache y, por lo tanto, detectar esta excepción es un error en sí mismo.


Basado en el área donde se detecta una situación excepcional


Otra cuestión es si debemos detectar algunas excepciones o pasarlas a alguien que comprenda mejor la situación. En otras palabras, debemos establecer áreas de responsabilidad. Examinemos el siguiente 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) { } } 

¿Cuál de las dos estrategias es más apropiada? El área de responsabilidad es muy importante. Inicialmente, puede parecer que el trabajo y la consistencia de WildInvestment dependen completamente de WildStrategy . Por lo tanto, si WildInvestment simplemente ignora esta excepción, irá al nivel superior y no deberíamos hacer nada. Sin embargo, tenga en cuenta que, en términos de arquitectura, el método Main detecta una excepción de un nivel mientras llama al método desde otro. ¿Cómo se ve en términos de uso? Bueno, así es como se ve:


  • la responsabilidad de esta excepción nos fue transferida a nosotros;
  • el usuario de esta clase no está seguro de que esta excepción se haya pasado previamente a través de un conjunto de métodos a propósito;
  • comenzamos a crear nuevas dependencias de las que nos deshacemos llamando a una capa intermedia.

Sin embargo, hay otra conclusión resultante de esta: deberíamos usar catch en el método DoSomethingWild . Y esto es un poco extraño para nosotros: WildInvestment casi no depende de algo. Quiero decir que si PlayRussianRoulette no funcionó, lo mismo sucederá con DoSomethingWild : no tiene códigos de retorno, pero tiene que jugar a la ruleta. Entonces, ¿qué podemos hacer en una situación tan desesperada? La respuesta es realmente simple: estar en otro nivel DoSomethingWild debería lanzar su propia excepción que pertenece a este nivel y envolverla en InnerException como la fuente original de un 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) { } } 

Al incluir una excepción en otra, transferimos el problema de un nivel de aplicación a otro y hacemos que su trabajo sea más predecible en términos de un consumidor de esta clase: el método Main .


Basado en problemas de reutilización


A menudo nos sentimos demasiado vagos para crear un nuevo tipo de excepción, pero cuando decidimos hacerlo, no siempre está claro en qué tipo basarnos. Pero son precisamente estas decisiones las que definen toda la arquitectura de situaciones excepcionales. Echemos un vistazo a algunas soluciones populares y saquemos algunas conclusiones.


Al elegir el tipo de excepción, podemos usar una solución hecha previamente, es decir, encontrar una excepción con el nombre que tenga un sentido similar y usarla. Por ejemplo, si obtuvimos una entidad a través de un parámetro y no nos gusta esta entidad, podemos lanzar InvalidArgumentException , indicando la causa de un error en Message. Este escenario se ve bien, especialmente porque InvalidArgumentException está en el grupo de excepciones que no se pueden detectar. Sin embargo, la elección de InvalidDataException será incorrecta si trabaja con algunos tipos de datos. Es porque este tipo está en el área System.IO , que probablemente no es con lo que se trata. Por lo tanto, casi siempre será un error buscar un tipo existente en lugar de desarrollar uno solo. Casi no hay excepciones para una gama general de tareas. Prácticamente todos son para situaciones específicas y si los reutiliza en otros casos, violará gravemente la arquitectura de situaciones excepcionales. Además, una excepción de un tipo en particular (por ejemplo, System.IO.InvalidDataException ) puede confundir a un usuario: por un lado, verá que la excepción pertenece al espacio de nombres System.IO , mientras que por otro lado se lanza desde Un espacio de nombres completamente diferente. Si este usuario comienza a pensar en las reglas para lanzar esta excepción, puede ir a referencesource.microsoft.com y encontrar todos los lugares donde se lanza :


  • internal class System.IO.Compression.Inflater

El usuario entenderá que alguien es todo pulgares este tipo de excepción lo confundió ya que el método que arrojó esta excepción no se ocupó de la compresión.


Además, en términos de reutilización, simplemente puede crear una excepción y declarar el campo ErrorCode en él. Eso parece una buena idea. Simplemente lanza la misma excepción, configura el código y usa solo un catch para lidiar con las excepciones, aumentando la estabilidad de una aplicación, nada más. Sin embargo, creo que debería repensar esta posición. Por supuesto, este enfoque hace la vida más fácil por un lado. Sin embargo, por otro lado, descarta la posibilidad de detectar un subgrupo de excepciones que tienen alguna característica común. Por ejemplo, ArgumentException que une un montón de excepciones por herencia. Otra desventaja grave es un código excesivamente grande e ilegible que debe organizar el filtrado basado en código de error. Sin embargo, introducir un tipo abarcador con un código de error será más apropiado cuando un usuario no tenga que preocuparse por especificar un error.


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

Al código que protege la llamada del analizador no le importa por qué falló el análisis: le interesa el error como tal. Sin embargo, si la causa del error se vuelve importante después de todo, un usuario siempre puede obtener el código de error de la propiedad ErrorCode . Y realmente no tiene que buscar las palabras necesarias en una subcadena de Message .


Si no elegimos reutilizar, podemos crear un tipo de excepción para cada situación. Por un lado, suena lógico: un tipo de error, un tipo de excepción. Sin embargo, no exagere: tener demasiados tipos de excepciones causará el problema de atraparlos, ya que el código de un método de llamada se sobrecargará con bloques de catch . Porque necesita procesar todos los tipos de excepciones que desea pasarle. Otra desventaja es puramente arquitectónica. Si no utiliza excepciones, confunde a quienes las utilizarán: pueden tener muchas cosas en común, pero se detectarán por separado.


Sin embargo, hay grandes escenarios para introducir tipos separados para situaciones específicas. Por ejemplo, cuando el error no afecta a toda una entidad, sino a un método específico. Entonces, este tipo de error debería ocupar un lugar tan importante en la jerarquía de la herencia que a nadie se le ocurriría atraparlo junto con otra cosa: por ejemplo, a través de una rama separada de la herencia.


Además, si combina ambos enfoques, puede obtener un poderoso conjunto de instrumentos para trabajar con un grupo de errores: puede introducir un tipo abstracto común y heredar casos específicos de él. La clase base (nuestro tipo común) debe obtener una propiedad abstracta, diseñada para almacenar un código de error, mientras que los herederos especificarán este código anulando esta propiedad.


 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 este enfoque obtenemos algunas propiedades maravillosas:


  • por un lado, seguimos capturando excepciones usando un tipo base (común);
  • Por otro lado, incluso detectando excepciones con este tipo de base todavía podemos identificar una situación específica;
  • Además, podemos detectar excepciones a través de un tipo específico en lugar de un tipo base sin usar la estructura plana de clases.

Creo que es muy conveniente.


Basado en pertenecer a un grupo específico de situaciones de comportamiento


¿Qué conclusiones podemos sacar con base en el razonamiento anterior? Tratemos de definirlos.


En primer lugar, ¿qué significa una situación? Por lo general, hablamos de clases y objetos en términos de entidades con algún estado interno y podemos realizar acciones en estas entidades. Por lo tanto, el primer tipo de situación de comportamiento incluye acciones en alguna entidad. A continuación, si observamos un gráfico de objetos desde el exterior, veremos que está representado lógicamente como una combinación de grupos funcionales: el primer grupo se ocupa del almacenamiento en caché, el segundo trabaja con bases de datos, el tercero realiza cálculos matemáticos. Diferentes capas pueden atravesar todos estos grupos, por ejemplo, capas de registro de estados internos, registro de procesos y rastreo de llamadas a métodos. Las capas pueden abarcar varios grupos funcionales. Por ejemplo, puede haber una capa de un modelo, una capa de controladores y una capa de presentación. Estos grupos pueden estar en una asamblea o en diferentes, pero cada grupo puede crear sus propias situaciones excepcionales.


Entonces, podemos construir una jerarquía para los tipos de situaciones excepcionales basadas en la pertenencia de estos tipos a uno u otro grupo o capa. Por lo tanto, permitimos que un código de captura navegue fácilmente entre estos tipos en la jerarquía.


Examinemos el siguiente código:


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

Como es Creo que el espacio de nombres es una forma perfecta de agrupar naturalmente los tipos de excepciones en función de las situaciones de comportamiento: todo lo que pertenece a grupos particulares debe permanecer allí, incluidas las excepciones. Además, cuando obtiene una excepción particular, verá el nombre de su tipo y también su espacio de nombres que especificará un grupo al que pertenece. ¿Recuerdas la mala reutilización de InvalidDataException que se define realmente en el espacio de nombres System.IO ? El hecho de que pertenezca a este espacio de nombres significa que este tipo de excepción se puede lanzar desde clases que están en el espacio de nombres System.IO o en una más anidada. Pero la excepción real fue lanzada desde un espacio completamente diferente, confundiendo a una persona que maneja el problema. Sin embargo, si coloca los tipos de excepciones y los tipos que arrojan estas excepciones en los mismos espacios de nombres, mantiene la arquitectura de los tipos coherente y facilita a los desarrolladores la comprensión de los motivos de lo que sucede.


¿Cuál es la segunda forma de agrupar en el nivel de código? Herencia:


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

Tenga en cuenta que para las entidades de aplicación habituales, heredan el comportamiento y los tipos de datos y grupos que pertenecen a un solo grupo de entidades . Sin embargo, para excepciones, heredan y se agrupan en función de un solo grupo de situaciones , porque la esencia de una excepción no es una entidad sino un problema.


Combinando estos dos métodos de agrupación podemos sacar las siguientes conclusiones:


  • debe haber un tipo de base de excepciones dentro de la Assembly que se generará en esta asamblea. Este tipo de excepciones debe estar en un espacio de nombres raíz del ensamblado. Esta será la primera capa de agrupación.
  • Además, puede haber uno o varios espacios de nombres dentro de un ensamblado. Cada uno de ellos divide el ensamblaje en zonas funcionales, definiendo los grupos de situaciones que aparecen en este ensamblaje. Estas pueden ser zonas de controladores, entidades de bases de datos, algoritmos de procesamiento de datos, etc. Para nosotros, estos espacios de nombres significan tipos de agrupación basados ​​en su función. Sin embargo, en términos de excepciones, se agrupan en función de problemas dentro del mismo ensamblado;
  • las excepciones deben heredarse de tipos en el mismo espacio de nombres de nivel superior. Esto garantiza que el usuario final comprenderá sin ambigüedades las situaciones y no detectará excepciones basadas en tipos incorrectos . Admito, sería extraño atrapar global::Finiki.Logistics.OhMyException por catch(global::Legacy.LoggerExeption exception) , mientras que el siguiente código parece absolutamente apropiado:

 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 } 

Aquí, el código de usuario llama a un método de biblioteca que, como sabemos, puede arrojar XmlParserServiceException en alguna situación. Y, como sabemos, esta excepción se refiere al espacio de nombres heredado JetFinance.FinancialPipe.FinancialPipeExceptionBase , lo que significa que puede haber otras excepciones, esta vez el microservicio XmlParserService crea solo una excepción, pero pueden aparecer otras excepciones en el futuro. Como tenemos una convención para crear tipos de excepciones, sabemos de qué entidad se heredará esta nueva excepción y ponemos una catch por adelantado. Eso nos permite omitir todas las cosas irrelevantes para nosotros.


¿Cómo construir tal jerarquía de tipos?


  • En primer lugar, debemos crear una clase base para un dominio. Llamémoslo una clase base de dominio. En este caso, un dominio es una palabra que abarca una serie de conjuntos, combinándolos en función de alguna característica: registro, lógica de negocios, IU. Me refiero a zonas funcionales de una aplicación que son lo más grandes posible.
  • A continuación, deberíamos introducir una clase base adicional para las excepciones que deben catch : todas las excepciones que se catch utilizando la palabra clave catch se heredarán de esta clase base;
  • Todas las excepciones que indican errores fatales deben heredarse directamente de una clase base de dominio. Así los separaremos de los atrapados en el nivel de arquitectura;
    - Divida el dominio en áreas funcionales basadas en espacios de nombres y declare el tipo base de excepciones que se lanzarán desde cada área. Aquí es necesario usar el sentido común: si una aplicación tiene un alto grado de anidación de espacio de nombres, no debe hacer un tipo base para cada nivel de anidación. Sin embargo, si hay una ramificación a nivel de anidamiento cuando un grupo de excepciones va a un espacio de nombres y otro grupo va a otro espacio de nombres, es necesario usar dos tipos básicos para cada subgrupo;
  • Se deben heredar excepciones especiales de los tipos de excepciones que pertenecen a áreas funcionales
  • Si se puede combinar un grupo de excepciones especiales, es necesario hacerlo en un tipo base más: por lo tanto, puede atraparlos más fácilmente;
  • Si supone que el grupo se verá atrapado con mayor frecuencia utilizando una clase base, introduzca el Modo mixto con Código de error.

Basado en la fuente de un error


La fuente de un error puede ser otra base para combinar excepciones en un grupo. Por ejemplo, si diseña una biblioteca de clases, las siguientes cosas pueden formar grupos de fuentes:


  • llamada de código inseguro con un error. Esta situación puede resolverse envolviendo una excepción o un código de error en su propio tipo de excepción mientras guarda los datos devueltos (por ejemplo, el código de error original) en una propiedad pública de la excepción;
  • una llamada de código por dependencias externas, que ha arrojado excepciones que nuestra biblioteca no puede detectar ya que están más allá de su área de responsabilidad. Este grupo puede incluir excepciones de los métodos de aquellas entidades que fueron aceptadas como los parámetros de un método actual o excepciones del constructor de una clase cuyo método ha llamado dependencia externa. Por ejemplo, un método de nuestra clase ha llamado un método de otra clase, cuya instancia fue devuelta a través de parámetros de otro método. Si una excepción indica que somos la fuente de un problema, deberíamos generar nuestra propia excepción al tiempo que conservamos la original en InnerExcepton . Sin embargo, si entendemos que el problema ha sido causado por una dependencia externa, ignoramos esta excepción como perteneciente a un grupo de dependencias externas más allá de nuestro control;
  • nuestro propio código que fue puesto accidentalmente en un estado inconsistente. Un buen ejemplo es el análisis de texto: sin dependencias externas, sin transferencia a unsafe mundo unsafe , pero se produce un problema de análisis.

Este capítulo fue traducido del ruso conjuntamente por el autor y por traductores profesionales . Puede ayudarnos con la traducción del ruso o el inglés a cualquier otro idioma, principalmente al chino o al alemán.

Además, si quieres agradecernos, la mejor manera de hacerlo es darnos una estrella en Github o bifurcar el repositorio github / sidristij / dotnetbook .

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


All Articles