Wednesday, January 8, 2014

Обработка ошибок в C#

Статья посвящена нестандартным способам обработки ошибок, а также превентировании некоторых видов ошибок при программировании на языке C#. Первой ошибкой рассмотрим пример, приведённый в одной из прошлых статей "Тонкости при использовании событий и делегатов" при генерации ошибки в цепочке событий. Если Вы не знакомы с принципом работы событий и делегатов, для понимания продемонстрированного решения рекомендую ознакомиться со статьями "Использование делегатов в C#" и "Использование событий с C#". Рассмотрим пример подписки нескольких обработчиков событий, один из которых генерирует исключение.
class Program
{
    static void Main(string[] args)
    {
        var p = new Program();
        p.CreatePublisher();
        Console.ReadLine();
    }
    public void CreatePublisher()
    {
        try
        {
            var publisher = new Publisher();
            publisher.OnPublish += (sender, args) => Console.WriteLine("Publisher 1");
            publisher.OnPublish += (sender, args) => Console.WriteLine("Publisher 2");
            publisher.OnPublish += (sender, args) =>
                {
                    throw new Exception("Throw publisher exception");
                };
            publisher.OnPublish += (sender, args) => Console.WriteLine("Publisher 3");
            publisher.Raise();
        }
        catch (Exception exception)
        {
            Console.WriteLine(exception);
        }
    }
}
public class Publisher
{
    public event EventHandler OnPublish = delegate { };
    public void Raise()
    {
        OnPublish(this, new EventArgs());
    }
}
Выше приведен пример кода, в котором в одном из цепочки событий генерируется exception. Возникает вопрос: будет ли продолжено выполнение кода после того, как мы перехватим это исключение? Если Вы предполагали, что будет вызвано три подписчика событий, один из которых перехвачен и вызывает исключение, то у меня для вас плохая новость. Если вызванный метод выбрасывает исключение, выполнение этого метода прекращается, исключение передается обратно коду, вызвавшему делегат, – и оставшиеся в списке вызовов методы не вызываются. Перехват исключения в вызывающем коде не меняет этого поведения. Чтобы как-то изменить это поведение, есть несколько вариантов: поискать готовое решение, которое может основываться на замене событий, например, использование паттерна "Publisher->Subscriber", либо использование классического паттерна "Наблюдатель" (Observer). 
К счастью, решение без использования паттернов, позволяющее решить данную проблему (правда, не столь элегантное), существует. Решение данной проблемы основано на использовании метода Delegate.GetInvocationList, который возвращает массив делегатов, представляющих список вызовов текущего делегата. Поскольку событие (event) – это обертка над MulticastDelegate, благодаря ключевому слову event, у события имеются некоторые дополнительные возможности (о них можно ознакомиться в статьях, названия которых приведены выше). Рассмотрим, как будет выглядеть наше решение.
class Program
{
    static void Main(string[] args)
    {
        var p = new Program();
        p.CreatePublisher();
        Console.ReadLine();
    }
    public void CreatePublisher()
    {
        try
        {
            var publisher = new Publisher();
            publisher.OnPublish += (sender, args) => Console.WriteLine("Publisher 1");
            publisher.OnPublish += (sender, args) => Console.WriteLine("Publisher 2");
            publisher.OnPublish += (sender, args) =>
                {
                    throw new Exception("Throw publisher exception");
                };
            publisher.OnPublish += (sender, args) => Console.WriteLine("Publisher 3");
            publisher.Raise();
        }
        catch (Exception exception)
        {
            Console.WriteLine(exception);
        }
    }
}
public class Publisher
{
    public event EventHandler OnPublish = delegate { };
    public void Raise()
    {
        var exceptions = new List<Exception>();
        foreach (var handler in OnPublish.GetInvocationList())
        {
            try
            {
                handler.DynamicInvoke(this, new EventArgs());
            }
            catch (Exception ex)
            {
                exceptions.Add(ex);
            }
        }
        if (exceptions.Any())
        {
            throw new AggregateException(exceptions);
        }
    }
}
Это способ обработки всех событий без прерывания цепочки событий.

