With this article, I continue to publish a series of articles, the result of which will be a book on the work of the .NET CLR, and .NET as a whole. For links - welcome under cat.
Probably one of the most important issues related to the topic of exceptions is the issue of building an exception architecture in your application. This question is interesting for many reasons. As for me, the main thing is the apparent simplicity with which it is not always obvious what to do. This property is inherent in all basic constructs that are used everywhere: they are IEnumerable
, IDisposable
and IObservable
and others-others. On the one hand, with their simplicity they attract, involve themselves in using in a variety of situations. On the other hand, they are full of whirlpools and fords, from which, without knowing how, sometimes, they will not get out at all. And, perhaps, looking at the future volume, the question has matured: so what is this about in exceptional situations?
This article is the first of four in a series of articles about exceptions. Full cycle:
- Type system architecture (this article)
- Events about exceptional situations
- Types of exceptional situations
- Serialization and processing units
Note
The chapter published on Habré is not updated and it is possible that it is already somewhat outdated. So, please ask for a more recent text to the original:
CLR Book: GitHub, table of contents
CLR Book: GitHub, chapter
Release 0.5.2 of the book, PDF: GitHub Release
But in order to come to some conclusions regarding the construction of the architecture of classes of exceptional situations, we need to save some experience with you regarding their classification. After all, only by understanding what we will deal with, how and in what situations a programmer should choose the type of error, and in which - make a choice regarding interception or omission of exceptions, it can be understood how to build a type system so that it becomes obvious to your user. code. Therefore, let us try to classify exceptional situations (not the types of exceptions themselves, but the situations themselves) according to various criteria.
According to the theoretical interception, exceptions can be easily divided into two types: those that will intercept precisely and those that will not intercept with a high degree of probability. Why with a high degree of probability ? Because there is always someone who tries to intercept, although it did not have to do it at all.
Let us first discuss the features of the first group: exceptions that should and will intercept.
When we introduce an exception of this type, then on the one hand we inform the external subsystem that we have entered a situation where further actions within our data do not make sense. And on the other hand, we mean that nothing global was broken and if we were removed, then nothing would change, and therefore this exception can be easily intercepted to remedy the situation. This property is very important: it determines the criticality of the error and the belief that if you catch the exception and simply clear the resources, you can safely execute the code further.
The second group, however strange it may sound, is responsible for exceptions that are not necessary to intercept. They can only be used to write to the error log, but not to be able to somehow correct the situation. The simplest example is the ArgumentException
and NullReferenceException
group exceptions. After all, in a normal situation, you should not, for example, catch the ArgumentNullException
exception because the source of the problem here will be you, and not anyone else. If you intercept this exception, then you admit that you made a mistake and gave to the method what was impossible to give to it:
void SomeMethod(object argument) { try { AnotherMethod(argument); } catch (ArgumentNullException exception) { // Log it } }
In this method we try to intercept an ArgumentNullException
. But in my opinion, his interception looks very strange: throwing the correct arguments to the method is completely our concern. It would not be correct to react after the fact: in such a situation the most correct thing you can do is to check the transmitted data in advance, before calling the method, or better yet, to build the code so that it would not be possible to get the wrong parameters.
Another group is the exclusion of fatal errors. If a certain cache is broken and the subsystem will not work correctly anyway? Then this is a fatal error and the code closest to the stack will not be guaranteed to intercept it:
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(); } }
And let CacheCorreptedException
be an exception, meaning "the cache on the hard disk is not consistent." Then it turns out that if the cause of such an error is fatal for the caching subsystem (for example, there are no access rights to the cache file), then further code if it cannot recreate the cache with the RecreateCache
command, and therefore the fact of interception of this exception is an error in itself.
Another question that stops our flight of thought in programming algorithms is an understanding: should these or other exceptions be intercepted, or should someone who understands them pass through them. Translating into the language of terms the question that we need to solve is to delineate the areas of responsibility. Let's look at the following code:
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) { } }
Which of the two proposed strategies is more correct? Area of responsibility is very important. Initially, it may seem that since the work of WildInvestment
and its consistency depends entirely on WildStrategy
, if WildInvestment
simply ignores this exception, it will go higher and do nothing more. However, please note that there is a purely architectural problem: the Main
method catches an exception from the architectural one layer, calling the architectural method the other. How does it look in terms of use? Yes, in general, it looks like this:
However, this conclusion should be different: we must put catch
in the DoSomethingWild
method. And this is a bit strange for us: WildInvestment
seems to be heavily dependent on someone. Those. if PlayRussianRoulette
could not work, then DoSomethingWild
too: he does not have return codes, but he is obliged to play roulette. What to do in such a seemingly hopeless situation? The answer is really simple: being in a different layer, DoSomethingWild
should throw its own exception, which refers to this layer and wrap the original one as the original source of the problem - in 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) { } }
Wrapping the exception to others, we essentially transfer the problematic from one application layer to another, making its work more predictable from the point of view of a user of this class: the Main
method.
Very often, we face a difficult task: on the one hand, we are too lazy to create a new type of exception, and when we do decide, it is not always clear what to start with: what type to take as a basis as a baseline. But it is precisely these solutions that determine the entire architecture of exceptional situations. Let's go over popular solutions and draw some conclusions.
When choosing the type of exceptions, you can try to take an already existing solution: find an exception with a similar meaning in the name and use it. For example, if we are given an entity through a parameter that for some reason does not suit us, we can throw an InvalidArgumentException
, indicating the reason for the error - in the Message. This script looks good, especially since InvalidArgumentException
is in an exception group that is not subject to mandatory interception. But a bad choice is InvalidDataException
if you are working with any data. Just because this type is in the System.IO
zone, and this is hardly what you are doing. Those. it turns out that to find an existing type because it is lazy to make your own - almost always it will not be the right approach. Exceptions that are created for the general range of tasks almost does not exist. Virtually all of them are created for specific situations and their reuse will be a gross violation of the architecture of exceptional situations. Moreover, having received an exception of a certain type (for example, the same System.IO.InvalidDataException
), the user will be confused: on the one hand, he will see the source of the problem in System.IO
as an exception namespace, and on the other, a completely different namespace of the release point. Plus, thinking about the rules for throwing this exception will go to referencesource.microsoft.com and find all the places of its release :
internal class System.IO.Compression.Inflater
And understand that just someone has crooked hands the choice of the type of exception confused it, because the method that threw the exception did not deal with compression.
Also, in order to simplify the reuse, you can simply take and create some one exception, by announcing the ErrorCode
field with the error code and live happily ever after. It would seem: a good solution. Throwing the same exception everywhere, putting the code, catch only one catch
thereby increasing the stability of the application: and nothing else to do. However, please disagree with this position. Acting in this way throughout the application, on the one hand, of course, you simplify your life. But on the other hand, you drop the opportunity to catch a subgroup of exceptions united by some common feature. As it is done, for example, with ArgumentException
, which under itself combines a whole group of exceptions through inheritance. The second serious drawback is excessively large and unreadable sheets of code that will organize filtering by error code. But if we take a different situation: when specifying an error should not be important for an end user, the introduction of a generic type plus an error code already looks like a much more correct application:
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);
The code that protects the call to the parser almost always makes no difference for what reason the parsing was overwhelmed: the fact of the error is important to it. However, if it still becomes important, the user can always isolate the error code from the ErrorCode
. To do this, it is not necessary to search for the right words by substring in Message
If you start from ignoring reuse issues, you can create an exception type for each situation. On the one hand, it looks logical: one type of error - one type of exception. However, here, as in everything, the main thing is not to overdo it: having the type of exceptional operations at each point of release causes you to intercept problems: the code of the calling method will be overloaded with catch
blocks. After all, he needs to handle all types of exceptions that you want to give him. Another minus is purely architectural. If you do not use inheritance, then you are disorienting the user of these exceptions: there may be a lot in common between them, and you have to intercept them separately.
However, there are good scenarios for introducing individual types for specific situations. For example, when a breakdown occurs not for the whole entity, but for a particular method. Then this type should be in the hierarchy of inheritance to be in such a place so that there is no thought to intercept it along with something else: for example, selecting it through a separate branch of inheritance.
Additionally, if you combine these two approaches, you can get a very powerful toolkit for working with a group of errors: you can enter a generalizing abstract type from which to inherit specific particular situations. The base class (our generic type) needs to be supplied with an abstract property storing the error code, and the heirs redefining this property will specify this error code:
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);
What wonderful properties will we get with this approach?
As for me, so very convenient option.
What conclusions can be made, based on the previously described arguments? Let's try to formulate them:
First, let's define what is meant by situations. When we talk about classes and objects, we are accustomed to first of all operate with entities with a certain internal state over which we can perform actions. It turns out that by doing so we found the first type of behavioral situation: actions on a certain entity. Further, if you look at the object graph, as if from the outside, you can see that it is logically combined into functional groups: the first is caching, the second is working with databases, and the third performs mathematical calculations. Through all these functional groups can go layers: a layer of logging of various internal states, logging of processes, tracing of method calls. Layers can be more encompassing: combining several functional groups. For example, model layer, controller layer, view layer. These groups can be located in one assembly, or in completely different ones, but each of them can create its own exceptional situations.
It turns out that if you argue in this way, then you can build a certain hierarchy of types of exceptional situations, based on the type of belonging to one group or layer, thereby creating the possibility for code that intercepts exceptions to allow easy semantic navigation in this type hierarchy.
Let's look at the code:
namespace JetFinance { namespace FinancialPipe { namespace Services { namespace XmlParserService { } namespace JsonCompilerService { } namespace TransactionalPostman { } } } namespace Accounting { /* ... */ } }
What does it look like? As for me, namespaces are a great opportunity for the natural grouping of types of exceptions according to their behavioral situations: everything that belongs to certain groups should be there, including exceptions. Moreover, when you get a certain exception, in addition to the name of its type, you will see its namespace, which clearly defines its identity. Remember an example of a bad reuse of the type InvalidDataException
, which is actually defined in the System.IO
namespace? Its belonging to this namespace means that, in essence, an exception of this type can be thrown out of classes that are in the System.IO
namespace or in a more nested one. But the exclusion itself was completely thrown out of another place, confusing the researcher of the problem. By focusing exception types on the same namespaces as types, throwing these exceptions, on the one hand, you keep the type architecture consistent, and on the other, you make it easier for the end developer to understand the causes.
What is the second way to group at the code level? Inheritance:
public abstract class LoggerExceptionBase : Exception { protected LoggerExceptionBase(..); } public class IOLoggerException : LoggerExceptionBase { internal IOLoggerException(..); } public class ConfigLoggerException : LoggerExceptionBase { internal ConfigLoggerException(..); }
Moreover, if in the case of ordinary application entities, inheritance means inheritance of behavior and data, combining types according to belonging to a single entity group , in case of exceptions, inheritance means belonging to a single situation group , since the essence of the exception is not the essence, but the problematic.
Combining both methods of grouping, we can draw some conclusions:
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
: .
?
catch
;. , , :
InnerExcepton
. — ;unsafe
, .Link to the whole book
CLR Book: GitHub
Release 0.5.0 books, PDF: GitHub Release
Source: https://habr.com/ru/post/419927/
All Articles