WEB

[译] NodeJS 错误处理最佳实践

2018-01-04  本文已影响39人  宫若石

NodeJS 的错误处理让人痛苦,在很长的一段时间里,大量的错误被放任不管。但是要想建立一个健壮的 Node.js 程序就必须正确的处理这些错误,而且这并不难学。如果你实在没有耐心,那就直接绕过长篇大论跳到“总结”部分吧。
英文原文:https://www.joyent.com/node-js/production/design/errors
这篇文章会回答 NodeJS 初学者的若干问题:

这篇文章可以划分成互相为基础的几个部分:

背景

本文假设:
你已经熟悉了 JavaScript、Java、 Python、 C++ 或者类似的语言中异常的概念,而且你知道抛出异常和捕获异常是什么意思。
你熟悉怎么用 NodeJS 编写代码。你使用异步操作的时候会很自在,并能用 callback(err,result) 模式去完成异步操作。你得知道下面的代码不能正确处理异常的原因是什么?

function myApiFunc(callback)
{
  /*
   * This pattern does NOT work!
   */
  try {
    doSomeAsynchronousOperation(function (err) {
      if (err)
        throw (err);
      /* continue as normal */
    });
  } catch (ex) {
    callback(ex);
  }
}

你还要熟悉三种传递错误的方式:
1.作为异常抛出。 2. 把错误传给一个 callback,这个函数正是为了处理异常和处理异步操作返回结果的。 3.在 EventEmitter 上触发一个 Error 事件。
接下来我们会详细讨论这几种方式。这篇文章不假设你知道任何关于 domains 的知识。
最后,你应该知道在 JavaScript 里,错误和异常是有区别的。错误是 Error 的一个实例。错误被创建并且直接传递给另一个函数或者被抛出。如果一个错误被抛出了那么它就变成了一个异常。举个例子:

throw new Error(‘something bad happened’);

但是使用一个错误而不抛出也是可以的:

callback(new Error(‘something bad happened’));

这种用法更常见,因为在 NodeJS 里,大部分的错误都是异步的。实际上,try/catch 唯一常用的是在 JSON.parse 和类似验证用户输入的地方。接下来我们会看到,其实很少要捕获一个异步函数里的异常。这一点和 Java,C++,以及其它严重依赖异常的语言很不一样。

操作失败和程序员的失误

把错误分成两大类很有用:

处理操作失败

就像性能和安全问题一样,错误处理并不是可以凭空加到一个没有任何错误处理的程序中的。你没有办法在一个集中的地方处理所有的异常,就像你不能在一个集中的地方解决所有的性能问题。你得考虑任何会导致失败的代码(比如打开文件,连接服务器,Fork 子进程等)可能产生的结果。包括为什么出错,错误背后的原因。之后会提及,但是关键在于错误处理的粒度要细,因为哪里出错和为什么出错决定了影响大小和对策。
你可能会发现在栈的某几层不断地处理相同的错误。这是因为底层除了向上层传递错误,上层再向它的上层传递错误以外,底层没有做任何有意义的事情。通常,只有顶层的调用者知道正确的应对是什么,是重试操作,报告给用户还是其它。但是那并不意味着,你应该把所有的错误全都丢给顶层的回调函数。因为,顶层的回调函数不知道发生错误的上下文,不知道哪些操作已经成功执行,哪些操作实际上失败了。
我们来更具体一些。对于一个给定的错误,你可以做这些事情:

(没有办法) 处理程序员的失误

