托管应用中的意外错误
托管应用中的意外错误
原著:Jason Clark
翻译:Abbey
源代码下载:NET0406.exe(122KB,ZIP自解压包)
NET0406IDEAbbey.exe(25KB,rar压缩包)
原文出处:.NET Column:Unexpected Errors in Managed Applications(MSDN Magazine June 2004)
在本期的 .NET
专栏中,我将介绍一些对付意外错误的技巧。通过捕获异常来处理异常失败是应用程序中常见的做法,对于托管应用程序,当遇到完全没有准备的错误时,我们通常也可以这么做。在理想情况下,你可能会建立一个明确的策略,使应用程序能发现并响应未处理异常、降低的安全许可以及其它有关的边缘情况。但在实际过程中,这些问题常常被忽略。
错误处理底层结构是一个很容易让人迷惑的虚幻的复杂主题。很容易让人陷入到细节当中。所以我会介绍一些相对简单的方法,它们能被广泛地应用于意外错误的处理。

公共语言运行时(CLR)具备一套用于管理未处理异常的规则。但如果你正在使用 Windows 窗体类,Windows
窗体消息泵使用其自己的未处理异常策略,它与 CLR 的策略是有所不同。同样,.NET框架作为 ASP.NET
的宿主在对付未处理异常方面也强制执行其自己的标准,由此可见,本文所讨论的这一主题的复杂之处。但不管怎样,在托管应用程序中采取一些慎重的步骤来对付未处理异常是非常重要的。
为了帮助你理解什么是未处理异常,让我们先来简单地看一个能已被处理的异常。Figure 1 中的代码示范了两个方法,其中一个方法调用了另一个方法。
说实话,这段代码有点怪怪的,但它能传达要点。当 MethodA 调用 MethodB 时,MethodB 会抛出一个 InvalidOperationException 类型的异常。这导致正常的执行在
异常被抛出的地方中止,执行引擎在调用堆栈中搜索辅助的 catch 处理例程。因为 MethodA 中有一个 catch子句处理该异常类型,于是执行引擎会在 MethodA 中找到该辅助的 catch 子句并执行它。一旦 catch 块执行完毕,MethodA 继续从 try/catch
语句块后面的代码开始正常运行。这是一个已处理异常的例子,它表示了当调用进入类库和托管代码后大多数托管应用程序
为了响应执行出错而必须实现的异常处理类型。
但是,如果 MethodA 用一个不等于 1 的参数来调用 MethodB,MethodB 则会抛出一个ArgumentException 异常,而 MethodA 并没有提供
这个异常的处理例程。此时,如果在调用堆栈中没有进一步提供 ArgumentException 异常的辅助处理代码,那么这个异常便成为一个未处理异常。
Figure 2 中的代码会抛出一个未处理异常,以响应用户按下其五个按钮中的任一个。每个按钮代表一个能产生未处理异常的不同的线程上下文,为了摸请托管代码响应未处理异常的方式,将 FFigure 2 中的代码编译成一个控制台应用程序,运行它(但不要在调试器模式下运行),然后随便点几个按钮
看看会发生什么情况。
第一次运行这个程序时,它所表现出的行为是令人惊讶的。尤其是当按下底部三个按钮中的任何一个时,你可能注意到这对程序似乎没有任何影响,
即使它们产生了被抛出的未处理异常!在某些情况下,未被处理的异常整个被 CLR 吞食(虽然这种行为在.NET Framework 2.0 中有所改变)。
下面是对该应用程序在执行过程中未处理异常处理器缺省行为的总结:
在应用程序主线程中发生的未处理异常将导致程序立即终止。
在主线程以外的其它线程中发生的未处理异常将被 CLR 吞食。这类线程包括手动线程、线程池线程以及 CLR 的完成器(finalizer)线程。如果你的
应用是个控制台程序,那么 CLR 会将异常文本输出至控制台(而你的程序一直在运行)。若非控制台程序,那么
当异常发生时,外部看不到任何异常迹象,并且程序一直保持运行状态!
在 Windows 窗体类的消息泵线程中发生的未处理异常将转由 Windows
窗体的未处理异常处理器进行处理。缺省情况下会弹出一个调试对话框,但这种行为可以被改写(稍后将进一步讨论)。
现在让我们来看看程序应该做些什么,用抽象一点的话说,就是未处理异常事件发生时它应该做些什么。
为了尽可能地减少程序中的未处理异常,许多开发人员都会捕获基本的 Exception 类型,就象下面这样:
try {
...
} catch (Exception e) {
// 处理任何 CLS 兼容异常
}
这样做却会事与愿违,因为类似上面这样的代码可以说是不符合要求的,而且实际上也会降低应用程序的健壮性。
为什么这么说呢:你的代码几乎无法确切知道如何适当处理 try 代码块漏掉的任何可能的异常。未处理异常情况比你想象的要多得多。在托管代码里,即使
不调用任何可能抛出各种不同异常的方法,包括安全异常、内存耗尽异常、执行引擎异常等等。所以,你的程序最好是始终捕获特定类型的异常,而将其它意料不到的异常
作为未处理异常。我会在本文梢后的部分讨论这个问题。
当某个异常成为未处理异常时,凭借经验,最好是终止程序的运行。未处理异常在应用程序中是一个 Bug,在一种不确定的状态下继续执行,将面临破坏数据的风险
以及其它一些丑陋的问题。某些服务器端应用程序非常复杂,在未处理异常事件中必须为某个特别的客户端单独解除状态和卸载线程。但大多数的客户端程序和许多服务器端程序
还不至于这么复杂,进程将被终止(也许是自动重新启动)。
Figure 2 所示的程序展示了未处理异常缺省的处理方式,当然,这意味着你的应用程序不能有
中由应用程序展示的缺省的未处理异常行为,该行为留下相当数量的被应用程序忽略的未处理异常,并且显示一个对话框对迷惑的用户作出解释。所幸的是,
需要做显著改进的代码很少。
Figure 3 中的代码示范了一个 Main 入口点的实现样板,该入口点为 CLR 未处理异常处理器和Windows
窗体未处理异常处理器两者设置了一个未处理异常处理器事件。其做法是通过为 AppDomain.UnhandledException 事件和 Application.ThreadException
事件提供处理例程。
请注意,本例中产生的异常信息只是作为调试信息被输出的。但是,对于一个正式出品的应用软件而言,
用事件日志、文件或者其它独立的存储机制报告错误信息会更有意义一些。本期专栏所附的代码中包括了
中所示的应用程序实现,该实现用一个文件来报告未处理异常数据。
除了以这样一种方式报告失败信息,以便在将来的软件版本中排除这些 Bug 之外,利用未处理异常通知还提供了一种方式使你
的应用程序在任何未处理异常事件中有一致的行为。从而改善程序的稳定性以及健壮性。
需要注意的是,对于 ASP.NET 应用来说,与 Windows 窗体 Application.ThreadException 事件类似的事件是 HttpApplication.Error。你可以
通过在 Global.asax 文件中定义一个 Application_Error 方法,从而为此事件定义一个处理器,或者你也可以直接在代码中注册此事件。ASP.NET
应用具备一个额外特性,该特性可以在未处理异常情况下将某个页面进行重定向处理。为了设定页面的这个特性而不让其进行缺省处理,你可以在 web.config 文件中象下面这样指定元素和属性:<customErrors defaultRedirect="..." /

