Обработка ошибок в C#: от try/catch к профессиональным техникам
В мире промышленной разработки на C# и .NET обработка ошибок — это не просто конструкция try/catch. Это целая философия, определяющая надёжность, читаемость и отказоустойчивость вашего кода. На продвинутом уровне разработчик должен не просто ловить исключения, но и грамотно их проектировать, фильтровать, логировать и восстанавливать состояние приложения. В этой статье мы разберём ключевые аспекты обработки ошибок в C#, которые помогут вам писать более качественный и профессиональный код.
1. Глубже в try/catch/finally: управление ресурсами и контекстом
Базовая конструкция try-catch-finally знакома каждому, но давайте посмотрим на неё под другим углом. Главная цель блока finally — гарантированное освобождение ресурсов, даже если произошло исключение. В современных версиях C# для этого чаще используют using, но finally остаётся незаменимым при работе с неуправляемыми ресурсами или сложной логикой завершения.
public void ProcessFile(string path)
{
FileStream? fileStream = null;
try
{
fileStream = File.OpenRead(path);
// Логика обработки файла
}
catch (FileNotFoundException ex)
{
// Специфичная обработка: файл не найден
Log.Error($"Файл {path} не найден: {ex.Message}");
throw; // Пробрасываем исключение дальше, сохраняя стек вызовов
}
catch (UnauthorizedAccessException)
{
Log.Warn($"Нет доступа к файлу: {path}");
// Можно выполнить альтернативное действие, не пробрасывая исключение
return;
}
finally
{
// Гарантированное освобождение ресурса
fileStream?.Dispose();
}
}
Обратите внимание на использование throw; (без аргумента) — это ключевой момент. В отличие от throw ex;, конструкция throw; сохраняет оригинальный стек вызовов, что критически важно для отладки. Никогда не используйте throw ex; в реальных проектах, так как это уничтожает информацию о месте возникновения исходной ошибки.
2. Пользовательские исключения: проектируем доменную модель ошибок
В больших приложениях стандартных исключений .NET часто недостаточно. Создание собственных классов исключений позволяет точно передавать контекст ошибки и упрощает обработку на верхних уровнях. Хорошее пользовательское исключение должно быть сериализуемым, содержать осмысленное сообщение и нести дополнительную информацию (например, идентификатор сущности).
[Serializable]
public class OrderProcessingException : Exception
{
public int OrderId { get; }
public string FaultyStep { get; }
public OrderProcessingException(int orderId, string faultyStep, string message)
: base(message)
{
OrderId = orderId;
FaultyStep = faultyStep;
}
public OrderProcessingException(int orderId, string faultyStep, string message, Exception inner)
: base(message, inner)
{
OrderId = orderId;
FaultyStep = faultyStep;
}
// Конструктор для десериализации
protected OrderProcessingException(
System.Runtime.Serialization.SerializationInfo info,
System.Runtime.Serialization.StreamingContext context) : base(info, context)
{
OrderId = info.GetInt32(nameof(OrderId));
FaultyStep = info.GetString(nameof(FaultyStep)) ?? string.Empty;
}
public override void GetObjectData(System.Runtime.Serialization.SerializationInfo info, System.Runtime.Serialization.StreamingContext context)
{
base.GetObjectData(info, context);
info.AddValue(nameof(OrderId), OrderId);
info.AddValue(nameof(FaultyStep), FaultyStep);
}
}
// Использование
public void ValidateOrder(int orderId)
{
try
{
// Логика валидации
}
catch (InvalidOperationException ex)
{
throw new OrderProcessingException(orderId, "Validation", "Ошибка валидации заказа", ex);
}
}
Правило: наследуйтесь от Exception (или одного из его подклассов, например InvalidOperationException), делайте класс сериализуемым и всегда предоставляйте минимум три конструктора: без параметров, с сообщением, с сообщением и внутренним исключением.
3. Фильтры исключений (Exception Filters) и when
Фильтры исключений — мощная, но часто недооценённая возможность C# (доступна с C# 6). Ключевое слово when позволяет задать условие, при котором блок catch будет выполнен. Это избавляет от необходимости повторно пробрасывать исключения внутри catch и делает код чище.
public void ProcessPayment(decimal amount)
{
try
{
// Вызов внешнего платёжного шлюза
PaymentGateway.Charge(amount);
}
catch (PaymentException ex) when (ex.ErrorCode == "INSUFFICIENT_FUNDS")
{
// Логика для недостатка средств
Log.Warn($"Недостаточно средств для оплаты {amount}");
InitiateRefund();
}
catch (PaymentException ex) when (ex.ErrorCode == "TIMEOUT")
{
// Логика для таймаута — можно повторить попытку
Log.Warn($"Таймаут при оплате {amount}, повторяем...");
RetryPayment(amount);
}
catch (PaymentException ex) when (ex.ErrorCode.StartsWith("AUTH_"))
{
// Ошибки авторизации — логируем и пробрасываем дальше
Log.Error($"Ошибка авторизации: {ex.ErrorCode}");
throw; // Фильтр сработал, блок catch выполнен, но мы можем пробросить
}
// Общий catch для всех остальных PaymentException
catch (PaymentException ex)
{
Log.Error($"Неизвестная ошибка оплаты: {ex.Message}");
throw;
}
}
Фильтры особенно полезны при логировании: вы можете поймать исключение, залогировать его и, если условие не выполнено, позволить исключению "пролететь" дальше без повторного проброса. Это снижает нагрузку на стек и упрощает отладку.
4. Best Practices и антипаттерны обработки ошибок
На продвинутом уровне важно не только знать, как ловить исключения, но и понимать, когда этого делать не стоит. Вот несколько ключевых принципов:
- Не используйте исключения для управления потоком. Исключения — это дорогостоящая операция. Для ожидаемых сценариев (например, отсутствие элемента в коллекции) используйте возвращаемые значения или паттерн
TryParse. - Ловите исключения на правильном уровне. Нижние слои (репозитории, сервисы) должны пробрасывать исключения вверх. Обрабатывайте их на границе модулей (контр