четверг, 3 ноября 2011 г.

Повторная генерация исключений

Обработка исключений появилась в mainstream языках программирования вот уже более трех десятилетий назад, но сегодня все еще можно встретить разработчиков, которые боятся их использовать. Некоторые считают, что генерация исключений в конструкторе повредит их хрупкой карме и их настигнет кара в виде поддержки кода двадцатилетней давности, написанного стадом безумных индусов. Некоторые все еще застряли в эпохе языка С и даже в языке C# интенсивно используют коды возврата в виде магических чисел или даже строк, считая, что исключения придумали трусы, а настоящие самураи могут обойтись и без них. (Хотя мы-то с вами знаем, что настоящие самураи следуют “Принципу самурая” и никаких кодов возврата не используют).

Дополнительную сложность добавляют конкретные платформы и языки программирования. Сегодня на собеседовании C# разработчика, когда речь заходит об обработке исключений, обязательно прозвучит вопрос: “А в чем отличие “проброса” исключения с помощью конструкций throw; и throw ex;?”. И хотя, это страшный баян и большинство разработчиков давно знают правильный ответ на этот вопрос, в реальном коде встретить “некошерный” вариант очень даже просто.

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

Давайте рассмотрим такой пример. Предположим у нас есть кастомный класс исключения по имени CustomException, а также простой класс SampleClass, конструктор которого генерирует это самое исключение.

// Простое кастомное исключение, чтобы было, что перехватывать
class CustomException : Exception { }
 
class SampleClass
{     
// Совершенно бесполезный класс,    
// конструктор которого только и делает, что бросает исключение    
public SampleClass() { throw new CustomException(); } }

Теперь давайте создадим объект этого класса с помощью generic метода, а также сделаем синглтон этого класса:

// Простой фабричный метод, создающий экземпляр объекта
public static T CreateInstance<T>() where T : new()
{
     return new T(); } // Простой класс синглтона class SampleClassSingleton {
     private static SampleClass _instance = new SampleClass();
     static SampleClassSingleton() { }
     public static SampleClass Instance { get { return _instance; } } }

Вопрос в следующем, какой блок catch будет выполнен при вызове метода CreateInstance<SampleClass>() или при обращении к SampleClassSingleton.Instance?

try
{
     CreateInstance<SampleClass>();
     // или
     var instance = SampleClassSingleton.Instance; } catch (CustomException e) {
     Console.WriteLine(e); } catch (Exception e) {
     Console.WriteLine(e); }

Не думаю, что для кого-то этот код будет большим откровением, но в обоих случаях “ожидаемый” блок catch(CustomException e) выполнен не будет, вместо этого будет выполнен блок catch(Exception e).

В первом случае (т.е. при использовании generic метода CreateInstance) на самом деле конструктор по умолчанию не вызывается напрямую, вместо этого используется Activator.CreateInstance, который оборачивает исходное исключение в TargetInvocationException. Именно поэтому срабатывает блок catch(Exception e), поскольку TargetInvocationException никак не подходит нашему первому блоку обработки исключений.

ПРЕДУПРЕЖДЕНИЕ
Не пишите в логи только e.Message, поскольку когда ляжет ваш продакш сервер от этой информации вам будет ни холодно ни жарко, поскольку наиболее ценная информация может таиться в одном из вложенных исключений. Даже в составе .Net Framework существует множество мест, которые оборачивают исходное исключение и пробрасывают его в качестве вложенного, ни говоря уже за кастомный код, который может обернуть нужное вам исключение в десяток вложенных.

При использовании синглтона, ситуация аналогична: поскольку инициализация поля _instance происходит в статическом конструкторе, то любые исключения, которые происходят во время его выполнения делают тип «невалидным». В результате, все последующие обращения к этому типу приводят к генерации исключения TypeLoadException с соответствующим вложенным исключением

ПРИМЕЧАНИЕ
Подробнее о том, для чего нужен пустой статический конструктор и к каким проблемам может привести его отсутствие см. О синглтонах и статических конструторах.

