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

C# 课程 - 第8课:灾难恢复。C# 示例中的异常和错误处理

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.81/5 (22投票s)

2016年5月2日

CPOL

18分钟阅读

viewsIcon

36668

downloadIcon

729

我的第 8 讲, 关于异常处理和项目中的错误处理组织

全部课程集


引言

在本文中,我将回顾使用 .NET 和 C# 语言开发应用程序的一个基本方面。我们将讨论异常和异常处理。请注意,我在这里回顾的是一个敏感话题,以下所有内容都是我个人的想法和观点。如果您有任何问题,我很乐意回答,但我还没有准备好就修辞性问题进行辩论,我只是分享我的观点。如果您不同意我的看法,请尝试寻找其他关于此主题的资料。

什么是异常处理?

良好的编程设计要求您的功能在完成有用和必要的工作之外,还能向您报告运行时发生的错误和意外情况。处理错误和从意外情况中恢复对于任何应用程序都是基本且必需的。处理错误和异常流程有很多方法,其中一种方法就是异常处理,我将在本文中对此进行回顾。

让我们看一个小例子:您的函数尝试将数据发送到服务器,但在中间网络连接断开了。最坏的情况是您的函数结束甚至崩溃而没有任何更新。更好的情况是它会返回 false。非常好的情况是函数会返回一个错误代码,其中包含来自网络层的信息,并将其写入日志,这样您最终就能理解您的流程以及哪里出了问题。

几乎所有设计良好的软件都会编写详细的日志,并返回有意义的错误代码以及对用户/技术人员关于错误性质的文本说明。这些信息对于排查和改进您的应用程序非常有用,所有现代应用程序内部都具有良好的错误处理和日志记录功能。此外,所有现代软件设计都假定可以从问题情况中 proper recovery。从最初的例子,当我们的函数未能将数据发送到服务器并收到,我们称之为 NO_NETWORK 错误时,它可以等待一段时间然后重试,或者使用另一个函数 ping 网络是否存活,然后当连接建立后再次尝试。所有这些事情都是由开发人员设计和实现的,它们也由程序代码控制。这实际上是软件开发的理念,即开发一个封装了某些功能的系统,它不仅在“良好”的条件下工作,而且能够抵御 faulty scenarios 并从中恢复。

以上所述对于您自己设计和创建的事物都很好,但在软件开发中,有很多事情是开发人员无法控制的,并且使用黑盒,或者错误发生的并非如我样本中所述的那样直接。当您使用 .NET 函数、Windows API、第三方 API 时,您无法影响它们并更改它们的流程或行为。当它们失败或崩溃时,您也无法更改它们,但您需要能够从中恢复。您依赖于其他开发者的产品和操作系统来执行您自己的软件。此外,还有一些您无法控制的函数,例如构造函数不返回任何错误代码及其执行结果,但那里可能会发生问题。与第三方一样,它们的性能在某些情况下可能会崩溃,您甚至不会收到任何通知。有很多这样的事情是您无法控制的,但您需要能够捕获这种情况并从中恢复。在面向对象的 C# 和 .NET 框架中,有一个机制可以通知此类错误并进行处理。这个机制被称为**异常处理**。

什么是异常处理?我喜欢引用维基百科的话:“***异常处理是响应计算过程中发生的异常——异常的或异常的情况,需要特殊的处理——通常改变正常程序执行流的过程。它由专门的编程语言构造或计算机硬件机制提供**”*。当发生异常并且您的应用程序已准备好时,它会有一个特定的异常处理程序,该处理程序会收到异常通知。有些异常您可以忽略或从中恢复,但有些异常,如内存溢出或堆栈溢出,您几乎无法 proper handle,在最好的情况下,您只能捕获它们并向用户通过消息或日志行通知这类异常。至少您可以进行一些清理工作,用户将被告知执行终止的原因。

几乎所有现代编程语言都内置了对异常的支持。您可以使用语言构造,使您能够收到异常通知 - 捕获它并运行一个 handler,该 handler 将根据异常类型执行 specific actions。C# 是支持异常处理的语言之一。在本文的后面,我将回顾 C# 中异常处理的构造和方法。

结构化异常处理 (SEH)

