我最近一直在重构一些代码,以改善异常处理,特别是有助于提高开发过程中提供给开发人员的信息水平。但是我担心我走错了路。
例如,我们抱怨以下代码对消息抛出异常:Sequence contains no elements,当数据库不包含具有提供的ID的记录时。
public Record GetRecord(string id)
{
    _dbContext.Records.First(r => r.Id == id);
}

重构代码后,它现在引发一个异常,消息为No record found with id: {0}
public Record GetRecord(string id)
{
    try
    {
        _dbContext.Records.First(r => r.Id == id);
    }
    catch(InvalidOperationException ex)
    {
        throw new ApplicationException(string.Format("No record found with id: {0}". id), ex);
    }
}

我可以理解,此错误消息可能更清楚,因为我们现在知道没有记录了找到了。但是,使用这种逻辑,我们应该在哪里停下来?当然,以下内容也将是一个“改进”。我认为包装此类异常是不好的做法,但是我愿意证明是错误的。是否有任何理由使之令人满意?应该在哪里划清界线?高层操作的上下文。
很少执行此操作,因为这会使调试更加困难。当您确定下层永远不是错误的真正根源时,这是适当的。

但是,当然可以将异常包裹在更合适的异常中吗?

评论

这里有一个关于例外的好话:blogs.msdn.com/b/ericlippert/archive/2008/09/10/…

#1 楼

没有理由这样做。如果您在日志中包括stacktrace,则您已经知道问题所在。您唯一想念的就是您可能从不同渠道知道的输入(通常不需要找出问题)。

请注意,您的示例返回void,这可能不是如您所愿,我应该说我要进行的更改是使用.FirstOrDefault()而不是First,它将返回默认值-引用类型的null。然后,您可以进行简单的null检查,以查看是否返回了记录,而不必开始使用try-catches。

是的,您可以执行所有这些操作以提供特定的错误消息。我个人不喜欢它,我更喜欢从代码本身派生它(例如通过前面提到的null检查)。

评论


\ $ \ begingroup \ $
@SaulMarquez如果您按ID查找,但记录不存在,则属于例外情况。代码错误(总是可能),有人删除了记录(System.Data.DataException ??),或者有人在进行伪造请求。我无法想到会导致按ID查找失败的普通事件。
\ $ \ endgroup \ $
– abuzittin gillifirca
2014-10-17 13:30



\ $ \ begingroup \ $
@abuzittingillifirca:一个示例是通过ID查找自删除以来的内容。您可能仍然在某处找到该ID,然后对其进行搜索。
\ $ \ endgroup \ $
–克里斯
2014-10-17 13:48

\ $ \ begingroup \ $
@Chris,这是一个特例。除非您有代码专门处理已删除的行,并且确定已删除的行,否则最好允许异常冒泡。
\ $ \ endgroup \ $
–阿伦
14-10-20在17:27

\ $ \ begingroup \ $
@Aron:可以,但是与此同时,我认为并非必须如此。如果您允许删除,那么对我来说,您可能最终会寻找已删除的内容,因此应该对此进行处理。这完全是设计选择的问题,但希望您可以看到,有时候不存在的id是可预测的行为。
\ $ \ endgroup \ $
–克里斯
14-10-20在17:31

\ $ \ begingroup \ $
我不同意。如果要处理供应商库,则有一个重要原因。例如,Oracle唯一抛出的异常就是OracleException。客户必须通过查找错误编号来找出错误。通过一次编写一个开关,我可以轻松地在“权限不足”和“无效请求”情况之间进行区分,并轻松地重新抛出异常,而客户不必了解Oracle。基本上,我可以在所有后端系统(数据库,Web服务等)中拥有相同范围的异常
\ $ \ endgroup \ $
– DarkWanderer
2015年2月3日,13:20

#2 楼

这取决于您希望如何向最终用户呈现错误状态。

经验法则:始终记录原始异常(该异常是较低级别的,并且通常是相当技术性的,会吓到非技术性用户)。但是,请将这些异常包装在更加用户友好的异常(更高级别)中,并使用通俗易懂的语言并让用户易于理解的消息。

