[DotNetBook] Excepciones: Arquitectura de sistema de tipo

Con este artículo, continúo publicando una serie de artículos, cuyo resultado será un libro sobre el trabajo de .NET CLR y .NET en general. Para enlaces, bienvenidos a cat.


Arquitectura de excepción


Probablemente uno de los temas más importantes con respecto al tema de las excepciones es el tema de construir una arquitectura de excepción en su aplicación. Esta pregunta es interesante por muchas razones. En cuanto a mí, lo principal es la aparente simplicidad con la que no siempre es obvio qué hacer. Esta propiedad es inherente a todas las construcciones básicas que se usan en todas partes: es IEnumerable e IDisposable and IObservable y otras. Por un lado, están haciendo señas por su simplicidad, involucrándose en su uso en una variedad de situaciones. Y, por otro lado, están llenos de remolinos y vados, de los cuales, sin saber cómo, a veces no salen. Y, tal vez, mirando el volumen futuro, su pregunta ha madurado: entonces, ¿qué es en situaciones excepcionales?


Nota


El capítulo publicado en Habré no está actualizado y, probablemente, está un poco desactualizado. Y, por lo tanto, consulte el original para obtener un texto más reciente:



Pero para llegar a algunas conclusiones con respecto a la construcción de la arquitectura de clases de situaciones excepcionales, debemos acumular cierta experiencia con respecto a su clasificación. Después de todo, solo después de haber entendido con qué trataremos, cómo y en qué situaciones el programador debe elegir el tipo de error, y en qué: hacer la elección con respecto a la captura u omisión de excepciones, puede comprender cómo puede construir un sistema de tipos de tal manera que sea obvio para su código Por lo tanto, trataremos de clasificar situaciones excepcionales (no los tipos de excepciones en sí, sino precisamente las situaciones) de acuerdo con varios criterios.


Según la posibilidad teórica de atrapar la excepción proyectada


En términos de intercepción teórica, las excepciones se pueden dividir fácilmente en dos tipos: las que interceptarán con precisión y las que tienen mayor probabilidad de interceptar. ¿Por qué con un alto grado de probabilidad ? Porque siempre habrá alguien que intente interceptar, aunque esto no tuvo que hacerse por completo.


Revelemos primero las características del primer grupo: excepciones que deberían capturar.


Cuando introducimos una excepción de este tipo, por un lado informamos al subsistema externo que estamos en una posición en la que otras acciones dentro de nuestros datos no tienen sentido. Por otro lado, queremos decir que no se rompió nada global y si somos eliminados, nada cambiará y, por lo tanto, esta excepción se puede interceptar fácilmente para mejorar la situación. Esta propiedad es muy importante: determina la importancia del error y la creencia de que si detecta la excepción y simplemente borra los recursos, puede ejecutar el código de manera segura.


El segundo grupo, no importa cuán extraño pueda sonar, es responsable de las excepciones que no necesitan ser atrapadas. Solo se pueden usar para escribir en el registro de errores, pero no para corregir de alguna manera la situación. El ejemplo más simple son las excepciones de grupo ArgumentException y NullReferenceException . De hecho, en una situación normal, no debe, por ejemplo, atrapar la excepción ArgumentNullException porque la fuente del problema aquí será usted, y nadie más. Si detecta esta excepción, entonces asume que cometió un error y le dio el método que no pudo darle a:


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

En este método, intentamos atrapar una ArgumentNullException . Pero en mi opinión, su intercepción parece muy extraña: lanzar los argumentos correctos al método es nuestra preocupación. No sería correcto reaccionar después del hecho: en tal situación, lo más correcto que se puede hacer es verificar los datos transmitidos por adelantado, antes de llamar al método, o incluso mejor, para construir el código de tal manera que simplemente no sea posible recibir parámetros incorrectos.


Otro grupo es la eliminación de errores fatales. Si un determinado caché está roto y el funcionamiento del subsistema no será correcto en ningún caso? Entonces este es un error fatal y no se garantizará que el código más cercano a la pila lo intercepte:


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

