Wednesday, April 15, 2015

Filter exceptions in C# 6.0

Эта статья посвящена фильтрам исключений в C# 6. Есть уже замечательная статья, написанная Сергеем Тепляковым, которая называется "Фильтры исключений в C# 6.0". Мы же постараемся рассмотреть фильтры исключений со стороны IL-кода, так как эта тема вызывает некоторые недопонимания у многих разработчиков, плюс она актуальна в связи с выходом в свет новой версии Visual Studio 2015 CTP 6, которую можно скачать по ссылке, и в которой уже добавлены новые изменения языка C# (в частности, замена для фильтра исключений оператора if на оператор when). 
Для начала приведу два куска кода, отличающиеся друг от друга, а затем вернусь к причинам написания этой статьи.
Ниже приведен код, написанный на C# 6 в последней редакции Visual Studio 2015.
class Program
{
    static void Main(string[] args)
    {
        try
        {
            TestMessage(null);
        }
        catch (InvalidOperationException ex) when (ex.Message == "message test can't be null")
        {
            WriteLine("Catch filtered exception");
        }
        catch (Exception ex)
        {
            WriteLine("Catch other exceptions");
        }
        ReadLine();
    }

    public static void TestMessage(string message)
    {
        if (string.IsNullOrEmpty(message))
            throw new InvalidOperationException("message test can't be null");
        Console.WriteLine(message);
    }
}
И код, который написан в C# 5:
class Program
{
    static void Main(string[] args)
    {
        try
        {
            TestMessage(null);
        }
        catch (InvalidOperationException ex)
        {
            if(ex.Message == "message test can't be null")
                Console.WriteLine("Catch filtered exception");
            else
            {
                throw;
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine("Catch other exceptions");
        }
        Console.ReadLine();
    }

    public static void TestMessage(string message)
    {
        if (string.IsNullOrEmpty(message))
            throw new InvalidOperationException("message test can't be null");
        Console.WriteLine(message);
    }
}
Итак, начнем с этих двух примеров. Дело в том, что в более ранних версиях Visual Studio 2015 и языка C# (он постоянно изменяется, и пока нет окончательной версии) вместо оператора when, использовался оператор if. Возможно, именно этот факт приводил к тому, что некоторые разработчики считали два этих примера эквивалентными с точки зрения того, что для них фильтры были своего рода синтаксическим сахаром, который можно переписать в код, который вы можете увидеть для C# 5. И это небеспочвенно.
Давайте начнем, пожалуй, с самого основного отличия этих двух кусков кода. Фильтры в C# 6 позволяют задать рядом с блоком catch условие, при котором блок catch будет вызван или же нет. Если условие возвращает true, то это значит, что найденный блок отвечает требованиям, и начинается раскрутка стека с вызовом всех блоков finally от места генерации исключения к обработчику.
Давайте посмотрим, что же происходит в этом коде на уровне IL-кода:
// Methods
    .method private hidebysig static
        void Main (
            string[] args
        ) cil managed
    {
        // Method begins at RVA 0x2050
        // Code size 74 (0x4a)
        .maxstack 2
        .entrypoint
        .locals init (
            [0] class [mscorlib]System.InvalidOperationException ex,
            [1] class [mscorlib]System.Exception ex,
            [2] bool CS$4$0000
        )

        IL_0000: nop
        .try
        {
            IL_0001: nop
            IL_0002: ldnull
            IL_0003: call void ExceptionFilters.Program::TestMessage(string)
            IL_0008: nop
            IL_0009: nop
            IL_000a: leave.s IL_0042
        } // end .try
        catch [mscorlib]System.InvalidOperationException
        {
            IL_000c: stloc.0
            IL_000d: nop
            IL_000e: ldloc.0
            IL_000f: callvirt instance string [mscorlib]System.Exception::get_Message()
            IL_0014: ldstr "message test can't be null"
            IL_0019: call bool [mscorlib]System.String::op_Equality(stringstring)
            IL_001e: ldc.i4.0
            IL_001f: ceq
            IL_0021: stloc.2
            IL_0022: ldloc.2
            IL_0023: brtrue.s IL_0030

            IL_0025: ldstr "Catch filtered exception"
            IL_002a: call void [mscorlib]System.Console::WriteLine(string)
            IL_002f: nop

            IL_0030: rethrow
        } // end handler
        catch [mscorlib]System.Exception
        {
            IL_0032: stloc.1
            IL_0033: nop
            IL_0034: ldstr "Catch other exceptions"
            IL_0039: call void [mscorlib]System.Console::WriteLine(string)
            IL_003e: nop
            IL_003f: nop
            IL_0040: leave.s IL_0042
        } // end handler

        IL_0042: nop
        IL_0043: call string [mscorlib]System.Console::ReadLine()
        IL_0048: pop
        IL_0049: ret
    } // end of method Program::Main
Как видим из примера, даже не разбираясь особо в IL-коде, можно заметить, что у нас следуют блоки try-> catch, и лишь затем в блоке catch идет проверка.
А теперь тот же пример, написанный на C# 6 и который мы разберем также с помощью ILSpy.
// Methods
    .method private hidebysig static
        void Main (
            string[] args
        ) cil managed
    {
        // Method begins at RVA 0x2050
        // Code size 85 (0x55)
        .maxstack 2
        .entrypoint
        .locals init (
            [0] class [mscorlib]System.InvalidOperationException,
            [1] class [mscorlib]System.Exception
        )

        IL_0000: nop
        .try
        {
            IL_0001: nop
            IL_0002: ldnull
            IL_0003: call void FilterExceptionSample.Program::TestMessage(string)
            IL_0008: nop
            IL_0009: nop
            IL_000a: leave.s IL_004e
        } // end .try
        filter
        {
            IL_000c: isinst [mscorlib]System.InvalidOperationException
            IL_0011: dup
            IL_0012: brtrue.s IL_0018

            IL_0014: pop
            IL_0015: ldc.i4.0
            IL_0016: br.s IL_002c

            IL_0018: stloc.0
            IL_0019: ldloc.0
            IL_001a: callvirt instance string [mscorlib]System.Exception::get_Message()
            IL_001f: ldstr "message test can't be"
            IL_0024: call bool [mscorlib]System.String::op_Equality(stringstring)
            IL_0029: ldc.i4.0
            IL_002a: cgt.un

            IL_002c: endfilter
        } // end filter
        catch
        {
            IL_002e: pop
            IL_002f: nop
            IL_0030: ldstr "Catch filtered exception"
            IL_0035: call void [mscorlib]System.Console::WriteLine(string)
            IL_003a: nop
            IL_003b: nop
            IL_003c: leave.s IL_004e
        } // end handler
        catch [mscorlib]System.Exception
        {
            IL_003e: stloc.1
            IL_003f: nop
            IL_0040: ldstr "Catch other exceptions"
            IL_0045: call void [mscorlib]System.Console::WriteLine(string)
            IL_004a: nop
            IL_004b: nop
            IL_004c: leave.s IL_004e
        } // end handler

        IL_004e: call string [mscorlib]System.Console::ReadLine()
        IL_0053: pop
        IL_0054: ret
    } // end of method Program::Main
Чтобы не грузить операторами в IL-коде, просто расскажу, что здесь происходит. У нас появился блок filter, в котором мы ищем тип exception, который удовлетворяет наше условие (функция isinst).
IL_000c: isinst [mscorlib]System.InvalidOperationException
IL_0011: dup
IL_0012: brtrue.s IL_0018
Затем мы опустим момент игры со стеком, в который мы копируем наш exception, а перейдем к фазе сравнений, в которой мы проверяем два сообщения на равенство, чтобы определить, что наш фильтр сработал.
IL_0018: stloc.0
            IL_0019: ldloc.0
            IL_001a: callvirt instance string [mscorlib]System.Exception::get_Message()
            IL_001f: ldstr "message test can't be"
            IL_0024: call bool [mscorlib]System.String::op_Equality(stringstring)
            IL_0029: ldc.i4.0
            IL_002a: cgt.un
Здесь код очень простой и примитивный, а если вы откроете его в ILSpy, то сможете увидеть и подсветку этого кода. Ниже показан результат запуска нашего примера.
Если ваш фильтр не сработает, то будет выполнен следующий поиск следующего exception, который удовлетворяет критерии и либо имеет фильтр исключений, либо нет.  То есть, вы можете в ряд написать несколько раз обработку одного и того же типа исключения. Например, код, который позволяет проверить результат обращения к странице в интернете.
public static void ProcessResponceCommand()
{
    try
    {
        throw new HttpException (404, "Page not found");
    }
    catch(HttpException ex) when (ex.GetHttpCode() == 404)
    {
        WriteLine("Page not found");
    }
    catch (HttpException ex) when (ex.GetHttpCode() == 403)
    {
        WriteLine("You must be logged to access this resource!!!");
    }
    catch(Exception ex)
    {
        WriteLine("Other errors");
    }
}
А теперь посмотрите, как этот код выглядит на уровне IL-кода.
.method public hidebysig static
    void ProcessResponceCommand () cil managed
{
    // Method begins at RVA 0x2120
    // Code size 129 (0x81)
    .maxstack 2
    .locals init (
        [0] class FilterException.HttpException,
        [1] class FilterException.HttpException,
        [2] class [mscorlib]System.Exception
    )

    IL_0000: nop
    .try
    {
        IL_0001: nop
        IL_0002: ldc.i4 404
        IL_0007: ldstr "Page not found"
        IL_000c: newobj instance void FilterException.HttpException::.ctor(int32string)
        IL_0011: throw
    } // end .try
    filter
    {
        IL_0012: isinst FilterException.HttpException
        IL_0017: dup
        IL_0018: brtrue.s IL_001e

        IL_001a: pop
        IL_001b: ldc.i4.0
        IL_001c: br.s IL_002f

        IL_001e: stloc.0
        IL_001f: ldloc.0
        IL_0020: callvirt instance int32 FilterException.HttpException::GetHttpCode()
        IL_0025: ldc.i4 404
        IL_002a: ceq
        IL_002c: ldc.i4.0
        IL_002d: cgt.un

        IL_002f: endfilter
    } // end filter
    catch
    {
        IL_0031: pop
        IL_0032: nop
        IL_0033: ldstr "Page not found"
        IL_0038: call void [mscorlib]System.Console::WriteLine(string)
        IL_003d: nop
        IL_003e: nop
        IL_003f: leave.s IL_0080
    } // end handler
    filter
    {
        IL_0041: isinst FilterException.HttpException
        IL_0046: dup
        IL_0047: brtrue.s IL_004d

        IL_0049: pop
        IL_004a: ldc.i4.0
        IL_004b: br.s IL_005e

        IL_004d: stloc.1
        IL_004e: ldloc.1
        IL_004f: callvirt instance int32 FilterException.HttpException::GetHttpCode()
        IL_0054: ldc.i4 403
        IL_0059: ceq
        IL_005b: ldc.i4.0
        IL_005c: cgt.un

        IL_005e: endfilter
    } // end filter
    catch
    {
        IL_0060: pop
        IL_0061: nop
        IL_0062: ldstr "You must be logged to access this resource!!!"
        IL_0067: call void [mscorlib]System.Console::WriteLine(string)
        IL_006c: nop
        IL_006d: nop
        IL_006e: leave.s IL_0080
    } // end handler
    catch [mscorlib]System.Exception
    {
        IL_0070: stloc.2
        IL_0071: nop
        IL_0072: ldstr "Other errors"
        IL_0077: call void [mscorlib]System.Console::WriteLine(string)
        IL_007c: nop
        IL_007d: nop
        IL_007e: leave.s IL_0080
    } // end handler

    IL_0080: ret
// end of method Program::ProcessResponceCommand
Наш код приобрел следующий вид:
Использовать фильтры исключений удобно в том случае, если нам нужно выполнить какие-то действия до раскрутки стека.  Также очень удобный способ обработки ошибок на примере для HttpException. Аналогичную обработку можно сделать для SqlException, обрабатывая свойство Number и другие типы ошибок, которые позволяют произведи анализ ошибки еще до раскрутки стека.
Побочные эффекты фильтров исключений
Есть интересный нюанс, который позволяет логировать ваши ошибки без раскрутки стека. Для этого нам нужно добавить первым блоком catch, который будет только логировать ошибки и возвращать false, что означает, что обработка будет передана дальше другим обработчикам catch. Об этой ситуации описал Мэдс Торгесен в статье 'New Features in C# 6'.
Exception filters are preferable to catching and rethrowing because they leave the stack unharmed. If the exception later causes the stack to be dumped, you can see where it originally came from, rather than just the last place it was rethrown.
It is also a common and accepted form of “abuse” to use exception filters for side effects; e.g. logging. They can inspect an exception “flying by” without intercepting its course. In those cases, the filter will often be a call to a false-returning helper function which executes the side effects:
Пример использования такого подхода в C# 6 можно посмотреть ниже.
static void Main(string[] args)
{
    try
    {
        TestMessage(null);
    }
    catch(Exception ex) when (Log(e)) {}
    catch (InvalidOperationException ex) when (ex.Message == "message test can't be null")
    {
        WriteLine("Catch filtered exception");
    }
    catch (Exception ex)
    {
        WriteLine("Catch other exceptions");
    }
    ReadLine();
}

private static bool Log(Exception e)
{
    /* log it */ ;
    return false;
}
Немного дополнения к статье, которые касаются не фильтров исключений, а новой версии языка C#. В предыдущей версии вместо статических классов с длинной записью Console.WriteLine, можно было написать using System.Console, и все работало без использования Console. Как это работало раньше, и как работает сейчас, показано на рисунке ниже.

Итоги

На этом буду завершать краткое использование фильтров исключений в C# 6.0. Надеюсь, для себя вы найдете полезной новую возможность языка C# и будете использовать ее в полную силу. В целом, фильтры исключений – достаточно мощная новая фишка, которая появилась в C#, и внимательное, вдумчивое использование данной особенности позволят как минимум удобно логировать ваши ошибки. Например, команда Roslyn и TypeScript использует эту возможность для генерации более детальных дампов, тогда почему бы также не посмотреть в эту сторону. C# 6.0 принесет нам несколько неплохих возможностей, так что стремимся узнать о них заранее, чтобы использовать в будущем по максимуму. 

No comments:

Post a Comment