Лучшие практики обработки исключений в C#

Автор оригинала: Thomas Ardal
  • Перевод

В рамках скорого старта курса "C# Developer. Professional" подготовили для вас перевод материала.

Приглашаем также всех желающих на бесплатный демо-урок «DI-контейнеры для C#». На этом занятии мы:

1) Разберемся с тем, что такое принцип DI и зачем он нужен;
2) Научимся применять DI без использования контейнеров;
3) Рассмотрим два популярных DI-контейнеры для C#: Windsor и Autofac, разберем их плюсы и минусы;
4) Научимся регистрировать зависимости, управлять их жизненным циклом, применять инъекцию зависимостей.


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

Не генерируйте исключения повторно

Я натыкаюсь на это снова и снова. Люди оказываются сбиты с толку тем, что исходный стек трейс «волшебным образом» исчезает при обработке ошибок. Чаще всего это вызвано повторной генерацией исключений. Давайте посмотрим на пример, в котором у нас есть вложенные try/catch:

try
{
    try
    {
        // Вызов какого-либо кода, который может сгенерировать исключение SpecificException

    }
    catch (SpecificException specificException)
    {
        log.LogError(specificException, "Specific error");
    }
    

    // Вызов какого-либо кода
}
catch (Exception exception)
{
    log.LogError(exception, "General erro");
}

Как вы, наверное, уже догадались, внутренний try/catch перехватывает, регистрирует и проглатывает исключение. Чтобы пробросить SpecificException в глобальный блок catch для его обработки, вам нужно пробросить его в стек. Вы можете сделать следующее:

catch (SpecificException specificException)
{
    // ...
    throw specificException;
}

Или так:

catch (SpecificException specificException)
{
    // ...
    throw;
}

Основное отличие здесь состоит в том, что в первом примере повторно генерируется SpecificException, что приводит к сбросу стек трейса исходного исключения, в то время как второй пример сохраняют все детали исходного исключения. Почти всегда предпочтительнее использовать второй пример.

Декорируйте исключения

Я достаточно редко вижу реализацию этой рекомендации на практике. Все исключения расширяют Exception, в котором есть словарь Data. Словарь можно использовать для включения дополнительной информации об ошибке. Отображается ли эта информация в вашем логе, зависит от того, какой фреймворк логирования и хранилище вы используете. В elmah.io записи Data отображаются на вкладке Data.

Информацию в словарь Data вносится посредством добавьте пар ключ/значение:

var exception = new Exception("En error happened");
exception.Data.Add("user", Thread.CurrentPrincipal.Identity.Name);
throw exception;

В этом примере я добавляю ключ с именем user с потенциальным именем пользователя, хранящимся в потоке.

Вы также можете декорировать исключения, сгенерированные сторонним кодом. Добавьте try/catch:

try
{
    service.SomeCall();
}
catch (Exception e)
{
    e.Data.Add("user", Thread.CurrentPrincipal.Identity.Name);
    throw;
}

Код перехватывает любые исключения, генерируемые методом SomeCall, и добавляет в них имя пользователя. Посредством добавления ключевого слова throw в блок catch исходное исключение пробрасывается дальше по стеку.

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

Вероятнее всего, у вас есть где-то код, похожий на этот:

try
{
    File.WriteAllText(path, contents);
}
catch (Exception e)
{
    logger.Error(e);
}

Простой перехват Exception и логирование его в предпочитаемом фреймворке быстро реализуются и справляются со своей задачей. Большинство библиотек, доступных в .NET, могут генерировать ряд различных исключений, и у вас может даже уже быть похожий шаблон в вашей кодовой базе. Перехват нескольких исключений в диапазоне от наиболее до наименее специфической ошибки — отличный способ определить, как вы хотите обрабатывать каждый конкретный тип исключения.

В следующем примере я четко демонстрирую понимание, какие исключения следует ожидать и как поступать с каждым конкретным типом:

try
{
    File.WriteAllText(path, contents);
}
catch (ArgumentException ae)
{
    Message.Show("Invalid path");
}
catch (DirectoryNotFoundException dnfe)
{
    Message.Show("Directory not found");
}
catch (Exception e)
{
    var supportId = Guid.NewGuid();
    e.Data.Add("Support id", supportId);
    logger.Error(e);
    Message.Show($"Please contact support with id: {supportId}");
}

