Как сохранить трассировку стека при повторной генерации исключения вне контекста перехвата?

TL;DR: как вызвать ранее перехваченное исключение позже, сохранив исходную трассировку стека исключения.

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

type Result<'TResult, 'TError> =
    | Success of 'TResult
    | Fail of 'TError

module Result =
    let bind f = 
        function
        | Success v -> f v
        | Fail e -> Fail e

    let create v = Success v

    let retnFrom v = v

    type ResultBuilder () =
        member __.Bind (m , f) = bind f m
        member __.Return (v) = create v
        member __.ReturnFrom (v) = retnFrom v
        member __.Delay (f) = f
        member __.Run (f) = f()
        member __.TryWith (body, handler) =
            try __.Run body
            with e -> handler e

[<AutoOpen>]
module ResultBuilder =
    let result = Result.ResultBuilder()

А теперь воспользуемся этим:

module Extern =
    let calc x y = x / y


module TestRes =
    let testme() =
        result {
            let (x, y) = 10, 0
            try
                return Extern.calc x y
            with e -> 
                return! Fail e
        }
        |> function
        | Success v -> v
        | Fail ex -> raise ex  // want to preserve original exn's stacktrace here

Проблема в том, что трассировка стека не будет включать источник исключения (в данном случае это функция calc). Если я запущу код, как написано, он выдаст следующее, что не дает никакой информации о происхождении ошибки:

System.DivideByZeroException : Attempted to divide by zero.
   at Microsoft.FSharp.Core.Operators.Raise[T](Exception exn)
   at PlayFul.TestRes.testme() in D:\Experiments\Play.fs:line 197
   at PlayFul.Tests.TryItOut() in D:\Experiments\Play.fs:line 203

Использование reraise() не сработает, ему нужен контекст catch. Очевидно, что следующий тип a работает, но усложняет отладку из-за вложенных исключений и может стать довольно уродливым, если этот шаблон wrap-reraise-wrap-reraise будет вызываться несколько раз в глубоком стеке.

System.Exception("Oops", ex)
|> raise

Обновление: TeaDrivenDev предложил в комментариях использовать ExceptionDispatchInfo.Capture(ex).Throw(), что работает, но требует оборачивать исключение во что-то другое, усложняя модель. Тем не менее, он сохраняет трассировку стека, и его можно превратить в довольно работоспособное решение.


person Abel    schedule 16.12.2016    source источник
comment
Я не уверен, что именно делает reraise, но если вы используете .NET 4.5, вы можете попробовать ExceptionDispatchInfo.Capture(ex).Throw().   -  person TeaDrivenDev    schedule 17.12.2016
comment
@TeaDrivenDev звучит интересно/круто/и намного больше, я не знал об этом добавлении языка. Глядя в него ...   -  person Abel    schedule 17.12.2016
comment
Оберните исключение в другое.   -  person Fyodor Soikin    schedule 17.12.2016
comment
@FyodorSoikin, разве это не мое запасное предложение в конце, или ты имеешь в виду что-то другое? То есть: raise (System.Exception("Oops", ex)).   -  person Abel    schedule 17.12.2016
comment
Да, это то, что я имел в виду. Извините, сразу не заметил.   -  person Fyodor Soikin    schedule 17.12.2016
comment
Вы можете найти этот блог пост полезный.   -  person Nikos Baxevanis    schedule 17.12.2016
comment
@NikosBaxevanis, это действительно очень интересно. Хотя это больше похоже на решение для получения трассировки стека для кода с совокупной стоимостью владения, и как таковое оно решает серьезную, но другую проблему. Трюк с потоком памяти для подделки трассировки стека в реальном исключении также великолепен.   -  person Abel    schedule 17.12.2016
comment
Если вы хотите сохранить трассировку стека, самое простое решение - полностью отказаться от любой монады и позволить обычному всплыванию исключений .NET выполнять свою работу...   -  person Mark Seemann    schedule 17.12.2016
comment
@MarkSeemann, да, наверное. Но я бы потерял компонуемость, и тогда мне вообще не следовало выбирать F#. Просто получить место, где произошла ошибка, не слишком странное требование, не так ли?   -  person Abel    schedule 17.12.2016
comment
@Abel У меня есть хороший опыт использования значений Both с соответствующими сообщениями об ошибках. Когда ошибка возникает в производстве, у меня не было проблем с определением источника. Опять же, с кодом, написанным в функциональном стиле, ошибки не так распространены.   -  person Mark Seemann    schedule 17.12.2016
comment
Кроме того, (@MarkSeemann), даже если не писать компилятор, чем точнее стек указывает на источник ошибки, тем лучше. Я не говорю, что не могу его найти, но он определенно полезен и экономит время, если стек не поврежден и разумен.   -  person Abel    schedule 17.12.2016