对于程序员的失误没有什么好做的。从定义上看,一段本该工作的代码坏掉了(比如变量名敲错),你不能用更多的代码再去修复它。一旦你这样做了,你就使用错误处理的代码代替了出错的代码。
有些人赞成从程序员的失误中恢复,也就是让当前的操作失败,但是继续处理请求。这种做法不推荐。考虑这样的情况:原始代码里有一个失误是没考虑到某种特殊情况。你怎么确定这个问题不会影响其他请求呢?如果其它的请求共享了某个状态(服务器,套接字,数据库连接池等),有极大的可能其他请求会不正常。
典型的例子是 REST 服务器(比如用Restify搭的),如果有一个请求处理函数抛出了一个ReferenceError(比如,变量名打错)。继续运行下去很有肯能会导致严重的 Bug,而且极其难发现。例如:

  1. 一些请求间共享的状态可能会被变成null,undefined或者其它无效值,结果就是下一个请求也失败了。
  2. 数据库(或其它)连接可能会被泄露,降低了能够并行处理的请求数量。最后只剩下几个可用连接会很坏,将导致请求由并行变成串行被处理。
  3. 更糟的是, postgres 连接会被留在打开的请求事务里。这会导致 postgres “持有”表中某一行的旧值,因为它对这个事务可见。这个问题会存在好几周,造成表无限制的增长,后续的请求全都被拖慢了,从几毫秒到几分钟[脚注4]。虽然这个问题和 postgres 紧密相关,但是它很好的说明了程序员一个简单的失误会让应用程序陷入一种非常可怕的状态。
  4. 连接会停留在已认证的状态,并且被后续的连接使用。结果就是在请求里搞错了用户。
  5. 套接字会一直打开着。一般情况下NodeJS 会在一个空闲的套接字上应用两分钟的超时,但这个值可以覆盖,这将会泄露一个文件描述符。如果这种情况不断发生,程序会因为用光了所有的文件描述符而强退。即使不覆盖这个超时时间,客户端会挂两分钟直到 “hang-up” 错误的发生。这两分钟的延迟会让问题难于处理和调试。
  6. 很多内存引用会被遗留。这会导致泄露,进而导致内存耗尽,GC 需要的时间增加,最后性能急剧下降。这点非常难调试,而且很需要技巧与导致造成泄露的失误联系起来。
    最好的从失误恢复的方法是立刻崩溃。你应该用一个restarter 来启动你的程序,在奔溃的时候自动重启。如果restarter 准备就绪,崩溃是失误来临时最快的恢复可靠服务的方法。
    奔溃应用程序唯一的负面影响是相连的客户端临时被扰乱,但是记住:

编写函数的实践

我们已经讨论了如何处理异常,那么当你在编写新的函数的时候,怎么才能向调用者传递错误呢?
最最重要的一点是为你的函数写好文档,包括它接受的参数(附上类型和其它约束),返回值,可能发生的错误,以及这些错误意味着什么。如果你不知道会导致什么错误或者不了解错误的含义,那你的应用程序正常工作就是一个巧合。所以,当你编写新的函数的时候,一定要告诉调用者可能发生哪些错误和错误的含义。

Throw, Callback 还是 EventEmitter

函数有三种基本的传递错误的模式。

用在那些具有复杂状态机的对象上,这些对象往往伴随着大量的异步事件。例如,一个套接字是一个EventEmitter,它可能会触发“connect“,”end“,”timeout“,”drain“,”close“事件。这样,很自然地可以把”error“作为另外一种可以被触发的事件。在这种情况下,清楚知道”error“还有其它事件何时被触发很重要,同时被触发的还有什么事件(例如”close“),触发的顺序,还有套接字是否在结束的时候处于关闭状态。

在大多数情况下,我们会把 callback 和 event emitter 归到同一个“异步错误传递”篮子里。如果你有传递异步错误的需要,你通常只要用其中的一种而不是同时使用。

那么,什么时候用throw,什么时候用callback,什么时候又用 EventEmitter 呢?这取决于两件事:

直到目前,最常见的例子是在异步函数里发生了操作失败。在大多数情况下,你需要写一个以回调函数作为参数的函数,然后你会把异常传递给这个回调函数。这种方式工作的很好,并且被广泛使用。例子可参照 NodeJS 的 fs 模块。如果你的场景比上面这个还复杂,那么你可能就得换用 EventEmitter 了,不过你也还是在用异步方式传递这个错误。

其次常见的一个例子是像JSON.parse 这样的函数同步产生了一个异常。对这些函数而言,如果遇到操作失败(比如无效输入),你得用同步的方式传递它。你可以抛出(更加常见)或者返回它。

对于给定的函数,如果有一个异步传递的异常,那么所有的异常都应该被异步传递。可能有这样的情况,请求一到来你就知道它会失败,并且知道不是因为程序员的失误。可能的情形是你缓存了返回给最近请求的错误。虽然你知道请求一定失败,但是你还是应该用异步的方式传递它。

