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

NodeJS的错误处理让人痛苦,在很长的一段时间里,大量的错误被听任无论。可是要想创建一个健壮的Node.js程序就必须正确的处理这些错误,并且这并不难学。若是你实在没有耐心,那就直接绕过长篇大论跳到“总结”部分吧。程序员

原文数据库

这篇文章会回答NodeJS初学者的若干问题:编程

  • 我写的函数里何时该抛出异常,何时该传给callback,何时触发EventEmitter等等。
  • 个人函数对参数该作出怎样的假设?我应该检查更加具体的约束么?例如参数是否非空,是否大于零,是否是看起来像个IP地址,等等等。
  • 我该如何处理那些不符合预期的参数?我是应该抛出一个异常,仍是把错误传递给一个callback。
  • 我该怎么在程序里区分不一样的异常(好比“请求错误”和“服务不可用”)?
  • 我怎么才能提供足够的信息让调用者知晓错误细节。
  • 我该怎么处理未预料的出错?我是应该用 try/catch ,domains 仍是其它什么方式呢?

这篇文章能够划分红互相为基础的几个部分:缓存

  • 背景:但愿你所具有的知识。
  • 操做失败和程序员的失误:介绍两种基本的异常。
  • 编写新函数的实践:关于怎么让函数产生有用报错的基本原则。
  • 编写新函数的具体推荐:编写能产生有用报错的、健壮的函数须要的一个检查列表
  • 例子:以connect函数为例的文档和序言。
  • 总结:全文至此的观点总结。
  • 附录:Error对象属性约定:用标准方式提供一个属性列表,以提供更多信息。

背景

本文假设:安全

你已经熟悉了JavaScript、Java、 Python、 C++ 或者相似的语言中异常的概念,并且你知道抛出异常和捕获异常是什么意思。
你熟悉怎么用NodeJS编写代码。你使用异步操做的时候会很自在,并能用callback(err,result)模式去完成异步操做。你得知道下面的代码不能正确处理异常的缘由是什么[脚注1]服务器

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

你还要熟悉三种传递错误的方式: - 做为异常抛出。 - 把错误传给一个callback,这个函数正是为了处理异常和处理异步操做返回结果的。 - 在EventEmitter上触发一个Error事件。网络

接下来咱们会详细讨论这几种方式。这篇文章不假设你知道任何关于domains的知识。闭包

最后,你应该知道在JavaScript里,错误和异常是有区别的。错误是Error的一个实例。错误被建立而且直接传递给另外一个函数或者被抛出。若是一个错误被抛出了那么它就变成了一个异常[脚注2]。举个例子:app

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

可是使用一个错误而不抛出也是能够的

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

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

操做失败和程序员的失误

把错误分红两大类颇有用[脚注3]:

  • 操做失败是正确编写的程序在运行时产生的错误。它并非程序的Bug,反而常常是其它问题:系统自己(内存不足或者打开文件数过多),系统配置(没有到达远程主机的路由),网络问题(端口挂起),远程服务(500错误,链接失败)。例子以下:
  • 链接不到服务器
  • 没法解析主机名
  • 无效的用户输入
  • 请求超时
  • 服务器返回500
  • 套接字被挂起
  • 系统内存不足
  • 程序员失误是程序里的Bug。这些错误每每能够经过修改代码避免。它们永远都无法被有效的处理。
  • 读取 undefined 的一个属性
  • 调用异步函数没有指定回调
  • 该传对象的时候传了一个字符串
  • 该传IP地址的时候传了一个对象

人们把操做失败和程序员的失误都称为“错误”,但其实它们很不同。操做失败是全部正确的程序应该处理的错误情形,只要被妥善处理它们不必定会预示着Bug或是严重的问题。“文件找不到”是一个操做失败,可是它并不必定意味着哪里出错了。它可能只是表明着程序若是想用一个文件得事先建立它。

与之相反,程序员失误是不折不扣的Bug。这些情形下你会犯错:忘记验证用户输入,敲错了变量名,诸如此类。这样的错误根本就无法被处理,若是能够,那就意味着你用处理错误的代码代替了出错的代码。

这样的区分很重要:操做失败是程序正常操做的一部分。而由程序员的失误则是Bug。