Ответы (2)


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

Но это верно только в том случае, если вы делаете между ними или в конце raise excn.

Я взял все идеи из комментариев и показываю их здесь в виде трех решений проблемы. Выберите то, что кажется вам наиболее естественным.

Захватите трассировку стека с помощью ExceptionDispatchInfo

В следующем примере показано предложение TeaDrivenDev в действии с использованием ExceptionDispatchInfo.Capture.

type Ex =
    /// Capture exception (.NET 4.5+), keep the stack, add current stack. 
    /// This puts the origin point of the exception on top of the stacktrace.
    /// It also adds a line in the trace:
    /// "--- End of stack trace from previous location where exception was thrown ---"
    static member inline throwCapture ex =
        ExceptionDispatchInfo.Capture ex
        |> fun disp -> disp.Throw()
        failwith "Unreachable code reached."

В примере в исходном вопросе (замените raise ex) будет создана следующая трассировка (обратите внимание на строку с "--- Конец трассировки стека из предыдущего места, где было выдано исключение ---") :

System.DivideByZeroException : Attempted to divide by zero.
   at Playful.Ex.Extern.calc(Int32 x, Int32 y) in R:\path\Ex.fs:line 118
   at [email protected](Unit unitVar) in R:\path\Ex.fs:line 137
   at Playful.Ex.Result.ResultBuilder.Run[b](FSharpFunc`2 f) in R:\path\Ex.fs:line 103
   at Playful.Ex.Result.ResultBuilder.TryWith[a](FSharpFunc`2 body, FSharpFunc`2 handler) in R:\path\Ex.fs:line 105
   --- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at Playful.Ex.TestRes.testme() in R:\path\Ex.fs:line 146
   at Playful.Ex.Tests.TryItOut() in R:\path\Ex.fs:line 153

Полностью сохранить трассировку стека

Если у вас нет .NET 4.5 или вам не нравится добавленная строка в середине трассировки ("--- Конец трассировки стека из предыдущего места, где было выдано исключение ---"), то вы можете сохранить стек и добавить текущую трассировку за один раз.

Я нашел это решение, следуя решению TeaDrivenDev, и наткнулся на Preserving stacktrace при повторной генерации исключений.

type Ex =
    /// Modify the exception, preserve the stacktrace and add the current stack, then throw (.NET 2.0+).
    /// This puts the origin point of the exception on top of the stacktrace.
    static member inline throwPreserve ex =
        let preserveStackTrace = 
            typeof<Exception>.GetMethod("InternalPreserveStackTrace", BindingFlags.Instance ||| BindingFlags.NonPublic)

        (ex, null) 
        |> preserveStackTrace.Invoke  // alters the exn, preserves its stacktrace
        |> ignore

        raise ex

В примере в исходном вопросе (замените raise ex) вы увидите, что трассировки стека хорошо связаны и что источник исключения находится сверху, где он должен быть:

System.DivideByZeroException : Attempted to divide by zero.
   at Playful.Ex.Extern.calc(Int32 x, Int32 y) in R:\path\Ex.fs:line 118
   at [email protected](Unit unitVar) in R:\path\Ex.fs:line 137
   at Playful.Ex.Result.ResultBuilder.Run[b](FSharpFunc`2 f) in R:\path\Ex.fs:line 103
   at Playful.Ex.Result.ResultBuilder.TryWith[a](FSharpFunc`2 body, FSharpFunc`2 handler) in R:\path\Ex.fs:line 105
   at Microsoft.FSharp.Core.Operators.Raise[T](Exception exn)
   at Playful.Ex.TestRes.testme() in R:\path\Ex.fs:line 146
   at Playful.Ex.Tests.TryItOut() in R:\path\Ex.fs:line 153