Y deje que CacheCorreptedException sea ​​una excepción que significa "el caché en el disco duro no es consistente". Luego resulta que si la causa de tal error es fatal para el subsistema de almacenamiento en caché (por ejemplo, no hay permisos para el archivo de caché), entonces el código adicional si no puede recrear el caché con el comando RecreateCache , y por lo tanto el hecho de atrapar esta excepción es un error en sí mismo.


Sobre la intercepción real de una excepción


Otra pregunta que detiene nuestro vuelo de pensamiento en los algoritmos de programación es la comprensión: ¿vale la pena atrapar estas u otras excepciones o vale la pena alguien que entiende dejarlos pasar? Traduciendo al lenguaje de los términos, la pregunta que debemos resolver es distinguir entre áreas de responsabilidad. Veamos 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 propuestas es más correcta? El área de responsabilidad es muy importante. Inicialmente, puede parecer que, dado que el trabajo de WildInvestment y su consistencia dependen completamente de WildStrategy , si WildInvestment simplemente ignora esta excepción, pasará a un nivel superior y no hay necesidad de hacer nada más. Sin embargo, tenga en cuenta que existe un problema puramente arquitectónico: el método Main detecta una excepción de una capa arquitectónicamente invocando un método arquitectónicamente diferente. ¿Cómo se ve en términos de uso? Sí, en general, se ve así:


  • la preocupación por esta excepción fue simplemente superada por nosotros;
  • el usuario de esta clase no está seguro de que esta excepción se arroje a través de varios métodos que tenemos ante nosotros específicamente
  • comenzamos a dibujar adicciones innecesarias, de las cuales nos deshacemos, causando una capa intermedia.

Sin embargo, otra conclusión se deduce de esta conclusión: debemos establecer catch en el método DoSomethingWild . Y esto es algo extraño para nosotros: WildInvestment parece depender mucho de alguien. Es decir si PlayRussianRoulette no pudo funcionar, entonces DoSomethingWild también: no tiene códigos de retorno, pero debe jugar a la ruleta. ¿Qué hacer en una situación tan desesperada? La respuesta es realmente simple: al estar en otra capa, DoSomethingWild debería lanzar su propia excepción, que se refiere a esta capa y envolver el original como la fuente original del problema, en 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) { } } 

Volviendo la excepción a otra, esencialmente transferimos los problemas de una capa de aplicación a otra, haciendo que su trabajo sea más predecible desde el punto de vista del usuario de esta clase: el método Main .


Por problemas de reutilización


Muy a menudo nos enfrentamos a una tarea difícil: por un lado, somos demasiado flojos para crear un nuevo tipo de excepción, y cuando decidimos, no siempre está claro de qué empujar: qué tipo tomar como base como base. Pero son precisamente estas decisiones las que determinan toda la arquitectura de situaciones excepcionales. Repasemos las soluciones populares y saquemos algunas conclusiones.


Al elegir el tipo de excepciones, puede intentar tomar una solución ya existente: encuentre una excepción con un significado similar en el nombre y úsela. Por ejemplo, si nos dieron una entidad a través de un parámetro que de alguna manera no nos conviene, podemos lanzar una InvalidArgumentException , que indica la causa del error en el Mensaje. Este escenario se ve bien, especialmente teniendo en cuenta que InvalidArgumentException está en el grupo de excepciones que no están sujetas a capturas obligatorias. Pero elegir InvalidDataException será malo si está trabajando con algún dato. Solo porque este tipo está en la zona System.IO , y esto no es lo que haces. Es decir Resulta que encontrar el tipo existente porque perezosamente hacer el tuyo casi siempre será el enfoque equivocado. Casi no hay excepciones que se crean para el círculo general de tareas. Casi todos están creados para situaciones específicas y su reutilización será una violación grave de la arquitectura de situaciones excepcionales. No solo eso, después de haber recibido una excepción de cierto tipo (por ejemplo, el mismo System.IO.InvalidDataException ), el usuario estará confundido: por un lado, verá el origen del problema en System.IO como un espacio de nombres de excepción y, por otro, un espacio de nombres de punto de lanzamiento completamente diferente. Además, pensando en las reglas para lanzar esta excepción, irá a referencesource.microsoft.com y encontrará todos los lugares donde se lanza :


  • internal class System.IO.Compression.Inflater

