文档章节

NodeJS错误处理最佳实践(2)

J
 JasonWP
发布于 2015/04/16 20:25
字数 5728
阅读 14
收藏 0

编写函数的实践

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

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

Throw, Callback 还是 EventEmitter

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

  • throw以同步的方式传递异常--也就是在函数被调用处的相同的上下文。如果调用者(或者调用者的调用者)用了try/catch,则异常可以捕获。如果所有的调用者都没有用,那么程序通常情况下会崩溃(异常也可能会被domains或者进程级的uncaughtException捕捉到,详见下文)。

  • Callback 是最基础的异步传递事件的一种方式。用户传进来一个函数(callback),之后当某个异步操作完成后调用这个 callback。通常 callback 会以callback(err,result)的形式被调用,这种情况下, err和 result必然有一个是非空的,取决于操作是成功还是失败。

  • 更复杂的情形是,函数没有用 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 核心模块的常见函数为例,做了一个总结,大致按照每种问题出现的频率来排列:

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

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

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

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

  • 在文档里写清楚只接受有效的IPV4的地址,当用户传进来“bob”的时候抛出一个异常。强烈推荐这种做法。

  • 在文档里写上接受任何string类型的参数。如果用户传的是“bob”,触发一个异步错误指明无法连接到“bob”这个IP地址。

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

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

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

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

编写新函数的具体建议

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

  1. 你的函数做什么得很清楚。

这点非常重要。每个接口函数的文档都要很清晰的说明: - 预期参数 - 参数的类型 - 参数的额外约束(例如,必须是有效的IP地址)

如果其中有一点不正确或者缺少,那就是一个程序员的失误,你应该立刻抛出来。