您不需要(也不能)对每个可能的异常都执行此操作,但是通常应该对合理预期会发生的错误状态执行此操作。在您的示例中,如果仅当不存在带有用户输入记录ID的记录时才生成InvalidOperationException,则将其包装在友好消息中是合理的,例如“找不到ID为xyz的记录”。 。

同样,当连接到数据库时遇到任何问题时,也可以期待SqlException,除非您的用户群是技术人员并且了解SQL,数据库等,否则最好将它们包装在更高级别带有友好消息的异常,例如“出现问题。请检查您的网络连接。如果再次出现问题,请与系统管理员或软件供应商联系。”。

取决于应用程序如何显示错误状态对于用户而言,“更多详细信息”视图可以显示原始异常和完整堆栈跟踪(作为包装异常中的原因附加),但是默认情况下应隐藏此类视图。这具有一些优点。现场支持人员可以立即查看原始异常,而无需检查日志。并且如果用户恰好是技术人员(通常是非技术用户),则用户也可以通过电话支持更好地描述错误。

还请避免使用硬编码字符串(如果您还没有避免使用它们)。它使它们不可重用且不可本地化。


有关检查数据有效性的注意事项:

阿巴斯建议只允许为意外行为生成异常,然后继续给出如何检查记录是否存在的替代方法(因为预期可能存在或可能不存在记录这一事实,这并非意外)使用异常。虽然这通常是理想的,并且对于简单的检查很有效,但我建议不要将其用于更复杂的检查,例如尝试将用户输入的字符串解析为数字。

用户输入的字符串可以是预期不能代表数字,并且将其解析为数字可能会失败。您可能很想在解析之前编写正则表达式检查以对字符串执行操作,但不仅是您尝试复制该语言已提供的内容,还可能会遗漏边缘情况并仍然获得解析异常-而且这不会使您的崩溃完全应用。在这种情况下,最好盲目尝试使用该语言的标准解析方法进行解析,并生成一个异常,然后通过警告用户输入的字符串不是有效数字来处理该异常。

但是,如果该语言的标准解析库提供了测试解析和正常失败的方法,则可以使用它们而不是自己测试解析和捕获异常。正如阿巴斯在评论中指出的那样,C#中有一个TryParse方法,其功能与阿巴斯答案中提出的TryGetRecord方法类似。 Java中不存在类似的实现(主要是由于缺少out参数),其中捕获异常是正确的方法。

评论


\ $ \ begingroup \ $
我同意在某些情况下需要例外,但是在解析用户输入时,我(我想也是微软)不希望抛出异常。一个示例是Int32.TryParse方法,该方法使用与out参数相同的技术。这样,可以捕获错误的输入,并且代码的正常流程可以继续。话虽这么说:在某些情况下,让异常发生并非坏习惯。
\ $ \ endgroup \ $
–阿巴斯
14-10-17在7:39

\ $ \ begingroup \ $
是的,我有种感觉,我应该添加关于该案例的注释(我刚刚做了)。我使用Java,但没有TryParse的等效项。因此,我们测试解析并捕获异常以了解输入是否可解析。
\ $ \ endgroup \ $
– ADTC
14-10-17在9:13

#3 楼

我认为您的故事有两个方面:

1。包装异常

这在发生不同类型的异常并且您想要区分它们的情况下很有用,当然当您要为每种异常提供特定的错误消息时。正如您已阅读自己的内容一样,这样做时最好遵循Microsoft给出的指导。我:

2。引发异常还是不引发异常

首先,这是.NET Framework中异常的定义(来自MSDN):


异常是任何错误条件或正在执行的程序遇到的意外行为。由于您的代码或您调用的代码中的错误(例如,共享库),操作系统资源不可用,公共语言运行库遇到的意外情况(例如,无法验证的代码)等等,可能引发异常。 。


如果仔细阅读此书,您将会理解,异常情况发生时会发生异常,而您通常无法控制这些事情。