有的时候,你会在一个Root问题里同时遇到操做失败和程序员的失误。HTTP服务器访问了未定义的变量时奔溃了,这是程序员的失误。当前链接着的客户端会在程序崩溃的同时看到一个ECONNRESET错误,在NodeJS里一般会被报成“Socket Hang-up”。对客户端来讲,这是一个不相关的操做失败, 那是由于正确的客户端必须处理服务器宕机或者网络中断的状况。

相似的,若是不处理好操做失败, 这自己就是一个失误。举个例子,若是程序想要链接服务器,可是获得一个ECONNREFUSED错误,而这个程序没有监听套接字上的error事件,而后程序崩溃了,这是程序员的失误。链接断开是操做失败(由于这是任何一个正确的程序在系统的网络或者其它模块出问题时都会经历的),若是它不被正确处理,那它就是一个失误。

理解操做失败和程序员失误的不一样, 是搞清怎么传递异常和处理异常的基础。明白了这点再继续往下读。

处理操做失败

就像性能和安全问题同样,错误处理并非能够凭空加到一个没有任何错误处理的程序中的。你没有办法在一个集中的地方处理全部的异常,就像你不能在一个集中的地方解决全部的性能问题。你得考虑任何会致使失败的代码(好比打开文件,链接服务器,Fork子进程等)可能产生的结果。包括为何出错,错误背后的缘由。以后会说起,可是关键在于错误处理的粒度要细,由于哪里出错和为何出错决定了影响大小和对策。

你可能会发如今栈的某几层不断地处理相同的错误。这是由于底层除了向上层传递错误,上层再向它的上层传递错误之外,底层没有作任何有意义的事情。一般,只有顶层的调用者知道正确的应对是什么,是重试操做,报告给用户仍是其它。可是那并不意味着,你应该把全部的错误全都丢给顶层的回调函数。由于,顶层的回调函数不知道发生错误的上下文,不知道哪些操做已经成功执行,哪些操做实际上失败了。

咱们来更具体一些。对于一个给定的错误,你能够作这些事情:

  • 直接处理。有的时候该作什么很清楚。若是你在尝试打开日志文件的时候获得了一个ENOENT错误,颇有可能你是第一次打开这个文件,你要作的就是首先建立它。更有意思的例子是,你维护着到服务器(好比数据库)的持久链接,而后遇到了一个“socket hang-up”的异常。这一般意味着要么远端要么本地的网络失败了。不少时候这种错误是暂时的,因此大部分状况下你得从新链接来解决问题。(这和接下来的重试不大同样,由于在你获得这个错误的时候不必定有操做正在进行)
  • 把出错扩散到客户端。若是你不知道怎么处理这个异常,最简单的方式就是放弃你正在执行的操做,清理全部开始的,而后把错误传递给客户端。(怎么传递异常是另一回事了,接下来会讨论)。这种方式适合错误短期内没法解决的情形。好比,用户提交了不正确的JSON,你再解析一次是没什么帮助的。
  • 重试操做。对于那些来自网络和远程服务的错误,有的时候重试操做就能够解决问题。好比,远程服务返回了503(服务不可用错误),你可能会在几秒种后重试。若是肯定要重试,你应该清晰的用文档记录下将会屡次重试,重试多少次直到失败,以及两次重试的间隔。 另外,不要每次都假设须要重试。若是在栈中很深的地方(好比,被一个客户端调用,而那个客户端被另一个由用户操做的客户端控制),这种情形下快速失败让客户端去重试会更好。若是栈中的每一层都以为须要重试,用户最终会等待更长的时间,由于每一层都没有意识到下层同时也在尝试。
  • 直接崩溃。对于那些本不可能发生的错误,或者由程序员失误致使的错误(好比没法链接到同一程序里的本地套接字),能够记录一个错误日志而后直接崩溃。其它的好比内存不足这种错误,是JavaScript这样的脚本语言没法处理的,崩溃是十分合理的。(即使如此,在child_process.exec这样的分离的操做里,获得ENOMEM错误,或者那些你能够合理处理的错误时,你应该考虑这么作)。在你机关用尽须要让管理员作修复的时候,你也能够直接崩溃。若是你用光了全部的文件描述符或者没有访问配置文件的权限,这种状况下你什么都作不了,只能等某个用户登陆系统把东西修好。
  • 记录错误,其余什么都不作。有的时候你什么都作不了,没有操做能够重试或者放弃,没有任何理由崩溃掉应用程序。举个例子吧,你用DNS跟踪了一组远程服务,结果有一个DNS失败了。除了记录一条日志而且继续使用剩下的服务之外,你什么都作不了。可是,你至少得记录点什么(凡事都有例外。若是这种状况每秒发生几千次,而你又无法处理,那每次发生都记录可能就不值得了,可是要周期性的记录)。

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

