【译】Node.js 中的错误处理

错误处理是 Node.js/JavaScript 编程中我们常常忽视的一环,即使有错误处理的意识,也很容易因为没有指导原则的无绪错误处理导致我们的代码反而变得臃肿混乱。

原文 Error Handling in Node.js 是笔者目前为止看过的对 Node.js 错误处理最系统、最详尽的叙述,下面是对这篇文章的翻译。


错误处理是一个痛点,并且使用了 Node.js 很长时间仍旧没有正确处理错误的情况非常普遍。然而,搭建健壮的 Node.js 应用需要正确地处理错误,并且也不难学习。如果你非常不耐烦了,请跳到下文的 “总结” 部分。

本文将回答下列问题,也是 Node.js 新手程序员经常问的:

  • 在我编写的函数里,我应该什么时候 throw 一个错误,什么时候应当传给回调,什么时候又应当通过 event emitter 来触发,或者采用其他方式?
  • 我的函数应当对它的参数做出哪些假设?我应当检测它们是否是正确的类型吗?我应该进行更多约束的检测,例如检测一个参数是否非 null,是否非负,是不是看起来像一个 IP 地址,等等
  • 我应该如何处理哪些不符合函数预期的参数?我应当抛出一个异常,或是传递错误给回调函数吗?
  • 从编程的角度,我应该如何区分不同类型的错误(例如一个 “Bad Request” 与一个 “Service Unavailable” 错误)?
  • 我应该如何为我的错误提供尽可能详尽的细节以便调用者能得知如何处理他们?
  • 我应当如何处理未预料的错误?我应该使用 try/catch, domains 还是其他的机制?

本文分为以下几个相互联系的部分:

  • 背景:你应当早已知悉的知识
  • 操作错误与程序员错误:介绍两种本质上不同的错误种类
  • 编写函数的模式:编写函数的一般标准,使得函数能产出有用的错误
  • 函数编写的具体建议:用于编写能产出有用错误的健壮函数的具体指南清单
  • 示例:为一个 connect 函数编写文档和前言
  • 总结:为上面的内容做一下总结
  • 附录Error 对象的常规属性:用于以标准的方式提供额外信息的属性名称列表

背景

本文假设:

  • 你熟悉 JavaScript, Java, Python, C ++ 或任何类似语言中的异常的概念,并且您知道 throwcatch 它们意味着什么。
  • 你熟悉 Node.js 编程,你能自如地使用异步操作以及异步操作完成后的 callback(err, result) 模式
  • 你知道下面的模式为什么不能有效地处理错误:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    function myApiFunc(callback) {
    /*
    * This pattern does NOT work!
    */
    try {
    doSomeAsynchronousOperation((err) => {
    if (err) {
    throw (err);
    }
    /* continue as normal */
    });
    } catch (ex) {
    callback(ex);
    }
    }

