Хабрахабр

[DotNetBook] Исключения: архитектура системы типов

NET CLR, и . С этой статьей я продолжаю публиковать целую серию статей, результатом которой будет книга по работе . За ссылками — добро пожаловать по кат. NET в целом.

Архитектура исключительной ситуации

Этот вопрос интересен по многим причинам. Наверное, один из самых важных вопросов, который касается темы исключений — это вопрос построения архитектуры исключений в вашем приложении. Это свойство присуще всем базовым конструкциям, которые используются повсеместно: это и IEnumerable, и IDisposable и IObservable и прочие-прочие. Как по мне так основная — это видимая простота, с которой не всегда очевидно, что делать. А с другой стороны, они полны омутов и бродов, из которых, не зная, как иной раз и не выбраться вовсе. С одной стороны, своей простотой они манят, вовлекают в использование себя в самых разных ситуациях. И, возможно, глядя на будущий объем у вас созрел вопрос: так что же такого в исключительных ситуациях?

Также ожидаются:
— Cобытия об исключительных ситуациях
— Виды исключительных ситуаций
— Сериализация и блоки обработки
Данная статья — первая из четырех в цикле статей про исключения.

Ведь только поняв, с чем мы будем иметь дело, как и в каких ситуациях программист должен выбирать тип ошибки, а в каких — делать выбор относительно перехвата или пропуска исключений, можно понять как можно построить систему типов таким образом чтобы это стало очевидно для пользователя вашего кода. Но для того чтобы прийти к каким-то выводам относительно построения архитектуры классов исключительных ситуаций мы должны с вами скопить некоторый опыт относительно их классификации. А потому, попробуем классифицировать исключительные ситуации (не сами типы исключений, а именно ситуации) по различным признакам.

По теоретической возможности перехвата проектируемого исключения

Почему с высокой степенью вероятности? По теоретическому перехвату исключения можно легко поделить на два вида: на те, которые перехватывать будут точно и на те, которые с высокой степенью вероятности перехватывать не будут. Потому что всегда найдется тот, кто попробует перехватить, хотя это и не нужно было совершенно делать.

Давайте для начала раскроем особенности первой группы: исключения, которые должны и будут перехватывать.

А с другой имеем в виду, что ничего глобального сломано не было и если нас убрать, то ничего не поменяется, а потому это исключение может быть легко перехвачено чтобы поправить ситуацию. Когда мы вводим исключение такого типа, то одной стороны мы сообщаем внешней подсистеме что мы вошли в положение, когда дальнейшие действия в рамках наших данных не имеют смысла. Это свойство очень важно: именно оно определяет критичность ошибки и уверенность в том что если перехватить исключение и просто очистить ресурсы, то можно спокойно выполнять код дальше.

Они могут быть использованы только для записи в журнал ошибок, но не для того чтобы можно было как-то поправить ситуацию. Вторая группа как бы это ни звучало странно — отвечает за исключения, которые перехватывать не нужно. Ведь в нормальной ситуации вы не должны, например, перехватывать исключение ArgumentNullException потому что источником проблемы тут будете являться именно вы, а не кто либо еще. Самый простой пример — это исключения группы ArgumentException и NullReferenceException. Если вы перехватываете данное исключение, то тем самым вы допускаете что вы ошиблись и отдали методу то, что отдавать ему было нельзя:

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

Тогда получается, что если причина такой ошибки фатальна для подсистемы кэширования (например, отсутствуют права доступа к файлу кэша), то дальнейший код если не сможет пересоздать кэш командой RecreateCache, а потому факт перехвата этого исключения является ошибкой сам по себе. И пусть CacheCorreptedException — это исключение, означающее "кэш на жестком диске не консистентен".

По фактическому перехвату исключительной ситуации

Переводя на язык терминов вопрос, который нам надо решить — разграничить зоны ответственности. Еще один вопрос, который останавливает наш полет мысли в программировании алгоритмов — это понимание: стоит ли перехватывать те или иные исключения или же стоит пропустить их сквозь себя кому-то более понимающему. Давайте рассмотрим следующий код:


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) { }
}

Зона ответственности — это очень важно. Какая из двух предложенных стратегий является более корректной? Однако, прошу заметить что существует чисто архитектурная проблема: метод Main ловит исключение из архитектурно одного слоя, вызывая метод архитектурно — другого. Изначально может показаться, что поскольку работа WildInvestment и его консистентность целиком и полностью зависит от WildStrategy, то если WildInvestment просто проигнорирует данное исключение, оно уйдет в уровень повыше и делать ничего более не надо. Да в общем так и выглядит: Как это выглядит с точки зрения использования?

  • заботу об этом исключении просто перевесили на нас;
  • у пользователя данного класса нет уверенности что это исключение прокинуто через ряд методов до нас специально
  • мы начинаем тянуть лишние зависимости, от которых мы избавились, вызывая промежуточный слой.