对于程序员的失误没有什么好作的。从定义上看,一段本该工做的代码坏掉了(好比变量名敲错),你不能用更多的代码再去修复它。一旦你这样作了,你就使用错误处理的代码代替了出错的代码。

有些人同意从程序员的失误中恢复,也就是让当前的操做失败,可是继续处理请求。这种作法不推荐。考虑这样的状况:原始代码里有一个失误是没考虑到某种特殊状况。你怎么肯定这个问题不会影响其余请求呢?若是其它的请求共享了某个状态(服务器,套接字,数据库链接池等),有极大的可能其余请求会不正常。

典型的例子是REST服务器(好比用Restify搭的),若是有一个请求处理函数抛出了一个ReferenceError(好比,变量名打错)。继续运行下去颇有肯能会致使严重的Bug,并且极其难发现。例如:

  1. 一些请求间共享的状态可能会被变成nullundefined或者其它无效值,结果就是下一个请求也失败了。
  2. 数据库(或其它)链接可能会被泄露,下降了可以并行处理的请求数量。最后只剩下几个可用链接会很坏,将致使请求由并行变成串行被处理。
  3. 更糟的是, postgres 链接会被留在打开的请求事务里。这会致使 postgres “持有”表中某一行的旧值,由于它对这个事务可见。这个问题会存在好几周,形成表无限制的增加,后续的请求全都被拖慢了,从几毫秒到几分钟[脚注4]。虽然这个问题和 postgres 紧密相关,可是它很好的说明了程序员一个简单的失误会让应用程序陷入一种很是可怕的状态。
  4. 链接会停留在已认证的状态,而且被后续的链接使用。结果就是在请求里搞错了用户。
  5. 套接字会一直打开着。通常状况下NodeJS 会在一个空闲的套接字上应用两分钟的超时,但这个值能够覆盖,这将会泄露一个文件描述符。若是这种状况不断发生,程序会由于用光了全部的文件描述符而强退。即便不覆盖这个超时时间,客户端会挂两分钟直到 “hang-up” 错误的发生。这两分钟的延迟会让问题难于处理和调试。
  6. 不少内存引用会被遗留。这会致使泄露,进而致使内存耗尽,GC须要的时间增长,最后性能急剧降低。这点很是难调试,并且很须要技巧与致使形成泄露的失误联系起来。

最好的从失误恢复的方法是马上崩溃。你应该用一个restarter 来启动你的程序,在奔溃的时候自动重启。若是restarter 准备就绪,崩溃是失误来临时最快的恢复可靠服务的方法。

奔溃应用程序惟一的负面影响是相连的客户端临时被扰乱,可是记住:

  • 从定义上看,这些错误属于Bug。咱们并非在讨论正常的系统或是网络错误,而是程序里实际存在的Bug。它们应该在线上很罕见,而且是调试和修复的最高优先级。
  • 上面讨论的种种情形里,请求没有必要必定得成功完成。请求可能成功完成,可能让服务器再次崩溃,可能以某种明显的方式不正确的完成,或者以一种很难调试的方式错误的结束了。
  • 在一个完备的分布式系统里,客户端必须可以经过重连和重试来处理服务端的错误。无论 NodeJS 应用程序是否被容许崩溃,网络和系统的失败已是一个事实了。
  • 若是你的线上代码如此频繁地崩溃让链接断开变成了问题,那么正真的问题是你的服务器Bug太多了,而不是由于你选择出错就崩溃。