Оберните исключение в исключение

Это было предложено Федором Сойкиным и, вероятно, является способом .NET по умолчанию, поскольку он используется во многих случаях в БКЛ. Однако во многих ситуациях это приводит к менее полезной трассировке стека и, imo, может привести к запутанной трассировке вверх ногами в глубоко вложенных функциях.

type Ex = 
    /// Wrap the exception, this will put the Core.Raise on top of the stacktrace.
    /// This puts the origin of the exception somewhere in the middle when printed, or nested in the exception hierarchy.
    static member inline throwWrapped ex =
        exn("Oops", ex)
        |> raise

Применяя так же (замените raise ex), как и в предыдущих примерах, это даст вам трассировку стека следующим образом. В частности, обратите внимание, что корень исключения, функция calc, теперь находится где-то посередине (здесь все еще довольно очевидно, но в глубоких трассировках с несколькими вложенными исключениями уже не так сильно).

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

System.Exception : Oops
  ----> System.DivideByZeroException : Attempted to divide by zero.
   at Microsoft.FSharp.Core.Operators.Raise[T](Exception exn)
   at Playful.Ex.TestRes.testme() in R:\path\Ex.fs:line 146
   at Playful.Ex.Tests.TryItOut() in R:\path\Ex.fs:line 153
   --DivideByZeroException
   at Playful.Ex.Extern.calc(Int32 x, Int32 y) in R:\path\Ex.fs:line 118
   at [email protected](Unit unitVar) in R:\path\Ex.fs:line 137
   at Playful.Ex.Result.ResultBuilder.Run[b](FSharpFunc`2 f) in R:\path\Ex.fs:line 103
   at Playful.Ex.Result.ResultBuilder.TryWith[a](FSharpFunc`2 body, FSharpFunc`2 handler) in R:\path\Ex.fs:line 105

Вывод

Я не говорю, что один подход лучше другого. Для меня просто бездумно делать raise ex не очень хорошая идея, если только ex не является недавно созданным и ранее не вызываемым исключением.

Прелесть в том, что reraise() фактически делает то же самое, что и Ex.throwPreserve выше. Поэтому, если вы считаете, что reraise() (или throw без аргументов в C#) — хороший шаблон программирования, вы можете использовать его. Единственная разница между reraise() и Ex.throwPreserve заключается в том, что последний не требует контекста catch, что, как мне кажется, дает огромное преимущество в удобстве использования.

Я думаю, в конце концов, это дело вкуса и того, к чему вы привыкли. Для меня я просто хочу, чтобы причина исключения была на первом месте. Большое спасибо первому комментатору, TeaDrivenDev, который направил меня к усовершенствованию .NET 4.5, что само по себе привело ко второму подходу выше. .

(извините, что отвечаю на мой собственный вопрос, но, поскольку никто из комментаторов этого не сделал, я решил активизироваться ;)

person Abel    schedule 17.12.2016

Для тех, кто пропустил пункт о «вне контекста catch» (например, я) — вы можете использовать reraise() для сохранения стека при выбрасывании из блока catch.

person Nikolai Koudelia    schedule 10.04.2018
comment
Да это верно. Эта ветка началась с монадических ситуаций, когда вы больше не находитесь в блоке catch, но хотите сохранить исходную трассировку, из которой исключение было перехвачено в первый раз. - person Abel; 10.04.2018