在您的示例中,对于给定的ID,不存在用户这一事实并非意料之外的代码行为。这只是意味着数据库中没有该ID的数据。尝试获取具有给定ID(显然将返回null)的对象并对其执行某些方法或从属性获取值时,这将导致异常。在这种情况下,这将是NullReferenceException

我建议您将代码重写为以下内容:找到后,将返回默认类型。由于Record是引用类型,因此将为null。现在您所要做的只是一个空
-检查使用此方法时。您还可以使用out参数,并使该方法返回布尔值。这样可以很容易地进行检查:

public Record GetRecord(string id)
{
    var foundRecord = _dbContext.Records.FirstOrDefault(r => r.Id == id);
}


然后用法:我偏离了Exception主题,但我认为这可能会帮助您重构代码。请记住,异常适用于您的代码执行意外的操作。 Ecxeption处理用于捕获可能导致程序异常行为的情况,而不是针对您(而非代码)期望的数据。希望您能理解我的意思,对您有所帮助! ;)

评论


\ $ \ begingroup \ $
我喜欢您如何使用out参数来测试获取记录。我很遗憾Java不支持它,尽管可以使用某些最终收集对象或将某种捕鱼网浸入该方法中来模拟它(但是由于工作量太大且难以维护,我们宁愿仅进行null检查代替)。
\ $ \ endgroup \ $
– ADTC
14-10-17在3:01

\ $ \ begingroup \ $
顺便说一句,在正常的应用程序运行过程中让异常产生并不总是一件坏事。我在回答中详细说明了一个用例,在这种情况下,生成异常实际上比预先验证数据以避免异常要好。
\ $ \ endgroup \ $
– ADTC
14-10-17在3:22

#4 楼

本质上,抛出异常可能会发生两件事:


它可以在调用堆栈中进一步处理
它可以保持未处理状态,要么冒泡直到发生导致应用程序崩溃或被吞下并记录(可能已采取一些非常通用的高级步骤来处理它可能留下的任何无效状态)

所以,让我们考虑一下这两个,以及异常包装如何适用于它们。

未处理的

对于未处理的异常,通常提供以下三个重要信息:

堆栈跟踪
消息
异常类型

实际上,在这一点上,异常类型实际上只是人类的一个特定部分,可读信息,而堆栈跟踪只是有关问题发生位置的进一步信息。因此,实际上,这三个都是相同的东西-为必须走过来并了解出了什么问题的人提供的诊断信息。

因此,这里包装异常的目的是提供更好的诊断信息。也许存在一些变量,可能与尝试找出发生的事情有关。指定特定类型的异常也可以提供重要信息(例如,知道问题是由数据库引起的)。

牢记这一点,让我们比较示例中的两个异常:


ApplicationException(string.Format("No record found with id: {0}". id), ex);
ApplicationException("An error occurred while processing the record"), ex);

两者都是ApplicationException,在任何情况下都没有那么大的帮助。但是第一个提供了有用的信息:找不到记录,以及该记录的ID。这立即意味着调试人员可以查看数据库的状态,或尝试了解为什么将错误的ID传递给此方法。尽管有人可以得出结论,仅从堆栈跟踪中找不到记录,但是他们无法获取ID,因此消息实际上只是一种人类可读的方式,可以显示潜在的重要信息。

另一方面,第二个没有提供任何有用的信息。 “发生了错误”,嗯,我们已经知道了。 “在处理记录时”-这只是对从堆栈跟踪中得到的内容的模糊重述。当我们抛出一个可能被处理的异常时,就会出现一个新的,非常重要的问题:抽象级别。这就是MSDN报价的含义。

作为示例,让我们以良好的旧“存储库”抽象为例,其中有一个IRepository,它使消费者不必担心数据如何持久化。是的,实际上,编写一个应用程序需要交换完全不同类型的持久性常常是不现实的,但这是一个很好的例子。具体的存储库类:SqlRepositoryFileRepository。前者将Record作为一行存储在数据库的表中,并以id为键。第二个将Record作为序列化的文本存储在名为{id} .txt的特定目录的文本文件中。