若是出现服务器常常崩溃致使客户端频繁掉线的问题,你应该把经历集中在形成服务器崩溃的Bug上,把它们变成可捕获的异常,而不是在代码明显有问题的状况下尽量地避免崩溃。调试这类问题最好的方法是,把 NodeJS 配置成出现未捕获异常时把内核文件打印出来。在 GNU/Linux 或者 基于 illumos 的系统上使用这些内核文件,你不只查看应用崩溃时的堆栈记录,还能够看到传递给函数的参数和其它的 JavaScript 对象,甚至是那些在闭包里引用的变量。即便没有配置 code dumps,你也能够用堆栈信息和日志来开始处理问题。

最后,记住程序员在服务器端的失误会形成客户端的操做失败,还有客户端必须处理好服务器端的奔溃和网络中断。这不仅是理论,而是实际发生在线上环境里。

编写函数的实践

咱们已经讨论了如何处理异常,那么当你在编写新的函数的时候,怎么才能向调用者传递错误呢?

最最重要的一点是为你的函数写好文档,包括它接受的参数(附上类型和其它约束),返回值,可能发生的错误,以及这些错误意味着什么。 若是你不知道会致使什么错误或者不了解错误的含义,那你的应用程序正常工做就是一个巧合。 因此,当你编写新的函数的时候,必定要告诉调用者可能发生哪些错误和错误的含义。

Throw, Callback 仍是 EventEmitter
函数有三种基本的传递错误的模式。

  • throw以同步的方式传递异常--也就是在函数被调用处的相同的上下文。若是调用者(或者调用者的调用者)用了try/catch,则异常能够捕获。若是全部的调用者都没有用,那么程序一般状况下会崩溃(异常也可能会被domains或者进程级的uncaughtException捕捉到,详见下文)。
  • Callback是最基础的异步传递事件的一种方式。用户传进来一个函数(callback),以后当某个异步操做完成后调用这个 callback。一般 callback 会以callback(err,result)的形式被调用,这种状况下, errresult必然有一个是非空的,取决于操做是成功仍是失败。
  • 更复杂的情形是,函数没有用 Callback 而是返回一个 EventEmitter 对象,调用者须要监听这个对象的 error事件。这种方式在两种状况下颇有用。
  • 当你在作一个可能会产生多个错误或多个结果的复杂操做的时候。好比,有一个请求一边从数据库取数据一边把数据发送回客户端,而不是等待全部的结果一块儿到达。在这个例子里,没有用 callback,而是返回了一个 EventEmitter,每一个结果会触发一个row 事件,当全部结果发送完毕后会触发end事件,出现错误时会触发一个error事件。

用在那些具备复杂状态机的对象上,这些对象每每伴随着大量的异步事件。例如,一个套接字是一个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 核心模块的常见函数为例,作了一个总结,大体按照每种问题出现的频率来排列:

函数 类型 错误 错误类型 传递方式 调用者
fs.stat 异步 file not found 操做失败 callback handle
JSON.parse 同步 bad user input 操做失败 throw try/catch
fs.stat 异步 null for filename 失误 throw none (crash)

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

吐槽:程序员失误仍是操做失败?

你怎么知道是程序员的失误仍是操做失败呢?很简单,你本身来定义而且记在文档里,包括容许什么类型的函数,怎样打断它的执行。若是你获得的异常不是文档里能接受的,那就是一个程序员失误。若是在文档里写明接受可是暂时处理不了的,那就是一个操做失败。

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

  • 在文档里写清楚只接受有效的IPV4的地址,当用户传进来“bob”的时候抛出一个异常。强烈推荐这种作法。
  • 在文档里写上接受任何string类型的参数。若是用户传的是“bob”,触发一个异步错误指明没法链接到“bob”这个IP地址。

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

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

那么 domainsprocess.on('uncaughtException') 呢?

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

编写新函数的具体建议

