65.9K
CodeProject 正在变化。 阅读更多。
Home

我抛出异常

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (18投票s)

2019年7月14日

CPOL

8分钟阅读

viewsIcon

17390

downloadIcon

144

爱丽丝梦游仙境般深入异常处理的兔子洞

目录

引言

异常处理并非小事。尽管 try-catch 块看起来很简单,但如何处理异常却不然。本文的起因是我希望在 AWS Lambda 函数中如何处理异常,但这里没有特定于 AWS Lambda 的内容。高级开发人员也应该意识到,这里并没有真正的新内容,我所做的只是将一些概念打包成一篇文章,并大量使用静态类、扩展方法、泛型参数、显式参数和条件延续运算符。此外,我没有探讨在线程或任务中捕获异常更复杂的问题。如果你对这个话题感兴趣,可以看看我的文章 线程简明概述

你捕获了一个异常,然后呢?

我将异常分为两类

  1. 我自己的代码抛出的异常
  2. 框架或第三方库抛出的异常
Test

无论哪种情况,某个地方都必须捕获异常。让我们看一个简单的异常测试夹具

using System;

namespace ITakeException
{
  class Program
  {
    static void Main(string[] args)
    {
      try
      {
        SayHi("Hi");
      }
      catch (Exception ex)
      {
        Console.WriteLine(ex);
      }
    }
  }

  static void SayHi(string msg)
  {
    throw new Exception("oops", new Exception("My Inner Self-Exception"));
  }
}

以上实现非常简单——它只是将异常写入控制台

改进日志记录

典型的日志记录(到文件、云日志应用程序等)看起来也没有更好。这既不适合人类阅读,也不适合机器阅读。当然,你可以编写一个程序来解析它,但这确实很糟糕。如果你提供一种更适合机器阅读的异常形式,让你的解析可以专注于识别问题,而不是解析看起来有点像人类可读消息,但实际上并不是的东西,那会怎么样?

StackTrace 和 StackFrame 类

进入 System.Diagonistics 中的 StackTraceStackFrameData

catch (Exception ex)
{
  var st = new StackTrace(ex, true);

  foreach (var frame in st.GetFrames())
  {
    var sfd = new StackFrameData(frame);
    Console.WriteLine(sfd.ToString() + "\r\n");
  }
}

StackFrameData 类

在这里,我使用了一个辅助类 StackFrameData 来从 StackFrame 中提取我想要报告的内容

public class StackFrameData
{
  public string FileName { get; private set; }
  public string Method { get; private set; }
  public int LineNumber { get; private set; }

  public StackFrameData(StackFrame sf)
  {
    FileName = sf.GetFileName();
    Method = sf.GetMethod().Name;
    LineNumber = sf.GetFileLineNumber();
  }

  public override string ToString()
  {
    return $"{FileName}\r\n{Method}\r\n{LineNumber}";
  }
}

现在,我们正在取得进展

ExceptionReport 类

有了另一个辅助工具,异常可以很容易地序列化为 JSON,以便机器可读。给定

catch (Exception ex)
{
  var report = new ExceptionReport(ex);
  string json = JsonConvert.SerializeObject(report);
  Console.WriteLine(json);
}

ExceptionReport 辅助类

public static class ExceptionReportExtensionMethods
{
  public static ExceptionReport CreateReport(this Exception ex)
  {
    return new ExceptionReport(ex);
  }
}

public class ExceptionReport
{
  public DateTime When { get; } = DateTime.Now;  