此外,你还要记录:

  • 调用者可能会遇到的操作失败(以及它们的name

  • 怎么处理操作失败(例如是抛出,传给回调函数,还是被 EventEmitter 发出)

  • 返回值

  1. 使用 Error 对象或它的子类,并且实现 Error 的协议。

你的所有错误要么使用 Error 类要么使用它的子类。你应该提供namemessage属性,stack也是(注意准确)。

  1. 在程序里通过 Error 的 name 属性区分不同的错误。

当你想要知道错误是何种类型的时候,用name属性。 JavaScript内置的供你重用的名字包括“RangeError”(参数超出有效范围)和“TypeError”(参数类型错误)。而HTTP异常,通常会用RFC指定的名字,比如“BadRequestError”或者“ServiceUnavailableError”。

不要想着给每个东西都取一个新的名字。如果你可以只用一个简单的InvalidArgumentError,就不要分成 InvalidHostnameError,InvalidIpAddressError,InvalidDnsError等等,你要做的是通过增加属性来说明那里出了问题(下面会讲到)。

  1. 用详细的属性来增强 Error 对象。

举个例子,如果遇到无效参数,把 propertyName 设成参数的名字,把 propertyValue 设成传进来的值。如果无法连到服务器,用 remoteIp 属性指明尝试连接到的 IP。如果发生一个系统错误,在syscal 属性里设置是哪个系统调用,并把错误代码放到errno属性里。具体你可以查看附录,看有哪些样例属性可以用。

至少需要这些属性:

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

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

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

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

  1. 若果你传递一个底层的错误给调用者,考虑先包装一下。

经常会发现一个异步函数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端口。我们通过例子来看文档怎么写:

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

  • 参数,类型以及其它一些约束被清晰的文档化。

  • 这个函数对于接受的参数是非常严格的,并且会在得到错误参数的时候抛出异常(程序员的失误)。

  • 可能出现的操作失败集合被记录了。通过不同的”name“值可以区分不同的异常,而”errno“被用来获得系统错误的详细信息。

  • 异常被传递的方式也被记录了(通过失败时调用回调函数)。

  • 返回的错误有”remoteIp“和”remotePort“字段,这样用户就可以定义自己的错误了(比如,一个HTTP客户端的端口号是隐含的)。

  • 虽然很明显,但是连接失败后的状态也被清晰的记录了:所有被打开的套接字此时已经被关闭。

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

总结

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

  • 操作失败可以被处理,也应当被处理。程序员的失误无法被处理或可靠地恢复(本不应该这么做),尝试这么做只会让问题更难调试。

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

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

  • 缺少参数或者参数无效是程序员的失误,一旦发生总是应该抛出异常。函数的作者认为的可接受的参数可能会有一个灰色地带,但是如果传递的是一个文档里写明接收的参数以外的东西,那就是一个程序员失误。

  • 传递错误的时候用标准的 Error 类和它标准的属性。尽可能把额外的有用信息放在对应的属性里。如果有可能,用约定的属性名(如下)。

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

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

脚注

  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官方技术博客

© 著作权归作者所有

J
粉丝 0
博文 6
码字总数 16822
作品 0
海淀
私信 提问
Node.js 的后期诊断和调试

在你希望判断出你的 Node.js 应用在生产环境中发生了什么错误时,后期诊断和调试就显得尤为重要了。 这里我们探讨 node-report 这个核心项目,用来帮助我们进行后期诊断和调试。 Node.js At...

TanJx
2017/06/21
1K
2
如何选择正确的Node框架:Express,Koa还是Hapi?

简介 Node.js是10年前首次推出的,目前它已经成为世界上最大的开源项目,在GitHub上有+59,000颗星,下载次数超过10亿。流行度快速增长的部分原因是Node.js允许开发人员在应用程序的客户端和服...

一二三OTT
04/24
0
0
【全开源+免费更新】doodoo.js快速入门教程

简介 Doodoo.js -- 中文最佳实践Node.js快速开发框架。支持Koa.js, Express.js中间件,支持模块机制,插件机制,钩子机制,让开发 Node.js 项目更加简单、高效、灵活。 特性 支持koa全部中间...

doodooke
2018/12/17
0
0
如何在 2016 年成为一个更好的 Node.js 开发者

本文主要讨论一些进行Node.js开发的最佳实践和建议,这些建议不仅仅适合开发者, 还适合那些管理与维护Node.js基础架构的工作人员。遵循本文提供的这些建议, 能够让你更好的进行日常的开发工...

oschina
2016/01/20
6.8K
5
Node.js + ELK 日志规范

nodejs 日志规范 一般前端开发同学,对日志其实不太敏感,毕竟前端大多数情况下,不太关心日志。即使有,也可能调用一些第三方的统计,比如百度统计或者别的等。在 (下文中简称) 推进过程中,...

人人贷大前端技术中心
昨天
0
0

没有更多内容

加载失败,请刷新页面

加载更多

浅析大数据 学习大数据后能做什么

大数据时代的到来使得大数据开发人才迎来了前所未有的机遇和挑战!一个绝佳的入行机会摆在了众人面前!于是,很多人都在打听,大数据到底有何应用?可以用来做什么?好程序员今天就为大家作出...

好程序员IT
18分钟前
1
0
C# USB视频人脸检测

此程序基于 虹软人脸识别进行的开发 SDK下载地址:https://ai.arcsoft.com.cn/ucenter/user/reg?utm_source=csdn1&utm_medium=referral 前提条件 从虹软官网下载获取ArcFace引擎应用开发包,...

是哇兴哥棒棒哒
29分钟前
2
0
Vagrant虚拟机硬盘扩容

# 停止虚拟机vagrant halt <machine_name># 进入VirtualBox VMs目录,查看并记录原磁盘uuid,留作后用vboxmanage showhdinfo box-disk1.vmdk# 克隆磁盘,vmdk格式无法调整大小,需要...

sskill
30分钟前
1
0
分布式商业萌芽,银行迎来发展新机遇

01 分布式商业萌芽,银行迎来发展新机遇 金融界:近几年区块链的热度经历了过山车般的转折。目前追逐区块链的资本也开始冷静下来,于此同时,各大商业银行对区块链的研究应用也越来越多。您认...

Java领航员
36分钟前
2
0
Spring系列教程六: Spring jdbcTemplate在Dao中的使用

概念 Spring中的jdbcTemplate的主要作用是实现数据的交互,下面我们就在dao层中如何使用jdbctemplate写测试案例 项目目录如下 基于xml实现jdbctemplate 这里我们使用的是JdbcDaoSupport这个类...

我叫小糖主
40分钟前
2
0

没有更多内容

加载失败,请刷新页面

加载更多

返回顶部
顶部