通用的准则就是你即可以同步传递错误(抛出),也可以异步传递错误(通过传给一个回调函数或者触发 EventEmitter 的 error事件),但是不用同时使用。以这种方式,用户处理异常的时候可以选择用回调函数还是用try/catch,但是不需要两种都用。具体用哪一个取决于异常是怎么传递的,这点得在文档里说明清楚。

差点忘了程序员的失误。回忆一下,它们其实是 Bug 。在函数开头通过检查参数的类型(或是其它约束)就可以被立即发现。一个退化的例子是,某人调用了一个异步的函数,但是没有传回调函数。你应该立刻把这个错抛出,因为程序已经出错而在这个点上最好的调试的机会就是得到一个堆栈信息,如果有内核信息就更好了。

因为程序员的失误永远不应该被处理,上面提到的调用者只能用 try/catch 或者回调函数(或者 EventEmitter)其中一种处理异常的准则并没有因为这条意见而改变。如果你想知道更多,请见上面的 (不要)处理程序员的失误。

下表以 NodeJS 核心模块的常见函数为例,做了一个总结,大致按照每种问题出现的频率来排列:


image.png

异步函数里出现操作错误的例子(第一行)是最常见的。在同步函数里发生操作失败(第二行)比较少见,除非是验证用户输入。程序员失误(第三行)除非是在开发环境下,否则永远都不应该出现。

吐槽:程序员失误还是操作失败?

你怎么知道是程序员的失误还是操作失败呢?很简单,你自己来定义并且记在文档里,包括允许什么类型的函数,怎样打断它的执行。如果你得到的异常不是文档里能接受的,那就是一个程序员失误。如果在文档里写明接受但是暂时处理不了的,那就是一个操作失败。

你得用你的判断力去决定你想做到多严格,但是我们会给你一定的意见。具体一些,想象有个函数叫做“connect”,它接受一个IP地址和一个回调函数作为参数,这个回调函数会在成功或者失败的时候被调用。现在假设用户传进来一个明显不是IP地址的参数,比如“bob”,这个时候你有几种选择:

这两种方式和我们上面提到的关于操作失败和程序员失误的指导原则是一致的。你决定了这样的输入算是程序员的失误还是操作失败。通常,用户输入的校验是很松的,为了证明这点,可以看Date.parse这个例子,它接受很多类型的输入。但是对于大多数其它函数,我们强烈建议你偏向更严格而不是更松。你的程序越是猜测用户的本意(使用隐式的转换,无论是JavaScript语言本身这么做还是有意为之),就越是容易猜错。本意是想让开发者在使用的时候不用更加具体,结果却耗费了人家好几个小时在Debug上。再说了,如果你觉得这是个好主意,你也可以在未来的版本里让函数不那么严格,但是如果你发现由于猜测用户的意图导致了很多恼人的bug,要修复它的时候想保持兼容性就不大可能了。

所以如果一个值怎么都不可能是有效的(本该是string却得到一个undefined,本该是string类型的IP但明显不是),你应该在文档里写明是这不允许的并且立刻抛出一个异常。只要你在文档里写的清清楚楚,那这就是一个程序员的失误而不是操作失败。立即抛出可以把Bug带来的损失降到最小,并且保存了开发者可以用来调试这个问题的信息(例如,调用堆栈,如果用内核文件还可以得到参数和内存分布)。

那么 domains 和 process.on('uncaughtException') 呢?

操作失败总是可以被显示的机制所处理的:捕获一个异常,在回调里处理错误,或者处理 EventEmitter 的“error”事件等等。Domains以及进程级别的‘uncaughtException’主要是用来从未料到的程序错误恢复的。由于上面我们所讨论的原因,这两种方式都不鼓励。

编写新函数的具体建议

我们已经谈论了很多指导原则,现在让我们具体一些。