Перехватывая ArgumentException и DirectoryNotFoundException перед перехватом общего Exception, я могу показать пользователю специализированное сообщение. В этих сценариях я не регистрирую исключение, поскольку пользователь может быстро исправить ошибки. В случае Exception я генерирую support id, регистрирую ошибку (используя декораторы, как показано в предыдущем разделе) и показываю сообщение пользователю.

Обратите внимание, что, хотя приведенный выше код служит для объяснения порядка обработки исключений, реализация потока управления, используя исключения подобным образом — практика не очень хорошая. Это прекрасная подводка к следующему совету:

Старайтесь избегать исключений

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

Одно из самых распространенных исключений — NullReferenceException. В некоторых случаях вы можете разрешить null, но забыть проверить на null. Вот пример, который генерирует NullReferenceException:

Address a = null;
var city = a.City;

Доступ к a выбрасывает исключение. Хорошо, но представьте, что a предоставляется в качестве параметра.

Если вы хотите разрешить city с нулевым значением, вы можете избежать исключения, используя null-condition оператор:

Address a = null;
var city = a?.City;

Добавляя ? при доступе к a C# автоматически обрабатывает сценарий, в котором адрес равен null. В этом случае переменной city будет присвоено значение null.

Другой распространенный пример исключений — это анализ чисел или логических значений. В следующем примере будет сгенерировано FormatException:

var i = int.Parse("invalid");

Строка invalid не может быть распаршена в виде целого числа. Чтобы не оборачивать это в try/catch, int предоставляет интересный метод, который вы, вероятно, уже использовали 1000 раз:

if (int.TryParse("invalid", out int i))
{
}

В случае, если invalid может быть распаршена как int, TryParse возвращает true и помещает распаршенное значение в переменную i. Еще одно исключение удалось избежать.

Создавайте пользовательские исключения

Забавно вспоминать, как я был Java-программистом (когда .NET находился в стадии бета-тестирования). Мы создавали собственные пользовательские исключения для всего чего угодно. Возможно, это происходило из-за более явной реализации исключений в Java, но я не вижу этого в .NET и C#. Создавая пользовательское исключение, у вас гораздо больше возможностей для перехвата определенных исключений, как уже было показано. Вы можете декорировать свое исключение пользовательскими переменными, не беспокоясь о том, поддерживает ли ваш логгер словарь Data:

public class MyVerySpecializedException : Exception
{
    public MyVerySpecializedException() : base() {}
    public MyVerySpecializedException(string message) : base(message) {}
    public MyVerySpecializedException(string message, Exception inner) : base(message, inner) {}
    
    public int Status { get; set; }
}

Класс MyVerySpecializedException (возможно, это не то имя класса, которое вы должны использовать в качестве примера :D) реализует три конструктора, которые должен иметь каждый класс исключения. Кроме того, я добавил свойство Status в качестве примера дополнительных данных. Это позволит нам написать такой код:

try
{
    service.SomeCall();
}
catch (MyVerySpecializedException e) when (e.Status == 500)
{
    // Do something specific for Status 500
}
catch (MyVerySpecializedException ex)
{
    // Do something general
}

Используя ключевое слово when, я могу перехватить MyVerySpecializedException, когда значение свойства Status равно 500. Все остальные сценарии попадут в общий catch MyVerySpecializedException.

Логируйте исключения

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

try
{
    service.SomeCall();
}
catch
{
    // Игнорируется
}

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

Существует несколько отличных фреймворков для ведения логов, таких как NLog и Serilog. Если вы веб-разработчик ASP.NET (Core), запись неперехваченных исключений может выполняться автоматически с помощью elmah.io или одного из других доступных инструментов.


Узнать подробнее о курсе "C# Developer. Professional".

Смотреть вебинар «DI-контейнеры для C#».

OTUS
Цифровые навыки от ведущих экспертов

