Mongoose House Technical Edition

错误处理

系统开发中,除了编写正常的业务逻辑外,免不了需要处理各种错误,本文简单讨论了错误处理的思路以及如何正确处理错误。

错误处理的困境

系统开发中,除了编写正常的业务逻辑外,免不了需要处理各种错误,甚至,处理错误的代码可能多于正常业务逻辑代码!

一般的,一个程序员习惯于首先编写正常的业务逻辑,然后,再回头处理各处异常。但是,如果一开始没有一个好的规划,就会发现处理错误的逻辑和正常逻辑搅和在一起,之后的代码往往难于阅读和维护。甚至发现为了处理有些错误,还需要重新修改程序的结构,导致不断地返工和重构。

而如果一开始编写正常业务逻辑的时候,同时考虑各个异常的处理,不仅干扰程序员的思路,需要额外花费大量精力,而且往往难于想清楚系统的业务行为。

也有一些开发语言或框架(譬如 Java),采用“抛出异常、统一处理”的方法,将这种窘境推迟到正常的业务逻辑编写完成后。但是,这种粗暴的处理错误的方法很难给用户带来更好的体验。举例来说,只要有异常发生,就跳转到错误页面,那么用户可能会过多地被指向错误页面,产生厌烦的心情;而事实上,有些异常对于用户是可以忽略的,或者采用一些折中的办法代替。这样做,可以给用户带来更好的体验。

现代软件开发中,流行小巧(譬如微服务)、敏捷的开发方法。往往是产品经理规划了大致的业务逻辑,程序员就开始干活儿了。那么,在编写代码中,各种细节的处理就都得由程序员来完成。

其次,现代的软件系统几乎不可能不和外界交互,往往需要调用第三方 API。即使第三方 API 非常强壮,也要假设它是不稳定的,因为网络本身是不稳定的,所以就要处理调用失败的情况,以及各种返回值的可能性。

最后,现代的软件系统的运行平台往往是多样的。譬如,手机 App 会由于手机不同而有一些不同的行为;Web App 会由于浏览器不同而不同;即便是服务端的系统,考虑到云环境、自动部署、失败迁移和恢复、微服务调用、消息队列、缓存等技术,也有比以往更为复杂的错误处理需要考虑。

错误处理的思路

我们都知道,在动手开发一个软件系统之前,如果能很好地规划软件系统中的错误处理,不仅可以使代码逻辑清晰、易于修改和维护;而且可以避免在编写正常业务逻辑的同时考虑错误情况,使程序员专注于编写正常的业务逻辑;最棒的是,可以更精细地控制错误,带来更好的用户体验。

那么,如何规划一个软件系统的错误处理?

为了想清楚这个问题,我在下面给出规划软件系统错误处理的思路。

编写错误处理的目的有两个,一是确保系统可以向外界(最终用户或其他系统)尽可能提供服务;这里所谓的“尽可能提供服务”包括,

  • 当发生非致命错误时,系统服务不会中断(譬如系统挂掉了);
  • 当发生错误导致某些功能不可用时,可用的功能仍然可以提供服务;
  • 当发生错误导致某些功能不可用时,不可用的功能可以降低服务的级别,提供服务(譬如使用缺省配置);
  • 当外界可以从错误中恢复时,需要给出恢复的办法(譬如提示用户电话号码输入错了);

二是确保错误都被尽量详细地记录下来,为之后分析出错的原因提供依据。也就是说,需要处理每一个错误,而且有必要尽量将有可能导致系统异常的错误都记录下来,拒绝随便将错误丢弃。

根据以上原则,我们可以想到,一、错误处理尽可能在需要处理的地方处理。也就是说,如果不需要处理这个错误,或者不知道怎么处理这个错误,那么最好不要处理它,也不要封装它;错误尽可能放在客户端处理(距离用户最近的地方),这时候你可能才能想明白应该怎么处理这个错误。二、错误尽可能在发生的时候记录,这样才能在发生错误以后,很方便地找到错误发生的地方。也就是说,错误一发生就要记录下来。

系统是为用户(最终用户或其他系统)服务的,那么错误处理方式应根据用户对错误的需求来决定。

如果用户是其他系统,最好的办法是把错误信息尽可能多的返回给其他系统,以便其他系统对错误有更多的知情权,这样才可以更好的决定错误的处理方式。需要注意的是,应该使用相同的形式返回错误,也就是说,如果使用 HTTP STATUS CODE,就统一使用 HTTP STATUS CODE,200 代表正确的数据,500 代表系统错误,400 代表请求的参数错误…… 这样子。如果使用返回值,譬如,在返回值中,总存在一个字段 code,code = 0 代表正常,code > 0,使用相应的错误编号。那么采用这种方式的话,要确保系统总是有返回值并包含 code 字段。否则,就会给调用方带来麻烦——调用方可能既需要处理有返回值的情况,又需要处理不同的 HTTP 状态。

进一步讲,在系统内部接口间调用也应该统一,譬如,出错后采用返回 null 或者错误对象,或者抛出异常,选择一种方式在系统内部统一起来。

