NodeJS中的错误处理
原⽂地址:
这篇⽂章会回答NodeJS初学者的若⼲问题:
我写的函数⾥什么时候该抛出异常,什么时候该传给callback, 什么时候触发EventEmitter等等。
我的函数对参数该做出怎样的假设?我应该检查更加具体的约束么?例如参数是否⾮空,是否⼤于零,是不是看起来像个IP地址,等等等。
我该如何处理那些不符合预期的参数?我是应该抛出⼀个异常,还是把错误传递给⼀个callback。
我该怎么在程序⾥区分不同的异常(⽐如“请求错误”和“服务不可⽤”)?
我怎么才能提供⾜够的信息让调⽤者知晓错误细节。
我该怎么处理未预料的出错?我是应该⽤ try/catch ,domains 还是其它什么⽅式呢?
背景
在NodeJS中,存在三种传递错误的⽅式:通过throw作为异常抛出;将错误传递给⼀个callback,这个函数正是为了处理异常和处理异步操作返回结果的;最后⼀种⽅法是在EventEmitter上触发⼀个Error事件
在JS中,错我和异常是有区别的。错误时Error的⼀个实例,错误被创建并且直接传递给另⼀个函数或者被抛出。如果⼀个错误被抛出了那么它就编程了⼀个异常。
操作失败和程序员的失误
我们⼀般将错误分为两⼤类:操作失败和程序员错误
操作失败
parse error怎么解决操作失败是正确编写的程序在运⾏时产⽣的错误,它并不是程序的Bug,反⽽经常是其他问题:系统本⾝(内存不⾜或者打开⽂件数过多),系统配置(没有到达远程主机的路由),⽹络问题(端⼝挂起),远程服务(500错误,连接失败)。例⼦如下
* 连接不到服务器
* ⽆法解析主机名
* ⽆效的⽤户输⼊
* 请求超时
* 服务器返回500
* 套接字被挂起
* 系统内存不⾜
程序员失误
程序员失误是程序⾥的Bug,这些错误往往可以通过修改代码避免
读取undefined的⼀个属性
调⽤异步函数没有指定回调
传递参数格式不正确(该传对象的时候传了⼀个字符串,该传IP地址的时候传了⼀个对象)
⼈们把操作失败和程序员的失误都称为‘错误’,但其实它们很不⼀样。操作失败是所有正确的程序应该
处理的错误情况,只要能妥善处理,这并不是⼀个严重的问题。‘⽂件不到’是⼀个操作失败,但是它并不⼀定意味着哪⾥出错了,它可能只是代表着程序如果想⽤⼀个⽂件得事先创建它。
与之相反,程序员失误是彻彻底底的Bug,⽐如:忘记验证⽤户输⼊,敲错了变量等,这样的错误根本没法处理,如果可以,那就意味着⽤处理错误的代码代替了出错的代码
这样的区分很重要:操作失败是程序正常操作的⼀部分,⽽由程序员的失误则是bug
处理操作失败
就像性能和安全问题⼀样,错误处理并不是刻意凭空加到⼀个没有任何错误处理的程序中的。你没有办法在⼀个集中的地⽅处理所有的⼀场,就像你不能在⼀个集中的地⽅解决所有的性能问题。你需要考虑任何会导致失败的代码(⽐如打开⽂件、连接服务器、fork⼦进程等)可能产⽣的结果。包括为什么出错,错误背后的原因。关键在于错误处理的粒度要细,因为哪⾥出错和为什么出错决定了影响⼤⼩和对策。你可能会发现在栈的某⼏层不断地处理相同的错误。这是因为底层除了向上层传递错误,上层再向它的上层传递错误以外,底层没有做任何有意义的事情。通常,只有顶层的调⽤者知道正确的应对是什么,是重试操作,报告给⽤户还是其它。但是那并不意味着,你应该把所有的错误全都丢给顶层的回调函数。因为,顶层的回调函数不知道发⽣错误的上下⽂,不知道哪些操作已经成功执⾏,哪些操作实际上失败了。对于⼀个给定的错误,你可以做这些事情
直接处理:有的时候该做什么很清楚。如果你在尝试打开⽇志⽂件的时候得到了⼀个ENOENT错误,很有可能你是第⼀次打开这个⽂件,你要做的就是⾸先创建它。更有意思的例⼦是,你维护着到服务器(⽐如数据库)的持久连接,然后遇到了⼀个“socket hang-up”的异常。这通常意味着要么远端要么本地的⽹络失败了。很多时候这种错误是暂时的,所以⼤部分情况下你得重新连接来解决问题。(这和接下来的重试不⼤⼀样,因为在你得到这个错误的时候不⼀定有操作正在进⾏)
把错误扩散到客户端:如果你不知道怎么处理这个异常,最简单的⽅式就是放弃你正在执⾏的操作,清理所有开始的,然后把错误传递给客户端。(怎么传递异常是另外⼀回事了,接下来会讨论)。这种⽅式适合错误短时间内⽆法解决的情形。⽐如,⽤户提交了不正确的JSON,你再解析⼀次是没什么帮助的。
重试操作:对于那些来⾃⽹络和远程服务的错误,有的时候重试操作就可以解决问题。⽐如,远程服务返回了503(服务不可⽤错误),你可能会在⼏秒种后重试。如果确定要重试,你应该清晰的⽤⽂档记录下将会多次重试,重试多少次直到失败,以及两次重试的间隔。 另外,不要每次都假设需要重试。如果在栈中很深的地⽅(⽐如,被⼀个客户端调⽤,⽽那个客户端被另外⼀个由⽤户操作的客户端控制),这种情形下快速失败让客户端去重试会更好。如果栈中的每⼀层都觉得需要重试,⽤户最终会等待更长的时间,因为每⼀层都没有意识到下层同时也在尝试。
直接崩溃:对于那些本不可能发⽣的错误,或者由程序员失误导致的错误(⽐如⽆法连接到同⼀程序⾥的本地套接字),可以记录⼀个错误⽇志然后直接崩溃。其它的⽐如内存不⾜这种错误,是JavaScript这样的脚本语⾔⽆法处理的,崩溃是⼗分合理的。(即便如此,在
这样的分离的操作⾥,得到ENOMEM错误,或者那些你可以合理处理的错误时,你应该考虑这么做)。在你⽆计可施需要让管理员做修复的时候,你也可以直接崩溃。如果你⽤光了所有的⽂件描述符或者没有访问配置⽂件的权限,这种情况下你什么都做不了,只能等某个⽤户登录系统把东西修好。
记录错误,其他什么都不做:有的时候你什么都做不了,没有操作可以重试或者放弃,没有任何理由崩溃掉应⽤程序。举个例⼦吧,你⽤DNS跟踪了⼀组远程服务,结果有⼀个DNS失败了。除了记录⼀条⽇志并且继续使⽤剩下的服务以外,你什么都做不了。但是,你⾄少得记录点什么(凡事都有例外。如果这种情况每秒发⽣⼏千次,⽽你⼜没法处理,那每次发⽣都记录可能就不值得了,但是要周期性的记录)。
处理程序员失误
对于程序员的失误没有什么好做的。从定义上看,⼀段本该⼯作的代码坏掉了(⽐如变量名敲错),你不能⽤更多的代码再去修复它。⼀旦你这样做了,你就使⽤错误处理的代码代替了出错的代码。
有些⼈赞成从程序员的失误中恢复,也就是让当前的操作失败,但是继续处理请求。这种做法不推荐。考虑这样的情况:原始代码⾥有⼀个失误是没考虑到某种特殊情况。你怎么确定这个问题不会影响其他请求呢?如果其它的请求共享了某个状态(服务器,套接字,数据库连接池等),有极⼤的可能其他请求会不正常。
典型的例⼦是REST服务器(⽐如⽤Restify搭的),如果有⼀个请求处理函数抛出了⼀个ReferenceError(⽐如,变量名打错)。继续运⾏下去很有肯能会导致严重的Bug,⽽且极其难发现。例如:
⼀些请求间共享的状态可能会被变成null,undefined或者其它⽆效值,结果就是下⼀个请求也失败了。
数据库(或其它)连接可能会被泄露,降低了能够并⾏处理的请求数量。最后只剩下⼏个可⽤连接会很坏,将导致请求由并⾏变成串⾏被处理。
更糟的是, postgres 连接会被留在打开的请求事务⾥。这会导致 postgres “持有”表中某⼀⾏的旧值,因为它对这个事务可见。这个问题会存在好⼏周,造成表⽆限制的增长,后续的请求全都被拖慢了,从⼏毫秒到⼏分钟[脚注4]。虽然这个问题和 postgres 紧密相关,但是它很好的说明了程序员⼀个简单的失误会让应⽤程序陷⼊⼀种⾮常可怕的状态。
连接会停留在已认证的状态,并且被后续的连接使⽤。结果就是在请求⾥搞错了⽤户。
套接字会⼀直打开着。⼀般情况下 NodeJS 会在⼀个空闲的套接字上应⽤两分钟的超时,但这个值可以覆盖,这将会泄露⼀个⽂件描述符。如果这种情况不断发⽣,程序会因为⽤光了所有的⽂件描述符⽽强退。即使不覆盖这个超时时间,客户端会挂两分钟直到“hang-up” 错误的发⽣。这两分钟的延迟会让问题难于处理和调试。
很多内存引⽤会被遗留。这会导致泄露,进⽽导致内存耗尽,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)的形式被调⽤,这种情况下, 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)其中⼀种处理异常的准则并没有因为这条意见⽽改变。如果你想知道更多,请见上⾯的 (不要)处理程序员的失误。
总结
学习了怎么区分操作失败,即那些可以被预测的哪怕在正确的程序⾥也⽆法避免的错误(例如,⽆法连接到服务器);⽽程序的Bug则是程序员失误。
操作失败可以被处理,也应当被处理。程序员的失误⽆法被处理或可靠地恢复(本不应该这么做),尝试这么做只会让问题更难调试。
⼀个给定的函数,它处理异常的⽅式要么是同步(⽤throw⽅式)要么是异步的(⽤callback或者EventEmitter),不会两者兼具。⽤户可以在回调函数⾥处理错误,也可以使⽤ try/catch捕获异常 ,但是不能⼀起⽤。实际上,使⽤throw并且期望调⽤者使⽤
try/catch 是很罕见的,因为 NodeJS ⾥的同步函数通常不会产⽣运⾏失败(主要的例外是类似于JSON.parse的⽤户输⼊验证函数)。
在写新函数的时候,⽤⽂档清楚地记录函数预期的参数,包括它们的类型、是否有其它约束(例如必须是有效的IP地址),可能会发⽣的合理的操作失败(例如⽆法解析主机名,连接服务器失败,所有的服务器端错误),错误是怎么传递给调⽤者的(同步,⽤throw,还是异步,⽤ callback 和 EventEmitter)。
缺少参数或者参数⽆效是程序员的失误,⼀旦发⽣总是应该抛出异常。函数的作者认为的可接受的参数可能会有⼀个灰⾊地带,但是如果传递的是⼀个⽂档⾥写明接收的参数以外的东西,那就是⼀个程序员失误。
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论