所有的托管程序均受制于一个被称为 CAS 的安全机制,(CAS 即:Code Access Security——代码访问安全)。在 CAS
的背后,其原理是代码能以部分信任方式运行,也就是说 CLR 即时编译器(Just-in-time
compiler)、校验器以及类库在代码实现的行为上强制执行某些规则,以检查你的代码是否可以执行某些操作。
CLR 提供的 CAS 功能十分强大,这一点对于希望局部执行你不信任的代码时尤其如此。但许多开发人员并不知道当不希望使用 CAS 特性时如何摆脱 CAS
约束。有关这方面托管代码的文档和工具是如此至少,真令我感到惊讶。让我们看个例子吧:
class App {
public static void Main() {
String[] entries = Directory.GetFileSystemEntries(@"C:\");
foreach (String s in entries) Console.WriteLine(s);
}
}
这段代码将列出C盘根目录下所有文件及目录的名字。将这段代码作为控制台应用程序进行编译并运行,你应该能看到一个目录项列表。现在将可执行文件
(不需要重新生成)拷贝到网络上的某个共享目录中,再运行之。此时,控制台输出的结果可能就是
Figure 4 所示的那样。
在输出的异常信息中,最紧要就是头四行:“Request for the permission of type … FileIOPermission …
failed”以及最后一行表示的拒绝代码访问的资源:“C:\”。(译者注:请求类型许可…FileIOPermission…失败)。
许多开发人员都会感到很惊讶,刚才运行得好好的程序,为何一拷贝到网络上的另一个位置运行就会崩溃!不需要修改代码或者系统配置就可以产生这种效果。这是因为 CLR 默认的安全策略
只准许来自某个网络位置的任何代码以部分信任方式运行。换句话说,在默认情况下,只有来自本地系统硬盘位置上的东东才会得到完全信任。当部分受信的代码企图执行某些未被
准许的操作时,就会导致 SecurityException 异常。
如果你对这些东西感到新鲜,那么你把一个可以正常运行的程序拷到网络上的某个共享文件夹中,然后尝试运行它,这是一种很有用的学习经验。除非你的程序是故意
针对 CAS 而写,否则它很可能会因安全异常而出问题。这时,找出部分受信代码中那种行为是不被允许的,这个过程非常具有指导性意义。
将来,Windows、CLR 以及各种开发工具将很可能越来越多地直接使用 CLR 的这一沙箱特性(也许你的应用程序是为数不多的故意利用此沙箱特性的程序之一)。
届时大多数的托管客户端程序和许多的托管 Web 程序将不能正常运行,除非它们以完全信任方式运行。
如果你的程序需要以完全信任方式运行,那么其启动行为之一便是要考虑检查代码是否被完全信任。只有如此,代码才有机会提醒用户软件的配置或者安装问题,然后再
温柔地关闭程序。这可比让用户遭受默认行为——难堪的安全异常要好得多,有的安全异常还可能激活未处理异常行为。
Figure 5 中的代码修改了之前的那个例子,首先对安全权限进行了检测,然后才执行应用程序逻辑。这段代码的点睛之处在于 System.Security.PermissionSet
对象的创建是以一组无限制许可的方式进行的。接着该代码在此许可集中调用 Demand
方法,如果执行的代码(或任何在调用堆栈中的代码)未获得这些许可,它将导致 SecurityException
异常。这也许是在允许应用程序向用户报告失败时,它能够要求以完全信任方式执行的最简单的策略了。
当然还有其它一些可选方法,只是它们都过于复杂。比如,你可以在程序中找出所有可能引发安全异常的地方,然后用 try/catch 块包起来。这样的设计
使代码能获得更安全的上下文环境。但是,这样做也有其不利的一面。那就是
这个方法需要分析每一种失败的情况以确定当正常的行为被沙箱拒绝后,要实现哪一种后备行为。
还有一种选择是使用 CAS 中的一个特性——“声明性安全”(declarative
security)。如果代码没有被授予充分的许可实现其任务,那么会导致运行时加载代码失败。当然,在这种情况下,因为 CLR 根本就不会加载你的代码,所以你无法向用户报告错误信息。于是,
对用户来说,结果就如同程序已然崩溃了一般。
实际上,当今基于.NET的程序已经分化为两大类:一类是以部分信任方式执行的专用程序,另一类则是以完全信任方式执行的程序。以部分信任方式执行的程序使用户从中受益,
它可以使用许多 CAS 特性,诸如“声明性安全”,安全异常的捕获,使用 SecurityManager.IsGranted 检查特定许可等等。但是,
如果应用程序不需要 CAS 或者不想受益于 CAS,
那么只要在程序启动时简单地检查一下即可,以便给用户一个比程序崩溃好得多得体验,并且代码投入也最小。此外,在 Visual
Studio 2005 里将包含一个名为 PermCalc 的工具,用它可以确定成功运行应用程序所需的全部许可。

关于异常处理的最佳方法和设计,不管是在微软内部还是在整个软件行业中都是一个颇有争议的话题。也许有一天我会做一个栏目,专门讨论微软 CLR
开发团队关于如何在托管代码中有效地使用异常处理所获得的一些成功经验。由于本文篇幅所限,无法覆盖异常处理最佳实践的所有内容。
不管怎样,有两条最佳实践是被广泛认同并且必须在实践中坚决贯彻的。此外,它们与在应用程序中使用未处理异常例程密切相关。
不要捕获( catch )或者抛出( throw )基本型的异常
尽可能多地使用 finally 块
本文前面我已经提及过不要捕捉基本型 System.Exception 异常。现在我们已经涉及到了未处理异常,回想一下:
开发人员每每面对这一主题时都很自然地存在这样的认识误区:
异常就是失败
捕捉基类型的异常几乎将捕获某种失败的可能性增加到了100%
捕捉基类型异常总是没错的
这种逻辑没有考虑捕获异常与处理失败的本质差别。捕捉特定的异常类型将使开发人员更加可能编写适合的处理代码。虽然捕获基类型异常具有隐藏意外失败的效果,但很可能无法在处理逻辑中覆盖所有基类型异常。
在大多数情况下,应当允许意外异常传播到调用堆栈——大量的托管代码历史数据证实了这个指导方针。同时,借助未处理异常的处理策略,你将从异常日志中对这些意外情况了然于心。
过度地捕捉异常将使你无法掌握意外失败(但现在是意料之中的了)。
现在让我们来看看 finally 块使用的指导方针吧。通常编写良好的托管代码使用的 try/finally 结构比 try/catch 结构要多。
其主要原因是 finally 块无论在有否异常发生的情况下都能执行一些方法级上的清理操作。许多方法都包括可称作“清理操作”的代码。这类操作包括关闭已打开的文件及一些
在方法中打开的资源、解开方法执行期间设置的同步锁等等。同时,如果你遵守第一条守则——只捕捉意料中的异常,那么最终实际捕获异常的方法数量就大大减少。
上述两条守则一定要联合应用。一环扣一环地捕捉异常,那么你的程序异常将进一步传播到调用堆栈的可能性就会大大增加。有时这些异常会成为未处理异常,
否则就会被堆栈代码捕获。不管怎么样,当异常传播到调用堆栈,应尽量多使用 finally 块,这样能减少程序出现的混乱。

在我讨论最佳实践这一主题的同时,如果你对 FxCop 这个工具还不熟悉,那么最好赶快行动起来。FxCop可以从
http://www.gotdotnet.com/team/fxcop 免费下载
。
Microsoft 的 CLR 开发团队维护着一套编写托管代码库的指导方针和最佳实践。他们也负责维护 FxCop 工具。这个工具可以分析出托管程序集是否违
反了上述指导方针和最佳实践。在微软公司内部,正在编写产品托管代码的开发团队都在使用 FxCop,许多人把 FxCop 作为日常生成应用程序过程的一部分。
微软会经常对 FxCop 进行更新,并加入新的规则。
如同大多数的分析工具一样,FxCop 并不能代替人的智慧。因此,它只能帮助你确定代码中违反特定指导原则的地方。比如,FxCop 可以告诉你在何处捕捉 System.Exception。FxCop
的指导原则被包含在被称为某个规则的扩展机制中。该工具使你能基于每一个分析的基础自由地选择启用和禁用诸规则。
FxCop 也支持创建或自定义规则(有关这方面的详细内容请参见 John Robbins 在本刊 Bugslayer专栏文章)。由该工具强制
执行的某些规则(比如命名规范)看似没有必要,但是记住,微软的开发人员在内部使用 FxCop 来增强其类库的一致性(让所有使用托管代码的开发人员都
能受益)。我建议你禁用那些对你的工程毫无益处的规则。我确信花费一些时间熟悉 FxCop 的用法对你的托管工程来说是非常值得的。
有什么意见和建议,请联系 dot-net@microsoft.com

Jason Clark 为 Microsoft 和 Wintellect 提供培训和咨询服务。他曾是 Windows NT
和Windows 2000 开发团队的一员。他与别人合著有《Programming Server-side Applications for
Microsoft Windows 2000 (Microsoft Press, 2000)》,你可以通过下面这个 e-mail 与他联系:JClark@Wintellect.com

这篇文章让我看得似是而非,最后还是不太明白应该如何处理那些未处理的异常。作者提供的压缩包里的源码采用了轻量级的命令行编译方式。对于喜欢使用IDE的朋友,我也整理了一个压缩包NET0406——Abbey 整理 (RAR格式,25KB)。
本文出自 MSDN Magazine 的
June 2004 期刊,可通过当地报摊获得,或者最好是
