异常处理最佳实践

适当的异常处理对于应用程序可靠性至关重要。 可以有意处理预期异常,以防止应用崩溃。 但是,崩溃的应用比具有未定义行为的应用更可靠且可诊断。

本文介绍处理和创建异常的最佳做法。

处理异常

以下最佳做法涉及如何处理异常:

使用 try/catch/finally 块从错误中恢复或释放资源

对于可能生成异常的代码,以及当应用可以从该异常恢复时,请在代码周围使用 try/catch 块。 在 catch 块中,始终按从派生程度最高到派生程度最低的顺序对异常排序。 (所有异常都派生自 Exception 该类。更多的派生异常不是由 catch 基异常类的子句前面的 catch 子句处理的。当代码无法从异常中恢复时,请不要捕获该异常。 如果可能,使调用堆栈上更高层的方法能够恢复。

使用 using 语句或 finally 块清除分配的资源。 在抛出异常时,优先使用 using 语句以自动清理资源。 使用 finally 块清理未实现 IDisposable的资源。 即使抛出异常,finally 子句中的代码几乎总是会被执行。

处理常见条件以避免异常

对于可能发生但可能会触发异常的条件,请考虑以避免异常的方式处理它们。 例如,如果尝试关闭已关闭的连接,则会收到一个 InvalidOperationException。 在尝试关闭连接之前, if 可以使用语句检查连接状态来避免这种情况。

if (conn.State != ConnectionState.Closed)
{
    conn.Close();
}
If conn.State <> ConnectionState.Closed Then
    conn.Close()
End IF

如果在关闭前未检查连接状态,可以捕获 InvalidOperationException 异常。

try
{
    conn.Close();
}
catch (InvalidOperationException ex)
{
    Console.WriteLine(ex.GetType().FullName);
    Console.WriteLine(ex.Message);
}
Try
    conn.Close()
Catch ex As InvalidOperationException
    Console.WriteLine(ex.GetType().FullName)
    Console.WriteLine(ex.Message)
End Try

选择的方法取决于预期事件发生的频率。

  • 如果此事件未经常发生(也就是说,如果此事件确实为异常事件并指示错误[如意外的文件尾]),则使用异常处理。 使用异常处理时,在正常情况下执行的代码更少。

  • 如果事件经常发生,并可能被视为正常执行的一部分,请检查代码中的错误条件。 检查常见错误条件时,执行的代码较少,因为避免了异常。

    注释

    前期检查在大多数情况下会消除异常。 但可能存在争用条件:受保护的条件在检查和操作之间会发生变化,在这种情况下,仍可能会引发异常。

调用 Try* 方法以避免异常

如果处理异常的性能开销过高,某些 .NET 库方法提供了替代的错误处理方式。 例如,如果要解析的值太大,无法由Int32表示,则会抛出一个OverflowException。 但是, Int32.TryParse 不会引发此异常。 相反,它返回一个布尔值,并具有一个 out 参数,其中包含成功后分析的有效整数。 Dictionary<TKey,TValue>.TryGetValue 尝试从字典中获取值的行为类似。

捕获取消和异步异常

调用异步方法时,最好捕获OperationCanceledException,而不是捕获从OperationCanceledException中派生的TaskCanceledException。 如果请求取消,许多异步方法将引发 OperationCanceledException 异常。 一旦观察到取消请求,这些异常就能有效地停止执行,并展开调用堆栈。

异步方法会在执行期间将引发的异常存储在它们返回的任务中。 如果异常存储在返回的任务中,则等待任务时会抛出该异常。 使用情况异常(例如 ArgumentException)仍会同步引发。 有关详细信息,请参阅 异步异常

设计类,以避免异常

类可以提供方法或属性,使你能够避免进行触发异常的调用。 例如,该 FileStream 类提供的方法有助于确定文件末尾是否已到达。 可以使用这些方法来避免在读取操作超过文件末尾时引发的异常。 以下示例演示如何在不触发异常的情况下读取到文件的末尾:

class FileRead
{
    public static void ReadAll(FileStream fileToRead)
    {
        ArgumentNullException.ThrowIfNull(fileToRead);

        int b;

        // Set the stream position to the beginning of the file.
        fileToRead.Seek(0, SeekOrigin.Begin);

        // Read each byte to the end of the file.
        for (int i = 0; i < fileToRead.Length; i++)
        {
            b = fileToRead.ReadByte();
            Console.Write(b.ToString());
            // Or do something else with the byte.
        }
    }
}
Class FileRead
    Public Sub ReadAll(fileToRead As FileStream)
        ' This if statement is optional
        ' as it is very unlikely that
        ' the stream would ever be null.
        If fileToRead Is Nothing Then
            Throw New System.ArgumentNullException()
        End If

        Dim b As Integer

        ' Set the stream position to the beginning of the file.
        fileToRead.Seek(0, SeekOrigin.Begin)

        ' Read each byte to the end of the file.
        For i As Integer = 0 To fileToRead.Length - 1
            b = fileToRead.ReadByte()
            Console.Write(b.ToString())
            ' Or do something else with the byte.
        Next i
    End Sub
End Class

避免异常的另一种方法是,在大多数常见的错误情况下返回 null (或默认值),而不是抛出异常。 常见的错误情况可以视为正常的控制流。 在这些情况下,通过返回 null (或默认值),可以最大程度地降低对应用的性能影响。

对于值类型,请考虑是使用 Nullable<T> 还是 default 用作应用的错误指示器。 通过使用 Nullable<Guid>default 变成 null 而不是 Guid.Empty。 有时,添加 Nullable<T> 可更加明确值何时存在或不存在。 在其他情况下,添加 Nullable<T> 可能会创建不必要的额外案例需要检查,这样做只会带来潜在的错误来源。

由于异常而方法未完成时还原状态

当异常从方法引发时,调用方应能够假定没有副作用。 例如,如果你的代码可以通过从一个帐户取钱并存入另一个帐户来转移资金,而在存款时引发了异常,你不希望取款仍然有效。

public void TransferFunds(Account from, Account to, decimal amount)
{
    from.Withdrawal(amount);
    // If the deposit fails, the withdrawal shouldn't remain in effect.
    to.Deposit(amount);
}
Public Sub TransferFunds(from As Account, [to] As Account, amount As Decimal)
    from.Withdrawal(amount)
    ' If the deposit fails, the withdrawal shouldn't remain in effect.
    [to].Deposit(amount)
End Sub

上述方法不会直接引发任何异常。 但是,你必须编写该方法,以便在存款操作失败时撤消取款。

解决这一情况的一种方法是,捕获由存款交易引发的异常,然后回滚取款。

private static void TransferFunds(Account from, Account to, decimal amount)
{
    string withdrawalTrxID = from.Withdrawal(amount);
    try
    {
        to.Deposit(amount);
    }
    catch
    {
        from.RollbackTransaction(withdrawalTrxID);
        throw;
    }
}
Private Shared Sub TransferFunds(from As Account, [to] As Account, amount As Decimal)
    Dim withdrawalTrxID As String = from.Withdrawal(amount)
    Try
        [to].Deposit(amount)
    Catch
        from.RollbackTransaction(withdrawalTrxID)
        Throw
    End Try
End Sub

此示例演示了如何使用 throw 重新引发原始异常,使调用方无需检查 InnerException 属性即可更轻松地查看问题的真正原因。 替代方法是引发新异常,并将原始异常作为内部异常包含。

catch (Exception ex)
{
    from.RollbackTransaction(withdrawalTrxID);
    throw new TransferFundsException("Withdrawal failed.", innerException: ex)
    {
        From = from,
        To = to,
        Amount = amount
    };
}
Catch ex As Exception
    from.RollbackTransaction(withdrawalTrxID)
    Throw New TransferFundsException("Withdrawal failed.", innerException:=ex) With
    {
        .From = from,
        .[To] = [to],
        .Amount = amount
    }
End Try

正确捕获和重新引发异常

引发异常后,它所包含的部分信息为堆栈跟踪。 堆栈跟踪是一个方法调用层次结构列表,它以引发异常的方法开头,以捕获异常的方法结尾。 如果您在 throw 语句中通过指定异常来重新抛出异常,例如 throw e,那么堆栈跟踪将在当前方法处重新开始,并且在引发该异常的原始方法与当前方法之间的方法调用列表将会丢失。 若要保留异常的原始堆栈跟踪信息,有两种选项,具体取决于您从何处重新抛出异常。

  • 如果从捕获异常实例的处理程序(catch 块)内部重新引发异常,请在不指定异常的情况下使用 throw 语句。 代码分析规则 CA2200 可帮助你在代码中找到可能无意中丢失堆栈跟踪信息的位置。
  • 如果要从处理程序(catch 程序块)以外的某个位置再次引发异常,请在需要再次引发异常时使用 ExceptionDispatchInfo.Capture(Exception) 捕获处理程序中的异常以及 ExceptionDispatchInfo.Throw()。 可以使用该 ExceptionDispatchInfo.SourceException 属性来检查捕获的异常。

以下示例演示如何使用 ExceptionDispatchInfo 类,以及输出可能的样子。

ExceptionDispatchInfo? edi = null;
try
{
    var txt = File.ReadAllText(@"C:\temp\file.txt");
}
catch (FileNotFoundException e)
{
    edi = ExceptionDispatchInfo.Capture(e);
}

// ...

Console.WriteLine("I was here.");

if (edi is not null)
    edi.Throw();

如果示例代码中的文件不存在,则会生成以下输出:

I was here.
Unhandled exception. System.IO.FileNotFoundException: Could not find file 'C:\temp\file.txt'.
File name: 'C:\temp\file.txt'
   at Microsoft.Win32.SafeHandles.SafeFileHandle.CreateFile(String fullPath, FileMode mode, FileAccess access, FileShare share, FileOptions options)
   at Microsoft.Win32.SafeHandles.SafeFileHandle.Open(String fullPath, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, Nullable`1 unixCreateMode)
   at System.IO.Strategies.OSFileStreamStrategy..ctor(String path, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, Nullable`1 unixCreateMode)
   at System.IO.Strategies.FileStreamHelpers.ChooseStrategyCore(String path, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, Nullable`1 unixCreateMode)
   at System.IO.StreamReader.ValidateArgsAndOpenPath(String path, Encoding encoding, Int32 bufferSize)
   at System.IO.File.ReadAllText(String path, Encoding encoding)
   at Example.ProcessFile.Main() in C:\repos\ConsoleApp1\Program.cs:line 12
--- End of stack trace from previous ___location ---
   at Example.ProcessFile.Main() in C:\repos\ConsoleApp1\Program.cs:line 24

引发异常

以下最佳做法涉及到如何引发异常:

使用预定义的异常类型

仅当预定义的异常类不适用时,才引入新的异常类。 例如:

注释

虽然最好尽可能使用预定义的异常类型,但不应引发某些保留的异常类型,例如AccessViolationExceptionIndexOutOfRangeExceptionNullReferenceExceptionStackOverflowException。 有关更多信息,请参阅 CA2201:不要触发保留的异常类型

使用异常生成器方法

类在实现中从不同位置引发相同的异常很常见。 为了避免代码过多,请创建一个帮助程序方法,该方法创建异常并返回它。 例如:

class FileReader
{
    private readonly string _fileName;

    public FileReader(string path)
    {
        _fileName = path;
    }

    public byte[] Read(int bytes)
    {
        byte[] results = FileUtils.ReadFromFile(_fileName, bytes) ?? throw NewFileIOException();
        return results;
    }

    static FileReaderException NewFileIOException()
    {
        string description = "My NewFileIOException Description";

        return new FileReaderException(description);
    }
}
Class FileReader
    Private fileName As String


    Public Sub New(path As String)
        fileName = path
    End Sub

    Public Function Read(bytes As Integer) As Byte()
        Dim results() As Byte = FileUtils.ReadFromFile(fileName, bytes)
        If results Is Nothing
            Throw NewFileIOException()
        End If
        Return results
    End Function

    Function NewFileIOException() As FileReaderException
        Dim description As String = "My NewFileIOException Description"

        Return New FileReaderException(description)
    End Function
End Class

某些关键 .NET 异常类型具有此类静态 throw 帮助程序方法,用于分配和引发异常。 应调用这些方法,而不是构造并引发相应的异常类型:

小窍门

以下代码分析规则可帮助你在代码中找到可以利用这些静态 throw 帮助程序的位置: CA1510CA1511CA1512CA1513

如果要实现异步方法,请调用 CancellationToken.ThrowIfCancellationRequested(),而不是先检查是否请求取消,然后构造并引发 OperationCanceledException。 有关详细信息,请参阅 CA2250

包含本地化的字符串消息

用户看到的错误消息派生自 Exception.Message 引发的异常的属性,而不是从异常类的名称派生。 通常,通过将消息字符串message传递给异常构造函数的参数来为属性赋值Exception.Message

对于本地化的应用程序,应为应用程序可以引发的每个异常提供本地化的消息字符串。 你使用资源文件来提供本地化的错误消息。 有关本地化应用程序和检索本地化字符串的信息,请参阅以下文章:

使用适当的语法

编写明确的句子并包括结束标点符号。 分配给该属性的 Exception.Message 字符串中的每个句子都应以句点结束。 例如,“日志表已溢出”这个句子使用了正确的语法和标点符号。

妥善放置 throw 语句

将 throw 语句放置在堆栈跟踪有帮助的位置。 堆栈跟踪从引发异常的语句开始,并在捕获异常的 catch 语句处结束。

不要在 finally 子句中引发异常

不要在 finally 子句中引发异常。 有关详细信息,请参阅代码分析规则 CA2219

不要从意外的位置引发异常

某些方法(例如 EqualsGetHashCodeToString 方法、静态构造函数和相等运算符)不应引发异常。 有关详细信息,请参阅代码分析规则 CA1065

同步抛出参数验证异常

在任务返回方法中,应在输入方法的异步部分之前验证参数并引发任何相应的异常,例如 ArgumentExceptionArgumentNullException。 在方法的异步部分引发的异常将存储在返回的任务中,并且在等待任务等状态之前不会出现。 有关详细信息,请参阅 任务返回方法中的异常

自定义异常类型

以下最佳做法涉及自定义异常类型:

将异常类名称以 Exception 结尾

当需要自定义异常时,请为其适当命名,并从Exception类派生。 例如:

public class MyFileNotFoundException : Exception
{
}
Public Class MyFileNotFoundException
    Inherits Exception
End Class

包含三个构造函数

创建自己的异常类时,至少使用三个常见构造函数:无参数构造函数、采用字符串消息的构造函数,以及采用字符串消息和内部异常的构造函数。

有关示例,请参阅 “如何:创建用户定义的异常”。

根据需要提供其他属性

仅当存在附加信息有用的编程方案时,才在异常中提供附加属性(不包括自定义消息字符串)。 例如,FileNotFoundException提供了FileName属性。

另请参阅