Поведение в обоих случаях является хорошо документированным и известным, так что если когда-то его могли считать багом, то теперь это уже фича. Но исправить эту ситуацию, к сожалению, мы не можем. Даже если мы перехватим исключение в методе CreateInstance и постараемся пробросить вложенное исключение, то мы потеряем стек вызовов, что сделает результирующее исключение значительно менее полезным:

public static T CreateInstance<T>() where T : new()
{
     try
     {
         return new T();
     }
     catch (TargetInvocationException e)
     {
         // Исходный стек вызовов потерян, теперь все будут думать, что виноваты мы!
         throw e.InnerException;
     } }

Аналогичная проблема ждет нас, если мы попробуем сохранить исключение при создании экземпляра _instance и будем пробрасывать его при обращении к свойству Instance. (Да, отложенная инициализация решит эту конкретную проблему, но сейчас речь не об этом).

Однако в .Net Framewor 4.5 появился класс, способный помочь в решении этой проблемы. Это класс ExceptionDispatchInfo, способный сохранить исходное исключение и потом пробросить его заново не теряя информацию о стеке вызовов. Конечно, основной смысл его применения не связан с “выпрямлением” статических конструкторов или метода Activator.CreateInstance. Главная его задача заключается в решении вопросов асинхронности и многопоточности, когда исключение происходит в одном потоке, а пробрасывается в другом.

Давайте рассмотрим следующий код:

Task<int> task = Task<int>.Factory.StartNew(() => { throw new CustomException(); });
try
{
     int result = task.Result; } catch (CustomException e) {
     // Неа, сюда нам с вами не попасть:(
     Console.WriteLine("CustomException caught: " + e); } catch (AggregateException e) {
     // А вот и наше исходное исключение!
     var inner = e.GetBaseException();
     Console.WriteLine("Aggregate exception caught: " + inner); }

Если “задача” падает с исключением, то исходное исключение (в нашем случае CustomException) будет завернуто в AggregateException. Причин такому поведению несколько: во-первых, хотя задача по своей природе представляет собой оболочку над некоторой длительной асинхронной операцией с единственным результатом, в некоторых случаях результат одной задачи может основываться на результатах параллельного выполнения нескольких задач. Например, мы можем объединить несколько задач в одну с помощью Task.WaitAll или мы можем связать несколько задач с помощью продолжений. Второй причиной такого поведения является невозможность проброса исходного исключения без искажения его стека вызовов.

Однако после появления в языке C# 5.0 новых возможностей по работе с асинхронностью, приоритеты несколько изменились. Одной из главных фич ключевых слов await и async является простота преобразования синхронного кода в асинхронный, но помимо «выпрямления» потока исполнения (который даже в случае применение задач оставляет желать лучшего), должна быть решена и задача обработки исключений. Поэтому при получении результатов задачи с помощью ключевого слова await, исходное AggregateException разворачивается и пробрасывается исходное исключение:

public static async void SimpleTask()
{
     Task<int> task = Task<int>.Factory.StartNew(() => { throw new CustomException(); });
     try
     {
         // await «разворачивает» исходное исключение сгенерированное внутри задачи
         // и пробрасывает именно его, а не AggregateException!
        int result = await task;
     }
     catch (CustomException e)
     {
         // Теперь вызывается этот обработчик, как и в случае синхронного вызова
         Console.WriteLine("CustomException caught: " + e);
     } }

ПРИМЕЧАНИЕ
Если вы не знакомы такими фичами языка C# 5.0, как async и await, то восполнить этот пробел можно с помощью статьи:
Асинхронные операции в C# 5

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

Для начала давайте для класса Task<T> создадим класс с методом расширения GetResult, который будет очень похож на свойство Result, но будет «выпрямлять» AggregateException и пробрасывать вложенное исключение без потери стека вызовов.

ПРИМЕЧАНИЕ
Такое поведение уже реализовано в составе .Net Framework 4.5 с помощью Task<T>.GetAwaiter().GetResult(), но давайте забудем об этом и сделаем тоже самое самостоятельно.

Пользоваться классом ExceptionDispatchInfo довольно просто: для этого достаточно захватить исключение в одном месте с помощью статического метода Capture, а затем пробросить это исключение в другом месте (и, возможно даже в другом потоке) с помощью метода Throw.

