- PVSM.RU - https://www.pvsm.ru -
С этой статьей я продолжаю публиковать целую серию статей, результатом которой будет книга по работе .NET CLR, и .NET в целом. За ссылками — добро пожаловать по кат.
Наверное, один из самых важных вопросов, который касается темы исключений — это вопрос построения архитектуры исключений в вашем приложении. Этот вопрос интересен по многим причинам. Как по мне так основная — это видимая простота, с которой не всегда очевидно, что делать. Это свойство присуще всем базовым конструкциям, которые используются повсеместно: это и IEnumerable
, и IDisposable
и IObservable
и прочие-прочие. С одной стороны, своей простотой они манят, вовлекают в использование себя в самых разных ситуациях. А с другой стороны, они полны омутов и бродов, из которых, не зная, как иной раз и не выбраться вовсе. И, возможно, глядя на будущий объем у вас созрел вопрос: так что же такого в исключительных ситуациях?
Но для того чтобы прийти к каким-то выводам относительно построения архитектуры классов исключительных ситуаций мы должны с вами скопить некоторый опыт относительно их классификации. Ведь только поняв, с чем мы будем иметь дело, как и в каких ситуациях программист должен выбирать тип ошибки, а в каких — делать выбор относительно перехвата или пропуска исключений, можно понять как можно построить систему типов таким образом чтобы это стало очевидно для пользователя вашего кода. А потому, попробуем классифицировать исключительные ситуации (не сами типы исключений, а именно ситуации) по различным признакам.
По теоретическому перехвату исключения можно легко поделить на два вида: на те, которые перехватывать будут точно и на те, которые с высокой степенью вероятности перехватывать не будут. Почему с высокой степенью вероятности? Потому что всегда найдется тот, кто попробует перехватить, хотя это и не нужно было совершенно делать.
Давайте для начала раскроем особенности первой группы: исключения, которые должны и будут перехватывать.
Когда мы вводим исключение такого типа, то одной стороны мы сообщаем внешней подсистеме что мы вошли в положение, когда дальнейшие действия в рамках наших данных не имеют смысла. А с другой имеем в виду, что ничего глобального сломано не было и если нас убрать, то ничего не поменяется, а потому это исключение может быть легко перехвачено чтобы поправить ситуацию. Это свойство очень важно: именно оно определяет критичность ошибки и уверенность в том что если перехватить исключение и просто очистить ресурсы, то можно спокойно выполнять код дальше.
Вторая группа как бы это ни звучало странно — отвечает за исключения, которые перехватывать не нужно. Они могут быть использованы только для записи в журнал ошибок, но не для того чтобы можно было как-то поправить ситуацию. Самый простой пример — это исключения группы ArgumentException
и NullReferenceException
. Ведь в нормальной ситуации вы не должны, например, перехватывать исключение ArgumentNullException
потому что источником проблемы тут будете являться именно вы, а не кто либо еще. Если вы перехватываете данное исключение, то тем самым вы допускаете что вы ошиблись и отдали методу то, что отдавать ему было нельзя:
void SomeMethod(object argument)
{
try {
AnotherMethod(argument);
} catch (ArgumentNullException exception)
{
// Log it
}
}
В этом методе мы пробуем перехватить ArgumentNullException
. Но на мой взгляд его перехват выглядит очень странным: прокинуть корректные аргументы методу — полностью наша забота. Было бы не корректно среагировать постфактум: в такой ситуации самое правильное что только можно сделать — это проверить передаваемые данные заранее, до вызова метода или же что еще лучше — построить код таким образом чтобы получение неправильных параметров было бы попросту не возможным.
Еще одна группа — это исключения фатальных ошибок. Если сломан некий кэш и работа подсистемы в любом случае будет не корректной? Тогда это — фатальная ошибка и ближайший по стеку код ее перехватывать гарантированно не станет:
T GetFromCacheOrCalculate()
{
try {
if(_cache.TryGetValue(Key, out var result))
{
return result;
} else {
T res = Strategy(Key);
_cache[Key] = res;
return res;
}
} cache (CacheCorreptedException exception)
{
RecreateCache();
return GetFromCacheOrCalculate();
}
}
И пусть CacheCorreptedException
— это исключение, означающее "кэш на жестком диске не консистентен". Тогда получается, что если причина такой ошибки фатальна для подсистемы кэширования (например, отсутствуют права доступа к файлу кэша), то дальнейший код если не сможет пересоздать кэш командой RecreateCache
, а потому факт перехвата этого исключения является ошибкой сам по себе.
Еще один вопрос, который останавливает наш полет мысли в программировании алгоритмов — это понимание: стоит ли перехватывать те или иные исключения или же стоит пропустить их сквозь себя кому-то более понимающему. Переводя на язык терминов вопрос, который нам надо решить — разграничить зоны ответственности. Давайте рассмотрим следующий код:
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)
{
}
}
Какая из двух предложенных стратегий является более корректной? Зона ответственности — это очень важно. Изначально может показаться, что поскольку работа WildInvestment
и его консистентность целиком и полностью зависит от WildStrategy
, то если WildInvestment
просто проигнорирует данное исключение, оно уйдет в уровень повыше и делать ничего более не надо. Однако, прошу заметить что существует чисто архитектурная проблема: метод Main
ловит исключение из архитектурно одного слоя, вызывая метод архитектурно — другого. Как это выглядит с точки зрения использования? Да в общем так и выглядит:
Однако, из данного вывода следует другой: catch
мы должны ставить в методе DoSomethingWild
. И это для нас несколько странно: WildInvestment
вроде как жестко зависим от кого-то. Т.е. если PlayRussianRoulette
отработать не смог, то и DoSomethingWild
тоже: кодов возврата тот не имеет, а сыграть в рулетку он обязан. Что же делать в такой казалось бы безвыходной ситуации? Ответ на самом деле прост: находясь в другом слое, DoSomethingWild
должен выбросить собственное исключение, которое относится к этому слою и обернуть исходное как оригинальный источник проблемы — в 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)
{
}
}
Обернув исключение другим мы по сути переводим проблематику из одного слоя приложения в другой, сделав его работу более предсказуемой с точки зрения пользователя этого класса: метода Main
.
Очень часто перед нами встает непростая задача: с одной стороны нам лень создавать новый тип исключения, а когда мы все-таки решаемся, не всегда ясно от чего отталкиваться: какой тип взять за основу как базовый. А ведь именно эти решения и определяют всю архитектуру исключительных ситуаций. Давайте пробежимся по популярым решениям и сделаем некоторые выводы.
При выборе типа исключений можно попробовать взять уже существующее решение: найти исключение с похожим смыслом в названии и использовать его. Например, если нам отдали через параметр какую-либо сущность, которая нас почему-то не устраивает, мы можем выбросить InvalidArgumentException
, указав причину ошибки — в Message. Этот сценарий выглядит хорошо, особенно с учетом того что InvalidArgumentException
находится в группе исключений, которые не подлежат обязательному перехвату. Но плохим будет выбор InvalidDataException
если вы работаете с какими-либо данными. Просто потому что этот тип находится в зоне System.IO
, а это врядли то, чем вы занимаетесь. Т.е. получается что найти существующий тип потому что лениво делать свой — практически всегда будет не правильным подходом. Исключений, которые созданы для общего круга задач почти не существует. Практически все из них созданы под конкретные ситуации и их переиспользование будет грубым нарушением архитектуры исключительных ситуаций. Мало того, получив исключение определенного типа (например, тот же System.IO.InvalidDataException
), пользователь будет запутан: с одной стороны он увидит источник проблемы в System.IO
как пространство имен исключения, а с другой — совершенно другое пространство имен точки выброса. Плюс ко всему, задумавшись о правилах выброса этого исключения зайдет на referencesource.microsoft.com [1] и найдет все места его выброса [2]:
internal class System.IO.Compression.Inflater
И поймет что просто у кого-то кривые руки выбор типа исключения его запутал, поскольку метод, выбросивший исключение компрессией не занимался.
Также в целях упрощения переиспользования можно просто взять и создать какое-то одно исключение, объявив у него поле ErrorCode
с кодом ошибки и жить себе припеваючи. Казалось бы: хорошее решение. Бросаете везде одно и то же исключение, выставив код, ловите всего-навсего одним catch
повышая тем самым стабильность приложения: и делать более ничего не надо. Однако, прошу не согласиться с такой позицией. Действуя таким образом по всему приложению вы с одной стороны, конечно, упрощаете себе жизнь. Но с другой — вы отбрасываете возможность ловить подгруппу исключений, объединенных некоторой общей особенностью. Как это сделано, например, с ArgumentException
, который под собой объединяет целую группу исключений путем наследования. Второй серьезный минус — чрезмерно большие и нечитаемые простыни кода, который будет организовывать фильтрацию по коду ошибки. А вот если взять другую ситуацию: когда конечному пользователю конкретизация ошибки не должна быть важна, введение обобщающего типа плюс код ошибки выглядит уже куда более правильным применением:
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);
Коду, который защищает вызов парсера почти всегда безразлично, по какой причине был завален парсинг: ему важен сам факт ошибки. Однако, если это все-таки станет важно, пользователь всегда сможет вычленить код ошибки из свойства ErrorCode
. Для этого вовсе не обязательно искать нужные слова по подстроке в Message
.
Если отталкиваться от игнорирования вопросов переиспользования, то можно создать по типу исключения под каждую ситуацию. С одной стороны это выглядит логично: один тип ошибки — один тип исключения. Однако, тут, как и во всем, главное не переусердствовать: имея по типу исключительных операций на каждую точку выброса вы порождаете тем самым проблемы для перехвата: код вызывающего метода будет перегружен блоками catch
. Ведь ему надо обработать все типы исключений, которые вы хотите ему отдать. Другой минус — чисто архитектурный. Если вы не используете наследования, то тем самым дезориентируете пользователя этих исключений: между ними может быть много общего, а перехватывать их приходится по отдельности.
Тем не менее существуют хорошие сценарии для введения отдельных типов для конкретных ситуаций. Например, когда поломка происходит не для всей сущности в целом, а для конкретного метода. Тогда этот тип должен быть в иерархии наследования находиться в таком месте чтобы не возникало мысли его перехватить заодно с чем-то еще: например, выделив его через отдельную ветвь наследования.
Дополнительно, если объединить эти два подхода, можно получить очень мощный инструментарий по работе с группой ошибок: можно ввести обобщающий абстрактный тип, от которого унаследовать конкретные частные ситуации. Базовый класс (наш обобщающий тип) необходимо снабдить абстрактным свойством, хранящем код ошибки, а наследники переопределяя это свойство будут этот код ошибки уточнять:
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);
Какие замечательные свойства мы получим при таком подходе?
Как по мне так очень удобный вариант.
Какие же выводы можно сделать, основываясь на ранее описанных рассуждениях? Давайте попробуем их сформулировать:
Для начала давайте определимся, что имеется ввиду под ситуациями. Когда мы говорим про классы и объекты, то мы привыкли в первую очередь оперировать сущностями с некоторым внутренним состоянием над которыми можно осуществлять действия. Получается что тем самым мы нашли первый тип поведенческой ситуации: действия над некоторой сущностью. Далее, если посмотреть на граф объектов как-бы со стороны, можно заметить что он логически объединен в функциональные группы: первая занимается кэшированием, вторая — работа с базами данных, третья осуществляет математические расчеты. Через все эти функциональные группы могут идти слои: слой логгирования различных внутренних состояний, журналирование процессов, трассировка вызовов методов. Слои могут быть более охватывающие: объединяющие в себе несколько функциональных групп. Например, слой модели, слой контроллеров, слой представления. Эти группы могут находиться как в одной сборке, так и в совершенно разных, но каждая из них может создавать свои исключительные ситуации.
Получается, что если рассуждать таким образом, то можно построить некоторую иерархию типов исключительных ситуаций, основываясь на принадлежности типа той или иной группе или слою создавая тем самым возможность перехватывающему исключения коду легкую смысловую навигацию в этой иерархии типов.
Давайте рассмотрим код:
namespace JetFinance
{
namespace FinancialPipe
{
namespace Services
{
namespace XmlParserService
{
}
namespace JsonCompilerService
{
}
namespace TransactionalPostman
{
}
}
}
namespace Accounting
{
/* ... */
}
}
На что это похоже? Как по мне, пространства имен — прекрасная возможность естественной группировки типов исключений по их поведенческим ситуациям: все, что принадлежит определенным группам там и должно находиться, включая исключения. Мало того, когда вы получите определенное исключение, то помимо названия его типа вы увидете и его пространство имен, что четко определит его принадлежность. Помните пример плохого переиспользования типа InvalidDataException
, который на самом деле определен в пространстве имен System.IO
? Его принадлежность данному пространству имен означает что по сути исключение этого типа может быть выброшено из классов, находящихся в пространстве имен System.IO
либо в более вложенном. Но само исключение при этом было выброшено совершенно из другого места, запутывая исследователя возникшей проблемы. Сосредотачивая типы исключений по тем же пространствам имен, что и типы, эти исключения выбрасывающие, вы с одной стороны сохраняете архитектуру типов консистентной, а с другой — облегчаете понимание причин произошедшего конечным разработчиком.
Каков второй путь группировки на уровне кода? Наследование:
public abstract class LoggerExceptionBase : Exception
{
protected LoggerExceptionBase(..);
}
public class IOLoggerException : LoggerExceptionBase
{
internal IOLoggerException(..);
}
public class ConfigLoggerException : LoggerExceptionBase
{
internal ConfigLoggerException(..);
}
Причем, если в случае с обычными сущностями приложения наследование означает наследование поведения и данных, объединяя типы по принадлежности к единой группе сущностей, то в случае исключений наследование означает принадлежность к единой группе ситуаций, поскольку суть исключения — не сущность, а проблематика.
Объединяя оба метода группировки, можно сделать некоторые выводы:
Assembly
) должен присутствовать базовый тип исключений, которые данная сборка выбрасывает. Этот тип исключений должен находиться в корневом для сборки пространстве имен. Это будет первый слой группировки;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
нет, а ошибка парсинга есть.Автор: sidristij
Источник [5]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/c-2/289039
Ссылки в тексте:
[1] referencesource.microsoft.com: https://referencesource.microsoft.com/
[2] все места его выброса: https://referencesource.microsoft.com/#System/sys/System/IO/compression/InvalidDataException.cs,2b389f14fb01ad1b,references
[3] GitHub: https://github.com/sidristij/dotnetbook/
[4] GitHub Release: https://github.com/sidristij/dotnetbook/releases/tag/0.4.0-Exceptions
[5] Источник: https://habr.com/post/419927/?utm_campaign=419927
Нажмите здесь для печати.