.NET中异常处理最佳实践

简介

    “我的软件从来不出错”你能相信吗?我几乎听到你们全部尖叫说我是个说慌者。“从不出错的软件从某种程度上讲是不可能的!”

    和普通人的观念相反,创造可信赖的,健壮的软件并不是一件不可能的事情。请注意,我并没有提及意欲控制核电站的无漏洞软件。我提到的仅仅是可以在无人看管的服务器或者客户端机器上运行的普通的商业软件,在长时间(几个星期或是几个月)可以无重大故障的工作。可预测的,我的意思是它拥有低出错率,你可以迅速理解出错原因然后快速搞定它,同是,它从不因为外部错误而毁坏数据。换句话说,软件是稳定的。软件中有漏洞是可以原谅的,甚至是被期望的。不可原谅的是您无法解决一个复发的漏洞,仅仅是因为您没用足够的信息。

    为了更好的理解我所说的,我看过不计其数的商业软件,DBMS是这样报告空间不足的错误的:“不能更新用户操作,请与系统管理员联系后在尝试。”

    虽然,用这则消息向一个商业用户报告一种未知的资源失败也许是恰当的,通常它应该是可以用来调试错误原因的全部调试信息。但是,如果没用东西被记入日志,了解当时的状况将会是一件非常费时的事情,通常,程序员会猜测许多可能的原因直到他们找到真正的错误。 

    在这篇文章中注意到这些,我将会集中精力介绍如何去更好的利用.NET的异常机制:我不会去讨论怎样正确的报告错误信息,因为我认为这个问题应该属于UI(用户界面)领域,同时它十分依赖正在开发的接口和所要面对的听众;一面向青少年的博客文章编辑者应该用一种与直接面向编程人员的socket server完全不同的方式来报告出错信息。

做好最坏的打算

    几个基础设计概念将会使你的程序更加健壮,同时提高用户处理意外错误的经验。“提高用户处理意外错误的经验”是什么意思呢?它是用户不会被你提供的令人惊异的对话框吓的发抖。它更多的是不要使用户误用数据,搞垮计算机,以确保计算机运转的更加安全。如果你的程序可以无损坏的处理空间不足错误,你将会增加用户的经验。

及早检查

    强大的类型检查和确认是防止意外异常,确保文档记录及代码检查的有力工具。发现问题的杀伤力越早,这个问题就越容易解决。设法在数月后去了解InvoiceItems 表中Customerid在Productid栏目中的作用几乎是不可能的,这不是开玩笑。如果你使用类而不是原始数据类型(比如 int ,string 等)来存储客户数据,编译器将不会给你处理上述事件的机会。

不要相信外部数据

    外部数据是不可相信的。不管这些数据是来自寄存器,数据库,硬盘,socket,你所书写的文件抑或是键盘,它们都必须被广泛的检查。所有的外部数据都应该被检查,只有这样你才可以信任它。我经常发现信任配置文件的程序,原因是它们的编写者从没想过有人会编辑或误用它。

唯一可信赖的外部设施是:显示器,鼠标和键盘

当你需要外部数据时,你可能遇到以下的情形:

◆没有足够的安全权限

◆信息不在那儿

◆信息不完整

◆信息是完整的但是不可用的

    不管它是注册信息,文件,SOCKET,数据库,网站服务抑或是串行端口,所有的外部数据源迟早都会出错。为安全错误做好准备才能把损失降到最小。


书写同样可能出错

不可靠的数据源同样是不可靠的数据仓库。当你保存数据时,类似的情况可能发生:

◆没有足够的安全权限

◆设备不在那儿

◆设备没有足够的空间

◆设备由物理错误

    以下便是压缩程序开始时创建临时文件,压缩完后重命名它而不是取代原来文件的原因:如果硬盘(或压缩软件)因某种原因出错,你将会丢失你的原始数据。

代码的安全性

    我的一个好朋友经常说:“一个好的程序员从不在他的项目中引用不安全的代码”。我不认为这是一个好的程序员的全部,但是的确几乎是这样。下面,我编译了一些常见的“不安全代码”,这些代码在经过异常处理后可以引用到你的项目中。

 

 

 