Y él entenderá que solo alguien tiene las manos torcidas la elección del tipo de excepción lo confundió, ya que el método que arrojó la excepción no estaba involucrado en la compresión.


Además, para simplificar la reutilización, simplemente puede tomar y crear una única excepción declarando un campo ErrorCode con un código de error y vivir feliz para siempre. Parecería: una buena solución. Lanza la misma excepción en todas partes, configura el código, captura solo una catch lo que aumenta la estabilidad de la aplicación: y no hay nada más que hacer. Sin embargo, no esté de acuerdo con esta posición. Actuando de esta manera a lo largo de la aplicación, por un lado, por supuesto, simplificas tu vida. Pero, por otro lado, descarta la capacidad de capturar un subgrupo de excepciones, unidas por alguna característica común. Cómo se hace esto, por ejemplo, con ArgumentException , que combina un grupo completo de excepciones por herencia. El segundo inconveniente grave son las hojas de código demasiado grandes e ilegibles que organizarán el filtrado por código de error. Pero si toma una situación diferente: cuando la finalización del error no debe ser importante para el usuario final, la introducción de un tipo de generalización más un código de error parece una aplicación mucho más correcta:


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

El código que protege la llamada del analizador casi siempre es indiferente por qué motivo se bloqueó el análisis: el hecho del error en sí mismo es importante para él. Sin embargo, si esto se vuelve importante, el usuario siempre podrá extraer el código de error de la ErrorCode . Para hacer esto, no es necesario buscar las palabras necesarias subcadenando en Message .


Si comienza a ignorar los problemas de reutilización, puede crear un tipo de excepción para cada situación. Por un lado, parece lógico: un tipo de error es un tipo de excepción. Sin embargo, aquí, como en todo, lo principal es no exagerar: tener operaciones excepcionales en cada punto de liberación, causa problemas de intercepción: el código del método de llamada se sobrecargará con bloques catch . Después de todo, él necesita manejar todo tipo de excepciones que quieras darle. Otra desventaja es puramente arquitectónica. Si no usa la herencia, desorienta al usuario de estas excepciones: puede haber mucho en común entre ellas y debe interceptarlas individualmente.


Sin embargo, hay buenos escenarios para introducir tipos particulares para situaciones específicas. Por ejemplo, cuando ocurre un desglose no para toda la entidad como un todo, sino para un método específico. Entonces este tipo debería estar en la jerarquía de herencia en un lugar tal que no se piense en interceptarlo junto con otra cosa: por ejemplo, seleccionándolo a través de una rama de herencia separada.


Además, si combina estos dos enfoques, puede obtener una caja de herramientas muy poderosa para trabajar con un grupo de errores: puede introducir un tipo abstracto generalizado del cual heredar situaciones particulares específicas. La clase base (nuestro tipo de generalización) debe estar equipada con una propiedad abstracta que almacene el código de error, y los herederos anularán esta propiedad para especificar este código de error:


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

¿Cuáles son las maravillosas propiedades que obtenemos con este enfoque?


  • por un lado, conservamos la captura de una excepción por el tipo básico;
  • por otro lado, al detectar una excepción por el tipo básico, todavía era posible descubrir una situación específica;
  • Además de todo, es posible interceptar un tipo específico, y no uno básico, sin utilizar la estructura plana de las clases.

En cuanto a mí, esta es una opción muy conveniente.


En relación a un solo grupo de situaciones de comportamiento


¿Qué conclusiones pueden extraerse en base al razonamiento descrito anteriormente? Tratemos de formularlos:


