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

 

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

Автор: admin от 13-08-2018, 06:45, посмотрело: 22

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



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



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

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 нет, а ошибка парсинга есть.



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





Источник: Хабр / Интересные публикации

Категория: Программирование

Уважаемый посетитель, Вы зашли на сайт как незарегистрированный пользователь.
Мы рекомендуем Вам зарегистрироваться либо войти на сайт под своим именем.

Добавление комментария

Имя:*
E-Mail:
Комментарий:
Полужирный Наклонный текст Подчеркнутый текст Зачеркнутый текст | Выравнивание по левому краю По центру Выравнивание по правому краю | Вставка смайликов Выбор цвета | Скрытый текст Вставка цитаты Преобразовать выбранный текст из транслитерации в кириллицу Вставка спойлера
Введите два слова, показанных на изображении: *