不要抛出new Exception()

    不要抛出 new Exception()。Exception是一个非常大的类,如果没有side-effect,很难去捕获。引用你自己的异常类,但是使它继承自AppliationException。通过这种方法,你可以设计一个专门的异常捕获程序去捕获框架抛出的异常,同时设计另一个异常捕获程序来处理自己抛出的异常。

    修订记录:在以下的评论部分中,David Levitt写信告诉我说,尽管Microsoft公司在MSDN doc中依然鼓吹使用System.ApplicationException做为基础类,但这已经不是一个好的习惯,就像Brad Adams在他的博客中所说的那样。这个方法是尽可能创造浅且宽泛的异常类层次,就像你经常处理类层次结构那样。我不马上改变文章内容的原因是在此介绍之前,我需要做更多的研究。做完这项研究后,我依然不能决定浅的类层次结构在异常处理中是否是个好办法,所以,在此处我给出了两种观点。但是,无论你做什么,不要抛出new Exception(),不要在需要时继承你自己的异常类。

 

 

不要抛出new Exception()

    不要抛出 new Exception()。Exception是一个非常大的类,如果没有side-effect,很难去捕获。引用你自己的异常类,但是使它继承自AppliationException。通过这种方法,你可以设计一个专门的异常捕获程序去捕获框架抛出的异常,同时设计另一个异常捕获程序来处理自己抛出的异常。

    修订记录:在以下的评论部分中,David Levitt写信告诉我说,尽管Microsoft公司在MSDN doc中依然鼓吹使用System.ApplicationException做为基础类,但这已经不是一个好的习惯,就像Brad Adams在他的博客中所说的那样。这个方法是尽可能创造浅且宽泛的异常类层次,就像你经常处理类层次结构那样。我不马上改变文章内容的原因是在此介绍之前,我需要做更多的研究。做完这项研究后,我依然不能决定浅的类层次结构在异常处理中是否是个好办法,所以,在此处我给出了两种观点。但是,无论你做什么,不要抛出new Exception(),不要在需要时继承你自己的异常类。 

 

不要把重要的异常信息放在message中

    异常是类。当你返回异常信息时,要创建存储数据的区域。如果你没有这样做,人们为了得到所需要得信息将需要解析Message。现在,想象如果你需要局部化甚至仅仅想纠正一个错误信息中的拼写错误,会对被调用的代码造成什么影响。你也许永远都不知道你这样做会损坏多少代码。

每个线程要有单独的catch (Exception ex)语句

    在你的应用程序中,普通的异常处理应该被集中解决。每个线程需要一个单独的try/catch模块,否则,你将会丢失异常导致非常难处理的问题的出现。当一个应用程序启动若干线程去做一些后台处理时,通常你需要创建一个用来存储处理结果的类。不要忘记添加用来存储可能发生的异常的区域,否则在主线程中你将无法与之通信。在"fire and forget"情况下,你可能需要在线程处理中复制主应用程序异常处理。


一般的异常捕获应该被记录

    你究竟使用什么工具来记录日志——log4net, EIF, Event Log, TraceListeners,text files等等都无关紧要。真正重要的是:如果你捕获一个异常,一定要在某处加以记录。但是仅记录一次——通常代码与记录异常的catch模块一起被丢掉,然后你以一个庞大的日志结束,此日志拥有太多重复信息。

要记录Exception的全部信息而不仅是Message

    在我们谈论记录日志时,不要忘记你应该经常性的记录Exception.ToString(),而不仅是Exception.Message。Exception.ToString()将会给你一个堆栈跟踪内部的异常和信息(messae)。通常,这个信息是及其珍贵的,如果你仅记录Exception.Message,你将会仅仅获得一些诸如“Object reference not set to an instance of an object”的信息。

每个线程只能有一个catch (Exception)语句

    有很少的异常()遵循这一法则。如果你需要捕获一个异常,最好使用你为这段代码编写的最明确的异常类。

    我经常发觉初学者认为好的代码是不抛出异常的代码。这是错误的。好的代码在需要是抛出异常,同时,仅处理那些它知道如何处理的异常。

    作为这个法则的一个应用,请看以下代码。我打赌书写这段代码的人读到这儿的时候想杀我,但是这是一则摘自真实世界的例子。事实上,真实世界的代码要更复杂一些——我为了说明问题将它大大简化了。第一个类(MyClass)在一个集合,第二个类(GenericLibrary)在另一个集合,这个集合满是普通代码。在开发机上,这段代码可以正确运行,可是在质量评价(QA)机上,这段代码经常返回“无效数据(Invalid number)”即使输入的数据是有效的。

你能说出为什么会这样吗?

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异常被抛出时,你的代码该如何处理。

不要总是吞掉异常

    你做的最糟糕的事情是在catch (Exception)后加了一个空的模块。永远不要这样做。

 