如果用户是最终用户,对错误处理的要求有两点:一、知道发生了什么;二、知道应该怎么做。除此以外的原则是错误处理尽量不要干扰用户正常的业务流程。对于用户需要知道发生了什么,其含义包括,

  1. 提示信息要准确。所谓准确是指,如果是网络的问题,就要提示“网络有问题,请检查网络连接”;如果是后台系统有问题,就提示“服务不可用,请稍后再试”;如果是用户输入错了,就提示到底是哪里输入错了,怎么改等。

  2. 对于不影响用户正常业务流程的错误,就不必让用户知道发生了什么,可以采用缺省处理,而不干扰用户正常的业务流程。譬如,需要显示一个数据供用户参考,但是没有取到这个数据,那么可以不显示处理。避免,弹出个对话框,或者跳转到错误页面。

  3. 知道发生了什么还包含给用户恰当的提示信息。譬如,系统后台报错“192.168.0.8 Connection refused”,如果直接把这条消息原封不动的展示给用户是不恰当的。应该提示用户“后台服务不可用,请稍后再试”类似这样的消息。进一步思考,什么时候把上面那条消息变成下面这条提示呢?那么,假设在后台调用第三方服务的时候发生了上面那条错误,那么你又如何鉴别上面那条错误是一个网络问题(或系统不可用的问题)呢?

让用户知道应该怎么做,是指错误处理的友好性。一般来讲,用户输入数据错了,可以在用户输入错误的地方给出提示信息,以便用户可以重新输入;而如果是后台系统报错,则需要跳转到错误页面,报告系统的某个功能,或某个模块,甚至整体系统都不可用了,清除用户操作的上下文,以便用户可以使用其他功能,或重置用户的业务流程。这种问题如果只是弹出个错误消息,或者在界面某处显示发生了错误,则用户会不清楚错误的上下文或边界在哪里。

除了准确的给出提示信息以外,还应该包含解决办法。也就是说,任何时候,应该让用户确保知道自己应该怎么做。关键是,解决办法的准确性。如果系统不能用了,就应该明确告诉用户不能用了,不要让用户再尝试使用。如果系统可以使用,但某些功能有限制,则也应该明确告诉用户,不要让用户以为系统不能用了,从另一方面降低了系统的可用性。

综上所述,

  1. 所有的错误都应记录下来,并且发生错误后,应尽早记录;
  2. 使用统一个错误处理方式,无论是系统内部还是对其他系统的接口;
  3. 根据用户的需求不同,错误分为需要用户处理的错误(包括重新输入数据、改变操作流程、重新验证身份等)、不需要用户处理的错误(包括各种后台服务的问题)和环境错误(包括网络问题、客户端问题等),对于不同类型的错误应可以区分,并给出恰当的信息。

为了达到上述目的,程序员在编写代码时,其核心是设计一个良好的错误对象,能涵盖上述需求。

设计错误对象

顾名思义,错误对象就是封装错误信息的数据结构。在系统中,它和正确的错误对象是一对儿,互为补充。在设计中,我们往往关注正确数据对象的设计,而忽略了错误数据对象的设计。下面,我想谈谈如何设计错误数据对象。(简单起见,下文中数据对象指的是正确的数据对象,错误对象指的是错误的数据对象。)

根据上面错误处理的设计思路,一个错误对象应该包含,

  1. 错误编号或错误类别
  2. 错误消息(面向调试)
  3. 错误消息(面向用户)
  4. 错误堆栈

给错误编号是一门学问。通过一个好的错误编号系统,可以很快定位到错误发生的地方甚至错误的原因。譬如,在产品设计中,当用户在遇到错误后,可以获得这个错误的编号。当用户把这个错误编号告诉系统管理员后,管理员就可以定位错误,帮助用户排除故障。这样做的好处是,在不把系统内部的结构暴露给用户的情况下,可以帮助用户解决问题。

在给错误编号的时候,一般使用不同的位来代表不同的含义。譬如,前三位是错误类型,第一位是主错误分类,

  • 2:逻辑错误;
  • 3:系统内部错误;
  • 4:输入数据错误;
  • 5:网络通讯错误;
  • 6:基础设施错误;
  • 7:数据库错误;
  • 8:第三方 API 错误;
  • 9:其他未归类错误;

第二、三位是子错误分类,譬如在 4 输入数据错误错误类别下,又分为,

  • 01:缺少输入数据(必须有的参数没有值);
  • 02:含有非法字符;
  • 03:数据格式不符合要求;
  • 04:数据类型不匹配;
  • 05:数据长度不在范围内;