咱们已经谈论了不少指导原则,如今让咱们具体一些。

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

    • 调用者可能会遇到的操做失败(以及它们的name)
    • 怎么处理操做失败(例如是抛出,传给回调函数,仍是被 EventEmitter 发出)
    • 返回值
  2. 使用 Error 对象或它的子类,而且实现 Error 的协议。
    你的全部错误要么使用Error 类要么使用它的子类。你应该提供name和message属性,stack也是(注意准确)。

  3. 在程序里经过 Errorname属性区分不一样的错误。
    当你想要知道错误是何种类型的时候,用name属性。 JavaScript内置的供你重用的名字包括“RangeError”(参数超出有效范围)和“TypeError”(参数类型错误)。而HTTP异常,一般会用RFC指定的名字,好比“BadRequestError”或者“ServiceUnavailableError”。

  4. 不要想着给每一个东西都取一个新的名字。若是你能够只用一个简单的InvalidArgumentError,就不要分红 InvalidHostnameError,InvalidIpAddressError,InvalidDnsError等等,你要作的是经过增长属性来讲明那里出了问题(下面会讲到)。

  5. 用详细的属性来加强 Error 对象。
    举个例子,若是遇到无效参数,把 propertyName 设成参数的名字,把 propertyValue 设成传进来的值。若是没法连到服务器,用 remoteIp 属性指明尝试链接到的 IP。若是发生一个系统错误,在syscal 属性里设置是哪一个系统调用,并把错误代码放到errno属性里。具体你能够查看附录,看有哪些样例属性能够用。
    至少须要这些属性:

name:用于在程序里区分众多的错误类型(例如参数非法和链接失败)

message:一个供人类阅读的错误消息。对可能读到这条消息的人来讲这应该已经足够完整。若是你从更底层的地方传递了一个错误,你应该加上一些信息来讲明你在作什么。怎么包装异常请往下看。

stack:通常来说不要随意扰乱堆栈信息。甚至不要加强它。V8引擎只有在这个属性被读取的时候才会真的去运算,以此大幅提升处理异常时候的性能。若是你读完再去加强它,结果就会多付出代价,哪怕调用者并不须要堆栈信息。

你还应该在错误信息里提供足够的消息,这样调用者不用分析你的错误就能够新建本身的错误。它们可能会本地化这个错误信息,也可能想要把大量的错误汇集到一块儿,再或者用不一样的方式显示错误信息(好比在网页上的一个表格里,或者高亮显示用户错误输入的字段)。
6. 若果你传递一个底层的错误给调用者,考虑先包装一下。
常常会发现一个异步函数funcA调用另一个异步函数funcB,若是funcB抛出了一个错误,但愿funcA也抛出如出一辙的错误。(请注意,第二部分并不老是跟在第一部分以后。有的时候funcA会从新尝试。有的时候又但愿funcA忽略错误由于无事可作。但在这里,咱们只讨论funcA直接返回funcB错误的状况)

在这个例子里,能够考虑包装这个错误而不是直接返回它。包装的意思是继续抛出一个包含底层信息的新的异常,而且带上当前层的上下文。用 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.

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

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

  • 保持原有的异常完整不变,保证当调用者想要直接用的时候底层的异常还可用。
  • 要么用原有的名字,要么显示地选择一个更有意义的名字。例如,最底层是 NodeJS 报的一个简单的Error,但在步骤1中能够是个 IntializationError 。(可是若是程序能够经过其它的属性区分,不要以为有责任取一个新的名字)
  • 保留原错误的全部属性。在合适的状况下加强message属性(可是不要在原始的异常上修改)。浅拷贝其它的像是syscallerrno这类的属性。最好是直接拷贝除了 namemessagestack之外的全部属性,而不是硬编码等待拷贝的属性列表。不要理会stack,由于即便是读取它也是相对昂贵的。若是调用者想要一个合并后的堆栈,它应该遍历错误缘由并打印每个错误的堆栈。

在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 */
}

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

  • 参数,类型以及其它一些约束被清晰的文档化。
  • 这个函数对于接受的参数是很是严格的,而且会在获得错误参数的时候抛出异常(程序员的失误)。
  • 可能出现的操做失败集合被记录了。经过不一样的”name“值能够区分不一样的异常,而”errno“被用来得到系统错误的详细信息。
  • 异常被传递的方式也被记录了(经过失败时调用回调函数)。
  • 返回的错误有”remoteIp“和”remotePort“字段,这样用户就能够定义本身的错误了(好比,一个HTTP客户端的端口号是隐含的)。
  • 虽然很明显,可是链接失败后的状态也被清晰的记录了:全部被打开的套接字此时已经被关闭。

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

