В рамках скорого старта курса "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#».
pshhpshh
Что на счет повторной обработки исключения? И ретраев?
Например, у меня реализована какая-то пайплайна, которая умеет обрабатывать внутренние ексепшены, сохранять стейт и т.д. и т.п. Уровнем выше, скажем, уже на вью уровне, есть тоже какой-то простой глобальный обработчик, так вот как определить, что «ошибка вообще ошибка, что-то пошло плохо, звоните админам» или что «ну бывает, мы вот где-то там внутри ожидали такое и сами позаботимся». Скажем, валидно при тротлинге.
И на счет ретраев, хорошая ли практика добавлять в свои кастомные ексепшены поле типа «IsRetryable»? Опять же как пример — тротлинг со стороны БД или какого-то сервиса. И уровнем выше пытаться повторить операцию в зависимости от флага. Или же это уже логика консюмера и сам консюмер должен знать все ексепшены, которые надо ретраить?