等。第四、五位是模块编号,第六、七位是函数编号,第八、九位是参数编号这样子。通过这种方式,就可以通过一个错误代码,很快定位错误原因以及错误发生的地方。以下是一些设计错误编号的技巧,

  1. 设计错误编号时,为了便于阅读不要使用太接近的数字,譬如,1 逻辑错误,2 系统错误… 应该使用较大范围的数字,譬如:400100 用户输入为空,400200 用户输入数据格式错误… 数字间用 1-2 个 0 隔开更容易辨识,也为扩展和细化分类提供了空间;

  2. 同时也要避免太多的 0 连接在一起,譬如,设计 400 为用户输入错误,00 为逻辑错误,最后的错误编号有可能变成 40000…;

  3. 数字尽量不要按顺序排列,采用有间隔的方式更容易辨识。譬如有三种错误,选择 1、5、9 的效果比 1、2、3 更好;

  4. 可以设计 0 为共通错误,其他数字为具体细分类。譬如 400 为用户输入错误,在用户输入错误下,又分为 410 为用户输入为空,430 为用户输入格式不匹配 … 这样做,对于编码过程中遇到的不好划分的用户输入错误,都还可以划分到 400 中。也避免了错误类别分得过细…

  5. 错误分类可以包括,用户能处理的和用户不能处理,系统可恢复的和不可恢复的错误,逻辑错误和物理错误,客户端错误和服务端错误等。

对于那些不是那么大型的系统,或者我们懒得给每个错误编号,不管什么原因,如果不提供错误编号的话,至少应该给错误分类。

为什么要给错误分类?

当调用方从被调用方获得一个错误对象后,无论是通过捕获异常或者返回值对象,假设错误对象没有分类,那么调用方就不知道怎么处理这个错误。譬如,是提示用户重新输入,还是跳转到错误页面告诉用户系统发生了问题。

大部分程序语言和框架中,错误消息都被设计为一个属性。实践证明,错误对象中包含两条错误消息属性是很棒的体验。我们都知道,程序员和用户看到的错误消息绝对不应该是相同的,因为两者的关注点不同。程序员关注的是程序层面,以及错误发生的上下文;用户关注的是软件层面,以及我应该怎么做。譬如就拿用户输入了一个错误的电话号码为例,程序员希望看到的消息是:哪个函数、哪个参数未通过正则表达式校验;或者短信网关返回了一个什么样的错误消息;而用户希望看到的是:“无法拨打电话,请检查电话号码。”这两个消息,无论哪方如果只能看到另一方的都不会满意。

所以,在错误对象中包含两条错误消息是一个不错的选择。

错误堆栈即便不包含在错误对象中关系也不大,相反,很多语言和框架中都把错误堆栈作为一个必要的属性设计到错误对象中。错误对象中不必包含错误堆栈的理由如下,

  1. 调试过 Java 程序的程序员都知道,当发生异常被打印出来后,往往我们只是需要知道最初发生错误的地方,当你在异常堆栈中找到这个地方后,其他部分就全是垃圾;

  2. 本着发生错误后就尽早记录的原则,我们立刻就能找到发生错误的地方;

  3. 异常堆栈信息往往很大(比较而言),在传递过程中还会越来越大,一个大对象在系统中被传来传去终归是不好的,毕竟里面也没有什么有用的信息。

另外一个面临的挑战是如何区分错误对象和数据对象?

一般的,有以下几种办法,

一、返回值是数据对象,抛出的异常是错误对象。譬如,Java语言。

这样做需要解决以下一些问题,

  1. 无论是否有错误对象,调用方都必须假设有错误对象,在被调用方外面包裹一层 try-catch;
  2. 被 try-catch 包裹很不舒服,因为如果你要在 try-catch 中赋值,在 try-catch 外使用的话(尽量保持 try-catch 包裹最少代码原则),你就必须在 try-catch 外声明变量;
  3. 接着你可能又要考虑在 try-catch 外声明变量的缺省值,因为考虑到 try-catch 内变量可能没有被赋值的情况;
  4. 另外,对于是否需要判断返回值为空(null)也会另你不爽,因为你已经假设有错误对象,用 try-catch 处理了,现在你还需要使用 if null 来处理返回值为空的情况;
  5. 如果流程中遇到了不能使用 try-catch 的情况(譬如,REST 调用、异步调用等),你又需要为错误对象来想出别的解决办法。

二、有两个返回值,一个是数据对象,一个是错误对象。譬如,Go 语言。

这样做比 try-catch 结构有很大的优势,但还是需要解决以下这些问题,

  1. 每个返回值都要处理 if err,如果不处理可能就把错误对象丢掉了(当然,丢掉了也不是不可以);
  2. 除了 err 对象以外,你还是需要处理返回值为空(null)的情况。

比较各种方案,简单的做法是只有一个返回值,不抛出异常。返回值有两个属性,数据对象放在一个属性里(譬如,属性名为 ok),错误对象放在一个属性里(譬如,属性名为 error)。如果正常返回数据,那么返回对象的 ok 属性中就有值;如果发生错误,那么错误信息就在返回对象的 error 属性中。这样,调用方处理起来就比较简单了。

以 Node.js 为例,


const result = thirdPartyService.call(...)

if (!result.ok)
	// 错误处理,譬如可以直接返回 result.error 对象,也可以提取 result.error.message 消息

// 下面就是正确的业务逻辑,返回值在 result.ok 中。