你的函数做什么得很清楚。 这点非常重要。每个接口函数的文档都要很清晰的说明: - 预期参数 - 参数的类型 - 参数的额外约束(例如,必须是有效的IP地址) 如果其中有一点不正确或者缺少,那就是一个程序员的失误,你应该立刻抛出来。 此外,你还要记录:

  1. 使用 Error 对象或它的子类,并且实现 Error 的协议。
    你的所有错误要么使用Error 类要么使用它的子类。你应该提供name和message属性,stack也是(注意准确)。
  2. 在程序里通过 Error 的 name属性区分不同的错误。
    当你想要知道错误是何种类型的时候,用 name 属性。 JavaScript 内置的供你重用的名字包括“RangeError”(参数超出有效范围)和“TypeError”(参数类型错误)。而 HTTP 异常,通常会用 RFC 指定的名字,比如“BadRequestError”或者“ServiceUnavailableError”。
  3. 不要想着给每个东西都取一个新的名字。
    如果你可以只用一个简单的 InvalidArgumentError,就不要分成 InvalidHostnameError,InvalidIpAddressError,InvalidDnsError等等,你要做的是通过增加属性来说明那里出了问题(下面会讲到)。
  4. 用详细的属性来增强 Error 对象。
    举个例子,如果遇到无效参数,把 propertyName 设成参数的名字,把 propertyValue 设成传进来的值。如果无法连到服务器,用 remoteIp 属性指明尝试连接到的 IP。如果发生一个系统错误,在syscal 属性里设置是哪个系统调用,并把错误代码放到 errno属性里。具体你可以查看附录,看有哪些样例属性可以用。
    至少需要这些属性:
    name:用于在程序里区分众多的错误类型(例如参数非法和连接失败)
    message:一个供人类阅读的错误消息。对可能读到这条消息的人来说这应该已经足够完整。如果你从更底层的地方传递了一个错误,你应该加上一些信息来说明你在做什么。怎么包装异常请往下看。
    stack:一般来讲不要随意扰乱堆栈信息。甚至不要增强它。V8引擎只有在这个属性被读取的时候才会真的去运算,以此大幅提高处理异常时候的性能。如果你读完再去增强它,结果就会多付出代价,哪怕调用者并不需要堆栈信息。

你还应该在错误信息里提供足够的消息,这样调用者不用分析你的错误就可以新建自己的错误。它们可能会本地化这个错误信息,也可能想要把大量的错误聚集到一起,再或者用不同的方式显示错误信息(比如在网页上的一个表格里,或者高亮显示用户错误输入的字段)。

在这个例子里,可以考虑包装这个错误而不是直接返回它。包装的意思是继续抛出一个包含底层信息的新的异常,并且带上当前层的上下文。用 verror 这个包可以很简单的做到这点。

举个例子,假设有一个函数叫做 fetchConfig,这个函数会到一个远程的数据库取得服务器的配置。你可能会在服务器启动的时候调用这个函数。整个流程看起来是这样的:
1.加载配置 1.1 连接数据库 1.1.1 解析数据库服务器的DNS主机名 1.1.2 建立一个到数据库服务器的TCP连接 1.1.3 向数据库服务器认证 1.2 发送DB请求 1.3 解析返回结果 1.4 加载配置 2 开始处理请求

假设在运行时出了一个问题连接不到数据库服务器。如果连接在 1.1.2 的时候因为没有到主机的路由而失败了,每个层都不加处理地都把异常向上抛出给调用者。你可能会看到这样的异常信息:

myserver: Error: connect ECONNREFUSED

这显然没什么大用。
另一方面,如果每一层都把下一层返回的异常包装一下,你可以得到更多的信息:

myserver: failed to start up: failed to load configuration: failed to connect to database server: failed to connect to 127.0.0.1
port 1234: connect ECONNREFUSED。

你可能会想跳过其中几层的封装来得到一条不那么充满学究气息的消息:

myserver: failed to load configuration: connection refused from database at 127.0.0.1 port 1234.

不过话又说回来,报错的时候详细一点总比信息不够要好。

如果你决定封装一个异常了,有几件事情要考虑:

在 Joyent,我们使用verror 这个模块来封装错误,因为它的语法简洁。写这篇文章的时候,它还不能支持上面的所有功能,但是会被扩展以期支持。

例子

