记得刚刚开始学Node.js时自己尝试着写了一个简单的http服务器,跟以前接触过的php相比感觉更自由,编起码来也更爽了。但是某天发现稍微一个很小的错误就导致整个http进程挂掉了,顿时有种不靠谱的感觉啊,跟php比起来感觉Node.js容错能力确实弱了很多,起码一个php文件出错也不会导致所有的服务都挂掉。
后来接触到Node.js web开发框架后感觉也不是那么轻易就让整个进程都挂掉的,于是便想研究下Node.js究竟是如何来处理各种异常从而避免整个进程挂掉的。
当我们的程序运行在Node.js进程里不小心抛出一个异常时便会触发process对象的_fatalException方法,并将异常对象err传进去,_fatalException方法主要做以下一些处理:
当process对象上有绑定domain时便调用domain对象的_errorHandler方法来处理,
if (process.domain && process.domain._errorHandler) caught = process.domain._errorHandler(er)
_errorHandler会返回一个布尔值来通知当前程序domain是否有对该异常进行处理,如果domain没有做处理,此时process对象便会触发一个绑定到process上的uncaughtException事件来处理该异常,并且同样会返回一个布尔值来通知当前程序是否有对异常进行处理。
if (!caught) caught = process.emit('uncaughtException', er);
走到这个地步时如果异常还没被正常的处理那么此时process就有点不高兴了,既然你们都不处理那我就准备让你们全部挂掉吧!(确实太狠了点啊),这个时候悲剧即将发生。。。
if (!caught) { try { if (!process._exiting) { process._exiting = true; process.emit('exit', 1); } } catch (er) { } }
如果异常都被妥妥的处理掉了那么Node.js进程便会处理当前事件的收尾的工作,比如调用process.nextTick传进去的回调函数在这个时候就准备被调用了,然后继续执行事件队列里的下一个事件
t = setImmediate(process._tickCallback)
总结下来Node.js中异常处理流程大概就是这样的:
这整个过程中有个很重要的处理环节没有加上去,那就是上面提到的domain对象。
首先简单介绍下domain对象的使用场景以及基本使用方法:
当我们开启一个Node.js的http服务器时不可避免的会出现各种我们没有预期到的异常,并且我们预先写好的try catch也无法捕捉。这时最关键的是如何保证整个服务进程不会挂掉,并且能够很友好的反馈给浏览器端的用户。尽管process对象提供了一个uncaughtException事件方法让我们可以处理异常并且保证当前的服务进程不会挂掉,但由于丢失了当前的上下文,说得直接点就是丢失了response对象很难向用户及时并且友好的输出错误提示,此时便陷入了用户会一直傻傻的等待服务器超时(早就关闭网站了)的尴尬场景。
有了domain模块我们便可以很方便的处理上面描述的场景了,刚刚开始接触domain这个模块时真不知道是个啥东西,名字都叫的怪怪的。后来去翻了先官网上有关domain的文档才知道这货到底有啥作用,我们就依照官网的示例来说明domain如何处理上述场景:
http.createServer(function(req, res) { var reqd = domain.create(); reqd.add(req); reqd.add(res); reqd.on('error', function(er) { console.error('Error', er, req.url); try { res.writeHead(500); res.end('Error occurred, sorry.'); } catch (er) { res.end('Error sending 500', er.message, req.url); } });
当res对象调用各种方法产生异常时,之前创建好的domain对象reqd便会收到通知,从而触发我们预先设置好的处理方法来即使并且友好的输出给用户,避免超时这种糟糕的用户体验!对于domain对象其他的方法大家可以直接翻看Node.js官网文档的介绍,我这里就不啰嗦了~
下面我们着重的来研究下domain对象为何如此神奇?
当我们require('domain')对象时便对event模块的EventEmitter对象产生了影响
EventEmitter.usingDomains = true;
紧跟着对process的domain属性进行了覆盖
Object.defineProperty(process, 'domain', { enumerable: true, get: function() { return _domain[0]; }, set: function(arg) { return _domain[0] = arg; } });
domain模块本身维护着一个存放domain对象的数组 _domain,再接着就是告诉process对象要使用到domain了
process._setupDomainUse(_domain, _domain_flag);
调用这个方法后影响到的地方可不少,之前我们说过Node.js每个事件都会调用一下_tickCallback来处理之前调用process.nextTick保存到事件队列里的回调函数,现在Node.js不调用了这个了,换成了调用_tickDomainCallback方法来代替_tickCallback。继续我们的domain模块,当创建一个新的domain对象时便初始化了的它的members属性来存放该domain要守护的对象,对照着上述的代码
var reqd = domain.create();
此时reqd.members=[], 于是我们调用add方法将req已经res对象都添加到domain中,由domain来帮他们处理各种错误。
reqd.add(req); reqd.add(res);
接着告诉domain当req或者res操作出异常时应该如何处理
reqd.on('error', function(er) { console.error('Error', er, req.url); try { res.writeHead(500); res.end('Error occurred, sorry.'); } catch (er) { console.error('Error sending 500', er, req.url); } });
其实就上面那样还是没法捕获到异常,甚至都无法响应,因为我们还没调用res.write或者res.end方法来向用户输出内容,就算我们加上
reqd.add(req); reqd.add(res); res.test('end');
依然无法像我们预期想象的那样进入异常处理回调方法里,别忘了将可能发生异常的代码放入domain.run中来执行,就像这样的:
var domain=require('domain'), http=require('http'); http.createServer(function(req, res) { var reqd = domain.create(); reqd.add(req); reqd.add(res); reqd.on('error', function(er) { console.error('Error', er, req.url); try { res.writeHead(500); res.end('Error occurred, sorry.'+ er.message); } catch (er) { console.error('Error sending 500', er, req.url); } }); reqd.run(function(){ res.test(); res.end('end'); }); }).listen(1337);
此时一切都已就绪,万事俱备只欠东风了,就等着各种异常来临了。ok, 此时由于res的某个操作(比如调用不存在的test方法)导致了一个异常的产生。根据最开始描述的处理流程,这个异常会被Node.js进程传到process._fatalException中进行处理,如果process上绑定有domain对象则会调用domain的_errorHandler方法来处理异常,那_errorHandler究竟如火如荼处理异常的呢?在讨论这个问题之前我们先回到上面的reqd.run方法中。调用domain对象的run方法时会先进入enter里做如下处理:
exports.active = process.domain = this; stack.push(this); _domain_flag[0] = stack.length;
将当期的domain对象设置成active并且绑定到process上,stack是一个保存domain对象的堆栈,用于domain嵌套使用的情况,其中_domain_flag是一个用于js与c++进行通信的对象。紧接着再执行我们的业务代码比如res.test()操作,此时便抛出了一个方法不存在的异常。由于进入enter方法后我们把当前domain对象绑定到了process上,所以异常就交给domain的_errorHandler方法来处理了,回到之前的问题,_errorHandler是如何处理异常的?
首先尝试着让之前绑定到domain上的error事件回调函数来处理该异常并清空当前process的domain属性,之所以所尝试是因为回调函数里可能又会抛出新的异常,当然了理想情况就是回调函数能够很好的处理掉异常并且不抛出新的异常,此时整个异常处理流程完美结束。如果有新的异常抛出,先将对stack堆栈进行出栈操作剔除已经使用过的当期domain对象,然后再看看栈里边是否还存在domain对象,有的话就用栈订上的domain又回到process._fatalException里继续处理刚刚回调函数抛出的新异常。stack为空的话此时已经没有domain对象可以来处理异常,至次本次异常处理以失败结束然后继续交给最开始讲到的uncaughtException事件来处理。当然了调用domain.run时并没有抛出异常,那么domain也需要进行出栈操作,来抵消enter方法时的入栈操作以保持stack堆栈的平衡。
其实上面的reqd.add(res)和reqd.add(req)是可以不要的,为什么可以不要呢?在什么情况下需要什么情况下又不需要?ok,我们再深入研究一下domain.add是如何工作的。官网中文档有介绍domain.add接收emitter类型的参数,也就是EventEmitter | Timer emitter or timer。为什么要这样呢,看下面的一段代码
var EventEmitter = require('events').EventEmitter; var e = new EventEmitter(); var timer = setTimeout(function () { e.emit('data'); }, 1000); function next() { e.once('data', function () { throw new Error('something wrong here'); }); } var d = domain.create(); d.on('error', function () { console.log('cache by domain'); }); d.run(next);
此时next函数里边绑定到e对象上的data事件被触发时domain对象是无法处理的,原因很明显,data回调函数的运行已经处理domain.run方法之外。那我就要这个domain来处理错误怎么办呢,此时domain.add方法就派上用场了,我们只需要简单的调用一下d.add(e)或者d.add(timer)就可以解决这个问题。domain.add方法为什么可以解决又是如何解决的呢?继续往下看。
当调用domain.add(e)时,如果上绑定有domain先移除再绑定新的domain,并将e对象加入新domain的members中,从而保持着对e对象的引用。不管是timer对象还是event对象在触发回调函数时都会先判断是否有绑定domain对象
if (this.domain && this !== process) this.domain.enter(); callback(); if (this.domain && this !== process) this.domain.exit();
这些操作和domain.run方法相似,先执行enter将domain对象绑定到process上,然后再执行回调当有异常发生时process会将异常传到domain上处理,最后再调用exit方法将该domain移出stack堆栈。所以上面的代码中必须得调用下d.add(e)或者d.add(timer)才会让domain对象捕获到回调中的异常。
整个Node.js异常处理就讲到这里了,其实在process._fatalException方法中调用domain来处理异常之前还进行了一个异常处理操作
var caught = _errorHandler(er);
这个处理主要涉及到Node.js的异步队列AsyncQueue在这里暂不做讨论,以后再做进一步的研究,文章有点长感谢能坚持看到结尾的同学们,不要吝啬你们的赞哦~
该文章来自于阿里巴巴技术协会(ATA)
作者:淘杰