Try/finally block
Наверное, все знают об использовании блока try/catch для перехвата ошибок при выполнении программы. Еще одной важной особенностью обработки исключений является возможность указать, что определенный код всегда должен быть запущен в случае исключения. Это может быть сделано с помощью блока finally вместе с блоками try или try/catch. Можем ли мы быть уверены в том, что блок finally выполнится всегда? Рассмотрим пример использования данного блока.
private static void Main(string[] args)
{
    try
    {
        var zero = 0;
        var a = 2/zero;
    }
    catch (DivideByZeroException exception)
    {
        Console.WriteLine(exception);
    }
    finally
    {
        Console.WriteLine("Finally block");
    }
    Console.ReadLine();
}
Это слишком надуманный пример. Обычно блок finally используют, чтобы освободить ресурсы, используемые программой, сделать отписку от событий и т.д. Рассмотрим пример, который Вы, вероятно, встречали не раз или видели похожий способ очистки ресурсов.
private static void Main(string[] args)
{
    FileStream fs = null;
    try
    {
        fs = new FileStream(Path.Combine(Directory.GetCurrentDirectory(), "test.txt"), FileMode.OpenOrCreate);
        fs.Write(new byte[] {1, 2, 3, 4}, 0, 4);
    }
    catch (IOException exception)
    {
        Console.WriteLine(exception);
    }
    finally
    {
        if(fs != null)
            fs.Close();
    }
    Console.ReadLine();
}
Вы могли видеть подобный код для выгрузки отдельного домена, отписки от событий, обнуления ссылок и т.д. Если Ваш класс реализует интерфейс IDisposable, то вызов метода try/finally можно переписать, заменив конструкцией using. Рассмотрим, как изменится код, приведенный выше.
private static void Main(string[] args)
{
    try
    {
        var file = Path.Combine(Directory.GetCurrentDirectory(), "test.txt");
                                           
        using (var fs = new FileStream(file, FileMode.OpenOrCreate))
        {
            fs.Write(new byte[] {1, 2, 3, 4}, 0, 4);
        }
    }
    catch (IOException exception)
    {
        Console.WriteLine(exception);
    }
    Console.ReadLine();
}
В оригинале конструкция приобрела такой вид:
try
    try
    finally
catch
Как видим, блок finally позволяет почистить мусор и освободить ресурсы.  Но всё ещё остается открытым вопрос, всегда ли будет гарантирован вызов блока finally. К сожалению, вызов блока finally не всегда гарантирован. Одной из таких ситуаций является та, в которой в блоке try происходит вызов бесконечного цикла. Иногда нужно иметь возможность корректно завершить приложение самому, записав при этом информацию в event log. В .NET 4.0 появился метод Environment.FailFast, который позволяет завершить процесс сразу после записи сообщения в журнал событий Windows, после чего включает сообщение в отчет об ошибках, отправляемый в корпорацию Майкрософт. Этот метод завершает процесс, минуя активные блоки try/finally и методы-финализаторы. Метод FailFast записывает строку message в журнале событий Windows-приложения, создает дамп приложения и затем завершает текущий процесс. Строка message также включена в отчет об ошибках для корпорации Майкрософт. Метод FailFast используется для завершения работы приложения вместо метода Exit, если повреждение состояния приложения не подлежит восстановлению, а выполнение блоков try/finally и методов завершения для этого приложения приведет повреждению ресурсов программы.
Примечание: довольно интересный метод для записи информации в event log добавила компания Microsoft в .NET 4.0. Сама компания рекомендует использовать этот метод для отладки приложения. К сожалению, я так и не смог представить, где может понадобиться такое чудо и чем эта новая фишка лучше старого доброго метода Exit, в который можно передать код выхода и при необходимости его обработать.
ApplicationException vs Exception
Если в своих приложениях Вы используете для ошибок класс ApplicationException, задумайтесь о том, чтобы от него отказаться. Использование создания  такого типа ошибок в приложении бессмысленно и не несет никакой смысловой нагрузки. Вот что говорится об этом в Framework Design Guidelines:
Do not throw or derive from System.ApplicationException.
JEFFREY RICHTER: System.ApplicationException is a class that should not be part of the .NET Framework. The original idea was that classes derived from SystemException would indicate exceptions thrown from the CLR (or system) itself, whereas non-CLR exceptions would be derived from ApplicationException. However, a lot of exception classes didn’t follow this pattern. For example, TargetInvocationException (which is thrown by the CLR) is derived from ApplicationException. So, the ApplicationException class lost all meaning. The reason to derive from this base class is to allow some code higher up the call stack to catch the base class. It was no longer possible to catch all application exceptions.
Джефри Рихтер рекомендует исключить этот класс с .NET, так как он не выполняет своей основной роли: разделить ошибки, которые бросаются исполняющей средой CLR, от ошибок несистемных (non-clr). Ошибки, которые генерируются средой CLR, должны наследоваться от SystemException; все остальные – от ApplicationException. Но поскольку в самом .NET Framework такого принципа не поддерживается, например, класс TargetInvocationException, то попытка словить ApplicationException потеряла всякий смысл. Вот что об этом пишет компания Microsoft:
"При разработке приложения, которому требуется создать свои собственные исключения, необходимо создать пользовательские исключения из класса Exception. Изначально предполагалось, что пользовательские исключения должны наследовать от класса ApplicationException, однако на практике это не имеет особого значения. Для получения дополнительной информации см. Лучшие методики обработки исключений".

NullReferenceException vs ArgumentNullException
Избегайте использования в коде явного вызова NullReferenceException. Если аргумент пустой и контракт метода запрещает пустые аргументы, бросайте исключение ArgumentNullException. Так Вы сможете отделить те ошибки, которые бросаете от системных ошибок. Например, если в какой-то метод Вы передали null, сами того не подозревая, и пытались его использовать. Понять, кто бросил такой тип ошибки – Вы или система – порой бывает очень трудно. Но при использовании ArgumentNullException такой проблемы не возникнет.
Примечание: этот пункт был добавлен по той причине, что автор данной статьи сам совершал такую ошибку. Очень сложно потом понять такую ошибку: система ее бросила или ты сам "наследил" в коде. Если можно в этом случае упростить себе жизнь, то почему бы этим не воспользоваться.