Комментарии 15

    +1
    Что на счет повторной обработки исключения? И ретраев?
    Например, у меня реализована какая-то пайплайна, которая умеет обрабатывать внутренние ексепшены, сохранять стейт и т.д. и т.п. Уровнем выше, скажем, уже на вью уровне, есть тоже какой-то простой глобальный обработчик, так вот как определить, что «ошибка вообще ошибка, что-то пошло плохо, звоните админам» или что «ну бывает, мы вот где-то там внутри ожидали такое и сами позаботимся». Скажем, валидно при тротлинге.

    И на счет ретраев, хорошая ли практика добавлять в свои кастомные ексепшены поле типа «IsRetryable»? Опять же как пример — тротлинг со стороны БД или какого-то сервиса. И уровнем выше пытаться повторить операцию в зависимости от флага. Или же это уже логика консюмера и сам консюмер должен знать все ексепшены, которые надо ретраить?
      +3
      Почти всегда предпочтительнее использовать второй пример.

      Сейчас уже предпочтительнее использовать exception filters.

        0
        Это не всегда годится: иногда требуется частичная обработка исключения с пробросом его дальше.
        Например, есть код типа такого:
        public class DisposableClass : IDisposable
        {
            public DisposableClass()
            {
                //Some initialization...
            }
            public SomeType Property { get; set; }
            public void Dispose()
            {
                //Do something...   
            }
        }
        
        public class SomeType { /*Some code*/}
        class Someclass
        {
            //... Some code
            public DisposableClass Method1()
            {
                DisposableClass result = new DisposableClass();
                try
                {
                    result.Property = new SomeType();
                }
                catch 
                {
                    result.Dispose(); //Надо выполнить очистку здесь, потому что больше негде.
                    throw;
                }
                return result;
            }
        }

        То есть, метод создает объект типа, для которого нужна очистка (т.е. он реализует IDisposable) и сохраняет его в локальной переменной, и планирует его вернуть — но что-то пошло не так.
        Если очистку здесь не выполнить, то ссылка на объект потеряется и он будет болтаться до сборки мусора, что, по-видимому, не согласуется с намерениями программиста.
        Ну, или в самом первом примере автора статьи: он реализует особую обработку, а фильтр исключений не позволит выполнить вообще никакой обработки.
          +1
          Ну, или в самом первом примере автора статьи: он реализует особую обработку, а фильтр исключений не позволит выполнить вообще никакой обработки.

          Я там вижу только логирование. Сделать логирование на фильтрах исключений — не проблема.


          Прямо скажем, я подозреваю, что я и disposal смогу на нем написать, но это уже не будет так хорошо читаться.

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

              Если вы правильно напишете "условие" (а именно вынесете его в отдельную локальную функцию), вероятность такого события не выше, чем вероятность ошибки в catch.

                +1
                А потом, чтобы далеко не лазить, кто-нибудь допишет к условию спереди какое-нибудь простое-простое дополнение через && или ||…
                Не, нафиг-нафиг… Хотя, если привыкнуть всегда ожидать пакости — то можно, наверное.
                  +1
                  А потом, чтобы далеко не лазить, кто-нибудь допишет к условию спереди какое-нибудь простое-простое дополнение через && или ||…

                  … а потом, чтобы далеко не лазить, кто-нибудь допишет условие к вашему catch. Того же уровня проблема.

        +1
        Занятно, но зачем документацию работы с исключениями из официальной документации перепечатывать, тем более в урезанном виде?
        И, обычно, стратегия обработки и работы с иключениями это часть дизайна решения. Почему бы сюда не включить «за» и «против» в предложеные стратегии? Например для «Может показаться очевидным, что нужно избегать исключений.» Почему? и т.д.
          +1
          Согласен. Со статьями нужно работать, а не просто писать чтобы было.

          По поводу исключений нужно начать статью с того факта, что исключения это крайне медленный механизм и всеми силами нужно их избегать. А то я видел проекты на C# где проверка бизнес логики была на исключениях…
            0
            По поводу исключений нужно начать статью с того факта, что исключения это крайне медленный механизм и всеми силами нужно их избегать.

            Мы же можем скачать себе на компьютер код бенчмарка демонстрирующее такое поведение? Реализованного на разных языках и платформах?
            Я помню разбор этот теста еще для C\C++ лет 20 назад, с обоснованиями. С тех пор ничего нового не видел для новых реализации CPU\OS\ и т.д.
            Из последнего что встречал Коды ошибок — это гораздо медленнее, чем исключения
              0
              Я помню разбор этот теста еще для C\C++ лет 20 назад, с обоснованиями. С тех пор ничего нового не видел для новых реализации CPU\OS\ и т.д.

              Отличие исключений в C++ от исключений в C# — в последнем случае сохраняется stacktrace, и это ОЧЕНЬ медленно. Поэтому исключения в C# стоит выбрасывать в действительно исключительных ситуациях, но не в штатных. Например, неправильный пользовательский ввод — это не исключение.


              Из последнего что встречал Коды ошибок — это гораздо медленнее, чем исключения

              Вы саму статью-то читали или только обратили внимание на жёлтый заголовок? Во-первых, речь о С++, а во-вторых, там сравнивается время работы кода в случае его успешного выполнения:


              На 64-битных архитектурах включение использования исключений приводит к замедлению кода примерно на 1% в сравнении с кодом, который просто останавливается при возникновении ошибки. Коды ошибок, обычная альтернатива исключениям, используемая для обработки ошибок, после возникновения которых работу можно продолжить, снижают производительность примерно на 5%.


              Ну да, 5% по сравнению с 1% выше в 5 раз. Вот только 5% — величина на уровне погрешности, для которой слово "гораздо" не применимо совершенно.

                0
                Отличие исключений в C++ от исключений в C# — в последнем случае сохраняется stacktrace, и это ОЧЕНЬ медленно.

                Какие операции подразумеваются под «это»? и по сравнению с чем «ОЧЕНЬ медленно»?
                Вы сравниваете С++ и .Net среду исполнения? или мы сравниваем внутри одного и того-же C++\С#\Java возврат кодов ошибок и исключений?
                Если следовать спецификации «Common Language Infrastructure (CLI) Partition I Concepts and Architecture», то нет какого-то условия создавать и хранить стек вызовов или нет. Он есть всегда. т.е. накладные расходы «константа», отличие в накладных расходах на «protected block», но, опять же они тоже константа, потому что «finally» тоже часть фильтра входящая в «protected block». Исходя из того, что работа делается одна и та же не должно быть разницы возвращать результат через throw или return, за исключеним накладынх расходов исполнения throw. С учетом того, что throw это не нормальное явление и редкое, то им можно пренебречь, особенно на фоне обработки и логирования самого Exception.
                Для С++ (давно не занимался) можно было при компиляции указать объем отладочной информации в исполняемом коде, и CaptureStackBackTrace (Captures a stack back trace by walking up the stack and recording the information for each frame.) часть Kernel32 Header: WinBase.h (include Windows.h) и получить стек вызова функции возможно.
                12.4.2.5 Overview of exception handling

                Each method in an executable has associated with it a (possibly empty) array of exception handling information. Each entry in the array describes a protected block, its filter, and its handler (which shall be a catch handler, a filter handler, a finally handler, or a fault· Protects a region including the current instruction pointer and handler). When an exception occurs, the CLI searches the array for the first protected block that
                · Is a catch handler block and
                · Whose filter wishes to handle the exception
                If a match is not found in the current method, the calling method is searched, and so on. If no match is found the CLI will dump a stack trace and abort the program.
                [Note: A debugger can intervene and treat this situation like a breakpoint, before performing any stack unwinding, so that the stack is still available for inspection through the debugger. end note]
                If a match is found, the CLI walks the stack back to the point just located, but this time calling the finally and fault handlers. It then starts the corresponding exception handler. Stack frames are discarded either as this second walk occurs or after the handler completes, depending on information in the exception handler array entry associated with the handling block.
                ...
                Protected regions, the type of the associated handler, and the location of the associated handler and (if needed) user-supplied filter code are described through an Exception Handler Table associated with each method.


                Так что ответ на вопрос «есть ли тест который можно запустить и посмотреть результаты» для актуальных реализаций железа и ОС — интересен. То, что возможно придется получить ошибку предсказания исполнения с CPU в результате исключения и как результат просадку производительности — соглашусь. Останутся пенальти промаха предвыборки данных и не попадания в кеши процессора — возможно. Научится ли предсказатель предсказывать ветку «исключения» как наиболее вероятную в результате такой стратеги? Сохранятся ли кеши и предвыборка данных?
          +1
          >Забавно вспоминать, как я был Java-программистом (когда .NET находился в стадии бета-тестирования). Мы создавали собственные пользовательские исключения для всего чего угодно. Возможно, это происходило из-за более явной реализации исключений в Java, но я не вижу этого в .NET и C#<
          Потому что исключения в c# изначально были реализованы через механизм SEH в винде, который обладает следующей особенностью: он дешев для установки, но дорог для выполнения. А в Java исключения идут как дополнительный код ошибки, у каждого метода есть исключения который внешний код должен обработать
            0
            Почти всегда предпочтительнее использовать второй пример.
            Мне больше нравится выкидывать новое исключение, оставляя исходное в InnerException.

            Thread.CurrentPrincipal
            Опасно с большой популярностью асинхронного кода. Возможно, лучше ClaimsPrincipal.Current, но с Principal я серьёзно не сталкивался. UPD нашёл в документации ms, что в asp.net core ClaimsPrincipal.Current тоже уже устарел и выброшен.

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

            Самое читаемое