你也应当熟悉 Node.js 中四种主要的传递错误的方式:

  • throw 一个错误(使其成为一个 异常
  • 将错误传递给 回调,回调是专门为处理错误和异步操作的结果而提供的函数
  • 传递错误给 Promise 对象的 reject
  • 在一个 EventEmitter 上触发 "error" 事件

我们将在下面讨论每种模式在什么时候使用。本文不假设你了解 domains。

最后的一点,你应当知道在 JavaScript 中(特别的,在 Node.js 中),错误(error)和异常(exception)存在一个差别。一个错误(error)是 Error 类的一个实例,错误可以被构建,然后传递给另外的函数或被抛出。当你 throw 一个错误,它就变成了一个异常。将一个错误做为一个异常使用:

1
throw new Error('something bad happened');

然而你可以单纯的创建一个 Error 而不是抛出它:

1
callback(new Error('something bad happened'));

这种方式在 Node.js 中更加普遍因为大部分错误是异步的。如我们缩减,需要从同步函数中 catch 错误是非常罕见的。这与 Java, C ++ 和其他大量使用异常的语言非常不同。


操作错误与程序员错误

将所有错误分为两大类是非常有帮助的:

  • 操作错误 表示正确编写的程序遇到的运行时问题。这不是程序的 Bug。实际上,这些通常是其他一些问题:系统本身(例如:内存耗尽,打开过多文件),系统配置(例如:没有到远程主机的路由),网络(例如:socket hang-up)或远程服务(例如:500 错误,连接失败等等)。例子如下:
    • 连接服务器失败
    • 解析域名失败
    • 无效的用户输入
    • 请求超时
    • 服务器返回 500 响应
    • 连接中止
    • 系统内存耗尽
  • 程序员错误 是程序的 Bug。这些总是能通过变更代码来避免。他们永远不能被正确的处理。
    • 尝试从 undefined 上读取属性
    • 没有回调的情况下调用异步函数
    • 在预期是一个对象的地方传入一个字符串
    • 在预期是一个 IP 地址字符串的地方传入一个对象

人们使用术语 “errors” 来讨论操作错误和程序员错误,但它们真的完全不同。操作错误是所有正确程序必须处理的错误,只要它们被处理,那么他们就不一定会表现出 Bug 或严重问题。“File not found” 时一个操作错误,但是它不一定意味着这是错的。它只是表示程序必须在寻找文件前创建好这个文件。

反之,程序错误是 Bug。他们是你犯了错误的情况,可能是忘记验证用户输入,写错了变量名等等。根据定义,没有办法处理这些。假如有,那也只是你用错误处理代码代替导致错误的代码。

这个区别非常重要:操作错误时程序正常运行的一部分,程序员错误是 Bug

有时,你会在一个根问题里同时遇到操作错误与程序员错误。如果一个 HTTP 服务器尝试使用一个未定义的变量,然后 crash,这是程序员错误。任何请求还在路上的客户端在 crash 的时候将会看到一个 ECONNRESET 错误,通常在 Node 中报告为 “socket hang-up”。对于客户端来说,这是一个单独的操作错误。这是因为正确的客户端必须处理服务器崩溃或网络不可用。

同样,未能处理操作错误本身就是程序员错误。例如,如果一个程序尝试连接服务器但是获得一个 ECONNREFUSED 错误,并且它没有为 socket 的 'error' 事件注册处理函数,那么程序将会 crash,那么就是程序员错误。连接失败是一个操作错误(因为任何正确的程序都可能会经历网络或其他组件发生故障),但是没有处理这个操作错误(这个行为)就时程序员错误了。

操作错误和程序员错误的区别是确定如何分发错误以及如何处理错误的基础。确保你在继续往下读之前已经懂了这些。

处理操作错误

就像性能和安全一样,错误处理并不是可以突然凭空加到一个没有任何错误处理的程序中的。你没有办法在一个集中的地方处理所有的异常,就像你不能在一个集中的地方解决所有的性能问题。任何可能失败的代码(打开一个文件,连接到一个服务器,fork 一个子进程,等等)都必须考虑操作失败时导致的结果,包括为什么出错(失效模式),错误意味着什么。稍后会详细介绍,但这里想说的关键点就是错误处理有必要以细粒度的方式完成,因为哪里出错和为什么出错决定了影响大小和对策

你最终可能在栈的多个层级处理相同的错误 —— 当低层级无法做任何有用的操作,除了向它的调用者上传错误,它的调用者又向上层的调用者传递错误…通常,只有顶层的调用者知道正确的错误应对是什么,是重试操作,向用户报告错误还是其他。但是,这不意味着你就应当把所有的错误全部丢给单一的顶层回调函数,因为这个回调自身无法知晓错误发生时的上下文,底层系列操作那些是成功完成的,那些操作是失败的。

具体一点。对于任何一个指定的错误,你可以做这些事:

  • 直接处理失败。有时,为了处理一个错误你应当做什么很清晰。如果你得到了尝试打开日志文件的一个 ENOENT 错误,也许这是这个程序第一次在这个系统运行,你只需要先创建一下这个日志文件。一个更有趣的例子,你维护一个与服务器(例如:数据库服务器)的持久连接,然后你得到了一个 “socket hang-up” 错误,这通常意味着远程的一端断开了连接或者网路出现了掉线,通常这种情况是短暂的,所以你通常通过 重连(reconnecting) 来应对这个错误。(与下文的 重试(retrying) 不同,因为在你得到这个错误的时候不一定有操作正在进行。)

  • 传递错误给客户端。如果你不知道如何处理错误,最简单的方式就是放弃你正在你正在尝试的操作,清理起始的数据和状态,发送一个错误给客户端(如何传递错误是另外一个问题,将在下面进行讨论)。这种方式适合错误短时间内无法解决的情形,比如,用户提交了不正确的JSON,你再解析一次是没什么帮助的。

  • 重试操作。对于网络与远程服务(例如 Web 服务)的错误,重试在有时候是有帮助的。例如,如果一个远程服务返回了一个 503(服务不可用错误),你可能想在几秒钟之后重试一下。如果你要重试,那你应该在文档中写明将会进行重试,要重试几次,每次重试之间的间隔。此外,不要假定每个操作都需要重试。如果你的操作在栈的较深层(例如:你的函数被一个客户端调用,而这个客户端又由一个由用户驱动的客户端调用),最好立即失败,让客户端自己去重试。如果,栈的每一层都认为自己需要在错误时重试,那么用户等待的时间可能比预期的要长,因为每一层没有意识到自己的下层也在重试

  • 直接崩溃。对于那些本不该发生的错误,或者由程序员失误导致的错误(例如:比如无法连接到一个本应该在相同程序中监听的本地套接字),最好记下错误信息然后 crash。其他的错误,如内存耗尽,无论如何都无法在像 JavaScript 这样的动态语言中被处理,所以程序崩溃是完全合理的。(也就是说,从 child_process 分离操作中得到 ENOMEM 错误,以及那些你能合理处理的错误时,你应该考虑这样做 —— 让子进程 crash)当你无计可施只能让负责人修复错误的时候,那么让程序直接崩溃吧。例如,文件描述符用尽,或者没有权限访问配置文件,那么你什么也做不了,只能让别人(例如系统运维工程师等)去修复。

  • 记下错误,其他什么也不做。有时候,你什么也做不了,没有什么可以让你重试或丢弃的东西,也没有理由让程序崩溃。例如你用 DNS 追踪一组远程服务(例如轮询 DNS 以检测域名是否解析正常),结果其中有一个服务失败无法 DNS 解析了。除了记下错误,继续处理剩下的服务之外你什么也无法做。但这种情况下至少你应当记录下一些信息。(但万事都有例外,如果这种错误每秒数以千计,你毫无办法应对,那么不太值得每次发生时都记录,但是可以定期记录)

(不要)处理程序员错误

你无法处理程序员错误。根据定义,这些代码预期中是破坏性的(例如有一个拼写错误的变量名),你无法用更多的代码来修复这个问题。假设你能,你也只是用错误处理代码代替了破坏性的代码。

有些人主张尝试从程序员错误中 recover —— 允许当前操作失败,但是继续处理请求。这是不推荐的,想一想程序员错误是你在编写代码时无法考虑到的(位置不确定性),你怎么确定这样做(继续处理)会不会影响其他请求?如果其他请求和当前发生程序员错误的代码共享任何状态(服务器、套接字、数据库连接池等),很可能其他请求将会做一些错误的事情。

一个经典的例子就是 REST 服务器(例如使用 restify 编写的),某一个请求处理抛出 ReferenceError(例如使用一个拼写错误的变量)。继续处理下去的话有很多种途径可以导致非常难以追查的严重 Bug,例如:

  1. 请求见共享的某些状态可能会保留为 null, undefined 或其他无效值,于是当下一个请求尝试使用它们时,程序将再次崩溃

  2. 数据库(或其他)连接可能被泄露(内存泄露),从而降低你将来可以处理的并行请求数。更严重的是,后面可能你只剩下几个连接,最终以串行而不是并行的方式来处理请求

  3. 更糟的是,postgres 连接会被留在打开的请求事务里。这会导致 postgres “持有” 表中某一行的旧值,因为它对这个事务可见。这个问题会存在好几周,造成表的有效大小无限制的增长 —— 导致后面的查询以数量级减缓 —— 从几毫秒到几分钟。虽然这个问题和 postgres 紧密相关,但是它很好的说明了程序员一个简单的失误会让应用程序陷入一种非常糟糕的状态。

  4. 一个连接可能被保持在已认证状态,然后被后续的请求使用,你最终可能在为一个错误的用户运行请求

  5. 套接字可能保持打开状态。Node.js 通常会在一个闲置的套接字上应用 2 分钟的超时,但是这个配置可能被复写,导致文件描述符泄露。如果发生这种情况,你可能会用尽文件描述符然后崩溃。即使你没有改写超时的配置,客户端也会挂起两分钟然后得到一个非预期的 hang-up 错误。两分钟的延迟将使得问题处理与调试变成烦恼。

  6. 内存引用被保留,这会造成泄露,导致系统内存用尽,或者(更糟糕的是)增长 GC(垃圾回收)的用时,导致性能严重受损。这个非常难以调试,有时你很难将内存泄露与程序员错误联系到一起。

从程序员错误中恢复的最好的方法就是立即 crash。你应当使用一个 restarter 来运行你的程序,以便在 crash 时能自动重启程序。有了 restarter,crash 是面对短暂的程序员错误时恢复可靠服务的最快方法。

在程序员错误 crash 的唯一不好的地方是连接的客户端可能会暂时中断,但请记住:

  • 根据定义,这些错误总是 Bug。我们不是在谈论合法的系统或网络失败,而是程序里的 Bug。在生产环境中这应该是比较罕见的,优先级最高的是必须 debug 和修复它们

  • 对于上述描述所有情况(以及更多情况),请求没有必要一定得成功完成。请求可能成功完成,请求可能再次 crash 服务器,请求可能以明显的方式不适当的完成(例如:503,但是服务器不 crash),或者请求可能以难以调试的微妙的方式错误地完成

  • 在一个可靠的分布式系统中,客户端必须能够通过重新连接和重试请求来处理服务器故障。无论 Node.js 程序是否允许 crash,网络和系统故障都是实实在在存在的

  • 如果你的生产环境程序过于频繁地 crash 以至于连接断开都成了一个问题,那么真正的问题时你的服务器过于 buggy,而不是在有 Bug 情况下的 crash

如果由于服务器 crash 客户端出现频繁断开的问题,你应当聚焦于造成服务 crash 的 Bug —— 让这些 Bug 成为异常 —— 而不是尝试避免在代码明显错误的情况下的 crash。debug 这些问题的最佳方式是配置 Node 在出现一个未捕获异常时 dump 内核详情。在 GNU/Linux 或着基于 illumos 的系统上使用 dump 的内核文件,你不仅可以查看程序 crash 时的堆栈记录,也能看到这些函数的参数,以及更多其他 JavaScript 对象,甚至那些在闭包里引用的变量。即使没有配置内核文件 dump,你也可以使用函数堆栈信息和日志来开始处理问题。

最后,请记住,服务器上的程序员错误指挥成为客户端上的操作错误,客户端必须处理服务器崩溃与网络故障。这不仅仅是理论上的 —— 在生产环境中确实都会发生。


编写函数的模式

我们已经讨论了如何处理错误,但是当你正在写一个新函数,你如何传递(deliver)错误给调用你这个函数的代码?

最最重要的一点是为你的函数 写好文档,包括它接受的参数(包括它们的类型、约束),返回什么,什么错误可能发生,这些错误意味着什么。如果你不知道可能发生什么错误,或者不了解错误的含义,那么你的程序的正常运行只是巧合。所以,如果你正在写一个新的函数,你必须告知调用者可能发生什么错误,每种错误意味着什么。

throw, 回调, reject 还是 EventEmitter?

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

  • throw 同步传递一个错误 —— 也就是说,在函数被调用处的相通上下文。如果调用者(或者调用者的调用者…)使用 try/catch,那么它们可以捕获这个错误。如果没有任何一个调用者捕获,程序通常 crash(错误也可能会被 domains 或者进程级的 uncaughtException 事件捕获,下文将进行讨论)。

  • 回调函数是异步传递错误的最基本方式。用户传给你一个函数(回调函数),稍后当异步操作完成时执行它。通常的形式是回调函数以 callback(err, result) 的方式调用,errresult 参数中只有一个为非 null,谁为 null 有操作成功还是失败决定

  • Promise rejections 是异步传递错误的常见方式。自包含对 async/await 支持的 Node.js 版本 8 发布以来,这种方式越来越受欢迎。这允许异步代码写得看起来向同步代码,并且可以使用 try/catch 捕获错误

  • 对于更复杂的情况,代替使用回调函数,函数本身可以返回一个 EventEmitter 对象,调用者应该在 EventEmitter 对象上监听 error 事件。这在两种特定的情况下很有用:

    • 当您执行可能产生多个错误或多个结果的复杂操作时。例如,考虑一个需要从数据区中获取多行数据的请求,一旦有数据到达时 stream 它们给客户端,而不是先等所有的数据先获取到。在这种情况下,你的函数最好返回一个 EventEmitter,对每一个结果都触发 row 事件,当所有结果报告完毕时触发 end 事件,当任何错误发生时触发 error 事件。
    • 对于那些具有复杂状态机的对象,可能繁盛许多不同的异步事件。例如,一个套接字是一个 EventEmitter,可能触发 “connect”, “end”, “timeout”, “drain”, 和 “close”。很自然地,我们会将 “error” 也作为套接字另一种可以被触发的事件。当使用这种方式,很重要的是清楚 “error” 何时被触发,是否任何其他事件会被触发,在同一事件可能会看到哪些其他事件(例如 “close”),它们发生的顺序如何,套接字是否会在事件结束时被关闭。

在大多数情况下,我们会将回调函数与 EventEmitter 归到 “异步错误传递” 这一类中。如果需要异步地传递错误,通常需要使用其中一个(回调函数或 EventEmitter),但不能同时使用。

那么,什么时候使用 throw,什么使用使用回调或 EventEmitter?这取决于两件事:

  • 是操作错误,还是程序员错误?
  • 函数本身是同步的,还是异步的?

到目前为止,最常见的情况是异步函数中的操作错误。这些中的大多数,你可能希望你的函数接收一个回调函数作为函数,你只需要将错误传递给回调函数。这非常有效,并且被广泛使用(加入了 async/await,回调的模式慢慢被替代)。关于例子可以查看 Node.js 的 fs 模块。如果你有一个比上面描述的更复杂的情况,你可能想要使用 EventEmitter,但你仍然时异步地传递错误。

下一个最常见的情况是同步函数中的操作错误,例如 JSON.parse。对于这些函数,如果你遇到一个操作错误(例如无效的用户输入),你必须同步地传递错误,你可以 throw 它或者 return 它。

对于一个给定的函数,如果任何操作错误可以异步传递,那么所有异步错误都应该异步传递。在某些情况下,你会立即知道请求会失败,但不是因为程序员错误。也许该函数缓存了最近请求的结果,并且缓存的是一个你将返回给调用者的错误对象。尽管你知道请求会立即失败,你也应该异步地传递错误(因为函数对外的预期是异步函数)。

通用规则是:函数可以同步(例如,通过 throw)或异步(通过将它们传递给回调或在 EventEmitter 上触发错误事件)来传递操作错误,但不应该同时执行这两者。这样,用户可以通过在回调中处理错误或使用 try/catch 来处理错误,但是他们永远不需要同时执行这两种操作。使用哪一种方式取决于函数如何传递其错误,并且应该参考函数的文档以决定。

差点遗漏了程序员错误。回想之前的观点:程序员错误总是 Bug。通常可以通过在函数开头检查函数的参数类型(和其他约束)来立即识别它们。一个比较落后的例子:有人可能在调用一个异步函数时没有传递回调函数。你应该立即抛出这些错误,因为程序已经出错而在这个点上最好的调试的机会就是得到一个堆栈信息,如果有出错时刻内核信息就更好了。为此,我们建议在函数开头校验所有参数。

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

下面是对 Node.js 核心库中的一些示例函数使用这些建议的摘要,按照每种问题出现的频率来粗略排序:

示例函数 函数类型 示例错误 错误类型 如何传递 调用者处理
fs.stat 异步 file not found 操作错误 回调函数 回调函数处理
JSON.parse 同步 bad user input 操作错误 throw try/catch
fs.stat 异步 filename is null 程序员错误 throw 无(crash)

异步函数(第一行)中的操作错误是目前最常见的错误。除了用户输入验证外,报告操作错误的同步函数在 Node.js 中非常少。然而,随着 Node.js 新版的发布(8.x+),大家开始将异步函数 Promise 化,并在 try/catch 中使用 await(之前说的 try 块中不应该出现异步操作,但是 await 的出现,改变了这一点)。(第 3 行的)程序员错误除了再开发中永远不应该出现。

bad input: 操作错误还是程序员错误?

你如何得知什么是操作错误,什么是程序员错误?很简单:取决于你如何定义与文档记录你的函数允许何种类型与如何解释它们。如果你接收到的是你的文档没有记录可接收的东西,那么就是程序员错误。如果输入的是你的文档记录可接受的但是你已说明目前不能正确处理的,那就是操作错误。

你必须根据自己的判断来决定你想要多严格,但我们可以制定一些建议:
具体点,想象一个叫 connect 的函数,它接收一个 IP 地址和一个回调,回调在函数执行成功或失败后执行。假设用户传递的东西显然不是有效的 IP 地址,例如 'bob' 字符串。在这种情况下,你有这些选择:

  • 文档说明该函数只接受表示有效 IPV4 地址的字符串,如果用户传入 'bob' 则立即抛出异常。强烈推荐这样做

  • 文档说明该函数接收任何字符串。如果用户传入 'bob',则发出一个异步错误表明不能连接到 'bob' 这个 IP 地址

这两点与上面的操作错误与程序员错误的指南一致。你决定了这样的错误是程序员错误还是操作错误。通常,用户输入校验的功能是十分松散的。例如,Date.parse ,它接收很多种类型的输入。但对于其他大部分函数,我们强烈建议更严格而不是更宽松。你的函数越是想猜测调用者的意思,就越可能猜错。你的本意是想让开发者在使用的时候更加自由,结果却耗费了开发者数个小时来 debug(由于你程序做了太多猜测,而有些猜测的逻辑存在 Bug)。此外,如果你觉得在将来的版本中让函数不那么严格是个好主意,那你可以这样做,但是,如果你发现由于猜测用户的意图导致了很多恼人的 Bug,你再想不破坏兼容性就能修复是不可能的。

笔者注:由严格到宽松可以,但是发现宽松的情况下有 Bug,此时要想修复或变得更严格,你就只能打破兼容性了

所以如果一个值怎么都不可能是有效的(本该是 string 却得到一个 undefined,本该是 IP 地址形式的 string 但明显不是),你应该在文档里写明这是不允许的并且立刻抛出一个异常。你要你用文档进行了说明,那这些错误就是程序员错误,而不是操作错误。通过立即抛出错误,你可以最大限度的减少 Bug 带来的破坏,并保留开发人员用于 Debug 需要的信息(例如,堆栈信息,如果进行了 dump core 还可以记下参数和内存信息)。

那么 domainsprocess.on('uncaughtException')

操作错误总是可以通过一个显式的机制来处理:捕获异常,回调中处理错误,在 EventEmitter 对象上处理 error 事件等等。domains 和进程级的 'uncaughtException' 事件主要用于尝试处理意外的错误或从意外的错误中恢复。根据上面给出的原因,不推荐恢复。


函数编写的具体建议

我们已经谈论了许多指导原则,现在我们具体点:

1. 弄清楚你的函数做的是什么

这是最最重要的事情,每个接口函数的文档应该明晰:

  • 期望接收哪些参数
  • 每个参数的类型
  • 对这些参数的任何其他约束(例如:必须是有效的 IP 地址)

如果这些中的点错了或者忘记遗漏,那就是程序错误,你应当立即抛出。

你还应当将这些点写入文档:

  1. 调用者应该能预见到的操作错误(包括错误的名字)
  2. 你的函数如何处理操作错误(被抛出,传递给回调函数,在 EventEmitter 对象上触发事件等等)
  3. 返回值

2. 为所有的错误使用 Error 对象(或子类),实现为错误制定的约定

你所有的错误应该使用 Error 类或其子类。你应当提供 namemessage 属性,堆栈也应该被收集(并且可靠)

3. 使用 Error 对象的name属性在程序上区分错误

当你需要弄清楚错误是什么类型时,使用 name 属性。JavaScript 内置的供你重用的名字包括 “RangeError”(参数超出有效范围)和 “TypeError”(参数类型错误)。对于 HTTP 错误,通常使用 RFC 给定的状态文本来命名错误,例如 “BadRequestError” 或 “ServiceUnavailableError”。

不要觉得需要为一切错误创造新名字。你不需要细分 InvalidHostnameError, InvalidIpAddressError, InvalidDnsServerError 等等,你可以只使用一个 InvalidArgumentError 然后用表述错误的属性来扩展它(见下文)。

4. 使用解释细节的属性来扩展 Error 对象

例如,如果是一个参数无效的错误,将 propertyName 属性设为无效属性的名字,将 propertyValue 设置为传入的值。如果连接服务器失败,使用 remoteIp 说明你尝试连接 IP。如果你得到了一个系统错误,请包含 syscall 属性说明是哪个系统调用失败了,并使用 errno 属性说明系统返回的 errno。有关要使用的示例属性名称,请参阅附录。

至少,你需要:

  • name:用于在程序上区分错误类型(例如:非法参数、连接失败)
  • message:人可读的错误信息,应该足够完整使读它的人能理解。如果是从堆栈的较低层级传递错误,你应该添加在 message 上添加你正在做的事情的说明。包装错误的更多讨论见下一点
  • stack:一般来讲,不要随便扰乱 stack 信息,甚至不要去扩展它。V8 引擎只有在这个属性真正被读取时才会去运算,以此显著提高错误处理的性能。如果你只是为了扩展 stack 而去读取它,即使你的调用者不需要 stack 信息你也会付出读取的代价

你也应当在错误信息中包含足够的信息,以便调用者无需解析你的错误 信息即可构建自己的错误信息。调用者可能希望对错误消息本地化,或将大量错误聚合在一起,或以不同的方式展示错误信息(比如在网页的一个表格里,或者高亮显示用户错误输入的字段)。

5. 如果将较低层级的错误传递给调用者,考虑包装一下

通常你会发现异步函数 funcA 调用了一些其他的异步函数 funcB,如果 funcB 发出一个错误,你会希望 funcA 发出相同的错误(注意有时并不这样,funcA 可能会重试,也可能忽略错误什么事情也不用做)。但我们只是考虑funcA想在这里直接返回funcB错误的简单情况。

在这种情况下,考虑包装一下 Error 对象而不是直接返回它。包装的意思是上传一个包含底层错误所有信息的新的错误,并且带上当前层的有用上下文。verror 模块提供了一种非常方便的包装方式。

例如,假设你有一个叫做 fetchConfig 的函数,从远程数据库拉取服务器配置。你可能会在服务器启动的时候调用这个函数。整个流程看起来是这样的:

  1. 加载配置
    1. 连接到数据库服务器。这一步将会:
      1. 解析数据库服务器的 DNS 域名
      2. 创建一个与数据库服务器的 TCP 连接
      3. 数据库服务器认证
    2. 数据库查询
    3. 解码返回数据
    4. 加载配置
  2. 开始处理请求

假如在运行时连接到数据库服务器出现问题,如果因为没有到达主机的路由 1.1.2 失败了,并且每一个层级将错误上传给调用者(理应如此),但是不包装错误,那么你可能或得到一个这样的错误信息:

myserver: Error: connect ECONNREFUSED

这显然没有多少帮助。

另一方面,如果每一层包装从下一层返回的错误,你可以获得更多信息:

1
2
3
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
>

你可能在某些层级想跳过包装,得到一个不那么学究气息的信息:

1
2
3
myserver: failed to load configuration: connection refused from database at
127.0.0.1 port 1234.
>

然而,报错的时候详细一点总比信息不够要好。

如果你决定包装错误,有一些事是你需要考虑的:

  • 保留原始错误不要篡改,确保调用者仍能使用底层的错误以防它需要从底层的错误中直接获取信息

  • 要么使用相同的名字,要么显式地选择一个更有意义的名字。例如,底层可能是来自 Node 的一个普通错误,但步骤 1 的错误可能是 InitializationError。(但是如果程序可以通过其它的属性区分,就不要觉得一定有责任取一个新的名字)

  • 保留原始错误的所有属性,适当地扩充 message 属性(但不要在原始错误上直接修改)。浅拷贝像 syscall, errno 这种类似的其他属性。您最好复制除 name, message 和 之外的所有属性,而不是硬编码要显式复制的属性列表。不要对 stack 做任何事情,因为即便只是读取它便已经非常昂贵了。如果调用者想要生成组合堆栈,它应该用(需要读取组合堆栈时)迭代输出每个错误的堆栈来代替组合堆栈 。

joynet 使用 verror 模块来包装错误,因为它简洁的语法。


示例

考虑一个异步连接到 IPV4 地址和端口的函数,下面是我们如何为它写文档的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/*
* 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 */
}