考虑有这样的一个函数,这个函数会异步地连接到一个 IPv4 地址的 TCP 端口。我们通过例子来看文档怎么写:

/*
* Make a TCP connection to the given IPv4 address.  Arguments:
*
*    ip4addr        a string representing a valid IPv4 address
*
*    tcpPort        a positive integer representing a valid TCP port
*
*    timeout        a positive integer denoting the number of milliseconds
*                   to wait for a response from the remote server before
*                   considering the connection to have failed.
*
*    callback       invoked when the connection succeeds or fails.  Upon
*                   success, callback is invoked as callback(null, socket),
*                   where `socket` is a Node net.Socket object.  Upon failure,
*                   callback is invoked as callback(err) instead.
*
* This function may fail for several reasons:
*
*    SystemError    For "connection refused" and "host unreachable" and other
*                   errors returned by the connect(2) system call.  For these
*                   errors, err.errno will be set to the actual errno symbolic
*                   name.
*
*    TimeoutError   Emitted if "timeout" milliseconds elapse without
*                   successfully completing the connection.
*
* All errors will have the conventional "remoteIp" and "remotePort" properties.
* After any error, any socket that was created will be closed.
*/
function connect(ip4addr, tcpPort, timeout, callback)
{
assert.equal(typeof (ip4addr), 'string',
    "argument 'ip4addr' must be a string");
assert.ok(net.isIPv4(ip4addr),
    "argument 'ip4addr' must be a valid IPv4 address");
assert.equal(typeof (tcpPort), 'number',
    "argument 'tcpPort' must be a number");
assert.ok(!isNaN(tcpPort) && tcpPort > 0 && tcpPort < 65536,
    "argument 'tcpPort' must be a positive integer between 1 and 65535");
assert.equal(typeof (timeout), 'number',
    "argument 'timeout' must be a number");
assert.ok(!isNaN(timeout) && timeout > 0,
    "argument 'timeout' must be a positive integer");
assert.equal(typeof (callback), 'function');

/* do work */
}

这个例子在概念上很简单,但是展示了上面我们所谈论的一些建议:

这看起来像是给一个很容易理解的函数写了超过大部分人会写的的超长注释,但大部分函数实际上没有这么容易理解。所有建议都应该被有选择的吸收,如果事情很简单,你应该自己做出判断,但是记住:用十分钟把预计发生的记录下来可能之后会为你或其他人节省数个小时。

总结

附录:Error 对象属性命名约定

强烈建议你在发生错误的时候用这些名字来保持和 Node 核心以及 Node 插件的一致。这些大部分不会和某个给定的异常对应,但是出现疑问的时候,你应该包含任何看起来有用的信息,即从编程上也从自定义的错误消息上。


image.png

脚注

  1. 人们有的时候会这么写代码,他们想要在出现异步错误的时候调用callback 并把错误作为参数传递。他们错误地认为在自己的回调函数(传递给 doSomeAsynchronousOperation 的函数)里throw 一个异常,会被外面的 catch 代码块捕获。try/catch和异步函数不是这么工作的。回忆一下,异步函数的意义就在于被调用的时候myApiFunc函数已经返回了。这意味着 try 代码块已经退出了。这个回调函数是由 Node 直接调用的,外面并没有 try 的代码块。如果你用这个反模式,结果就是抛出异常的时候,程序崩溃了。
  2. 在 JavaScript 里,抛出一个不属于Error的参数从技术上是可行的,但是应该被避免。这样的结果使获得调用堆栈没有可能,代码也无法检查name属性,或者其它任何能够说明哪里有问题的属性。
  3. 操作失败和程序员的失误这一概念早在 NodeJS 之前就已经存在存在了。不严格地对应者 Java 里的 checked 和 unchecked 异常,虽然操作失败被认为是无法避免的,比如 OutOfMemeoryError,被归为 uncheked 异常。在 C 语言里有对应的概念,普通异常处理和使用断言。维基百科上关于断言的的文章也有关于什么时候用断言什么时候用普通的错误处理的类似的解释。
  4. 如果这看起来非常具体,那是因为我们在产品环境中遇到这样过这样的问题。这真的很可怕。
上一篇 下一篇

猜你喜欢

热点阅读