不要把重要的异常信息放在message中

    异常是类。当你返回异常信息时,要创建存储数据的区域。如果你没有这样做,人们为了得到所需要得信息将需要解析Message。现在,想象如果你需要局部化甚至仅仅想纠正一个错误信息中的拼写错误,会对被调用的代码造成什么影响。你也许永远都不知道你这样做会损坏多少代码。

每个线程要有单独的catch (Exception ex)语句

    在你的应用程序中,普通的异常处理应该被集中解决。每个线程需要一个单独的try/catch模块,否则,你将会丢失异常导致非常难处理的问题的出现。当一个应用程序启动若干线程去做一些后台处理时,通常你需要创建一个用来存储处理结果的类。不要忘记添加用来存储可能发生的异常的区域,否则在主线程中你将无法与之通信。在"fire and forget"情况下,你可能需要在线程处理中复制主应用程序异常处理。


一般的异常捕获应该被记录

    你究竟使用什么工具来记录日志——log4net, EIF, Event Log, TraceListeners,text files等等都无关紧要。真正重要的是:如果你捕获一个异常,一定要在某处加以记录。但是仅记录一次——通常代码与记录异常的catch模块一起被丢掉,然后你以一个庞大的日志结束,此日志拥有太多重复信息。

要记录Exception的全部信息而不仅是Message

    在我们谈论记录日志时,不要忘记你应该经常性的记录Exception.ToString(),而不仅是Exception.Message。Exception.ToString()将会给你一个堆栈跟踪内部的异常和信息(messae)。通常,这个信息是及其珍贵的,如果你仅记录Exception.Message,你将会仅仅获得一些诸如“Object reference not set to an instance of an object”的信息。

每个线程只能有一个catch (Exception)语句

    有很少的异常()遵循这一法则。如果你需要捕获一个异常,最好使用你为这段代码编写的最明确的异常类。

    我经常发觉初学者认为好的代码是不抛出异常的代码。这是错误的。好的代码在需要是抛出异常,同时,仅处理那些它知道如何处理的异常。

    作为这个法则的一个应用,请看以下代码。我打赌书写这段代码的人读到这儿的时候想杀我,但是这是一则摘自真实世界的例子。事实上,真实世界的代码要更复杂一些——我为了说明问题将它大大简化了。第一个类(MyClass)在一个集合,第二个类(GenericLibrary)在另一个集合,这个集合满是普通代码。在开发机上,这段代码可以正确运行,可是在质量评价(QA)机上,这段代码经常返回“无效数据(Invalid number)”即使输入的数据是有效的。

你能说出为什么会这样吗?

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异常被抛出时,你的代码该如何处理。

不要总是吞掉异常

    你做的最糟糕的事情是在catch (Exception)后加了一个空的模块。永远不要这样做。

 

 

 

清理代码应该放在finally模块中

 

    理论上,由于你并没有处理许多普通的异常,同时你拥有一个中央异常处理函数,你的代码应该有远比catch模块多的finally模块。不要把处理代码,如关闭流,恢复状态(就像鼠标指针)放在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变量哪里去了?它不再需要,因为我们可以返回内容后使得处理代码执行。这是拥有可以在函数返回后执行的代码的优势之一:你可以清空可能在返回状态时依然需要的资源。

经常使用using

    仅仅在一个对象上调用Dispose()函数是远远不够的。关键字using将会阻止资源泄漏即使在有异常出现的地方。

 

 

 

不要在错误条件下返回特殊值

特殊值存在很多问题:

◆异常使得普通的事件更快,因为当你从函数返回特殊值时,每一个函数返回需要被检查,这个过程至少消耗一个进程寄存器或者更多,这些导致了代码的运行缓慢。

◆特殊值可以或者将被忽略。

◆特殊值不携带堆栈追踪,可以丰富错误细节。

◆经常发生的情况是函数没有恰当的可以反映错误情况的值返回。为表示“被清除”这一错误,你该让如

下函数返回什么值呢?

public int divide(int x, int y)

{

return x / y;

}

不要使用异常去暗示资源的丢失

 

    微软建议在极端的普通情况下你应该使用返回特殊值。我知道我写的恰恰与之相反,我也不想这样,但是大多数API一致时生活会变得更加容易,所以我建议你谨慎的遵守这条法则。

    我观察.net框架,注意到几乎使用这一风格的唯一的API是那些返回一定资源的API(如Assembly.GetMnifestStream 方法)。所有的这些API在缺乏资源的情况下均返回空。



不要把异常处理方法作为从函数中返回信息的手段

    这是一个极差的设计。不仅异常的处理缓慢(就像名字暗示的一样,他们意味着只被使用在异常情况),而且代码中许多的try/catch模块会导致代码很难维护。恰当的类设计可以提供普通的返回值。如果你确实在危机中想返回数据作为一个异常,那么你的方法可能做了太多的工作需要分解。