这个例子在概念上很简单,但演示了我们所谈论的一系列建议:

  • 参数,它们的类型,值的约束都有明确的记录

  • 函数对它接收的参数是严格的,当得到的是一个无效的输入时他会抛出错误(程序员错误)

  • 记录了一系列可能的操作错误。不同的 name 属性值用于在逻辑上区分不同的错误,errno 用于获取系统错误的详细信息

  • 错误的传递方式也被记录(失败是调用 callback

  • 返回的错误具有 remoteIpremotePort 字段以便使用者可以定义自定义的错误 message

  • 尽管显而易见,但连接失败后的状态清楚记录在案:任何打开的 socket 将已经被关闭

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


总结

  • 学习了区分操作错误,即那些可以被预测的哪怕在正确的程序里也无法避免的错误(例如,无法连接到服务器),和程序员错误,即那些程序中的 Bug

  • 操作错误可以也应当被处理,程序员错误无法被处理或从中可靠地恢复(也不应当),尝试这样做只会更难调试

  • 一个给定的函数,它处理异常的方式要么是同步(用 throw 方式)要么是异步的(用 callback 或者 EventEmitter),不会两者兼具。用户可以在回调函数里处理错误,也可以使用 try/catch 捕获异常 ,但是不能一起用。实际上,使用 throw 并且期望调用者使用 try/catch 是很罕见的,因为 Node.js 里的同步函数通常不会产生运行失败(主要的例外是类似于 JSON.parse 的用户输入验证函数)。

  • 在写新函数的时候,用文档清楚地记录函数预期的参数,包括它们的类型、其它约束(例如 “必须是有效的IP地址”),可能会合理发生的操作失败(例如无法解析主机名,连接服务器失败,或其他服务器端错误),以及错误是怎么传递给调用者的(同步,用 throw,还是异步,用 callbackEventEmitter)。

  • 缺少或无效的参数时程序员错误,这种错误发生时你应当总是 throw。可能作者决定接收的函数存在灰色地带,但是如果你传递的不是它所记录的能接受的,则始终是程序员错误

  • 当传递错误时,使用标准的 Error 类以及其他标准属性。在其他属性上添加尽可能多的有帮助的附加信息。在可能的情况下,使用约定的属性名(如下)。


附录:错误对象约定属性名

强烈推荐你使用这些属性名以与 Node.js 核心以及 Node.js 插件传递的错误保持一致。这些中的大多数不会适用于给定的错误,但是出现疑问的时候,您应该以编程方式和自定义错误消息包含任何看似有用的信息。

FuChee wechat
扫一扫,关注我