关于 .NET 异常处理的最佳实践






4.90/5 (404投票s)
关于 .NET 中异常处理的设计指南,将帮助您创建更健壮的软件
目录
- 引言
- 为最坏的情况做计划
- 安全编码
- 不要抛出新的 Exception()
- 不要将重要的异常信息放在 Message 字段中
- 每个线程只放置一个 catch (Exception ex)
- 捕获的通用异常应被发布
- 记录 Exception.ToString(); 绝不要只记录 Exception.Message!
- 每个线程不要捕获 (Exception) 多次
- 永远不要吞噬异常
- 清理代码应放在 finally 块中
- 到处使用 "using"
- 不要在错误条件下返回特殊值
- 不要使用异常来指示资源的缺失
- 不要将异常处理作为从方法返回信息的一种方式
- 使用异常来处理不应被忽略的错误
- 重新抛出异常时不要清除堆栈跟踪
- 在不增加语义值的情况下避免更改异常
- 异常应标记为 [Serializable]
- 如有疑问,不要使用 Assert,而要抛出异常
- 每个异常类应至少包含最初的三个构造函数
- 使用 AppDomain.UnhandledException 事件时要小心
- 不要重复造轮子
- VB.NET
- 结论
- 历史
引言
“我的软件从不失败”。你能相信吗?我几乎能听到你们所有人在尖叫,说我是个骗子。“从不失败的软件几乎是不可能的!”
与普遍看法相反,创建可靠、健壮的软件并非几乎不可能。请注意,我不是指没有 bug 的软件,那种用于控制核电站的软件。我指的是常见的业务软件,它可以在服务器上,甚至是一台桌面机器上,无人值守地运行很长时间(几周或几个月),并且能够可预测地运行,而没有任何重大的故障。所谓可预测,我的意思是它的故障率很低,你可以轻松理解故障条件以便快速修复,并且它在外部故障发生时绝不会损坏数据。
换句话说,稳定的软件。
软件有 bug 是可以原谅的,甚至是可以预料的。不可原谅的是出现一个你无法修复的重复 bug,因为你没有足够的信息。
为了更好地理解我的意思,我见过无数的业务软件,在数据库管理系统出现磁盘空间不足的错误时,会报告类似这样的信息:
“无法更新客户详细信息。请联系系统管理员稍后再试。”
虽然这个消息可能足以向业务用户报告一个未知的资源故障,但所有太多的调试信息就是这些了,用于调试错误原因。没有任何日志记录,理解发生了什么将耗时,并且程序员通常会猜测很多可能的原因,直到他们找到真正的原因。
请注意,在本文中,我将只专注于如何更好地使用 .NET 异常:我不会讨论如何正确报告错误消息,因为我认为这属于 UI 领域,并且在很大程度上取决于正在开发的界面和目标受众;一个面向青少年的博客文本编辑器应该以完全不同于套接字服务器的方式报告错误消息,后者将只被程序员直接使用。
为最坏的情况做计划
一些基本的设计概念将使您的程序更加健壮,并在出现意外错误时改善用户体验。我说的“在意外错误出现时改善用户体验”是什么意思?并不是说用户会因为您向他展示的精美对话框而感到欣喜。更多的是指不破坏数据,不导致计算机崩溃,并安全地运行。如果您的程序可以在磁盘空间不足的错误中安然通过而没有任何损害,那么您就改善了用户体验。
尽早检查
强类型检查和验证是防止意外异常以及记录和测试代码的有力工具。执行得越早检测到问题,就越容易修复。几个月后,试图理解 CustomerID
出现在发票项表 (InvoiceItems) 的 ProductID
列上的原因,既不有趣也不容易。如果您使用类来存储客户数据,而不是使用原始类型(例如 int
、string
等),那么编译器很可能永远不会允许您这样做。
不要信任外部数据
外部数据不可靠。必须进行广泛检查。无论数据来自注册表、数据库、磁盘、套接字、您刚刚写入的文件还是键盘,都无关紧要。所有外部数据都应进行检查,然后您才能依赖它。我经常看到程序信任配置文件,因为程序员从未想过有人会编辑该文件并损坏它。
唯一可靠的设备是:视频、鼠标和键盘。
任何时候你需要外部数据,你可能会遇到以下情况
- 安全权限不足
- 信息不存在
- 信息不完整
- 信息完整但无效
它实际上与注册表项、文件、套接字、数据库、Web 服务或串行端口无关。所有外部数据源都会迟早失败。计划安全失败并尽量减少损害。
写入操作也会失败
不可靠的数据源也是不可靠的数据存储库。当您保存数据时,可能会发生类似情况
- 安全权限不足
- 设备不存在
- 空间不足
- 设备存在物理故障
这就是为什么压缩程序创建临时文件并在完成后重命名它,而不是更改原始文件:如果磁盘(甚至软件)因某种原因失败,您就不会丢失原始数据。
安全编码
我的一位朋友经常说:“一个好的程序员是在他的项目中从不引入坏代码的人。”我不相信仅凭这一点就能成为一名好程序员,但肯定会让你接近成功。下面,我整理了一份关于在异常处理方面,您可以在项目中引入的最常见的“坏代码”列表。
不要抛出新的 Exception()
请不要这样做。Exception
是一个过于宽泛的类,很难在不产生副作用的情况下捕获它。派生您自己的异常类,但要从 ApplicationException
派生。这样,您可以为框架抛出的异常设置一个专门的异常处理程序,为自己抛出的异常设置另一个处理程序。
修订说明:David Levitt 在下面的评论区写信给我,他说尽管微软在 MSDN 文档 中仍然鼓吹使用 System.ApplicationException
作为基类,但根据 Brad Adams 的说法,这已不再被认为是好的做法,正如他在 他的博客 中所述。理念是创建尽可能浅而宽的异常类层次结构,就像您通常对待类层次结构一样。我没有立即更改文章的原因是,在我在此处介绍它之前,我需要做更多研究。在进行所有这些研究之后,我仍然无法确定浅层类层次结构在异常处理中是否是一个好主意,所以我决定在这里保留这两种观点。但是,无论您做什么,都不要抛出新的 Exception()
,并在需要时派生您自己的 Exception
类。
不要将重要的异常信息放在 Message 字段中
异常是类。当您返回异常信息时,创建字段来存储数据。如果您在这方面失败了,人们将需要解析 Message 字段来获取他们需要的信息。现在,想想当您需要本地化甚至只是更正错误消息中的拼写错误时,调用代码会发生什么。您可能永远不知道您会因此破坏多少代码。
每个线程只放置一个 catch (Exception ex)
通用的异常处理应在应用程序的中央点进行。每个线程都需要一个单独的 try/catch 块,否则您会丢失异常,并遇到难以理解的问题。当应用程序启动多个线程进行后台处理时,您通常会创建一个类来存储处理结果。不要忘记添加一个字段来存储可能发生的异常,否则您将无法将其传达给主线程。在“发送即忘”的情况下,您可能需要在线程处理程序中复制主应用程序异常处理程序。
捕获的通用异常应被发布
无论您使用什么进行日志记录——log4net、EIF、事件日志、TraceListeners、文本文件等——都没关系。真正重要的是:如果您捕获了一个通用异常,请将其记录在某个地方。但只记录一次——代码中经常充斥着记录异常的 catch 块,最终导致一个巨大的日志,其中包含太多重复的信息而无法有用。
记录 Exception.ToString(); 绝不要只记录 Exception.Message!
当我们谈论日志记录时,不要忘记您应该始终记录 Exception.ToString()
,而永远不要记录 Exception.Message
。Exception.ToString()
会为您提供堆栈跟踪、内部异常和消息。这通常是无价的信息,如果您只记录 Exception.Message
,您只会得到类似“对象引用未设置为 an object 的实例”的内容。
每个线程不要捕获 (Exception) 多次
此规则有一些罕见的例外(无意的双关)。如果您需要捕获异常,请始终使用您正在编写的代码最具体的异常。
我总是看到初学者认为好的代码是不抛出异常的代码。事实并非如此。好的代码会根据需要抛出异常,并且只处理它知道如何处理的异常。
作为此规则的一个示例,请看以下代码。我敢打赌写这段代码的人看到这个会杀了我,但这是来自真实世界的一个例子。实际上,真实世界中的代码更复杂一些——为了教学目的,我大大简化了它。
第一个类(MyClass
)在一个程序集中,第二个类(GenericLibrary
)在另一个程序集中,一个包含通用代码的库。在开发机器上,代码运行正常,但在 QA 机器上,代码总是返回“无效数字”,即使输入的数字是有效的。
你能说为什么会这样吗?
public class MyClass
{
public static string ValidateNumber(string userInput)
{
try
{
int val = GenericLibrary.ConvertToInt(userInput);
return "Valid number";
}
catch (Exception)
{
return "Invalid number";
}
}
}
public class GenericLibrary
{
public static int ConvertToInt(string userInput)
{
return Convert.ToInt32(userInput);
}
}
问题在于过于通用的异常处理程序。根据 MSDN 文档,Convert.ToInt32
只抛出 ArgumentException
、FormatException
和 OverflowException
。因此,这些是应该处理的唯一异常。
问题出在我们的设置中,该设置不包含第二个程序集(GenericLibrary
)。现在,当我们调用 ConvertToInt
时,会发生 FileNotFoundException
,而代码假定这是因为数字无效。
下次您编写“catch (Exception ex)
”时,请尝试描述您的代码在抛出 OutOfMemoryException
时会如何表现。
永远不要吞噬异常
你能做的最糟糕的事情就是捕获 (Exception) 并给它一个空的 code 块。绝不要这样做。
清理代码应放在 finally 块中
理想情况下,由于您不处理很多通用异常并有一个中央异常处理程序,您的代码应该有更多的 finally 块而不是 catch 块。永远不要在 finally 块之外执行清理代码,例如关闭流、恢复状态(如鼠标光标)。养成这个习惯。
人们经常忽略的一件事是,try/finally 块如何使您的代码更具可读性和健壮性。它是清理代码的绝佳工具。
举个例子,假设您需要从文件中读取一些临时信息并将其作为字符串返回。无论发生什么,您都需要删除该文件,因为它只是临时的。这种返回与清理工作需要一个 try/finally 块。
让我们看看最简单的没有使用 try/finally 的代码
string ReadTempFile(string FileName)
{
string fileContents;
using (StreamReader sr = new StreamReader(FileName))
{
fileContents = sr.ReadToEnd();
}
File.Delete(FileName);
return fileContents;
}
这段代码在抛出异常时也有问题,例如在 ReadToEnd
方法中:它会在磁盘上留下一个临时文件。所以,我确实看到有些人试图通过编码来解决它,就像这样
string ReadTempFile(string FileName)
{
try
{
string fileContents;
using (StreamReader sr = new StreamReader(FileName))
{
fileContents = sr.ReadToEnd();
}
File.Delete(FileName);
return fileContents;
}
catch (Exception)
{
File.Delete(FileName);
throw;
}
}
代码变得复杂,并且开始重复代码。
现在,看看 try/finally 解决方案是多么干净和健壮
string ReadTempFile(string FileName)
{
try
{
using (StreamReader sr = new StreamReader(FileName))
{
return sr.ReadToEnd();
}
}
finally
{
File.Delete(FileName);
}
}
fileContents 变量去了哪里?它不再是必需的,因为我们可以返回内容,并且清理代码在返回点之后执行。这是能够运行的代码的一个优点 在 函数返回之后:您可以清理可能需要用于 return 语句的资源。
到处使用 "using"
仅调用对象的 Dispose()
是不够的。using
关键字将防止资源泄漏,即使存在异常。
不要在错误条件下返回特殊值
特殊值存在很多问题
- 异常使得常规情况更快,因为当您从方法返回特殊值时,每个方法返回都需要检查,这会消耗至少一个额外的处理器寄存器,导致代码变慢
- 特殊值可能被忽略,而且一定会忽略
- 特殊值不携带堆栈跟踪和丰富的错误详细信息
- 很多时候,没有合适的值可以从函数返回以表示错误条件。您会从以下函数返回什么值来表示“除以零”错误?
public int divide(int x, int y)
{
return x / y;
}
不要使用异常来指示资源的缺失
微软建议您在极常见的情况下使用返回特殊值。我知道我刚才写了相反的观点,而且我也不喜欢它,但当大多数 API 一致时,生活会更轻松,所以我建议您谨慎遵循此风格。
我查看了 .NET 框架,发现几乎唯一使用此风格的 API 是返回某些资源的 API(例如 Assembly.GetManifestStream
方法)。所有这些 API 在资源缺失时都返回 null。
不要将异常处理作为从方法返回信息的一种方式
这是一个糟糕的设计。异常不仅慢(顾名思义,它们只用于异常情况),而且代码中大量的 try/catch 块会使代码更难理解。适当的类设计可以适应常见的返回值。如果您确实需要将数据作为异常返回,那么您的方法可能做得太多了,需要拆分。
使用异常来处理不应被忽略的错误
我将使用一个真实世界的例子。在开发一个 API 以便人们可以访问 Crivo(我的产品)时,您应该做的第一件事就是调用 Login
方法。如果 Login
失败或未调用,所有其他方法调用都会失败。我选择在 Login
方法失败时抛出异常,而不是简单地返回 false,这样调用程序就无法忽略它。
重新抛出异常时不要清除堆栈跟踪
堆栈跟踪是异常携带的最有用的信息之一。通常,我们需要在 catch 块中进行一些异常处理(例如回滚事务)并重新抛出异常。请看正确(和错误)的做法:错误的做法
try
{
// Some code that throws an exception
}
catch (Exception ex)
{
// some code that handles the exception
throw ex;
}
为什么这是错误的?因为当您检查堆栈跟踪时,异常的点将是“throw ex;
”所在的行,隐藏了真正的错误位置。试试看。
try
{
// Some code that throws an exception
}
catch (Exception ex)
{
// some code that handles the exception
throw;
}
有什么变化?与“throw ex;
”(它会抛出一个新异常并清除堆栈跟踪)相反,我们只有“throw;
”。如果您不指定异常,throw 语句将只重新抛出 catch 语句捕获的同一个异常。这将使您的堆栈跟踪保持不变,但仍然允许您在 catch 块中放置代码。
在不增加语义值的情况下避免更改异常
只有当您需要为异常添加一些语义值时才更改异常——例如,您正在做一个 DBMS 连接驱动程序,因此用户不关心特定的套接字错误,只想知道连接失败了。
如果您需要这样做,请务必将原始异常保留在 InnerException 成员中。不要忘记您的异常处理代码也可能有一个 bug,如果您有 InnerException,您可能会更容易找到它。
异常应标记为 [Serializable]
许多场景需要异常是可序列化的。当从另一个异常类派生时,不要忘记添加该属性。您永远不知道您的方法何时会被 Remoting 组件或 Web 服务调用。
如有疑问,不要使用 Assert,而要抛出异常
不要忘记 Debug.Assert
会从发布代码中移除。在检查和验证时,抛出异常通常比在代码中放置断言更好。
将断言保留用于单元测试、内部循环不变量以及那些由于运行时条件(如果您仔细想想,这是一个非常罕见的情况)而不应失败的检查。
每个异常类应至少包含最初的三个构造函数
这样做很容易(只需从其他异常类复制粘贴定义)并且不这样做将不允许您的类的用户遵循其中一些指南。
我指的是哪些构造函数?此页面
上描述的最后三个构造函数。
使用 AppDomain.UnhandledException 事件时要小心
修订说明:Phillip Haack
在 我的博客
中指出我遗漏了这一重要内容。其他常见的错误来源是 Application.ThreadException
事件。使用它们有很多注意事项
- 异常通知发生得太晚:当您收到通知时,您的应用程序将无法再响应异常。
- 如果异常发生在主线程上(实际上是任何从非托管代码开始的线程),应用程序将终止。
- 很难创建能够一致工作的通用代码。引用 MSDN 的话:“此事件仅发生在应用程序启动时系统创建的应用程序域中。如果应用程序创建了其他应用程序域,则在这些应用程序域中为此事件指定委托无效。”
- 当代码运行这些事件时,除了异常本身之外,您将无法获得任何有用的信息。您将无法关闭数据库连接、回滚事务或执行任何有用的操作。对于初学者来说,使用全局变量的诱惑会很大。
确实,您永远不应将整个异常处理策略建立在这些事件之上。将它们视为“安全网”,并记录异常以供进一步检查。之后,请务必纠正未正确处理异常的代码。
不要重复造轮子
有很多优秀的框架和库可以处理异常。微软提供了其中两个,我在此提及
- Exception Management Application Block (异常管理应用程序块)
- Microsoft Enterprise Instrumentation Framework (微软企业仪器框架)
但是,请注意,如果您不遵循严格的设计指南,例如我在此处展示的指南,这些库将几乎无用。
VB.NET
如果您阅读了本文,您会注意到我使用的所有示例都是 C# 的。这是因为 C# 是我喜欢的语言,而且 VB.NET 有自己的特殊指南。
模拟 C# 的 "using" 语句
不幸的是,VB.NET 仍然没有 using
语句。Whidbey 会有,但在此之前,每次需要处置对象时,您应该使用以下模式
Dim sw As StreamWriter = Nothing
Try
sw = New StreamWriter("C:\crivo.txt")
' Do something with sw
Finally
If Not sw is Nothing Then
sw.Dispose()
End if
End Finally
如果您在调用 Dispose
时执行了不同的操作,那么您很可能做错了,并且您的代码可能会失败和/或泄漏资源。
不要使用非结构化错误处理
非结构化错误处理也称为 On Error Goto
。Djikstra 教授在 1974 年写《 "Go To 语句的危害" 》时,他做得非常好。那已经是 30 多年前了!请尽快从您的应用程序中移除所有非结构化错误处理的痕迹。我向您保证,On Error Goto
语句会给您带来麻烦。
结论
我希望本文能帮助一些人更好地编写代码。我希望本文不仅仅是一个封闭的实践列表,而是关于如何处理代码中的异常以及如何使我们的程序更健壮的讨论的起点。
我不敢相信我写了这一切而没有任何错误或有争议的观点。我很想听听您对这个主题的意见和建议。
历史
- 2005 年 2 月 9 日 - 初始版本
- 2005 年 2 月 21 日 - 添加了关于
ApplicationException
、AppDomain.UnhandledException
和Application.ThreadException
的信息