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

34uae6usmglyw10vxga3sgfgh8c.jpeg С этой статьей я продолжаю публиковать целую серию статей, результатом которой будет книга по работе .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 и найдет все места его выброса:


  • 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 не затрагивая при этом ничего лишнего: то что не попало в зону нашей ответственности пролетит мимо.

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


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


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

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


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


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


© Habrahabr.ru