static class TaskExtensions
{
     public static T GetResult<T>(this Task<T> task)
     {
         try
         {
             T result = task.Result;
             return result;
         }
         catch (AggregateException e)
         {
             ExceptionDispatchInfo di = ExceptionDispatchInfo.Capture(e.InnerException);
             di.Throw();
             return default(T);
         }
     } }

Теперь, если изменить предыдущий фрагмент кода и заменить task.Result на вызов метода Task.GetResult, то мы сможем перехватывать конкретный тип исключения вместо исключения AggregateException.

Task<int> task = Task<int>.Factory.StartNew(() => { throw new CustomException(); });
try
{
     int result = task.GetResult(); } catch (CustomException e) {
     // Теперь мы можем перехватывать CustomException, а не AggregateException
     Console.WriteLine("CustomException caught: " + e); }

Аналогичным образом можно изменить наш метод CreateInstance, который будет перехватывать исключение TargetInvocationException и пробрасывать вложенное исключение:

public static T CreateInstance<T>() where T : new()
{
     try
     {
         var t = new T();
         return t;
     }
     catch (TargetInvocationException e)
     {
         // Захватываем вложенное исключение в ExceptionDispatchInfo
         ExceptionDispatchInfo di = ExceptionDispatchInfo.Capture(e.InnerException);
         // Пробрасываем это исключение с сохранением всей информации
         di.Throw();
         // Компилятор не знает, что di.Throws() всегда генерит исключение, поэтому
         // без этой строки кода мы получим ошибку компиляции, что метод не всегда
         // возвращает результат
         return default(T);
     } }

Теперь, при попытке вызвать этот метод из функции Main мы получим вразумительное исключение с нормальным стеком исполнения:

ConsoleApplication1.CustomException: Exception of type 'ConsoleApplication1.CustomException' was thrown.
at ConsoleApplication1.SampleClass..ctor() in
c:\Projects\ConsoleApplication1\ConsoleApplication1\Program.cs:line 19
--- End of stack trace from previous location where exception was thrown ---
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
at ConsoleApplication1.Program.CreateInstance[T]() in c
\Projects\ConsoleApplication1\ConsoleApplication1\Program.cs:
line 50
at ConsoleApplication1.Program.Main(String[] args) in c:\ \Projects\ConsoleApplication1\ConsoleApplication1\Program.cs:line 63

Класс синглтона можно реализовать подобным образом.

Класс ExceptionDispatchInfo едва ли будет киллер-фичей новой версии .Net Framework, однако с наступлением эры асинхронности у него точно найдется достойная область применения. Так, например, он уже используется в библиотеке реактивных расширений для реализации паттерна await (эта реализация доступна только в экспериментальном релизе) и может использоваться всеми, кто захочет реализовать этот паттерн самостоятельно.

4 комментария:

  1. Очень интересная статья! Сергей, это здорово что ты пишешь про .НЕТ 4.5, будет намного проще вникнуть, как только это чудо таки выйдет.

    ОтветитьУдалить
  2. Это да, всегда интересно быть уже во всеоружии, когда выходит новая версия. Тогда и обосновать переход на нее проще, да и кривая обучения сглаживается очень здорово.

    ОтветитьУдалить
  3. На самом деле, существовало, просто в виде недокументированного хака. Тем не менее работает и до 4.5, и с успехом применялось для тестов (в production-код все-таки боязно такие хаки нести):

    private static void PreserveStackTrace(Exception exception)
    {
    MethodInfo preserveStackTrace = typeof(Exception).GetMethod("InternalPreserveStackTrace", BindingFlags.Instance | BindingFlags.NonPublic);
    preserveStackTrace.Invoke(exception, null);
    }

    Ну и где-то глубже, пусть для тех же тасков:
    catch (AggregateException e) {
    var f = e.InnerExceptions.First();
    PreserveStackTrace(f);
    throw f;
    }

    f будет брошен с оригинальным стек трейсом, а текущий будет утерян.

    ОтветитьУдалить
  4. @Ivan: спасибо. Насколько я понимаю, именно этот трюк использовался в Rx-ах еще до выхода .NET 4.5 для проброса исключений с сохранением стек-трейса.

    ОтветитьУдалить