异常通常是发生在正常执行流程之外的某个事件。有两种类型的异常:

  • 硬件异常 - 由 CPU 触发。例如除以零
  • 软件异常 - 由软件或操作系统触发

结构化异常处理是处理硬件和软件异常的常用方法。SEH 是 Microsoft 原生的异常处理方式,它具有 finally 机制,而标准 C++ 异常例如就没有。SEH 是为每个执行线程设置和处理的。

我将尝试解释结构化异常处理的工作原理。每个执行线程在其信息块中都有一个指向 **_EXCEPTION_REGISTRATION_RECORD** 的链接。当执行 **try** 操作符时,它会在 **_EXCEPTION_REGISTRATION_RECORD** 的头部添加一个记录。当 try 块完成时,会执行反向操作。该记录有一个指向 **msvcrt.dll** 中的 **__except_handler3** 函数的指针。如果存在,则所有 **catch** 和 **finally** 块都由 **__except_handler3** 例程调用。当在用户模式下发生异常时,操作系统会按顺序解析 **_EXCEPTION_REGISTRATION_RECORD**,直到某个 handler 通过返回值或 handler 列表完成来指示它已处理异常。列表中的最后一个始终是 **kernel32** 的 **UnhandledExceptionFilter**,它会显示 **General protection fault** 错误消息。一旦操作系统完成 handler,它会再次遍历 handler 列表以查找那些可以执行资源清理的 handler。完成所有这些之后,执行将返回到内核模式,在那里进程将恢复或终止。

C# 中的异常处理

如前所述,C# 内置了异常处理机制。C# 使用 .NET 异常处理基础,而 .NET 使用**结构化异常处理 (SEH)**。C# 有三个关键字用于异常处理:

  • **try** - try 是一个操作符,用于标识您正在等待异常的代码区域,以及发生异常后需要恢复或清理资源的代码所在的位置。
  • **catch** - 操作符用于在捕获到特定类型或任何类型的异常后执行的代码。catch 后面的代码用于从异常中恢复并在异常被引发后更改执行流。如果您在 catch 语句中使用特定异常类型,它只会查找该类型的异常,否则会捕获任何异常。只有在发生异常时,catch 后面的代码才会**执行**。
  • **finally** - 包含通常在 **try** 块中的代码之后执行资源清理的代码。finally 块中的代码**总是执行**,而不管是否发生异常。

下面的代码演示了一个简单的 C# 代码,展示了上述所有操作符的用法。

        void SampleFunction()
        {
            try
            {
                //code that may raise exception and need to be revovered\cleaned up
            }
            catch (AccessViolationException av)
            {
                //code to recovery from AccessViolationException
            }
            catch (UnauthorizedAccessException ua)
            {
                //code to recovery from UnauthorizedAccessException
            }
            catch
            {
                //code to recovery from all other exception types
            }
            finally
            {
                //code that will be executed forever and that will do cleaning up
            }
        }

操作符使用规则

  • 作为开发人员,您可以决定如何组合操作符来进行异常处理。可以是一个 **try** 和一个 **finally**,一个 **try** 和一个 **catch**,或者一个 **try** 和多个 **catches**。也可以是 **try**、**catch** 和 **finally**。
  • 可以有嵌套的 **try** 操作符。
  • **try** 块必须至少与一个 **catch** 或 **finally** 连接。您应根据自己的需求决定。
  • 如果您有多个 **catches** 并且发生了异常,运行时会从上到下查找 handler,如果您有特定的异常 handler,它们应该放在前面。
  • 一旦捕获到异常,运行时会先执行与同一 **try** 连接的 **catch** 块中的代码,然后执行 **finally** 块中的代码。在执行 **catch** 块之前,所有嵌套的 **finally** 块都会被执行。

一旦您捕获了异常并在 catch 块中,您有 3 种选择:

  • 将相同的异常抛给堆栈上方的代码。下面的代码演示了如何做到这一点。
 catch(FileNotFoundException e)
      {
         Console.WriteLine("[Data File Missing] {0}", e);
         throw new FileNotFoundException(@"[data.txt not in c:\temp directory]",e);
      }
  • 抛出带有附加数据的新异常给堆栈上方的代码。

  • 允许代码自然地离开 **catch** 块并从下一个操作符继续执行。