为那些不该被忽略的错误使用异常

    我使用现实世界的例子来说明这个问题。在开发一个API以便人们可以访问Crivo(我的产品)的时候,你应该做的第一件事是调用Login函数。如果Login失败,或未被调用,其他的每个函数调用将会失败。我的选择是如果Login函数调用失败就从中抛出一个异常,而不是简单的返回错误,这样调用程序就不能忽略它。



当再次抛出异常时不要清空堆栈追踪

    堆栈追踪是一个异常携带的最有用的信息之一。经常,我们需要在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连接驱动驱动,以便用户可以不必担心特殊的socket错误而仅仅需要知道连接失败。

    如果你总是需要这样做,那么,请在InnerException成员中保持最初的异常。不要忘记你的异常处理代码中也许同样有漏洞,这样如果你有InnerException,你就会很容易的找到它。

 

 

异常应该用[Serializable]标识

    大量的情形需要异常是可序列化的。当从另一个异常类继承的时候,不要忘记增添这一属性。你将永远都不知道,你的函数什么时候将被远程组件或服务器调用。

有疑惑时,不要断言,抛出异常

    不要忘记Debug.Assert已经从释放代码中移除。在检查和确认的时候,在代码中抛出异常要比加入声明好一些。

    为单元测试,内部循环变量,为那些由运行条件(如果你考虑的话,是非常稀有的条件)决定的永远不该出错控制保存声明。


每一个异常类都应该至少拥有三个初始化构造函数

    做到这点是很容易的(仅仅是从其他异常类拷贝和复制定义)然而没有能这样不会允许使用你类的用户遵循以下的几条原则。

    我提到的是那些构造函数呢?是这一页上最后描述的三个构造函数。

使用AppDomain.UnhandledException事件时要小心

 

    修订笔记:在我的博客中,Philip Haack指出了这一重要遗漏。其他错误的共同源头是Application.ThreadException事件。使用它们时有如下诸多告诫:

◆异常通知出现的太晚:当你收到通知时,你的应用程序已经不能对异常作出反应了。

◆异常如果发生在主线程(事实上,是任何由无管理代码启动的线程)中,应用程序将会结束。

◆很难编写可以不间断工作的普通代码。引用MSDN的一段话:“这个事件仅仅发生在应用程序启动时由系统创建的应用程序领域。如果应用程序创建额外的应用程序领域,在哪些应用程序领域中为这一事件指定代表也是没有作用的。”

◆当代码处理这些事件时,除了异常本身你没有权力使用任何有用信息。你不能关闭数据连接,回滚事务,或其他有用的事情。对初学者来说,使用全局变量的诱惑是巨大的。

    确实,你不应该把你全部的异常处理策略放在这些事件的基础上。想象他们是“安全网“的同时为未来的测试记录异常。之后,确保更正那些没有正确处理异常的代码。

不要重新创造轮子

    有许多很好的框架和库来处理异常。其中的两个是微软提供的,我在这儿介绍以下:

◆异常管理应用模块

◆微软企业使用框架

尽管如此,值得注意的是如果你没有严格的按我所说的原则设计,上述的库则几乎是没用的。

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。 1974年,Djikstra教授在撰写“Go To statement considered harmful”时,非常擅长于此。不过那已是三十年前的事了!请尽快从你的应用程序中移除所有无结构错误处理的痕迹。我敢保证On Error Goto语句会对你不利。

结论

    我希望这篇文章可以帮助某些人更好的编码。不仅仅是一些总结出来的经验,我希望这篇文章是讨论如何在我们的代码中使用异常,如何使我们的程序更健壮的起跑点。

    我不认为我所写的这些没有任何错误和有争议的观点。非常乐意听到您关于此话题的意见和建议。

关于作者


    Daniel Turini:十一岁时开始开发软件。在过去的二十年中,他开发软件的同时使用了各种不同的机器和语言,从基于(ZX81,MSX)Z80到大型计算机。他仍然有研究ASM的激情,虽然从没有使用过它。从专业角度讲,Daniel Turini开发系统来管理大型数据库,这些数据库主要有Sybase 和SQL Server。他所写的大部分方法是面向金融市场,集中于信贷系统。

    迄今为止,Daniel Turini已经学习了大约20种计算机语言。他十分迷恋C#语言和.NET框架,他非常善于做服务器端工作和可重复利用的组件。

你可能感兴趣的:(asp.net)