Para comenzar, decidamos qué se entiende por situaciones. Cuando hablamos de clases y objetos, estamos acostumbrados principalmente a operar entidades con algún estado interno sobre el cual podemos llevar a cabo acciones. Resulta que al hacerlo encontramos el primer tipo de situación de comportamiento: acciones en una determinada entidad. Además, si observa el gráfico de objetos como si fuera desde el exterior, notará que está lógicamente combinado en grupos funcionales: el primero trata con el almacenamiento en caché, el segundo trata con bases de datos, el tercero realiza cálculos matemáticos. Las capas pueden pasar por todos estos grupos funcionales: una capa de registro de varios estados internos, registro de procesos, rastreo de llamadas a métodos. Las capas pueden ser más amplias: combinar varios grupos funcionales. Por ejemplo, una capa de modelo, una capa de controlador, una capa de presentación. Estos grupos pueden estar en el mismo ensamblaje, o en grupos completamente diferentes, pero cada uno de ellos puede crear sus propias situaciones excepcionales.


Resulta que si argumenta de esta manera, puede construir una jerarquía de tipos de situaciones excepcionales, en función del tipo que pertenece a un grupo o capa en particular, creando así la capacidad de capturar excepciones al código para facilitar la navegación semántica en esta jerarquía de tipos.


Miremos el código:


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

¿Cómo se ve? En cuanto a mí, los espacios de nombres son una gran oportunidad para agrupar naturalmente tipos de excepciones de acuerdo con sus situaciones de comportamiento: todo lo que pertenece a ciertos grupos debería estar allí, incluidas las excepciones. Además, cuando reciba una cierta excepción, además del nombre de su tipo, verá su espacio de nombres, que determinará claramente su afiliación. ¿Recuerda el mal ejemplo de reutilización del tipo InvalidDataException que se define realmente en el espacio de nombres System.IO ? Su pertenencia a este espacio de nombres significa que, en esencia, una excepción de este tipo puede ser eliminada de las clases ubicadas en el espacio de nombres System.IO o en una más anidada. Pero la excepción misma fue expulsada de un lugar completamente diferente, confundiendo al investigador del problema que surgió. Al enfocar los tipos de excepción en los mismos espacios de nombres que los tipos que arrojan estas excepciones, mantiene la arquitectura de tipo consistente por un lado y, por otro lado, facilita que el desarrollador final comprenda las razones de lo que sucedió.


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


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

Además, si en el caso de las entidades de aplicación ordinarias, herencia significa la herencia de comportamiento y datos, combinando los tipos al pertenecer a un solo grupo de entidades , entonces, en el caso de excepciones, herencia significa pertenecer a un solo grupo de situaciones , ya que la esencia de la excepción no es la esencia, sino la problemática.


Combinando ambos métodos de agrupación, podemos sacar algunas conclusiones:


  • dentro del ensamblaje ( Assembly ) debe estar presente el tipo básico de excepciones que arroja este ensamblaje. Este tipo de excepción debe estar en el espacio de nombres raíz para el ensamblado. Esta será la primera capa de la agrupación;
  • Más adentro del ensamblaje, puede haber uno o más espacios de nombres diferentes. Cada uno de ellos divide el ensamblaje en algunas zonas funcionales, definiendo así los grupos de situaciones que surgen en este ensamblaje. Estas pueden ser zonas de controladores, entidades de bases de datos, algoritmos de procesamiento de datos y otros. Para nosotros, estos espacios de nombres son una agrupación de tipos por afiliación funcional y, desde el punto de vista de las excepciones, una agrupación por zonas problemáticas del mismo ensamblado;
  • La herencia de excepciones puede ir solo de tipos en el mismo espacio de nombres o en la raíz más. Esto garantiza una comprensión inequívoca de la situación por parte del usuario final y la ausencia de interceptación de excepciones dejadas al interceptar según el tipo básico.De acuerdo: sería extraño global::Finiki.Logistics.OhMyExceptiontenerlo catch(global::Legacy.LoggerExeption exception), pero el siguiente código se ve absolutamente armonioso:

 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. Es decir ;
  • , : , catch ;
  • – . ;
  • , . : , , , . , - : , — , , , ;
  • , : ;
  • , Mixed Mode c ErrorCode.


. , , :


  • unsafe , . : , (, ) ;
  • , , , .. . , , . , , . — — InnerExcepton . — ;
  • Nuestro propio código que se ingresó aleatoriamente en un estado no consistente. Analizar texto es un buen ejemplo. No hay dependencias externas, no hay retirada unsafe, pero hay un error de análisis.

Enlace a todo el libro



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


All Articles