本文要点
- 对于常见的错误类型,优先使用内置异常及其子类;
- 使用的异常类型要能够指示错误的来源,是应用程序本身,还是调用的库,或者是环境问题;
- 异常类型应该可以帮助运维人员确定需要由谁首先检查错误;
- 避免使用错误代码来区分由同一个方法引发的不相关的错误类型;
- 永远不要捕获或抛出 ApplicationException。
异常是使用.NET 时必然会遇到的问题,但是,有太多的开发人员没有从 API 设计的角度考虑这个问题。在大部分工作中,他们自始至终都知道需要捕获什么异常以及哪些异常需要写入全局日志。如果你设计了可以让你正确使用异常的 API,则可以显著减少修复缺陷的时间。
谁的错?
异常设计背后的基本理论始于这样一个问题,“谁的错?”为了方便本文的讨论,这个问题的答案将总是以下三者之一:
- 库
- 应用程序
- 环境
当我们说“库”有问题,我们是指当前执行的某个方法有内部缺陷。在这种情况下,“应用程序”是调用库方法的代码(这有点混杂难分,因为库和应用程序代码可能在相同的程序集中。)最后,“环境”是指应用程序之外一切无法控制的东西。
库缺陷
最典型的库缺陷是 NullReferenceException 。对库而言,它没有任何理由抛出可以被应用程序检测到的空引用异常。如果遇到了空,则库代码应该总是抛出一个更具体的异常,说明什么为空以及如何纠正这个问题。对于参数而言,这显然是一个 ArgumentNullException 异常。而如果属性或字段为空,则 InvalidOperationException 通常更合适。
根据定义,任何表明库缺陷的异常都是该库中需要修复的 Bug。那并不是说应用程序代码没有 Bug,而是说库的 Bug 需要首先修复。只有那样,才能让应用程序开发人员知道他也犯了错误。
这样做的原因是,可能有许多人使用同样的库。如果一个人在不应该传入空的地方错误地传入了空,则其他人想必也会犯同样的错误。把 NullReferenceException 替换为一个可以清晰地显示出什么出错的异常,应用程序开发人员立即就可以知道什么出错了。
“成功之核(The Pit of Success)”
如果你读过有关.NET 设计模式的早期文献,那么你会经常碰到短语“成功之核”。其基本思想是这样的:让代码容易被正确使用,不容易被误用,并确保异常可以告诉你哪里出错了。遵循这个 API 设计理念,几乎可以保证开发人员一开始就编写出正确的代码。
这就是为什么一个没有注释的 NullReferenceException 是如此糟糕。除了堆栈跟踪外(可能非常深入库代码),没有任何信息可以帮助开发人员确定他们哪里做错了。另一方面,ArgumentNullException 和 InvalidOperationException 则为库作者提供了一种方法,让他们可以向应用程序开发人员说明如何修复问题。
其他库缺陷
下一个库缺陷是 ArithmeticException 系列,包括 DivideByZeroException、FiniteNumberException 和 OverflowException。再次,这总是意味着库方法的内部缺陷,即使那个缺陷只是一个缺失的参数有效性检查。
库缺陷的另外一个例子是 IndexOutOfRangeException 。从语义上讲,它和 ArgumentOutOfRangeException 没什么不同,参见 IList.Item ,但它只适用于数组索引器。由于应用程序代码通常不会使用裸数组,所以这意味着,自定义的集合类会有 Bug。
自.NET 2.0 引入泛型列表以来, ArrayTypeMismatchException 就很少见了。触发该异常的情况相当怪异。根据文档:
当系统无法将数组元素转换成声明的数组类型时会抛出 ArrayTypeMismatchException。例如,一个 String 类型的元素无法存入一个 Int32 数组,因为这两种类型之间无法转换。应用程序一般是不需要抛出这类异常的。
要做到这一点,前面提到的 Int32 数组必须存入一个 Object[] 类型的变量。如果你使用了原始数组,则库需要对此进行检查。由于这个原因及其他许多方面的考虑,最好是不要使用原始数组,而是将它们封装到一个合适的集合类中。
通常,其他转换问题是通过 InvalidCastException 异常反映出来的。回到我们的主题,类型检查应该意味着永远不会抛出 InvalidCastException 异常,而是向调用者抛出 ArgumentException 或 InvalidOperationException 异常。
MemberAccessException 是一个基类,涵盖了各种基于反射的错误。除了直接使用反射外,COM 互操作和动态关键词的不正确使用都会触发该异常。
应用程序缺陷
典型的应用程序缺陷是 ArgumentException 及其子类 ArgumentNullException 和 ArgumentOutOfRangeException。以下是其他你可能不知道的子类:
- System.ComponentModel. InvalidAsynchronousStateException
- System.ComponentModel. InvalidEnumArgumentException
- System. DuplicateWaitObjectException
- System.Globalization. CultureNotFoundException
- System.IO.Log. ReservationNotFoundException
- System.Text. DecoderFallbackException
- System.Text. EncoderFallbackException
所有这些都明确地表明应用程序有错误,而问题就出在调用库方法的行里。那条语句的两个部分都很重要。考虑下面的代码:
foo.Customer = null; foo.Save();
如果上述代码抛出了一个 ArgumentNullException 异常,那么应用程序开发人员会很困惑。它应该抛出一个 InvalidOperationException 异常,说明当前行之前有什么地方出了问题。
以异常为文档
典型的程序员不阅读文档,至少不会首先阅读文档。相反,他或她会阅读公共 API,编写一些代码并运行。如果代码不能正常运行,就到 Stack Overflow 上搜索异常信息。如果该程序员够幸运,则很容易在那里找到答案以及指向正确文档的链接。但即使如此,程序员们很可能也不会真正地读它。
那么,作为库作者,我们如何解决这个问题?第一步是直接将部分文档复制到异常中。
更多对象状态异常
InvalidOperationException 有一个众所周知的子类 ObjectDisposedException 。它的用途显而易见,然而,很少有可销毁类会忘记抛出这个异常。如果忘记了,则常见的结果是抛出 NullReferenceException 异常。该异常是由 Dispose 方法将可销毁子对象置为空所导致的。
与 InvalidOperationException 密切相关的是 NotSupportedException 异常。这两种异常很容易区分:InvalidOperationException 是指“你现在不能那样操作”,而 NotSupportedException 是指“你永远不能对这个类做那种操作”。理论上讲,NotSupportedException 应该只在使用抽象接口时出现。
例如,一个不可变集合在遇到 IList.Add 方法时应该抛出 NotSupportedException 异常。相比之下,一个可冻结集合在冻结状态下遇到该方法时会抛出 InvalidOperationException 异常。
NotSupportedException 一个越来越重要的子类是 PlatformNotSupportedException 。该异常表示,操作可以在某些运行环境里进行,但不能在其他环境里进行。例如,当将代码从.NET 移植到 UWP 或.NET Core 时,你可能需要使用这个异常,因为它们没有提供.NET Framework 的所有特性。
难以捉摸的 FormatException
微软在设计.NET 的第一个版本时犯了一些错误。例如,从逻辑上讲, FormatException 是一个参数异常类型,甚至文档也说“该异常是在参数格式无效时抛出”。但是,不管出于什么原因,它实际上没有继承 ArgumentException。它也没有地方存放参数名称。
我们暂时提供的建议是不要抛出 FormatException 异常,而是自己创建 ArgumentException 的子类,可以命名为“ArgumentFormatException”或其他效果类似的名称。这可以为你提供必要的信息,如参数名称和实际使用的值,减少调试时间。
这把我们带回了最初的主题“异常设计”。是的,当你自行开发的解析器检测到了问题,你可以只抛出一个 FormatException 异常,但那无法为想要使用你的库的应用程序开发人员提供帮助。
有关这个框架设计缺陷,另外一个例子是 IndexOutOfRangeException。从语义上讲,它和 ArgumentOutOfRangeException 没什么不同,然而,这个特例只是针对数组索引器吗?不,那样想就错了。看下 IList.Item 的实例集,该方法只会抛出 ArgumentOutOfRangeException 异常。
环境缺陷
环境缺陷源于世界并不完美这样一个事实,诸如数据宕机、Web 服务器无响应、文件丢失等场景。当 Bug 报告中出现环境缺陷时,需要考虑以下两个方面:
- 应用程序正确地处理了缺陷吗?
- 在这个环境里,是什么导致了缺陷?
通常,这会涉及人员分工。首先,应用程序开发人员应该第一个查找问题的答案。这不仅仅是说要处理错误并恢复,而且要生成一个有用的日志。
你可能想知道,为什么要从应用程序开发人员开始。应用程序开发人员要对运维团队负责。如果一次 Web 服务器调用失败,则应用程序开发人员不能只是甩手大叫“不是我的问题”。他或她首先需要确保异常提供了足够的细节信息,让运维人员可以开展他们的工作。如果异常仅仅提供了“服务器连接超时”的信息,那么他们怎么能知道涉及了哪台服务器?
专用异常
NotImplementedException
NotImplementedException 表示且仅表示一件事:这项特性还在开发过程中。因此,NotImplementedException 提供的信息应该总是包含一个任务跟踪软件的引用。例如:
throw new NotImplementedException(" 参见工单#42.");
你可以提供更详细的信息,但实际上,你记录的任何信息几乎立刻就会过期。因此,最好是只将读者导向工单,他们可以在那里看到诸如该特性按计划将会在何时实现这样的信息。
AggregateException
AggregateException 是必要之恶,但很难使用。它本身不包含任何有价值的信息,所有的细节信息都隐藏在它的 InnerExceptions 集合中。
由于 AggregateException 通常只包含一个项,所以在库中将它解封装并返回真正的异常似乎是合乎逻辑的。一般来说,你不能在没有销毁原始堆栈跟踪的情况下再次抛出一个内部异常,但从.NET 4.5 开始,该框架提供了使用 ExceptionDispatchInfo 的方法。
解封装 AggregateException
catch (AggregateException ex) { if (ex.InnerExceptions.Count == 1) // 解封装 ExceptionDispatchInfo.Capture(ex.InnerExceptions[0]).Throw(); else throw; // 我们真的需要 AggregateException }
无法回答的情况
有一些异常无法简单地纳入这个主题。例如, AccessViolationException 表示读取非托管内存时有问题。对,那可能是由原生库代码所导致的,也可能是由应用程序错误地使用了同样的代码库所导致的。只有通过研究才能揭示这个 Bug 的本质。
如果可能,你就应该在设计时避免无法回答的异常。在某些情况下,Visual Studio 的静态代码分析器甚至可以分析该规则所涵盖的标识冲突。
例如, ApplicationException 实际上已经废弃。 Framework 设计指南明确指出,“不要抛出或继承 ApplicationException。”为此,应用程序不必抛出 ApplicationException 异常。虽说初衷如此,但看下下面这些子类:
- Microsoft.JScript.BreakOutOfFinally
- Microsoft.JScript.ContinueOutOfFinally
- Microsoft.JScript.JScriptException
- Microsoft.JScript.NoContextException
- Microsoft.JScript.ReturnOutOfFinally
- System.Reflection.InvalidFilterCriteriaException
- System.Reflection.TargetException
- System.Reflection.TargetInvocationException
- System.Reflection.TargetParameterCountException
- System.Threading.WaitHandleCannotBeOpenedException
显然,这些子类中有一些应该是参数异常,而其他的则表示环境问题。它们全都不是“应用程序异常”,因为他们只会被.NET Framework 的库抛出。
同样的道理,开发人员不应该直接使用 SystemException 。同 ApplicationException 一样,SystemException 的子类也是各不相同,包括 ArgumentException、NullReferenceException 和 AccessViolationException。微软甚至建议忘掉 SystemException 的存在,而只使用其子类。
无法回答的情况有一个子类别,就是基础设施异常。我们已经看过 AccessViolationException,以下是其他的基础设施异常:
- CannotUnloadAppDomainException
- BadImageFormatException
- DataMisalignedException
- TypeLoadException
- TypeUnloadedException
这些异常通常很难诊断,可能会揭示出库或调用它的代码中存在的难以理解的 Bug。因此,和 ApplicationException 不同,把它们归为无法回答的情况是合理的。
实践:重新设计 SqlException
请记住这些原则,让我们看下 SqlException 。除了网络错误(你根本无法到达服务器)外,在 SQL Server 的 master.dbo.sysmessages 表中有超过 11000 个不同的错误代码。因此,虽然该异常包含了你需要的所有底层信息,但是,除了简单地捕获 & 记录外,你实际上难以做任何事。
如果我们要重新设计 SqlException,那么我们会希望,根据我们期望用户或开发人员做什么,将其分解成多个不同的类别。
SqlClient.NetworkException会表示所有说明数据库服务器本身之外的环境存在问题的错误代码。
SqlClient.InternalException会包含说明服务器存在严重故障(如数据库损坏或无法访问硬盘)的错误代码。
SqlClient.SyntaxException相当于我们的 ArgumentException。它是指你向服务器传递了糟糕的 SQL(直接或者因为 ORM 的 Bug)。
SqlClient.MissingObjectException会在语法正确但数据库对象(表、视图、存储过程等)不存在时出现。
SqlClient.DeadlockException出现在两个或多个进程试图修改相同的信息产生冲突时。
这些异常中的每一种都隐含着一个行动方案。
- SqlClient.NetworkException:重试操作。如果频繁出现,则请联系运维人员。
- SqlClient.InternalException:立即联系 DBA。
- SqlClient.SyntaxException:通知应用程序或数据库开发人员。
- SqlClient.MissingObjectException:请运维人员检查上一次数据库部署是否丢了东西。
- SqlClient.DeadlockException:重试操作。如果频繁发生,则查找设计错误。
如果要在实际的工作中这样做,那么我们必须将所有 11000 多个 SQL Server 错误代码映射到那些类别中的一个,这是一项特别令人望而生畏的工作,这也就解释了为什么 SqlException 是现在这个样子。
总结
当设计 API 时,为了便于纠正问题,要将异常根据需要执行的动作的类型进行组织。这样更容易编写出自校代码,记录更准确的的日志,更快地将问题传达给合适的人或团队。
关于作者
Jonathan Allen在 90 年代末开始参与面向医务室的 MIS 项目,把它们从 Access 和 Excel 逐步提升为一种企业级的解决方案。他花了五年时间编写金融行业自动交易系统,然后决定转向高端用户界面开发。在业余时间里,他喜欢学习 15 到 17 世纪之间的西方格斗技巧,并进行相关写作。
评论