System.Exception

在 C# 中,在 catch 块中,您可以使用变量名来指向从 **System.Exception** 派生的异常类。使用这个变量名,您可以获得关于异常的详细信息。实际上,**System.Exception** 或从它派生的类型是 .NET 处理异常的方式,并且所有的异常处理都基于这个类。大多数 .NET 语言的编译器都允许您只生成派生自 **System.Exception** 的异常。

请看下面的代码。

            try
            {
                int i = 0;
                Console.WriteLine(1/i);//division by 0 causes exception
            }
            catch(Exception e)
            {
                Console.WriteLine("Exception help link is: " + e.HelpLink);
                Console.WriteLine("Exception HReult is: " + e.HResult);
                Console.WriteLine("Exception message is: " + e.Message);
                Console.WriteLine("Exception source is: " + e.Source);
                Console.WriteLine("Exception stack trace is: " + e.StackTrace);
                Console.WriteLine("Exception target site i: " + e.TargetSite);
                Console.WriteLine();
            }

代码的结果如下:

从我的角度来看,您可以从异常类中获得的最有趣的内容是:

  • StackTrace - 显示从异常被抛出的方法到被捕获的方法的堆栈跟踪表示。
  • TargetSite - 提供抛出异常的方法的名称。
  • Message - 获取描述当前异常的消息。
  • HResult - 获取分配给特定异常的 **HRESULT** 数值。

使用这些字段,您可以将它们打印到日志中,然后如果您的程序运行时发生了一些异常,您就能轻松地找到它们发生的位置并理解它们。

生成自己的异常

当您的函数执行不正确或无法解决其任务时,您可以(有人说应该)生成异常。大多数情况下,运行时在发生异常时会生成异常对象。异常是告知调用者您遇到了异常情况,并且该情况应该在那里或在上层进行处理的方式。如果您决定创建并抛出自己的异常,您应该这样做:

  • 确保您的函数的调用者知道您将抛出它。
  • 确保您的函数的调用者知道您为什么以及何时抛出它。
  • 确保您的函数的调用者知道如果您的异常被抛出该怎么办。
  • 决定将使用哪种类型来表示您的异常。
  • 如果没有标准类型满足您的需求,您可以决定创建自己的异常类型。

CLS 兼容异常

在 C# 中,每个生成的异常都派生自 **System.Exception**,这是 C# 语言的限制。这也由 **CLS**(公共语言规范)要求,但还有其他语言创建 **String**、**Int** 或任何非 **CLS** 兼容类型的异常。早期版本的 .NET 运行时只支持 **CLS** 兼容异常,不捕获任何其他类型。这意味着如果您的代码使用了用另一种语言编写的组件,并且该组件引发了非 **CLS** 兼容异常,您将无法捕获它。从 **CLR** 2.0 开始,.NET 有 **RuntimeWrappedException** 类,用于此类情况。现在,当出现 **CLS** 不兼容异常时,此类会将其包装起来,您的代码将接收到一个派生自 **System.Exception** 的异常。通过使用这种方法,Microsoft 提高了 C# 应用程序的健壮性,现在它们可以捕获所有类型的异常。

如果您想区分处理 **CLS** 和**非 CLS** 异常,您可以按正确的顺序捕获它们。