现在让我们看一下我们的GetRecord方法。如果我将无效的id传递给这两个参数,会发生什么?好吧,它们都抛出异常,但是都抛出完全不同的异常类型。一个可能抛出一个InvalidOperationException(如果像示例中那样使用IQueryable)或另一个SqlException,另一个则抛出FileNotFoundException。那个IRepository抽象层,突然我们的抽象就泄漏了。在不了解IRepository所有可能的实现方式的情况下,没有任何调用者希望明智地处理这些问题。实际上,他们不仅需要了解广泛的类,而且还需要非常具体的实现细节。基于SQL的存储库是否抛出InvalidOperationExceptionSqlException?基于文件的存储库是否抛出FileNotFoundExceptionDirectoryNotFoundException

因此,为了保持其抽象性,存储库都应抛出可被调用代码理解的异常。如果他们需要使用特定类型的异常来发出特定问题的信号,则这应该是一个常见异常,该异常以捕获器理解的抽象级别(“未找到记录”)描述该问题,而不是抛出器理解的抽象级别( “找不到文件”,“找不到Sql记录”。有用的信息仅在消息中提供。假设我们不打算解析该消息以从中获取有用的信息(这将是一种非常复杂且容易出错的处理方式),那么调用者将无法对这些异常做任何有用的事情。 ApplicationException没有指定任何错误信息。

另一方面,YAGNI在这里非常重要。如果您控制调用此方法的代码,并且知道不会处理此类异常,那么不费吹灰之力定义自定义异常类型然后将其忽略将是毫无意义的。但是关键是在编写引发可能由其使用者处理的异常的代码时,确保以正确的抽象级别抛出异常是包装的一个很好的理由。

结论

这比我预期的要长,所以仅再次重申其充分的理由:


为以后的调试/诊断提供重要的信息,但包装的异常无法提供这些信息
确保对于可以通过调用代码合理地处理的异常,该异常以正确的抽象级别提供信息。

其中,后者的情况要多得多,在示例中您的问题,可能不会引起关注。不过,前者是第一个例外与您的“为什么也不要”示例之间的关键区别功能。通过上述一个或两个原因提供重大价值。

#5 楼

我认为对于第一种情况,您应该遵循@Jeroen的建议并使用FirstOrDefault,因为异常处理非常昂贵,您不想每次都可能发生异常。

如果您真的想包装异常,则不要使用ApplicationException包装异常,因为它非常抽象。每个异常都是一个ApplicationException

现在,如果您仍然确定要引发异常,则应使用自定义异常。当您的数据存储中不存在您的ID时,出现在RecordNotFoundException的某行。此异常清楚地表明未找到记录,并且使用您的API的开发人员将立即理解该记录(如果未找到,则您会遇到另一种问题!)。

关于SqlException,我真的不认为您应该在此级别上接受它。您应该让客户端(UI,Web服务等)决定如何处理SqlException,因为您无法控制它。

评论


\ $ \ begingroup \ $
自定义例外FTW!
\ $ \ endgroup \ $
–马拉奇♦
14-10-17在13:07

#6 楼

@TopinFrassi对此有所提示,但是您应该准备好未找到记录,因此您应该将该代码编码到应用程序中,不要使其成为异常。


public Record GetRecord(string id)
{
    try
    {
        _dbContext.Records.First(r => r.Id == id);
    }
    catch(InvalidOperationException ex)
    {
        throw new ApplicationException(string.Format("No record found with id: {0}". id), ex);
    }
}



而不是这样做,您只需要添加if语句

public Record GetRecord(string id)
{
    if (_dbContext.Records.First(r => r.Id == id).Any())
    {
        return _dbContext.Records.First(r => r.Id == id);
    }
}


,但这将调用查询两次

您甚至应该尝试调用GetRecord方法之前,就应该已经有一个布尔方法检查数据库中的记录开始。

public Record GetRecord(string id)
{
    return _dbContext.Records.First(r => r.Id == id);
}

public boolean DoesRecordExist(string id)
{
    retrun _dbContext.Records.First(r => r.Id == id) ? true : false ;
}

var id = "42"; // should be passed in somehow
Record recordIWant;
if (DoesRecordExist(id))
{
    recordIWant = GetRecord(id);
}
else
{
    /*Handle issue*/
}


在尝试检索不存在的记录的情况下,您应该在输入时而不是在实际记录时真正地处理此问题检索

,因此您应该做的是在用户输入错误ID或输入错误ID时提醒用户,这样应用程序将不允许用户通过不显示在断开记录中的内容来选择该项目第一名

评论


\ $ \ begingroup \ $
可能有点旧,但是我不确定是否要两次调用数据库(首先检查记录是否存在,然后获取记录)。似乎使用FirstOrDefault()会更加干净,如果记录不存在,它将返回null。然后,您可以在代码中执行if(record!= null){}
\ $ \ endgroup \ $
–尼克·比尔(Nick De Beer)
17年2月18日在21:02

#7 楼

除非您有充分的理由将它们包装起来,否则这是一个不好的做法。除非确定当前的意图和实现都是合理的,否则不要包装异常。不要为将来的偶然事件或其他未经证明的收益而包装异常。

当您想包装异常时的一些示例: t是一个真正的异常,但是是一个结果,但是您使用的库错误地使用异常来表示结果。然后,将特定异常替换为结果,例如null或false。您应该在调用库后立即执行此操作。
当某些东西在控件之外使用两个实现时,会抛出相同的可操作异常,但每个都使用自己的表示形式。您可能希望将这两个异常中的每一个包装到一个统一的异常中。这是一种非常罕见的情况,但有时会发生。
在事件发生期间,任何异常都可能是可操作的或结果。通常,尽管您希望在调用方中而不是在被调用方中使用此方法。
在将异常传递到应用程序之外时。您的应用程序的异常可能仅包含有关开发人员和日志记录的信息。如果是这样,则在将其传递到应用程序之外时,则不想传递此数据。通常用标记为passthru的异常替换所有异常,这些异常会在记录一次日志后删除要记录的数据并给出通用消息。您通常想让用户知道出了点问题,请联系支持和信息以帮助查找日志(通常是最小时间戳,有些人可能会加密日志)。在当前图层上方的图层中进行操作,但是它所源自的图层并不知道这一点。这种情况很少发生。如果源代码是可以编辑的代码,通常仍然可以找到在源代码处标记异常的方法。
在极少数情况下,您可能想添加更多有意义的信息。例如,在执行X时发生SQL错误。问题在于这是人们在认为有用之前先添加信息,并且除了包装外还有其他方法(您的异常是类,您可以添加各种内容,例如堆栈信息)。通常,人们会优先考虑弥补异常,而不是识别和修复错误。
从控件之外的来源接收到异常时,您想用更灵活的异常类型替换它。这仅应发生在边界上,并可能导致不必要的复杂性。与上面类似。不要包装可以添加的异常。不幸的是,在大多数语言中,设置基本异常并不是一件容易的事。

它往往是包装异常的反模式。人们之所以喜欢这样做,是因为事情应该不了解底层的实现。实际上是:


学科上的问题。要么不按需包装异常,要么不知道何时使某方面的知识多于不应有的层次。在很多情况下,人们可能会担心他们不应该一开始就抛出的异常。通常,您会让用户单击此控件,但是在显示该控件之前您不检查先决条件,而是依靠异常将其精美地呈现给用户。
反效果。通过将下面的层中的所有异常包装到自己的层中,您最终制作了一个本应不知道实现的层。
不必要。当您看到未处理的异常(它们到达顶部时除外)被包裹时,它们什么也没做。您刚刚添加了许多新类,尝试了catch等等,但是同样的事情来了。您要做的就是从概念上重命名某些内容。
最终会破坏/隐藏信息,使其变得模棱两可或难以获取。
通常最终会出现使用异常包装重新实现堆栈跟踪的情况。最终将成为不必要地在较低级别上多次实施顶级异常处理的结果。否则为时过早。

不,这不是一个好习惯。这是一个坏习惯。这也是您可能需要在蔚蓝的月亮中做一次的事情。