总结

  • 学习了怎么区分操做失败,即那些能够被预测的哪怕在正确的程序里也没法避免的错误(例如,没法链接到服务器);而程序的Bug则是程序员失误。
  • 操做失败能够被处理,也应当被处理。程序员的失误没法被处理或可靠地恢复(本不该该这么作),尝试这么作只会让问题更难调试。
  • 一个给定的函数,它处理异常的方式要么是同步(用throw方式)要么是异步的(用callback或者EventEmitter),不会二者兼具。用户能够在回调函数里处理错误,也可使用 try/catch捕获异常 ,可是不能一块儿用。实际上,使用throw而且指望调用者使用 try/catch 是很罕见的,由于 NodeJS里的同步函数一般不会产生运行失败(主要的例外是相似于JSON.parse的用户输入验证函数)。
  • 在写新函数的时候,用文档清楚地记录函数预期的参数,包括它们的类型、是否有其它约束(例如必须是有效的IP地址),可能会发生的合理的操做失败(例如没法解析主机名,链接服务器失败,全部的服务器端错误),错误是怎么传递给调用者的(同步,用throw,仍是异步,用 callbackEventEmitter)。
  • 缺乏参数或者参数无效是程序员的失误,一旦发生老是应该抛出异常。函数的做者认为的可接受的参数可能会有一个灰色地带,可是若是传递的是一个文档里写明接收的参数之外的东西,那就是一个程序员失误。
  • 传递错误的时候用标准的 Error 类和它标准的属性。尽量把额外的有用信息放在对应的属性里。若是有可能,用约定的属性名(以下)。

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

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

Property name Intended use
localHostname the local DNS hostname (e.g., that you're accepting connections at)
localIp the local IP address (e.g., that you're accepting connections at)
localPort the local TCP port (e.g., that you're accepting connections at)
remoteHostname the DNS hostname of some other service (e.g., that you tried to connect to)
remoteIp the IP address of some other service (e.g., that you tried to connect to)
remotePort the port of some other service (e.g., that you tried to connect to)
path the name of a file, directory, or Unix Domain Socket (e.g., that you tried to open)
srcpath the name of a path used as a source (e.g., for a rename or copy)
dstpath the name of a path used as a destination (e.g., for a rename or copy)
hostname a DNS hostname (e.g., that you tried to resolve)
ip an IP address (e.g., that you tried to reverse-resolve)
propertyName an object property name, or an argument name (e.g., for a validation error)
propertyValue an object property value (e.g., for a validation error)
syscall the name of a system call that failed
errno the symbolic value of errno (e.g., "ENOENT"). Do not use this for errors that don't actually set the C value of errno.Use "name" to distinguish between types of errors.

脚注

  1. 人们有的时候会这么写代码,他们想要在出现异步错误的时候调用callback 并把错误做为参数传递。他们错误地认为在本身的回调函数(传递给 doSomeAsynchronousOperation 的函数)里throw 一个异常,会被外面的catch代码块捕获。try/catch和异步函数不是这么工做的。回忆一下,异步函数的意义就在于被调用的时候myApiFunc函数已经返回了。这意味着try代码块已经退出了。这个回调函数是由Node直接调用的,外面并无try的代码块。若是你用这个反模式,结果就是抛出异常的时候,程序崩溃了。

  2. 在JavaScript里,抛出一个不属于Error的参数从技术上是可行的,可是应该被避免。这样的结果使得到调用堆栈没有可能,代码也没法检查name属性,或者其它任何可以说明哪里有问题的属性。

  3. 操做失败和程序员的失误这一律念早在NodeJS以前就已经存在存在了。不严格地对应者Java里的checked和unchecked异常,虽然操做失败被认为是没法避免的,好比 OutOfMemeoryError,被归为uncheked异常。在C语言里有对应的概念,普通异常处理和使用断言。维基百科上关于断言的的文章也有关于何时用断言何时用普通的错误处理的相似的解释。

  4. 若是这看起来很是具体,那是由于咱们在产品环境中遇到这样过这样的问题。这真的很可怕。


本文做者系OneAPM 工程师王龑 ,想阅读更多好的技术文章,请访问OneAPM官方技术博客

相关文章
相关标签/搜索