.Net 异常最佳做法

异常信息原因

异常是易于滥用的那些构造之一。这可能包括不应该在应有的情况下引发异常或在没有充分理由的情况下捕获异常。还有一个引发错误异常的问题,它不仅无助于我们,而且会使我们困惑。另一方面,存在正确处理异常的问题。如果使用不当,异常处理会变得更糟。所以,在本文中,我将简单介绍一些有关引发和处理异常的最佳实践。展示如何抛出适当的异常可以为我们节省很多调试方面的麻烦。我还将讨论当我们想要查找错误时不良的异常处理如何引起误导。

抛出异常

何时抛出异常

在很多情况下,抛出异常是有意义的,在这里,我将对其进行描述并讨论为什么抛出它们是一个好主意。请注意,本文中的许多示例都经过简化以证明这一点。例如,没有使用一种方法来检索数组元素。或者在某些情况下,我没有使用以前提倡的技术来关注当前观点。因此,自然而然地,示例并不试图在所有方面都成为异常处理的理想形式,因为这样会引入额外的元素,从而可能使读者分心。

1:不可能完成过程并给出结果(快速失败)

static void FindWinner(int[] winners)
{
    if (winners == null)
    {
        throw new System.ArgumentNullException($"参数 {nameof(winners)} 不能为空",nameof(winners));
    }
    
    OtherMethodThatUsesTheArray(winner);
}

假设我们有上述方法,在这里我们抛出一个异常,因为从这种方法中获得没有赢家数组的结果是不可能的。另一个要点是该方法的用户易于使用。想象一下,我们没有引发异常,而是将数组传递给OtherMethodThatUsesTheArray method,而该方法引发了NullReferenceException。通过不抛出异常,调试变得更加困难。因为此代码的调试器必须首先查看OtherMethodThatUsesTheArray方法,因为这就是错误的来源。然后,他找出赢家的论点是产生此错误的地方。当我们抛出异常时,我们确切地知道错误的根源,而不必在代码库中追逐错误。另一个问题是不必要的资源使用,假设在达到框架发生异常之前,我们进行了一些昂贵的处理。现在,如果该方法在没有相关参数或您所没有的情况下无法提供我们的结果,则实际上浪费了很多资源,而实际上该方法最初可能无法成功。请记住,当可能发生错误时,我们不会抛出异常,但是当错误阻止流程时,我们会抛出异常。有时我们也可以避免使用异常,而使用try-stuff模式int.TryParse并且不抛出异常。

2:给定对象的当前状态,调用对象的成员可能会产生无效的结果,或者可能会完全失败

void WriteLog(FileStream logFile)
{
    if (!logFile.CanWrite)
    {
        throw new System.InvalidOperationException("日志文件不能是只读的");
    }
    // Else write data to the log and return.
}

在这里,传递给WriteLog方法的文件流以不可写的方式进行创建。在这种情况下,我们知道此方法将不起作用。因为我们无法登录到不可写的文件。另一个例子是当我们有一个类,我们希望在收到它时处于特定状态。通过抛出异常并节省资源和调试时间,我们又一次快速失败。

捕获通用的非特定异常并引发更特定的异常


void WriteLog(FileStream logFile)
{
    if (!logFile.CanWrite)
    {
        throw new System.InvalidOperationException("日志文件不能是只读的");
    }
    // Else write data to the log and return.
}

关于异常,有一个经验法则,程序产生的异常越具体,调试和维护就越容易。换句话说,通过这样做,我们的程序会产生更准确的错误。因此,我们应始终努力尽可能地抛出更具体的异常。这就是为什么抛出异常喜欢System.Exception,System.SystemException,System.NullReferenceException,或者System.IndexOutOfRangeException是不是一个好主意。最好不要使用它们,并且将它们看作是错误消息是由框架生成的信号,我们将花费大量时间进行调试。在上面的代码中,您看到我们抓住IndexOutOfRangeException并抛出了一个新的ArgumentOutOfRangeException向我们显示了实际错误的来源。我们还可以使用它来捕获框架生成的异常并引发新的自定义异常。这使我们可以添加其他信息,或者可能以不同的方式进行处理。只要确保将原始异常作为内部异常传递到自定义异常中,否则stacktrace将丢失。

4:例外情况引发异常

这听起来似乎很明显,但有时可能会很棘手。我们的程序中有某些事情会发生,我们不能将它们视为错误。因此,我们不会抛出异常。例如,搜索查询可能返回空,或者用户登录尝试可能失败。在这种情况下,最好返回某种有意义的消息,然后抛出异常。正如史蒂夫·麦康奈尔(Steve McConnell)在《代码完整》一书中所说的那样,“例外应该保留给真正的例外 ”,而不是期望的例外。

不要使用异常来更改程序的流程

以下面的代码为例。

[HttpPost]
public ViewResult CreateProduct(CreateProductViewModel viewModel)
{
    try
    {
        ValidateProductViewModel(viewModel);
        CreateProduct(viewModel);
    }
    catch (ValidationException ex)
    {
        return View(viewModel);
    }
}