И это для нас несколько странно: WildInvestment вроде как жестко зависим от кого-то. Однако, из данного вывода следует другой: catch мы должны ставить в методе DoSomethingWild. если 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. При выборе типа исключений можно попробовать взять уже существующее решение: найти исключение с похожим смыслом в названии и использовать его. Но плохим будет выбор InvalidDataException если вы работаете с какими-либо данными. Этот сценарий выглядит хорошо, особенно с учетом того что InvalidArgumentException находится в группе исключений, которые не подлежат обязательному перехвату. IO, а это врядли то, чем вы занимаетесь. Просто потому что этот тип находится в зоне System. получается что найти существующий тип потому что лениво делать свой — практически всегда будет не правильным подходом. Т.е. Практически все из них созданы под конкретные ситуации и их переиспользование будет грубым нарушением архитектуры исключительных ситуаций. Исключений, которые созданы для общего круга задач почти не существует. IO. Мало того, получив исключение определенного типа (например, тот же System. IO как пространство имен исключения, а с другой — совершенно другое пространство имен точки выброса. InvalidDataException), пользователь будет запутан: с одной стороны он увидит источник проблемы в System. Плюс ко всему, задумавшись о правилах выброса этого исключения зайдет на referencesource.microsoft.com и найдет все места его выброса:

  • 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. Мало того, когда вы получите определенное исключение, то помимо названия его типа вы увидете и его пространство имен, что четко определит его принадлежность. Его принадлежность данному пространству имен означает что по сути исключение этого типа может быть выброшено из классов, находящихся в пространстве имен System. IO? Но само исключение при этом было выброшено совершенно из другого места, запутывая исследователя возникшей проблемы. 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
}

И, насколько мы знаем, это исключение находится в пространстве имен, наследуя JetFinance. Заметьте, что тут происходит: мы как пользовательский код вызываем некий библиотечный метод, который, насколько мы знаем, может при некоторых обстоятельствах выбросить исключение XmlParserServiceException. FinancialPipeExceptionBase, что говорит о возможном упущении других исключений: это сейчас микросервис XmlParserService создает только одно исключение, но в будущем могут появиться и другие. FinancialPipe. И поскольку у нас есть конвенция в создании типов исключений, мы точно знаем от кого это новое исключение будет наследоваться и заранее ставим обобщающий catch не затрагивая при этом ничего лишнего: то что не попало в зону нашей ответственности пролетит мимо.

Как же построить иерархию типов?

  • Для начала необходимо сделать базовый класс для домена. Назовем его доменным базовым классом. Домен в данном случае — это обобщающее некоторое количество сборок слово, которое объединяет их по некоторому глобальному признаку: логгирование, бизнес-логика, UI. Т.е. максимально крупные функциональные зоны приложения;
  • Далее необходимо ввести дополнительный базовый класс для исключений, которые перехватывать необходимо: от него будут наследоваться все исключение, которые будут перехватываться ключевым словом catch;
  • Все исключения которые обозначают фатальные ошибки – наследовать напрямую от доменного базового класса. Тем самым вы отделили их от перехватываемых архитектурно;
  • Разделить домен на функциональные зоны по пространствам имен и объявить базовый тип исключений, которые будут выбрасываться из каждой функциональной зоны. Тут стоит дополнительно орудовать здравым смыслом: если приложение имеет большую вложенность пространств имен, то делать по базовому типу для каждого уровня вложенности, конечно, не стоит. Однако, если на каком-то уровне вложенности происходит ветвление: одна группа исключений ушла в одно подпространство имен, а другая — в другое, то тут, конечно, стоит ввести два базовых типа для каждой подгруппы;
  • Частные исключения наследовать от типов исключений функциональных зон
  • Если группа частных исключений может быть объединена, объединить их еще одним базовым типом: так вы упрощаете их перехват;
  • Если предполагается что группа будет чаще перехватываться по своему базовому классу, ввести Mixed Mode c ErrorCode.

По источнику ошибки

Например, если вы разрабатываете библиотеку классов, то группами источников могут стать: Еще одним поводом для объединения исключений в некоторую группу может выступать источник ошибки.

  • Вызов unsafe кода, который отработал с ошибкой. Данную ситуацию следует обработать следующим образом: обернуть исключение либо код ошибки в собственный тип исключения, а полученные данные об ошибке (например, оригинальный код ошибки) сохранить в публичном свойстве исключения;
  • Вызов кода из внешних зависимостей, который вызвал исключения, которые наша библиотека перехватить не может, т.к. они не входят в ее зону ответственности. Сюда могут входить исключения из методов тех сущностей, которые были приняты в качестве параметров текущего метода или же конструктора того класса, метод которого вызвал внешнюю зависимость. Как пример, метод нашего класса вызвал метод другого класса, экземпляр которого был получен через параметры метода. Если исключение говорит о том что источником проблемы были мы сами — генерируем наше собственное исключение с сохранением оригинального — в InnerExcepton. Если же мы понимаем что проблема именно в работе внешней зависимости — пропускаем исключение насквозь как принадлежащее к группе внешних непоконтрольных зависимостей;
  • Наш собственный код, который был случайным образом введен в не консистентное состояние. Хорошим примером может стать парсинг текста. Внешних зависимостей нет, ухода в unsafe нет, а ошибка парсинга есть.

Ссылка на всю книгу

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть