.NET中异常处理的最佳实践
目录
- 介绍
- 做最坏的打算
- 提前检查
- 不要信任外部数据
- 可信任的设备:摄像头、鼠标以及键盘
- “写操作”同样可能失效
- 安全编程
- 不要抛出“new Exception()”
- 不要将重要的异常信息存储在Message属性中
- 每个线程要包含一个try/catch块
- 捕获异常后要记录下来
- 不要只记录Exception.Message的值,还需要记录Exception.ToString()
- 要捕获具体的异常
- 不要中止异常上抛
- 清理代码要放在finally块中
- 不要忘记使用using
- 不要使用特殊返回值去表示方法中发生的异常
- 不要使用“抛出异常”的方式去表示资源不存在
- 不要将“抛出异常”作为函数执行结果的一种
- 可以使用“抛出异常”的方式去着重说明不能被忽略的错误
- 不要清空了堆栈跟踪(stack trace)信息
- 异常类应标记为Serializable
- 使用”抛出异常”代替Debug.Assert
- 每个异常类至少包含三个构造方法
- 不要重复造轮子
- VB.NET
- 模拟C#中的using语句
- 不要使用非结构化异常处理(On Error goto)
- 总结
介绍
“我的软件程序从来都不会出错”。你们相信吗?我几乎可以肯定所有人都会大喊我是个骗子。“软件程序几乎不可能没有bug!”
事实上,开发一个可信任、健全的软件程序并不是不可能的事情。注意我这里并不是指那些用于控制核电站的软件,而是指一些常见的商业软件,这些软件可能运行在服务器上,又或者PC机上,它们可以连续工作几个星期甚至几个月都不会出现重大问题。可以猜到,我刚才的意思是指软件有一个比较低的出错率,你可以迅速找到出错的原因并快速修复,并且出现的错误并不会造成重大的数据损坏。
换句话说,我的意思是指软件比较稳定。
软件中有bug是可以理解的。但是如果是经常出现的bug,并且因为没有足够的提示信息导致你不能迅速修复它,那么这种情况是不可被原谅的。
为了更好地理解我上面所说的话,我举个例子:我经常看见无数的商业软件在遇到硬盘不足时给出这样的错误提示:
“更新客户资料失败,请与系统管理员联系然后重试”。
除了这些外,其他任何信息都没有被记录。要搞清楚到底什么原因引起的这个错误是一件非常耗时的过程,在真正找到问题原因之前,程序员可能需要做各种各样的猜测。
注意在这篇文章中,我主要讲怎样更好地处理.NET编程中的异常,并没有打算讨论怎样显示合适的“错误提示信息”,因为我觉得这个工作属于UI界面开发者,并且它大部分依赖于UI界面类型以及最终使用软件的用户。比如一个面向普通用户的文本编辑器的“错误提示信息”应该完全不同于一个Socket通信框架,因为后者直接用户是程序员。
做最坏的打算
遵守一些基本的设计原则可以让你的程序更加健全,并且当错误发生时,能够提升用户体验。我这里说到的“提升用户体验”并不是指错误的提示窗体能够让用户高兴,而是指发生的错误不会损坏原有数据,不会让整个电脑崩溃。如果你的程序遇到硬盘不足的错误,但是程序不会造成其他任何负面效果(仅仅提示错误信息,不会引起其他问题,译者注),那么这时候就提升了用户体验。
提前检查
强类型检查和验证是避免bug发生的有力方法。你越早发现问题,就越早修复问题。几个月后再想搞清楚“为什么InvoiceItems表中的ProductID栏会存在一个CustomerID数据?”是一件不太容易并且相当恼火的事情。如果你使用一个类代替基本类型(如int、string)去存储客户(Customer)的数据的话,编译器就不会允许刚才那件事情(指将CustomerID和ProductID混淆,译者注)发生。
不要信任外部数据
外部数据是不可靠的,我们的软件程序在使用它们之前必须严格检查。无论这些外部数据来自于注册表、数据库、硬盘、socket还是你用键盘编写的文件,所有这些外部数据在使用前必须严格进行检查。很多时候,我看到一些程序完全信任配置文件,因为开发这些程序的程序员总是认为没有人会编辑配置文件并损坏它。
可信任的设备:摄像头、鼠标以及键盘
当你需要用到外部数据时,你可能会遇到以下情况:
1)没有足够的安全权限
2)数据不存在
3)数据不完整
4)数据完整,但是格式不对
不管数据源是注册表中的某个键、一个文件、socket套接字、数据库、Web服务或者串口,以上情况均可能发生。所有的外部数据总会有失效的可能。
“写操作”同样可能失效
不可信任的数据源同样也是一种不可信任的数据仓库。当你存储数据时,相似情况依旧可能会发生:
1)没有足够的安全权限
2)设备不存在
3)没有足够的空间
4)存储设备发生了物理错误
这就是为什么一些压缩软件在工作时创建了一个临时文件,当工作完成后再重命名,而不是直接修改源文件。原因是如果硬盘损坏(或者软件异常)可能导致原始数据丢失。(译者遇见过这种情况,备份数据时断电,结果原来的旧版备份被损坏了,译者注)
安全编程
我的一个朋友告诉我:一个好的程序员从来不会在他的程序中编写糟糕的代码。我觉得这只是成为一个好程序员的必要条件而不是充分条件。下面我整理了一些当你进行异常处理时,可能会编写的“糟糕代码”:
不要抛出“new Exception()”
请别这样做。Exception是一个非常抽象的异常类,捕获这类异常通常会产生很多负面影响。通常情况下应该定义我们自己的异常类,并且需要区分系统(framework)抛出的异常和我们自己抛出的异常。
不要将重要的异常信息存储在Message属性中
异常都封装在类中。当你需要返回异常信息时,请将信息存储在一些单独的属性中(而不要放在Message属性中),否则人们很难从Message属性中解析出他们需要的信息。比如当你仅仅需要纠正一下拼写错误,如果你将错误信息和其它提示内容一起以String的形式写在了Message属性中,那么别人该怎样简单地获取他们要的错误信息呢?你很难想象到他们要做多少努力。
每个线程要包含一个try/catch块
一般异常处理都放在了程序中一个比较集中的地方。每个线程都需要有一个try/catch块,否则你会漏掉某些异常从而出现难以理解的问题。当一个程序开启了多个线程去处理后台任务时,通常你会创建一个类型来存储各个线程执行的结果。这时候请不要忘记了为类型增加一个字段来存储每个线程可能发生的异常,否则的话,主线程不会知道其他线程的异常情况。在一些“即发即忘”的场合(意思主线程开启线程后不再关心线程的运行情况,译者注),你可能需要将主线程中的异常处理逻辑复制一份到你的子线程中去。
捕获异常后要记录下来
不管你的程序是使用何种方式记录日志——log4net、EIF、Event Log、TraceListeners或者文本文件等,这些都不重要。重要的是:当你遇到异常后,应该在某个地方将它记录在日志中。但是请仅仅记录一次,否则的话,你最后会得到一个非常大的日志文件,包含了许多重复信息。
不要只记录Exception.Message的值,还需要记录Exception.ToString()
当我们谈到记录日志时,不要忘了我们应该记录Exception.ToString()的值,而不是Exception.Message。因为Exception.ToString()包含了“堆栈跟踪”(stack trace)信息,内部异常信息以及Message。通常这些信息非常重要,而如果你只记录Exception.Message的话,你只可能看到类似“对象引用未指向堆中实例”这样的提示。
要捕获具体的异常
如果你要捕获异常,请尽可能的捕获具体异常(而非Exception)。
我经常看见初学者说,一段好的代码就是不能抛出异常的代码。其实这说法是错误的,好的代码在必要时应该抛出相应的异常,并且好的代码只能捕获它知道该怎么处理的异常(注意这句话,译者注)。
下面的代码作为对这条规则的说明。我敢打赌编写下面这段代码的那个家伙看见了会杀了我的,但是它确实是摘取自真实编程工作中的一段代码。
第一个类MyClass在一个程序集中,第二个类GenericLibrary在另一个程序集中。在开发的机器上运行正常,但是在测试机器上却总是抛出“数据不合法!”的异常,尽管每次输入的数据都是合法的。
你们能说说这是为什么吗?
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.dll)打包进去。所以程序运行后,ConvertToInt方法会抛出FileNotFoundException异常,但是我们捕获的异常是Exception,所以会提示“数据不合法”。
不要中止异常上抛
最坏的情况是,你编写catch(Exception)这样的代码,并且在catch块中啥也不干。请不要这样做。
清理代码要放在finally块中
大多数时候,我们只处理某一些特定的异常,其它异常不负责处理。那么我们的代码中就应该多一些finally块(就算发生了不处理的异常,也可以在finally块中做一些事情,译者注),比如清理资源的代码、关闭流或者回复状态等。请把这当作习惯。
有一件大家容易忽略的事情是:怎样让我们的try/catch块同时具备易读性和健壮性。举个例子,假设你需要从一个临时文件中读取数据并且返回一个字符串。无论什么情况发生,我们都得删除这个临时文件,因为它是临时性的。
让我们先看看最简单的不使用try/catch块的代码:
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去哪里了?它不再需要了,因为返回点在清理代码前面。这是让代码在方法返回后才执行的好处:你可以清理那些返回语句需要用到的资源(方法返回时需要用到的资源,所以资源只能在方法返回后才能释放,译者注)。
不要忘记使用using
仅仅调用对象的Dispose()方法是不够的。即使异常发生时,using关键字也能够防止资源泄漏。
不要使用特殊返回值去表示方法中发生的异常
因为这样做有很多问题:
1)直接抛出异常更快,因为使用特殊的返回值表示异常时,我们每次调用完方法时,都需要去检查返回结果,并且这至少要多占用一个寄存器。降低代码运行速度。
2)特殊返回值能,并且很可能被忽略
3)特殊返回值不能包含堆栈跟踪(stack trace)信息,不能返回异常的详细信息
4)很多时候,不存在一个特殊值去表示方法中发生的异常,比如,除数为零的情况:
public int divide(int x, int y) { return x / y; }
不要使用“抛出异常”的方式去表示资源不存在
微软建议在某些特定场合,方法可以通过返回一些特定值来表示方法在执行过程中发生了预计之外的事情。我知道我上面提到的规则恰恰跟这条建议相反,我也不喜欢这样搞。但是一些API确实使用了某些特殊返回值来表示方法中的异常,并且工作得很好,所以我还是觉得你们可以谨慎地遵循这条建议。
我看到了.NET Framework中很多获取资源的API方法使用了特殊返回值,比如Assembly.GetManifestStream方法,当找不到资源时(异常),它会返回null(不会抛出异常)。
不要将“抛出异常”作为函数执行结果的一种
这是一个非常糟糕的设计。代码中包含太多的try/catch块会使代码难以理解,恰当的设计完全可以满足一个方法返回各种不同的执行结果(绝不可能到了必须使用抛出异常的方式才能说明方法执行结果的地步,译者注),如果你确实需要通过抛出异常来表示方法的执行结果,那只能说明你这个方法做了太多事情,必须进行拆分。(这里原文的意思是,除非确实有异常发生,否则一个方法不应该仅仅是为了说明执行结果而抛出异常,也就是说,不能无病呻呤,译者注)
可以使用“抛出异常”的方式去着重说明不能被忽略的错误
我可以举个现实中的例子。我为我的Grivo(我的一个产品)开发了一个用来登录的API(Login),如果用户登录失败,或者用户并没有调用Login方法,那么他们调用其他方法时都会失败。我在设计Login方法的时候这样做的:如果用户登录失败,它会抛出一个异常,而并不是简单的返回false。正因为这样,调用者(用户)才不会忽略(他还没登录)这个事实。
不要清空了堆栈跟踪(stack trace)信息
堆栈跟踪信息是异常发生时最重要的信息,我们经常需要在catch块中处理一些异常,有时候还需要重新上抛异常(re-throw)。下面来看看两种方法(一种错误的一种正确的):
错误的做法:
try { // Some code that throws an exception } catch (Exception ex) { // some code that handles the exception throw ex; }
为什么错了?因为当我们检查堆栈跟踪信息时,异常错误源变成了“thorw ex;”,这隐藏了真正异常抛出的位置。试一下下面这种做法:
try { // Some code that throws an exception } catch (Exception ex) { // some code that handles the exception throw; }
有什么变化没?我们使用“throw;”代替了“throw ex;”,后者会清空原来的堆栈跟踪信息。如果我们在抛出异常时没有指定具体的异常(简单的throw),那么它会默认地将原来捕获的异常继续上抛。这样的话,上层代码捕获的异常还是最开始我们通过catch捕获的同一个异常。
异常类应标记为Serializable
很多时候,我们的异常需要能被序列化。当我们派生一个新的异常类型时,请不要忘了给它加上Serializable属性。谁会知道我们的异常类会不会用在Remoting Call或者Web Services中呢?
使用”抛出异常”代替Debug.Assert
当我们发布程序后,不要忘了Debug.Assert将会被忽略。我们在代码中做一些检查或者验证工作时,最好使用抛出异常的方式代替输出Debug信息。
将输出Debug信息这种方式用到单元测试或者那些只需要测试当软件真正发布后确保不会出错的场合。
每个异常类至少包含三个构造方法
做这件事相当简单(直接从其他的类型粘贴拷贝相同的代码即可),如果你不这样做,那么别人在使用你编写的异常类型时,很难遵守上面给出的一些规则的。
我指的哪些构造方法呢?这三个构造方法可以参见这里。
不要重复造轮子
已经有很多在异常处理方面做得比较好的框架或库,微软提供的有两个:
Exception Management Application Block
Microsoft Enterprise Instrumentation Framework
注意,如果你不遵守我上面提到的一些规则,这些库对你来讲可能没什么用。
VB.NET
如果你已经读完整篇文章,你就会发现所有的示例代码都是用C#编写的。那是因为C#是我比较喜欢的.NET语言,并且VB.NET有它自己的一些特殊规则。
模拟C#中的using语句
不幸的是,VB.NET中并没有using语句。你每次在释放一个对象的非托管资源时,不得不这样去做:
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方法的话,很可能会出现错误(有关Dispose方法的调用,请关注 新书。译者注)。
不要使用非结构化异常处理(On Error Goto)
非结构化异常处理也叫“On Error Goto”,Djikstra(艾兹赫尔·戴克斯特拉)在1974年说过“goto语句有害无益”,这已经是30年之前了!请删除你代码中的所有goto式的语句,我向你保证,他们万害无一益。(艾兹赫尔·戴克斯特拉提出了“goto有害论”、信号量和PV原语,解决了有趣的哲学家就餐问题。《软件故事》一书中讲Fortran语言时提到过他。译者注)
总结
我希望本篇文章能够让一部分人能够提高他们的编码质量,也希望这篇文章是讨论怎样有效地进行异常处理的开始,并让我们编写的程序更加健壮。