  [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
  public string ApplicationMessage { get; set; }

  public string ExceptionMessage { get; set; }

  public List<StackFrameData> CallStack { get; set; } = new List<StackFrameData>();

  [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
  public ExceptionReport InnerException { get; set; }

  public ExceptionReport(Exception ex)
  {
    ExceptionMessage = ex.Message;
    var st = new StackTrace(ex, true);
    var frames = st.GetFrames() ?? new StackFrame[0];
    CallStack.AddRange(frames.Select(frame => new StackFrameData(frame)));
    InnerException = ex.InnerException?.CreateReport();
  }
}

我们就得到了一个漂亮的 JSON 对象

注意,我们终于也报告了内部异常!

麦田里的守望者

虽然这里可能会有一些反对意见,但我将更进一步。我不想总是写

try
{
  Stuff();
}
catch(Exception ex)
{
  var report = new ExceptionReport(ex);
  string json = JsonConvert.SerializeObject(report);
  // Log somewhere
}

我也不想依赖其他开发人员正确地做这件事。当然,我可以这样做

try
{
  Stuff();
}
catch(Exception ex)
{
  Logger.Log(ex);
}

但我仍然有 try-catch 语句的“污染”。那么(这里就是反对声最大的部分)改为这样写怎么样

Catcher.Try(() => SayHi("Hi"));

在...的帮助下

public static class Catcher
{
  public static T Try<T>(Func<T> fnc, Action final = null)
  {
    try
    {
      T ret = fnc();
      return ret;
    }
    catch (Exception ex)
    {
      Log(ex);
      throw;
    }
    finally
    {
      final?.Invoke();
    }
  }

  public static void Try(Action fnc, Action final = null)
  {
    try
    {
      fnc();
    }
      catch (Exception ex)
    {
      Log(ex);
      throw;
    }
    finally
    {
      final?.Invoke();
    }
  }

  private static void Log(Exception ex)
  {
    var report = new ExceptionReport(ex);
    string json = JsonConvert.SerializeObject(report, Formatting.Indented);
    Console.WriteLine(json);
  }
}

现在我们得到

这有两个问题

  1. 我们正在获取包含 Try 调用的堆栈跟踪(现在 main 中的匿名方法我就不提了。总会有副作用!)
  2. Catcher 会重新抛出异常,这可能不是我们真正想要的。

第一个问题通过这种方式解决:在 Catcher 类中的异常上,我们告诉报告器跳过最后一个堆栈项

Log(ex, 1);

报告器然后像这样调用

private static void Log(Exception ex, int exceptLastN)
{
  var report = new ExceptionReport(ex, exceptLastN);
  string json = JsonConvert.SerializeObject(report, Formatting.Indented);
  Console.WriteLine(json);
}

有了

public static class ExceptionReportExtensionMethods
{
  ...
  public static T[] Drop<T>(this T[] items, int n)
  {
    return items.Take(items.Length - 1).ToArray();
  }
}

在报告器中

var frames = st.GetFrames()?.Drop(exceptLastN) ?? new StackFrame[0];

现在我们看到

至于第二个问题,我们可以实现一个不重新抛出异常的 SilentTry static 方法,例如

public static void Try(Action fnc, Action final = null)
{
  try
  {
    fnc();
  }
  catch (Exception ex)
  {
    Log(ex, 1);
  }
  finally
  {
    final?.Invoke();
  }
}

扩展测试夹具

static void Main(string[] args)
{
  Catcher.SilentTry(() => SayHi("Hi"), () => Console.WriteLine("Bye"));
  var ret = Catcher.SilentTry(() => Divide(0, 1), () => Console.WriteLine("NAN!"));
  Console.WriteLine(ret);
}

static void SayHi(string msg)
{
  throw new Exception("oops", new Exception("My Inner Self-Exception"));
}

static decimal Divide(decimal a, decimal b)
{
  return a / b;
}

(你知道只有 decimalinteger 类型才会抛出除零异常吗? Double 返回“无穷大”!)

所以现在,我们得到两个日志条目并最终执行

并且请注意,由于这些是静默尝试,因此不会在调用者中抛出异常。嗯……

大杂烩

或者垃圾处理(我又听到尖叫声了!)。因为 Func<T> 调用是最复杂的

public static bool SilentTry<T>(Func<T> fnc, out T ret, 
              Action final = null, T defaultValue = default(T), Action onException = null)
{
  bool ok = false;
  ret = defaultValue;

  try
  {
    ret = fnc();
    ok = true;
  }
  catch (Exception ex)
  {
    Log(ex, 1);
    onException?.Invoke();
  }
  finally
  {
    final?.Invoke();
  }

  return ok;
}

以及我们的测试夹具

bool ok = (Catcher.SilentTry(
  () => Divide(5, 2),
  out decimal ret,
  defaultValue: decimal.MaxValue,
  onException: () => Console.WriteLine("Divide by zero!!!"),
  final: () => Console.WriteLine("The answer is not 42.")
));

Console.WriteLine($"Success? {ok} Result = {ret}");

一个非异常

在通过调用 () => Divide(5, 0) 发生除零错误时

好吧,这太疯狂了,但它说明了可选参数能做什么。

问题

它变得更疯狂了

  • finally 块或 onException 调用中发生异常时会发生什么?
  • 当记录器抛出异常时会发生什么?!
  • 以这种方式捕获特定异常怎么样?

第一个问题通过使用 Catcher 并借助几个扩展方法“轻松”处理

public static void Try(this Action action)
{
  Catcher.Try(action);
}

public static void SilentTry(this Action action)
{
  Catcher.SilentTry(action);
}

并将 finally 实现为对 final 操作的条件 TrySilentTry

public static bool SilentTry<T>(Func<T> fnc, out T ret, 
              Action final = null, T defaultValue = default(T), Action onException = null)
{
  bool ok = false;
  ret = defaultValue;

  try
  {
    ret = fnc();
    ok = true;
  }
  catch (Exception ex)
  {
    Log(ex, 1);
    SilentTry(() => onException?.Invoke());
  }
  finally
  {
    final?.SilentTry();
  }

  return ok;
}

第二个问题通过在日志记录周围放置 try-catch 来解决,然后可以尝试做其他事情

private static void Log(Exception ex, int exceptLastN)
{
  try
  {
    var report = new ExceptionReport(ex, exceptLastN);
    string json = JsonConvert.SerializeObject(report, Formatting.Indented);
    Console.WriteLine(json);
  }
  catch (Exception loggerException)
  {
    // Log failure handler
  }
}

第三个问题可以通过使用泛型参数来解决,以捕获您特别想捕获的类型。再次使用 Func SilentTry 实现

public static bool SilentTry<E, T>(Func<T> fnc, out T ret, Action final = null, 
       T defaultValue = default(T), Action onException = null) where E: Exception
{
  bool ok = false;
  ret = defaultValue;

  try
  {
    ret = fnc();
    ok = true;
  }
  catch (Exception ex)
  {
    Log(ex, 1);
    onException?.Invoke();

    if (ex.GetType().Name != typeof(E).Name)
    {
      throw;
    }
  }
  finally
  {
    final?.SilentTry();
  }

  return ok;
}

现在请注意(为了可读性,注意显式参数用法)

bool ok = (Catcher.SilentTry<Exception, decimal>(
  () => Divide(5, 0),
  out decimal ret,
  defaultValue: decimal.MaxValue,
  onException: () => Console.WriteLine("Divide by zero!!!"),
  final: () => Console.WriteLine("The answer is not 42.")
));

异常被抛给调用者

然而,如果我们得到想要的异常

bool ok = (Catcher.SilentTry<DivideByZeroException, decimal>(
  () => Divide(5, 0),
  out decimal ret,
  defaultValue: decimal.MaxValue,
  onException: () => Console.WriteLine("Divide by zero!!!"),
  final: () => Console.WriteLine("The answer is not 42.")
));

它不是

上述代码的唯一问题是,我们现在必须显式指定我们静默处理的异常类型和返回参数类型。或者您可能喜欢这个版本

public static bool SilentTry<E>(Action action, Action final = null, 
Func<bool> onOtherException = null, Func<bool> onSpecifiedException = null) where E : Exception
{
  bool ok = false;

  try
  {
    action();
    ok = true;
  }
  catch (Exception ex)
  {
    Log(ex, 1);

    if (ex.GetType().Name == typeof(E).Name)
    {
      SilentTry(() => ok = onSpecifiedException?.Invoke() ?? false);
    }
    else
    {
      SilentTry(() => ok = onOtherException?.Invoke() ?? false);
    }
  }
  finally
  {
    final?.SilentTry();
  }

  return ok;
}

所以这允许你做这样的事情

if (!Catcher.SilentTry<SqlException>(() =>
  () => GetDataFromDatabase(),
  onSpecifiedException: () => GetDataFromLocalCache()))
{
  HandleTotalFailureToGetData();
}

很疯狂,对吧?我怀疑大多数人更喜欢等效的

try
{
  GetDataFromDatabase();
}
catch(SqlException)
{
  try
  {
    GetDataFromLocalCache();
  }
  catch
  {
    HandleTotalFailureToGetData();
  }
}
catch
{
  HandleTotalFailureToGetData();
}

哎呀。我不喜欢,主要是因为带有 catch 块的代码意图更难阅读。

throw vs. throw ex

这里简单提一下。几乎没有理由使用 throw ex;,因为它会重置异常堆栈

鉴于

Catcher.Try(ThrowSomething);

...

static void ThrowSomething()
{
  throw new Exception("Foobar");
}

throw 给你

请注意,这里的异常堆栈显示了对 ThrowSomething 的调用。

如果我将捕获器更改为使用 throw ex;,我们会得到

请注意,异常堆栈被重置,现在我们看到堆栈从 Catcher.Try 方法开始。我们丢失了抛出异常的 ThrowSomething 方法!

内部异常

当你因处理另一个异常而抛出异常时,内部异常会很有用。使用上面的例子,当我们有一个从本地缓存获取数据的回退时,我们可以这样写

try
{
  GetDataFromDatabase();
}
catch(SqlException ex)
{
  if (!cacheExists)
  {
    throw new Exception("Cache doesn't exist", ex);
  }
}

这里,内部异常告诉我们,最初从基类获取数据时引发了异常,并且由于我们没有本地缓存,所以我们抛出异常,因为回退也失败了。我们可以稍微不同地实现 Try 方法,允许 onException Action 指定回退

public static void Try(Action fnc, Action final = null, Action onException = null)
{
  try
  {
    fnc();
  }
  catch (Exception ex)
  {
   Log(ex, 1);
   try
    {
      onException?.Invoke();
    }
    catch (Exception ex2)
    {
      Log(ex2, 1);
      var newException = new Exception(ex2.Message, ex);
      Log(newException, 1);
      throw newException;
    }
    finally
    {
      final?.Try();
    }
  }
}

似乎 finally 块只有在异常回退失败时才有意义。如果我们尝试一下

Catcher.Try(GetDataFromDatabase, onException: GetDataFromCache);

回退也失败了

static void GetDataFromDatabase()
{
  throw new Exception("EX: GetDataFromDatabase");
}

static void GetDataFromCache()
{
  throw new Exception("EX: GetDataFromCache");
}

我们看到了带有内部异常的日志

请注意,我们没有获得外部异常的调用堆栈、文件名和方法,因为我们创建了一个新的 Exception 对象,只传入了回退的异常消息。这非常烦人,在我看来是 Exception 类的缺点。正如截图所示,我们确实获得了内部和外部异常的相关日志,这些日志可以合并。首先是 Try 方法

public static void Try(Action fnc, Action final = null, Action onException = null)
{
  try
  {
    fnc();
  }
  catch (Exception ex)
  {
    try
    {
      onException?.Invoke();
    }
    catch (Exception ex2)
    {
      Log(ex2, ex, 1);
      var newException = new Exception(ex2.Message, ex);
      throw newException;
    }
    finally
    {
      final?.Try();
    }
  }
}

然后是一个结合了两个异常的 Log 方法

private static void Log(Exception outer, Exception inner, int exceptLastN)
{
  try
  {
    var outerReport = new ExceptionReport(outer, exceptLastN);
    var innerReport = new ExceptionReport(inner, exceptLastN);
    outerReport.InnerException = innerReport;
    string json = JsonConvert.SerializeObject(outerReport, Formatting.Indented);
    Console.WriteLine(json);
  }
  catch (Exception loggerException)
  {
    // Log failure handler
  }
}

请注意,日志仅在 exception 回退失败时创建

这似乎更有用,并且很好地演示了如何通过一些智能包装 try-catch 来实际改进您从日志中获得的结果。

Debug vs. Release

当您将构建更改为“Release”时,Visual Studio 仍然会生成 PDB 文件

这个文件用于在 Exception 类中提供行号和源文件名,因此也用于通过 StackTrace 类生成堆栈跟踪。如果您不希望发布版本中包含 PDB 文件,则必须更改高级构建设置。首先,在“构建”->“配置管理器”对话框中将项目设置为发布模式

然后右键单击项目并选择“构建”部分,然后单击“高级”并为调试信息输出选择“

不幸的是,使用IDE时,这必须为解决方案中的每个项目完成。更简单的方法是在将应用程序部署到生产环境的任何过程中直接省略PDB文件,或者,如果您在命令行中使用msbuild,请将此添加到发布设置配置文件中

<DebugSymbols>false</DebugSymbols>
<DebugType>None</DebugType>

或者,从命令行指定

/p:DebugSymbols=false /p:DebugType=None

无论你如何操作,你现在会注意到异常是原始形式,因此代码生成的格式化 JSON 不再包含文件名或行号

我们可以通过对 StackFrameDate 类进行一些重构来去除 null0

public class StackFrameData
{
  [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
  public string FileName { get; private set; }

  public string Method { get; private set; }

  [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
  public int? LineNumber { get; private set; }

  public StackFrameData(StackFrame sf)
  {
    FileName = sf.GetFileName();
    Method = sf.GetMethod().Name;
    int ln = sf.GetFileLineNumber();
    LineNumber = ln == 0 ? new int?() : ln;
  }

  public override string ToString()
  {
    return $"{FileName}\r\n{Method}\r\n{LineNumber}";
  }
}

产生

结论

这是一次狂野而粗犷的旅程(正是我喜欢的那种),基本上是对异常处理语法的语法糖衣(尽管你可能不喜欢)。尽管本文的最初重点更多是创建机器可读的日志报告,但我希望为创建一致的异常处理方法播下了一颗种子。你可能不喜欢这种语法糖衣,但希望你能从本文中获得一些想法,找到适合你的解决方案。

历史

  • 2019年7月15日:初版
我抛出异常 - CodeProject - 代码之家
© . All rights reserved.