异常处理的最佳实践(下)

前文对异常处理的策略作了大体的介绍,本文将侧重于一些细节,有助于帮我们更好地在异常发生时定位问题。

异常处理模式

前文所述的异常处理策略主要侧重于系统的顶层,包括服务端请求处理和用户操作处理,也即最后一层屏障。对于代码结构较为简单的小型的App来说,往往通过函数调用堆栈就可以判断异常在代码中的位置了,于是这样的异常处理策略通常是够用的了。然而对于庞大的企业级应用来说,代码层次要复杂得多,尤其是使用依赖注入等技术对模块进行了解耦以后,仅仅通过抛出异常的堆栈信息往往难以追溯到问题发生的具体原因。此时就需要一些额外的模式来增加异常中包含的信息。

异常传递模式

构造一个新的异常包装原有的异常,并将新异常抛出。

例:

try {
  …
} catch (Exception ex) {
  throw newInvalidOperationException("Failed to process data XXX." ,ex);
}

这个模式有什么作用呢?回忆一下我们在工作中上碰到的异常,最头痛的恐怕就是类似NullPointException这样详细而无用的类型。对于那些因为脏数据而导致的问题,为了判断变量为空的原因,需要耗费大量精力阅读源代码,设想各种边界条件,从而倒推出可能的非法输入。即使发现了一处问题,也无法确定是不是就是该问题导致的异常,从而令那些Bug无法被放心地关闭,而是像幽灵一下永远困扰着项目,时不时就要reopen一下。

而采用了异常传递模式以后,我们可以确保异常真实地反映了异常发生时所涉及的数据及代码行为,而非那些无用的详细信息。这样,我们就可以简单地找出那份出错的数据,用调试模式再跑一边逻辑,不但大大提高了定位问题的效率,也可以用于验证所采用的解决方案是否真正修复了问题。

注意:新异常的堆栈信息仅限于其被抛出的位置,为了尽可能保留现场的细节,需要将原来的异常作为子异常嵌入新异常中。然而并非所有的语言框架都支持子异常,有时需要通过自定义异常的方式来支持该模式。另外打印异常的时候,务必也要将子异常信息通过递归的方式输出,从而便于通过日志分析问题。

循环中的异常处理

异常传递模式的一个特殊应用场景便是在循环中处理异常。对于一个循环操作来说,如果简单地在循环外部作异常处理,一旦其中任何一次循环中出了问题,整个操作就会被打断。现实中脏数据往往只是占很小的比例,并且循环之间也没有依赖关系。仅仅因为个别数据的问题导致其他正常数据无法继续处理,这是一种非常低效而不合理的处理方式。

因此更好的方式是在循环内做异常处理。然而此时新的问题又来了——如果有复数个脏数据出现,该如何向上汇报?仅仅将异常打印出来怕是不够的,因为外层逻辑可能需要通过分析异常的详细信息来执行不同的操作。此时更好的解决方案是应用异常传递模式来对循环中的异常进行收集:

varexList = new List<Exception>();
while(…) {
  try {
  } catch (Exception ex) {
    exList.Add(new InvalidOperationException(ex,data));
  }
}
if (exList.Count > 0){
  throwAggregateException("Exception in loop xxx.", exList);
}

注:并非所有的语言框架对此模式有原生支持,很可能需要自定义实现。另外打印异常的策略也需要权衡,例如只打印前n个子异常,或者进一步根据异常的类型做分类等等。

异常记录模式

记录异常并将其重新抛出。

例:

try {
  …
} catch (Exception ex) {
  log(ex);
  throw;
}

该模式的要点在于,记录异常后,不能吃掉异常,而要将异常重新抛出。在关键节点应用改模式,有助于分析错误,记录用户行为,并追踪恶意活动和安全隐患。在实践中,往往会将这些异常信息打印到特殊的日志流中,从而触发一系列报警及恢复动作。

异常保护模式

记录异常,将原来的异常替换为另一个异常,并抛出这个新的异常。

例:

try {
  …
} catch (Exception ex) {
  log (ex);
  throw newInvalidOperationException("Failed to process data XXX.");
}

看到这个大家可能会问“哎?这个模式和异常传递模式不是差不多咩?不对,子异常怎么记录一下就丢了啊?”哼哼,其实这正是此模式的目的——将子异常丢弃,外部就不知道异常的细节了嘛。在现实中,异常信息的泄露往往是非常危险的,很可能暴露系统实现的细节,甚至被黑客挖出一些漏洞来。因此将关键信息丢弃,丢给外部一个看似友好却不包含任何细节信息的异常,是一种不得不采取的措施。

然而问题来了,这样的模式在防住敌人的同时,也难住了自己人——通过日志虽然可以查到内部异常的细节,然而却难以和用户当时碰到的异常场景对应上。通过比对各处日志的时间戳也许是个办法,然而数据量大起来就会变得难以分析。

好在这个问题并非是一个鱼和熊掌不可兼得的单选题。有一个简单的解决方案便是在记录原始异常和抛出新异常的时候,生成一个GUID。根据这个唯一的GUID,便可以将异常的细节和发生异常的场景一一对应起来,从而可以对问题发生的场景进行完整的复现。

对于使用了SOA架构的企业应用来说,该模式也是一种避免异常在WebService调用之间传递的方式。当然,这可能意味着需要额外构造一套通过GUID收集各处异常信息的工具,方便完整地分析问题。

异常的分类

当异常发生的时候,除了无奈地记录一下,弹出个框告知用户以外,难道真的没有什么别的办法了么?答案当然是否定的,如果是网络抽风之类的问题,完全可以先重试几下,说不定就通了呢。然而并非所有的异常都适合使用重试的手段来处理。如果是因为错误的输入造成的异常,即使重试到世界末日也不会有什么用的,反而会加重服务器的负担呢。

于是为了能够细致地处理异常,往往需要将异常进行如下的分类:

数据异常

可以进一步分为服务端数据异常和客户端数据异常。客户端数据异常往往意味着用户的无效输入;服务端数据异常则通常是因为数据库或配置文件中的脏数据。无论是哪一种数据异常,忽略后继续执行后续操作是危险的,重复本次操作则是无意义的,因此唯一的处理方式就是记录并报错。

系统异常

系统异常通常归因于IOException,尤其同是网络相关的操作发生的异常。对于此类异常,只要令系统恢复到正常状态,重试之前的操作就可以成功执行下去。重试的方式有多种:

即时重试

即在操作失败时不断重试(可以指定最大次数)。该策略常用于客户端,有助于提升用户体验。注意如果重试的次数很多,每次重试的间隔必须递增(常用的方式是翻倍),以避免产生数据风暴对服务器施加进一步的压力。

队列重试

将处理失败的请求重新加入队列,等后续数据处理完后再重试。该策略常用于服务端,对于不需要实时处理的请求,这个方式可以节省对系统资源的占用,并将个别系统的问题进行隔离。

编码异常

顾名思义,就是代码写得一塌糊涂所造成的异常啦。严格地来说,编码异常是不应该被单独分类的。或者说,这类异常就不应该出现在线上系统中。对于需求中所描述的典型数据和典型场景,还跑不通,就可以视为编码异常。在实践中,测试往往没那么全面,于是一些边界条件所触发的异常,也可以视为是某种“数据问题”。因此这种类型的异常的处理方式,和数据异常基本是一致的。

小结

本文描述了一些异常处理中一些细节技巧,然而这些技巧还是要配合前文所述的基本策略才能更好地发挥作用。异常处理其实并不难,关键就是如何尽可能完整地收集到复现问题所需的数据,并且向上层的处理策略提供特定采取操作所需的信息。可惜大部分语言框架并未提供完整的异常处理策略,很多还需要大家自己动手实现。作为参考,可以看一下Enterprise Library中基于.net的实现(https://msdn.microsoft.com/en-us/library/dn169621.aspx)。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值