我在一些需要处理的旧代码中看到了这种模式。如您所见,ValidateProductViewModel 如果视图模型无效,则由于某种原因该方法将引发异常。然后,如果视图模型无效,它将捕获该模型并返回错误的视图。我们最好将上面的代码更改为下面的代码

[HttpPost]
public ViewResult CreateProduct(CreateProduct viewModel)
{
    bool viewModelIsValid = ValidateProductViewModel(viewModel);
        
    if(!viewModelIsValid) return View(viewModel); 
        
    CreateProduct(viewModel);
    
     return View(viewModel); 
}

在这里,负责验证的方法在视图模型无效的情况下返回布尔值,而不是引发异常

不返回错误代码,而是引发异常

抛出异常总是比返回错误代码更安全。原因是如果调用代码忘记检查或返回错误代码并继续执行该怎么办?但是,如果我们抛出异常,那将不会发生。

确保清除抛出异常的任何副作用

private static void MakeDeposit(Account account,decimal amount)
{
    try
    {
        account.Deposit(amount);
    }
    catch
    {
        account.RollbackDeposit(amount);
        throw;
    }
}

在这里,我们知道调用deposit方法时可能会发生错误。我们应该确保如果发生异常,则对系统的任何更改都会回滚。

try
{
    DBConnection.Save();
}
catch
{
    // 回滚数据库操作
    DBConnection.Rollback();

    // 重新抛出异常,让外界知道错误消息
    throw;
}

您也可以使用事务作用域,而不用这种方式进行处理。请记住,您也可以在“最终阻止”中执行此操作。

如果您捕获了一个异常并且无法正确处理它,请确保将其重新抛出

在某些情况下,当我们捕获到异常但我们不打算处理它时,也许我们只是想记录它。就像是:

try
{
    conn.Close();
}
catch (InvalidOperationException ex)
{
    _logger.LogError(ex.Message,ex);
    
     //不好的做法,堆栈跟踪丢失了
    //抛出ex; 
    
    //优良作法,保持堆栈跟踪
    抛出;
    throw;
}

如您在上面的代码中看到的那样,我们不仅应该重新抛出异常,而且还应该以不丢失堆栈跟踪的方式重新抛出该异常。在这种情况下,如果使用throw ex,则会丢失堆栈跟踪,但是如果使用前不带instace ex的throw,则会保留堆栈跟踪。

不要将异常用作参数或返回值

在大多数情况下,使用Exception作为参数或返回值没有意义。也许只有当我们在异常工厂中使用它时,它才有意义。

Exception AnalyzeHttpError(int errorCode) {
    if (errorCode < 400) {
         throw new NotAnErrorException();
    }
    switch (errorCode) {
        case 403:
             return new ForbiddenException();
        case 404:
             return new NotFoundException();
        case 500:
             return new InternalServerErrorException();
        …
        default:
             throw new UnknownHttpErrorCodeException(errorCode);
     }
}

防止程序抛出异常(如果可能),导致异常昂贵

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

以上面的代码为例,我们将用于关闭连接的代码放在try块中。如果连接已经关闭,它将引发异常。但是也许我们可以在不导致程序引发异常的情况下实现相同的目标?

if (conn.State != ConnectionState.Closed)
{
    conn.Close();
}

如您所见,try块是不必要的,如果连接已经关闭,它将导致程序引发异常,并且异常的开销比check开销大。

创建自己的异常类

框架并未涵盖所有可能发生的异常。有时我们需要创建自己的异常类型。可以将它们定义为类,就像C#中的任何其他类一样。我们创建自定义异常类通常是因为我们想以不同的方式处理该异常。或那种特殊的异常对我们的应用程序非常关键。要创建自定义异常,我们需要创建一个派生自System.Exception的类。

[Serializable()]
public class InvalidDepartmentException : System.Exception
{
    public InvalidDepartmentException() : base() { }
    public InvalidDepartmentException(string message) : base(message) { }
    public InvalidDepartmentException(string message,System.Exception inner) : base(message,inner) { }

    // 当异常从远程服务器传播到客户端时,需要序列化构造函数
    protected InvalidDepartmentException(SerializationInfo info,StreamingContext context) { }
}

在这里,派生类定义了四个构造函数。一种默认构造函数,一种用于设置message属性,一种用于同时设置Message和InnerException。第四个构造函数用于序列化异常。另请注意,异常应可序列化。

处理(捕获)异常

何时捕获异常
捕获异常比抛出异常更容易被滥用。但是,当应用程序达到维护阶段时,这种滥用会导致很多痛苦。在以下部分中,我将描述有关处理异常的一些最佳实践。

1:当异常处理

我在许多应用程序中看到过,try块用于抑制异常。但这不是try-catch块的用途。通过这样的尝试,我们改变了系统的行为,使发现错误的难度超过了应有的程度。这种现象非常普遍,以至于我们对其滥用有一个术语,即Pokemon异常处理。大多数情况下,发生的事情是try-catch块吞没了错误,错误最终在我们应用程序中的其他位置而不是原始位置出现。真正的痛苦是,大多数时候错误消息根本没有任何意义,因为它不是原始错误的来源。这使得调试体验令人沮丧。