Обработка ошибок при использовании тасков
Если Вы знакомы с классом Task и моделью TPL (Task Parallel Library) при проектировании и разработке приложений, то Вы, наверное, уже сталкивались с обработкой ошибок в ней. Если Вам не приходилось обрабатывать возникшие ошибки при некорректном завершении задания или если оно не выполнилось по какой-то причине, то Вам очень повезло. Но для готовности к разным неожиданностям рекомендую посмотреть, как обрабатывать такие ошибки.
Если таск бросил ошибку, то его выполнение прекращено. Ошибка таска сохраняется как часть AggregateException (AE) и хранится в объекте таска Exception (E) проперти. AE пробрасывается на функции.Wait, .Result или .WaitAll. Рассмотрим пример обработки ошибок.
internal class Program
{
    private static void Main(string[] args)
    {
        try
        {
            var task1 = Task.Factory.StartNew<int>(Method1);
            var result = task1.Result;
        }
        catch (AggregateException ae)
        {
            Console.WriteLine(ae.InnerException.Message);
        }
        Console.ReadLine();
    }

    private static int Method1()
    {
        throw new NotImplementedException();
    }
}
Чтобы отобразить все ошибки, которые привели к выбросу ошибки, нужно вызвать метод Flatten().

try
{
    var task1 = Task.Factory.StartNew<int>(Method1);
    var result = task1.Result;
}
catch (AggregateException ae)
{
    var exceptions = ae.Flatten();
    foreach (var exception in exceptions.InnerExceptions)
    {
        Console.WriteLine(exception.Message);
    }
}
Правила работы с ошибками при использовании тасков (Exception handling design):
  • при использовании методов .Wait, .Result или .WaitAll нужно обрамлять в блок try/catch;
  • чтобы отловить необработанные критические ошибки при вызове тасков, выполнить подписку на TaskScheduler.UnobservedTaskException.
Но при работе с тасками существует еще один подход к обработке ошибок. Если нужно выполнить какую-то логику именно для тасков, которые прокинули ошибки, можно воспользоваться другим подходом на основе метода .ContinueWith. Рассмотрим, как можно использовать такой подход.
var task1 = Task.Factory.StartNew<int>(Method1);
var failedTask = task1.ContinueWith(task =>
    {
        Console.WriteLine("Call failed task");
        //TODO Write some logic
    }, TaskContinuationOptions.OnlyOnFaulted);
var successTask = task1.ContinueWith(task =>
    {
        var result = task.Result;
        Console.WriteLine(result);
    }, TaskContinuationOptions.NotOnFaulted);
В данном подходе наблюдается явное разделение тех функций, выполненных успешно, от тех, которые бросили ошибку. Но есть один тип ошибок, который, по сути, ошибкой не является. Это exception OperationCanceledException. Этот тип ошибки вызывается в том случае, если пользователь отменил выполнение таска. Более детально с этим можно ознакомиться в статье "Отмена задач (Taskcancellation)", в которой речь идет об использовании класса Task и об отмене операций пользователем. Рассмотрим небольшой пример с обработкой OperationCanceledException.
var tokenSource = new CancellationTokenSource();
CancellationToken ct = tokenSource.Token;
var task = Task.Factory.StartNew(() =>
{
    //TODO write some code in loop
    if (ct.IsCancellationRequested)
    {
        ct.ThrowIfCancellationRequested();
    }

}, tokenSource.Token); // Pass same token to StartNew.
       
//Cancel method
tokenSource.Cancel();

// Just continue on this thread, or Wait/WaitAll with try-catch:
try
{
    task.Wait();
}
catch (AggregateException ae)
{
    var exceptions = ae.Flatten();
    foreach (var exception in exceptions.InnerExceptions)
    {
        if (exception is OperationCanceledException)
        {
            //ignore cancel
        }
        else
            Console.WriteLine(exception.Message);
    }
}
CancellationTokenSource создаёт маркёры отмены (свойство Token) и обрабатывает запросы на отмену операции.
CancellationToken - маркёр отмены, позволяющий несколькими способами отслеживать запросы на отмену операции: опросом свойства IsCancellationRequested, регистрацией callback-функции (через перегруженный метод Register), ожиданием на объекте синхронизации (свойство WaitHandle).

OperationCanceledException - исключение, выброс которого по соглашению означает, что запрос на отмену операции был обработан и операция должна считаться отменённой. Предпочтительный способ генерации исключения - вызов метода ThrowIfCancellationRequested.

Итоги
В данной статье рассмотрена обработка ошибок при использовании подписки на события; обработка ошибок с использованием блоков try/catch/finally; также упомянуто best practice при использовании и обработки ошибок и описаны способы обработки ошибок в модели TPL.

No comments:

Post a Comment