try {
    // some code
catch(RuntimeWrappedException ex) {
    // non-CLS exceptions
catch(Exception ex) {
    // CLS exceptions
}

.NET 中的异常层次结构

有两种类型的异常:由 **CLR** 运行时生成的异常和由应用程序生成的异常。为此,Microsoft 开发人员创建了两个派生自 **System.Exception** 的类型:**ApplicationException** 和 **SystemException**。计划使用 **ApplicationException** 作为由应用程序生成的异常的父类,而 **SystemException** 作为 **CLR** 异常的父类。现实情况有所不同,系统异常有时会派生 **ApplicationException**,一些应用程序异常会派生 **SystemException**,一些直接派生 **System.Exception**。最后,存在一些混乱,Microsoft 甚至想消除 **ApplicationException** 和 **SystemException**,但这会破坏现有软件,因此它们保留了向后兼容性。

大多数情况下,当运行时出现问题时会抛出 **SystemException**。当您设计应用程序和自己的异常时,您应该从 **Exception** 类派生它们,而不是 **SystemException**。也不建议捕获 **SystemException** 并重新抛出。运行时最关键的异常是:

  • ExecutionEngineException
  • StackOwerflowException
  • OutOfMemoryException

互操作异常也派生自 **SystemException**,以下异常也是系统异常:

  • COMException
  • Win32Exception
  • SEHException

典型异常示例

并非所有异常都容易模拟和重现。有时只需要特定的条件,但其中一些您可以轻松地在代码中生成。下面看我的一些例子。

            try 
            {
                //out of range
                char[] arr = new char[5];
                char c = arr[6];
            }
            catch(IndexOutOfRangeException e)
            {
                Console.WriteLine("Out of range catch with message: " + e.Message);
            }
            try 
            {
                //null reference
                object o = null;
                o.ToString();
            }
            catch (NullReferenceException e)
            {
                Console.WriteLine("Null reference catch with message: " + e.Message);
            }
            try
            {
                //argument null exception
                string s = null;
                "somestring".Contains(s);
            }
            catch (ArgumentNullException e)
            {
                Console.WriteLine("Argument null catch with message: " + e.Message);
            }

要玩这个代码,您可以下载附带在文章中的示例代码。

异常最佳实践

以下是我认为对异常处理重要的建议列表,并希望与您分享。还有更多来自不同来源的建议,您可以搜索它们。我没有包含一些建议,因为我认为它们不太相关或我不同意一些建议。这只是建议,不是规则,您可以决定对您的工作使用哪种方法。

  • 不要捕获所有异常 - 您应该只捕获您期望并且知道如何在代码中处理的异常。不要替他人做决定,也不要向调用者隐藏问题。您应该允许调用您方法的代码做出决定,并且不要捕获您无法处理的异常。
  • 不要忘记使用 **finally** - 大多数开发人员只在代码中使用 **try** 和 **catch**。他们至少使用这些是好的,但 **finally** 是一个非常强大的操作符和异常处理方法。您不应该忘记它,并将其用于任何情况下都应执行的代码块。您应该将所有资源清理和关闭代码放在 finally 块中,这将确保您正确释放资源并且没有内存泄漏。
  • 您可以在代码中捕获所有异常并在捕获异常后执行一些操作,但之后您应该从 **catch** 块中重新抛出它。这是一种很好的风格和方法。
  • 如果有可能在不捕获异常的情况下通过简单的检查操作符来检查某个条件或执行验证,请这样做。异常是为了异常情况而存在的,而不是为了检查您是否在 null 指针上调用了什么。
  • 将您的 **catch** 块从非常具体的异常开始,到最通用的异常结束。
  • 优先使用框架异常而不是您自己的异常。尽可能避免生成新的异常类型。
  • 当您引发异常时,请尝试用最大的信息填充其字段,并使这些信息格式正确且有意义。
  • 不要抛出通用的异常类型,如 **System.Exception** 或 **System.SystemException**。
  • 只有当您知道异常被抛出的原因以及如何处理情况时,才捕获特定的异常。仅仅为了好玩而捕获是不正确的工作方式,可能会影响重要的应用程序流程。

错误代码实践

如果您查看 **Windows API**,您会发现相当多的 API 返回 **HRESULT**,这是 Windows API 中使用的错误代码。打开文件 **Winerror.h**,在那里您会找到 Microsoft 为其函数结果定义的 **HRESULT** 错误代码的完整列表。此外,如果某些 Windows API 运行失败,您可以通过调用 **GetLastError API** 来了解原因,该 API 将返回一个 **DWORD** 值,您可以使用某些工具或在网上查找将其转换为错误消息。基于此,您可以看到对于 Windows API,Microsoft 选择使用错误代码而不是引发异常。当然,在您运行某些 API 时可能会抛出异常,但很有可能它将是系统运行时异常,而不是由 API 实现代码引发的异常。

将执行结果包装成错误代码具有优点:

  • 您可以为操作的结果创建一个特定的类型,其中可以包含您想要的任何数据。
  • 您无需从像 Exception 这样的任何类型派生即可实现您的结果。
  • 您对从函数返回的数据拥有完全控制权。
  • 您的函数的调用者不必关心除了系统之外的任何异常类型,因为您没有抛出任何东西。

在我职业生涯中,实现基于异常的软件遇到的最大问题是开发人员对异常的目的、角色和理念的理解不同。让我们回顾一下正在实现一个由低级功能、中间层逻辑和高级 UI 组成的 3 层软件的 3 个开发团队。当您处理错误代码时,解释一些内容会更容易。您编写规范,创建一个包含代码的全局文件,每个人都使用它们。函数即使失败,也只是返回一些错误的 कोड,仅此而已。有了异常,生活会复杂得多。您需要捕获异常,一旦未捕获,您的应用程序可能会意外关闭/崩溃,或者用户会收到来自运行时的烦人消息。现在,让我们回到我们的示例。在此示例中,低层团队实施了所有内容,并且无论何时他们无法完成某项工作或出现问题,他们都会一直抛出异常。如果异常一直被抛出,这是很好的。您可以想象,如果某个开发人员在函数工作不正常时没有抛出异常。这意味着上层会认为它按预期工作,而您可能会有一个非常难以捕获的 bug。这不仅仅是一个问题,糟糕的事情会发生在上面的层级。某个开发人员可能会在中层捕获异常并将其抛给 UI 层,有些可能会选择在中层处理它,有些则根本不捕获它。这会在项目中造成如此大的混乱,以至于您完全无法控制。

我参与过几十个尝试基于异常构建错误处理的项目。只有一个成功了。如果团队决定使用异常而不是错误代码,他们应该非常紧密地合作,并且每个工程师都应该以相同的方式理解项目中的错误处理理念。否则,您将失败。这也是大型公司和大型项目选择错误代码作为结果通知主要方式,并将异常用于异常情况和真正异常情况的主要原因。异常的另一个问题是,异常被抛出后,执行不再是线性地一个操作符接一个操作符,而是跳转到 catch 和 finally 块,并非所有开发人员都能够设计和实现以这种方式工作的软件。决定只使用异常,这是一个重要的决定,您应该考虑其优点和缺点。如果您问我,我更喜欢使用组合方式,并为项目的返回值创建自己的类型,并并行使用异常来处理一些常见的异常情况。我将在下面回顾这一点。

异常 vs 错误

在深入研究 C# 中的异常和异常处理之后,我想加入到可能数百万篇名为“异常 vs 错误”或类似名称的出版物和讨论中。自从异常的早期时代以来,人们一直在讨论什么更好,异常还是错误,以及您应该决定使用什么。我不想继续争论什么更好或不好,而是想分享我和我的团队是如何在这方面工作的。

当我们在开发某个模块或应用程序的一部分,并向其他部分公开某种 API 时,我们从那里返回错误代码。我们定义一个包含错误代码的 **ENUM**,然后将其包装到一个结构中,该结构包含一个用于该错误的文本消息,这就是我们从所有 API 返回的 **Error** 结构。在 API 实现内部,我们尝试处理可能的异常,如果我们知道它们。注意:我们只处理我们知道如何处理的异常。相当多的时候,当我们捕获异常并且知道如何从中恢复时,我们会将其包装到我们的 **Error** 结构中,其中 **Error.Code** 是类似 **ERR_XCEPTION**(我们的枚举值之一)的东西,而 **Error.Text** 是异常文本消息。当使用这种方法时,我们的 API 用户可以依赖我们的 **Error** 结构作为操作的结果,并将其集成到他们的 UI 和日志系统中。从我的角度来看,这是最高效的工作方式,它非常方便、可扩展且灵活。那些我们有时不知道如何处理的异常,我们会捕获它们,记录它们并重新抛出;有时我们忽略它们;有时我们根据情况采取其他解决方案。

基于此,如果您问我错误代码 vs 异常?我的答案是两者兼有,您应该同时使用它们并根据您的需求进行集成。

来源

Jeffrey Richter - CLR via C#

Andrew Troelsen - Pro C# 5.0 and the .NET 4.5 Framework

https://msdn.microsoft.com

https://www.wikipedia.org

 

 

© . All rights reserved.