2:在实际可以处理异常并从异常中恢复时使用尝试阻止

这种情况的一个很好的例子是,当程序提示用户输入文件和文件的路径时,该路径不存在。如果应用程序抛出错误,我们可以从中恢复,方法可能是捕获异常并要求用户输入另一个文件路径。因此,您应该将捕获块的顺序从最具体的到最不具体的。

public void OpenFile(string filePath)
{
  try
  {
     File.Open(path);
  }
  catch (FileNotFoundException ex)
  {
     Console.WriteLine("找不到指定的文件路径,请输入其他路径");
     PromptUserForAnotherFilePath();
  }
}

您可以使用的另一件事是异常过滤器。异常过滤器的工作方式类似于catch块的if语句。如果检查结果为true,则执行catch块,否则将忽略catch块。

private static void Filter()
{
    try
    {
        A();
    }
    catch (OperationCanceledException exception) when (string.Equals(nameof(ExceptionFilter),exception.Message,StringComparison.Ordinal))
    {
    }
}

3:您想捕获一个通用异常并抛出一个具有更多上下文的更具体的异常

int GetInt(int[] array,int index)
{
    try
    {
        return array[index];
    }
    catch(System.IndexOutOfRangeException e)
    {
        throw new System.ArgumentOutOfRangeException("Parameter index is out of range.",e);
    }
}

以上面的代码为例。在这里,我们捕获IndexOutOfBound 异常并抛出ArgumentOutOfRangeException。这样,我们就可以更清楚地了解错误的来源,并且可以更快地找到问题的根源。

4:您想部分处理异常并将其传递给进一步处理

try
{
    // Open File
}
catch (FileNotFoundException e)
{
    _logger.LogError(e);
    // Re-throw the error.
    throw;     
}

在上面的示例中,我们捕获了异常,记录了所需的信息,然后重新抛出了异常。

仅在应用程序的最高层出现捕获异常

在每个应用程序中,都有一点应该吞下异常。例如,大多数时候,在Web应用程序中,我们不希望用户看到异常错误。我们希望向用户展示一些通用信息,并保留异常信息以用于调试。在这种情况下,我们可以将代码块包装在try-catch块中。如果发生错误,我们将捕获异常,并记录错误,并将一些通用信息返回给用户,例如下面的代码。

[HttpPost]
public async Task<IActionResult> UpdatePost(PostViewModel viewModel)
{
    try
    {
         _mediator.Send(new UpdatePostCommand{ PostViewModel = viewModel});
         return View(viewModel);
    }
    catch (Exception e)
    {
        _logger.LogError(e);
       return View(viewModel);
    }
}

请注意,在这种情况下,我们仍然会记录错误,但是错误不会使图层冒泡。因为这是最后一层,所以实际上上面没有任何层。换句话说,应该使用异常而不是重新抛出异常的唯一代码应该是UI或公共API。一些开发人员倾向于配置某种全局方式来处理在此层中发生的异常。您可以看一下我以前使用这种技术的帖子。当涉及到异常处理时,最重要的事情是异常处理永远都不应隐藏问题。

最后

Final块用于清除try块中使用的任何剩余资源,而无需等待运行时的垃圾收集器完成该对象。我们可以使用它来关闭数据库连接,文件流等。请注意,FileStream 在调用close之前,我们首先检查object是否为null。不这样做,finally块可能会抛出自己的异常,这根本不是一件好事。

FileStream file = null;
var fileinfo = new FileInfo("C:\\file.txt");
try
{
    file = fileinfo.OpenWrite();
    file.WriteByte(0xF);
}
finally
{
    // Check for null because OpenWrite might have failed.
    if (file != null)
    {
        file.Close();
    }
}

我们可以在有或没有catch块的情况下使用它,重要的是,无论是否发生异常,总是总是要执行块。另一个重要的一点是,由于数据库连接之类的资源非常昂贵,因此最好尽快在finally块中关闭它们,而不是等待垃圾收集器为我们完成它。using由于FileStream is正在实施,因此我们也可以使用该语句IDisposable。值得一提的是,using语句只是一种语法糖,可以转换为尝试并最终阻止,并且更具可读性,并且总体而言是更好的选择。

如有哪里讲得不是很明白或是有错误,欢迎指正
如您喜欢的话不妨点个赞收藏一下吧

相关文章

在上文中,我介绍了事件驱动型架构的一种简单的实现,并演示...
上文已经介绍了Identity Service的实现过程。今天我们继续,...
最近我为我自己的应用开发框架Apworks设计了一套案例应用程序...
HAL(Hypertext Application Language,超文本应用语言)是一...
在前面两篇文章中,我详细介绍了基本事件系统的实现,包括事...
HAL,全称为Hypertext Application Language,它是一种简单的...