BT

如何利用碎片时间提升技术认知与能力? 点击获取答案

.NET异常设计原则

| 作者 Jonathan Allen 关注 529 他的粉丝 ,译者 谢丽 关注 9 他的粉丝 发布于 2016年12月20日. 估计阅读时间: 22 分钟 | Google、Facebook、Pinterest、阿里、腾讯 等顶尖技术团队的上百个可供参考的架构实例!

本文要点

  • 对于常见的错误类型,优先使用内置异常及其子类;
  • 使用的异常类型要能够指示错误的来源,是应用程序本身,还是调用的库,或者是环境问题;
  • 异常类型应该可以帮助运维人员确定需要由谁首先检查错误;
  • 避免使用错误代码来区分由同一个方法引发的不相关的错误类型;
  • 永远不要捕获或抛出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。以下是其他你可能不知道的子类:

所有这些都明确地表明应用程序有错误,而问题就出在调用库方法的行里。那条语句的两个部分都很重要。考虑下面的代码:

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报告中出现环境缺陷时,需要考虑以下两个方面:

  1. 应用程序正确地处理了缺陷吗?
  2. 在这个环境里,是什么导致了缺陷?

通常,这会涉及人员分工。首先,应用程序开发人员应该第一个查找问题的答案。这不仅仅是说要处理错误并恢复,而且要生成一个有用的日志。

你可能想知道,为什么要从应用程序开发人员开始。应用程序开发人员要对运维团队负责。如果一次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世纪之间的西方格斗技巧,并进行相关写作。

 

查看英文原文:Designing with Exceptions in .NET

评价本文

专业度
风格

您好,朋友!

您需要 注册一个InfoQ账号 或者 才能进行评论。在您完成注册后还需要进行一些设置。

获得来自InfoQ的更多体验。

告诉我们您的想法

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我
社区评论

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

讨论

登陆InfoQ,与你最关心的话题互动。


找回密码....

Follow

关注你最喜爱的话题和作者

快速浏览网站内你所感兴趣话题的精选内容。

Like

内容自由定制

选择想要阅读的主题和喜爱的作者定制自己的新闻源。

Notifications

获取更新

设置通知机制以获取内容更新对您而言是否重要

BT