自定义组件中的自定义异常






4.21/5 (13投票s)
2007年7月9日
11分钟阅读

46373
本文讨论了何时抛出异常、为何需要花费精力定义自定义异常类、如何为组件的使用者提供更多信息,以及如何对异常进行单元测试。
概述
大多数关于异常的文章只涵盖了异常处理的理念和技巧。原因很简单:我们大多数人编写的是面向最终用户的软件,在这种情况下抛出异常意义不大。然而,如果您正在编写一个自定义组件,那么通知组件用户出现问题的唯一方法就是抛出异常。
在本文中,我将为您提供一些关于何时抛出异常、为何要花费精力定义自定义异常类、如何为使用您组件的开发人员提供更多信息,以及最后如何对异常进行单元测试的思路。我将尽量避免使用“好”或“坏”之类的标签,以便您能根据自身情况做出最佳选择。
引言
抛出异常通常是为了告知您程序中的其他部分发生了问题。在 GUI 层这样做通常没有意义,因为您有其他方式来处理这种情况:只需显示一条消息、进行一些日志记录、尝试恢复或直接退出。在业务层这样做更为合适:您不希望从这里显示任何视觉元素,更不希望关闭应用程序,因此,如果您无法恢复,至少要告知应用程序发生了什么。在简单的情况下,使用通用的 Exception 类就足够了,但如果您想以不同的方式处理不同的“错误”,或者,例如,记录有关此“错误”的更多信息,那么您就必须创建一个自定义异常,并将所有相关信息放入该类中。
当您构建自定义组件或在团队中工作时,自定义异常就变得必不可少。让我们更详细地看看。
自定义异常
通常,您会在两种不同的情况下抛出自定义异常。第一种情况是当其他组件调用您的方法并传入无效参数时。有时抛出 `InvalidArgumentException` 已经足够,但如果您想帮助使用您组件的开发人员,您可能会想在这里抛出一个自定义异常。第二种情况是当底层组件抛出异常时。我们将逐一讨论这些情况。
无效输入
假设您提供了一个仅用于计算 x 除以 y 的组件。您想检查 y 是否不为零,如果为零则抛出异常。
Public Function Divide(ByVal x As Single, ByVal y As Single) As Single
If y = 0 Then Throw New ArgumentException("y shouldn't be zero")
Return x / y
End Function
如果程序用户遇到此错误,他们可能会联系支持部门——即使用您组件的开发人员——并表示他们收到了一个奇怪的错误,提示“Y 不应为零”。该开发人员随后搜索他的代码,却找不到此异常的来源。现在让我们重写代码
Public Function Divide(ByVal x As Single, ByVal y As Single) As Single
If y = 0 Then Throw New DivisionByZeroException()
Return x / y
End Function
该开发人员会立即识别出原因,因为异常消息将类似于“未处理 DivisionByZero 异常”。在调用代码中,他将能够捕获并处理这种特定类型的异常。
现在这个例子可能看起来有点不现实,但总体的想法是,现有的框架异常过于笼统(它们本应如此),最好是提供一个更具描述性的异常,它只在特定情况下发生。有人可能会争辩说,描述性的异常消息就足够了,但在大多数情况下,开发人员希望以不同的方式处理不同类型的异常,例如
Try
Dim z = myComponent.Divide(x, y)
Catch ex As DivisionByZeroException
'do something
Catch ex As OverflowException
'do something
Catch ex As Exception
'we don't know what happened
End Try
此外,您可能希望为调用者提供更多数据。您可以通过向异常类添加其他属性来实现这一点,这样调用代码就可以检查这些属性并做出决策。我们将在本文稍后讨论这一点。
其他组件抛出的异常
当我们调用其他代码并且该代码抛出异常时,我们倾向于不处理它,而是让我们的调用者——大概是主应用程序——来处理它。我将向您展示为什么这不是一个非常好的主意。考虑以下代码片段
Function GetConfigValue(ByVal param As String) As Single
Return My.Settings.Item(param)
End Function
在这里,我们可能会遇到三种不同的异常。首先,如果我们的配置文件丢失,我们将得到一个 `FileNotFoundException`。接下来,如果我们没有通过参数参数标识的设置,将抛出 `SettingsPropertyNotFoundException`。最后,如果该值无法转换为 `Single`,我们将得到一个 `InvalidCastException`。在编写调用代码时,前两种异常都很难理解。如果我调用 `GetConfigValue` 方法,我至少期望得到一个与配置相关的异常,而不是关于文件或转换的异常。
一种可能的解决方案是创建两个自定义异常:`MissingConfigFileException` 和 `ConfigValueIsNotSingleException`。将 `MissingConfigFileException` 继承自 `FileNotFoundException` 可能很方便,因为它已经有了 `FileName` 属性,开发人员可以利用该属性来识别问题。同样,`ConfigValueIsNotSingleException` 可以继承自 `InvalidCastException`。然而,这并不是真正必要的,因为我们将原始异常作为 `InnerException` 属性提供。所以,我们的代码变成了
Function GetConfigValue(ByVal param As String) As Single
Try
Return My.Settings.Item(param)
Catch ex As IO.FileNotFoundException
Throw New MissingConfigFileException(ex)
Catch ex As InvalidCastException
Throw New ConfigValueIsNotSingleException(ex)
End Try
End Function
异常的定义如下
Public Class ConfigValueIsNotSingleException
Inherits InvalidCastException
Public Sub New(ByVal ex As Exception)
MyBase.New(ex.Message, ex)
End Sub
End Class
Public Class MissingConfigFileException
Inherits IO.FileNotFoundException
Public Sub New(ByVal ex As IO.FileNotFoundException)
MyBase.New(ex.Message, ex.FileName, ex)
End Sub
End Class
通常,您会向异常类添加更多属性,以便调用者获得有关异常的更多信息。例如,我们可以向 `ConfigValueIsNotSingleException` 类添加 `ParameterName` 和 `ActualValue` 属性。这些属性将是 `ReadOnly` 的,并且对应的私有字段应在构造函数中设置。
Public Class ConfigValueIsNotSingleException
Inherits InvalidCastException
Public Sub New(ByVal ex As Exception, _
ByVal paramname As String, ByVal value As Object)
MyBase.New(ex.Message, ex)
Me._parameterName = paramname
Me._value = value
End Sub
Private _value As Object
Public ReadOnly Property ActualValue() As Object
Get
Return _value
End Get
End Property
Private _parameterName As String
Public ReadOnly Property ParameterName() As String
Get
Return _parameterName
End Get
End Property
End Class
...
Function GetConfigValue(ByVal param As String) As Single
Try
Return My.Settings.Item(param)
Catch ex As IO.FileNotFoundException
Throw New MissingConfigFileException(ex)
Catch ex As InvalidCastException
Throw New ConfigValueIsNotSingleException(ex, _
param, My.Settings.Item(param))
End Try
End Function
让我们看看调用者如何使用改进后的异常
Sub ShowMe()
Try
Dim x = GetConfigValue("StringParameter")
Catch ex As ConfigValueIsNotSingleException
MsgBox(String.Format("The value of {0} is {1}, which is not Single",_
ex.ParameterName, ex.ActualValue))
End Try
End Sub
显然,开发人员现在可以轻松地识别导致异常的原因。为简单起见,在接下来的部分中,我们将省略两个添加的属性,回到 `ConfigValueIsNotSingleException` 的一个更简单的版本。
减少异常类的数量
虽然为每种异常情况都有一个自定义异常类对使用您组件的开发人员来说可能非常有帮助,但拥有大量类会使您的命名空间混乱并引起混淆。有时为几种情况定义一个单一的异常类会更好。我们可以将一些附加信息(用于标识异常的原因)放入一个属性中。例如,与其拥有 `ConfigValueIsNotSingleException`、`ConfigValueIsNotIntegerException` 等,我们可以为配置相关代码中的所有类型转换错误定义一个名为 `InvalidTypeInConfigException` 的单一类。为了识别确切的问题,我们可以有一个 `Reason` 属性,该属性是枚举类型的。该类的定义如下
Public Class InvalidTypeInConfigException
Inherits InvalidCastException
Public Sub New(ByVal ex As Exception, ByVal reason As ExceptionReason)
MyBase.New(ex.Message, ex)
Me._reason = reason
End Sub
Private _reason As ExceptionReason
Public ReadOnly Property Reason() As ExceptionReason
Get
Return _reason
End Get
End Property
Public Enum ExceptionReason
ConfigValueIsNotSingle
ConfigValueIsNotInteger
'...
End Enum
End Class
调用代码变成
Function GetConfigValue(ByVal param As String) As Single
Try
Return My.Settings.Item(param)
Catch ex As IO.FileNotFoundException
Throw New MissingConfigFileException(ex)
Catch ex As InvalidCastException
Throw New InvalidTypeInConfigException(ex,_
ExceptionReason.ConfigValueIsNotSingle)
End Try
End Function
与异常相关的事件
有时您想通知调用者即将抛出异常。例如,如果调用者提供了无效数据,我们可以给他一个纠正情况的机会。这个模式被 `System.Windows.Forms.DataGridView` 类等使用。如果发生错误,该类会检查是否为此事件设置了处理程序。如果处理程序存在,则会调用它。如果不存在,则抛出异常。用户负责在处理程序中纠正错误。事件参数提供了 `Exception` 属性等。处理程序可以检查此属性,例如进行日志记录。事件参数还提供 `ThrowException` 属性,处理程序可以将其设置为 `True` 或 `False`,具体取决于我们是否确实要抛出此异常或以更温和的方式处理它。
为了实现此模式,我们必须为事件参数构造另一个类。我们的类应该包含 `ReadOnly` 的 `Exception` 属性、`ThrowException` 属性以及调用者可以修改以处理情况的其他一些属性。让我们看一个例子
Public Class UnhandledConfigExceptionEventArgs
Inherits EventArgs
Private _exception As InvalidTypeInConfigException
Public ReadOnly Property Exception() As InvalidTypeInConfigException
Get
Return _exception
End Get
End Property
Private _throw As Boolean = True
Public Property ThrowException() As Boolean
Get
Return _throw
End Get
Set(ByVal value As Boolean)
_throw = value
End Set
End Property
Private _value As Object
Public Property ActualValue() As Object
Get
Return _value
End Get
Set(ByVal value As Object)
_value = value
End Set
End Property
Public Sub New(ByVal exception As InvalidTypeInConfigException, _
ByVal value As Object)
Me._exception = exception
Me._value = value
Me._throw = True
End Sub
End Class
接下来,包含 `GetConfigValue()` 方法的类应该有一个事件
Public Event ConfigException As EventHandler(_
Of UnhandledConfigExceptionEventArgs)
现在,让我们看看如何调用此模式。为简单起见,我们忽略了可能发生 `FileNotFoundException` 的情况。我们的目的是让组件的消费者处理配置值无法转换为 `Single` 的情况,并可能提供替代值。
Function GetConfigValue(ByVal param As String) As Single
Dim value = My.Settings.Item(param)
Try
Return CSng(value)
Catch ex As InvalidCastException
Dim ConfigException As New InvalidTypeInConfigException(ex,_
ExceptionReason.ConfigValueIsNotSingle, param, value)
Dim e As New UnhandledConfigExceptionEventArgs(ConfigException, value)
RaiseEvent ConfigException(Me, e)
If e.ThrowException Then
Throw ConfigException
Else
Return e.ActualValue
End If
End Try
End Function
如果抛出异常,我们将引发相应的事件。现在调用者可以选择处理事件,检查 `Exception` 属性,并可能通过 `ActualValue` 属性提供自定义值。让我们看看实际效果
Sub InvalidTypeInConfigExceptionThrown(ByVal sender As Object, ByVal e As_
UnhandledConfigExceptionEventArgs)
e.ActualValue = 0
e.ThrowException = False
LogExeption(e.Exception)
End Sub
回到 `GetConfigValue()`。在引发事件之后,我们检查 `ThrowException` 属性。在事件发生之前,它被设置为 `True`,但处理程序可能已将其设置为 `False`。如果已设置为 `False`,则返回修改后的值。希望它已修改为 `Single` 值,否则我们将遇到另一个 `InvalidCastException`。如果 `ThrowException` 仍然为 `True`,我们将像上一节一样抛出自定义异常。
如果调用 `GetConfigValue()` 的是开发人员本身,那么这种模式就没有太大意义,因为异常可以通过直接的 try-catch 方式来处理。但是,如果该方法是从我们自己的自定义组件或某些第三方组件内部调用的,开发人员无法控制 `GetConfigValue()` 方法的返回值,除非我们提供此事件。在理想情况下,每当我们遇到环境问题时,我们都应该抛出异常,这样我们的 `GetConfigValue()` 方法就无法提供正确的值。然而,在某些情况下,用户可能不希望抛出异常,而是希望使用“虚假”方法结果来继续正常流程。
因此,您只能在我们的方法从我们的组件内部调用时使用此模式。有时阻止异常发生并让其余代码执行是有意义的。
异常消息和本地化
上述异常返回与底层异常相同的消息。如果调用代码忘记处理此特定异常,这可能会引起混淆。毕竟,您已经费了这么大力气提供了一个自定义异常。为什么不提供自定义消息呢?
您可能不想将消息放在代码中。更合适的地方是资源文件。另一个使用资源的原因是,您的消息可以轻松自定义。
因此,假设我们添加了两个字符串资源:`ConfigValueIsNotSingleMessage` 和 `ConfigValueIsNotIntegerMessage`。消息可以是格式字符串,例如“设置 {1} 的值 {0} 不是单个数字”。我们应该覆盖 `Message` 属性
Public Overrides ReadOnly Property Message() As String
Get
Select Case Reason
Case ExceptionReason.ConfigValueIsNotSingle : Return _
String.Format(My.Resources.ConfigValueIsNotSingleMessage,_
ActualValue, ParameterName)
Case ExceptionReason.ConfigValueIsNotInteger : Return _
String.Format(My.Resources.ConfigValueIsNotIntegerMessage, _
ActualValue, ParameterName)
End Select
Return MyBase.Message
End Get
End Property
测试
当然,我们希望为我们的自定义组件编写单元测试。在测试 `GetConfigValue` 方法时,我们应该测试在应该抛出异常时确实抛出了异常。测试框架通常为测试抛出异常的测试方法提供 `ExpectedExceptionAttribute`。然而,这样我们只能验证我们的异常是否被抛出。而我们希望验证我们的异常是否具有正确的属性值。让我们看看测试代码
<Test()> _
Sub GetConfigValueThrowsExceptionWithConfigValueIsNotSingleReason()
Try
Dim x = GetConfigValue("StringParameter")
Assert.Fail("InvalidTypeInConfigException has not been thrown")
Catch ex As InvalidTypeInConfigException
Assert.AreEqual(_
InvalidTypeInConfigException.ExceptionReason.ConfigValueIsNotSingle,_
ex.Reason, "Reason should be ConfigValueIsNotSingle")
End Try
End Sub
这里我们不使用 `ExpectedExceptionAttribute`,因为我们正在捕获异常。因此,为了验证它已被抛出,我们使用 `Assert.Fail` 语句。如果异常被抛出,我们永远不会到达这一行。如果异常被抛出,但它是另一种类型的异常,我们不会捕获它,测试将失败。如果异常是正确类型的,我们捕获它并验证所有相关属性。在我们的例子中,这是 `Reason` 属性。
序列化异常
基类 Exception 被标记为 Serializable。这意味着它可以被传递到另一个 AppDomain,甚至另一个应用程序。虽然这种情况很少发生,但您应该做好准备。默认情况下,只有继承的成员会被序列化。每当您的异常跨越 AppDomain 边界时,它就会丢失您添加的所有自定义字段。防止这种情况很简单——您需要重写 `GetObjectData` 方法以进行序列化,并添加一个特定的受保护构造函数以进行反序列化。然而,很容易忘记这些事情。
结论
异常并没有引起社区(尤其是大公司)的太多关注。一个明显的原因可能是我们仍然期望我们的代码是完美的。异常是如此不规则和烦人的东西,以至于我们的“正确”代码应该是结构良好且具有所有其他优点,而我们却允许我们的异常相当丑陋。另一个原因,也许更潜意识的,是一种原始的迷信:不要谈论异常,否则它们就会来找你。
我坚信,对于现有的每种编程模式——无论是 GoF、Microsoft 还是您自己的模式——都应该有一种相应的异常处理和/或抛出模式。至少异常应该成为模式的重要组成部分。毕竟,异常就是类,所以许多现有的模式都可以应用于它们。也许有一天我们会听到关于异常工厂、异常策略和异常观察者的说法。然而,由于异常并非简单的对象,根据您对它们的态度,它们可能会给您优雅的设计带来混乱,或者在应用程序层之间提供受控的执行流和信息交换。无论您是否喜欢,异常就在那里,您应该将它们变成您的盟友,否则它们很快就会变成您的敌人。
历史
- 2007年7月9日 - 原始版本发布
- 2007年7月26日 - 文章已编辑并移至 CodeProject.com 的主要文章库
- 2007年9月22日 - 添加了关于序列化的部分