Node.js 异步异常的处理与domain模块解析
发布于 4 年前 作者 dead-horse 34921 次浏览 最后一次编辑是 3 个月前

异步异常处理

异步异常的特点

由于node的回调异步特性,无法通过try catch来捕捉所有的异常:

try {
  process.nextTick(function () {
  	foo.bar();
  });
} catch (err) {
  //can not catch it
}

而对于web服务而言,其实是非常希望这样的:

//express风格的路由
app.get('/index', function (req, res) {
  try {
    //业务逻辑
  } catch (err) {
    logger.error(err);
    res.statusCode = 500;
    return res.json({success: false, message: '服务器异常'});
  }
});

如果try catch能够捕获所有的异常,这样我们可以在代码出现一些非预期的错误时,能够记录下错误的同时,友好的给调用者返回一个500错误。可惜,try catch无法捕获异步中的异常。所以我们能做的只能是:

app.get('/index', function (req, res) {
  // 业务逻辑  
});

process.on('uncaughtException', function (err) {
  logger.error(err);
});

这个时候,虽然我们可以记录下这个错误的日志,且进程也不会异常退出,但是我们是没有办法对发现错误的请求友好返回的,只能够让它超时返回。

domain

在node v0.8+版本的时候,发布了一个模块domain。这个模块做的就是try catch所无法做到的:捕捉异步回调中出现的异常。
于是乎,我们上面那个无奈的例子好像有了解决的方案:

var domain = require('domain');

//引入一个domain的中间件,将每一个请求都包裹在一个独立的domain中
//domain来处理异常
app.use(function (req,res, next) {
  var d = domain.create();
  //监听domain的错误事件
  d.on('error', function (err) {
    logger.error(err);
    res.statusCode = 500;
    res.json({sucess:false, messag: '服务器异常'});
    d.dispose();
  });
  
  d.add(req);
  d.add(res);
  d.run(next);
});

app.get('/index', function (req, res) {
  //处理业务
});

我们通过中间件的形式,引入domain来处理异步中的异常。当然,domain虽然捕捉到了异常,但是还是由于异常而导致的堆栈丢失会导致内存泄漏,所以出现这种情况的时候还是需要重启这个进程的,有兴趣的同学可以去看看domain-middleware这个domain中间件。

诡异的失效

我们的测试一切正常,当正式在生产环境中使用的时候,发现domain突然失效了!它竟然没有捕获到异步中的异常,最终导致进程异常退出。经过一番排查,最后发现是由于引入了redis来存放session导致的。

var http = require('http');
var connect = require('connect');
var RedisStore = require('connect-redis')(connect);
var domainMiddleware = require('domain-middleware');

var server = http.createServer();
var app = connect();
app.use(connect.session({
  key: 'key',
  secret: 'secret',
  store: new RedisStore(6379, 'localhost')
}));
//domainMiddleware的使用可以看前面的链接
app.use(domainMiddleware({
  server: server,
  killTimeout: 30000
}));

此时,当我们的业务逻辑代码中出现了异常,发现竟然没有被domain捕获!经过一番尝试,终于将问题定位到了:

var domain = require('domain');
var redis = require('redis');
var cache = redis.createClient(6379, 'localhost');

function error() {
  cache.get('a', function () {
    throw new Error('something wrong');
  });
}

function ok () {
  setTimeout(function () {
    throw new Error('something wrong');
  }, 100);
}
var d = domain.create();
d.on('error', function (err) {
  console.log(err);
});

d.run(ok);    //domain捕获到异常
d.run(error); //异常被抛出

奇怪了!都是异步调用,为什么前者被捕获,后者却没办法捕获到呢?

Domain剖析

回过头来,我们来看看domain做了些什么来让我们捕获异步的请求(代码来自node v0.10.4,此部分可能正在快速变更优化)。如果对domain还不甚了解的同学可以先简单过一下domain的文档

node事件循环机制

在看Domain的原理之前,我们先要了解一下nextTick_tickCallback两个方法

function laterCall() {
  console.log('print me later');
}

process.nextTick(laterCallback);
console.log('print me first');

上面这段代码写过node的人都很熟悉,nextTick的作用就是把laterCallback放到下一个事件循环去执行。而_tickCallback方法则是一个非公开的方法,这个方法是在当前时间循环结束之后,调用之以继续进行下一个事件循环的入口函数。

换而言之,node为事件循环维持了一个队列,nextTick入队,_tickCallback出列。

domain的实现

在了解了node的事件循环机制之后,我们再来看看domain做了些什么。
domain自身其实是一个EventEmitter对象,它通过事件的方式来传递捕获的错误。这样我们在研究它的时候,就简化到两个点:

  • 什么时候触发domain的error事件:

    • 进程抛出了异常,没有被任何的try catch捕获到,这时候将会触发整个process的processFatal,此时如果在domain包裹之中,将会在domain上触发error事件,反之,将会在process上触发uncaughtException事件。
  • domain如何在多个不同的事件循环中传递:

    1. 当domain被实例化之后,我们通常会调用它的run方法(如之前在web服务中的使用),来将某个函数在这个domain示例的包裹中执行。被包裹的函数在执行的时候,process.domain这个全局变量将会被指向这个domain实例。当这个事件循环中,抛出异常调用processFatal的时候,发现process.domain存在,就会在domain上触发error事件。
    2. 在require引入domain模块之后,会重写全局的nextTick_tickCallback,注入一些domain相关的代码
    //简化后的domain传递部分代码
    function nextDomainTick(callback) {
      nextTickQueue.push({callback: callback, domain: process.domain});
    }
    
    function _tickDomainCallback() {
      var tock = nextTickQueue.pop();
      //设置process.domain = tock.domain
      tock.domain && tock.domain.enter();
      callback();
      //清除process.domain
      tock.domain && tock.domain.exit();        
      }
    };
    

    这个是其在多个事件循环中传递domain的关键:nextTick入队的时候,记录下当前的domain,当这个被加入队列中的事件循环被_tickCallback启动执行的时候,将新的事件循环的process.domain置为之前记录的domain。这样,在被domain所包裹的代码中,不管如何调用process.nextTick, domain将会一直被传递下去。

    1. 当然,node的异步还有两种情况,一种是event形式。因此在EventEmitter构造函数有如下代码:
      if (exports.usingDomains) {
        // if there is an active domain, then attach to it.
        domain = domain || require('domain');
        if (domain.active && !(this instanceof domain.Domain)) {
          this.domain = domain.active;
        }
      }
    

    实例化EventEmitter的时候,将会把这个对象和当前的domain绑定,当通过emit触发这个对象上的事件时,像_tickCallback执行的时候一样,回调函数将会重新被当前的domain包裹住。

    1. 而另一种情况,是setTimeoutsetInterval,同样的,在timer的源码中,我们也可以发现这样的一句代码:
     if (process.domain) timer.domain = process.domain;
    

    EventEmmiter一样,之后这些timer的回调函数也将被当前的domain包裹住了。

node通过在nextTick, timer, event三个关键的地方插入domain的代码,让它们得以在不同的事件循环中传递。

更复杂的domain

有些情况下,我们可能会遇到需要更加复杂的domain使用。

  • domain嵌套:我们可能会外层有domain的情况下,内层还有其他的domain,使用情景可以在文档中找到
// create a top-level domain for the server
var serverDomain = domain.create();

serverDomain.run(function() {
  // server is created in the scope of serverDomain
  http.createServer(function(req, res) {
    // req and res are also created in the scope of serverDomain
    // however, we'd prefer to have a separate domain for each request.
    // create it first thing, and add req and res to it.
    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) {
        console.error('Error sending 500', er, req.url);
      }
    });
  }).listen(1337);
});

为了实现这个功能,其实domain还会偷偷的自己维持一个domain的stack,有兴趣的童鞋可以在这里看到。

回头解决疑惑

回过头来,我们再来看刚才遇到的问题:为什么两个看上去都是同样的异步调用,却有一个domain无法捕获到异常?理解了原理之后不难想到,肯定是调用了redis的那个异步调用在抛出错误的这个事件循环内,是不在domain的范围之内的。我们通过一段更加简短的代码来看看,到底在哪里出的问题。

var domain = require('domain');
var EventEmitter = require('events').EventEmitter;

var e = new EventEmitter();

var timer = setTimeout(function () {
  e.emit('data');  
}, 10);

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);

此时我们同样发现,错误不会被domain捕捉到,原因很清晰了:timere两个关键的对象在初始化的时候都时没有在domain的范围之内,因此,当在next函数中监听的事件被触发,执行抛出异常的回调函数时,其实根本就没有处于domain的包裹中,当然就不会被domain捕获到异常了!

其实node针对这种情况,专门设计了一个API:domain.add。它可以将domain之外的timerevent对象,添加到当前domain中去。对于上面那个例子:

d.add(timer);
//or
d.add(e);

timer或者e任意一个对象添加到domain上,就可以让错误被domain捕获了。

再来看最开始redis导致domain无法捕捉到异常的问题。我们是不是也有办法可以解决呢?
@python发烧友](http://weibo.com/81715239) 的这条微博我们就能理解,其实对于这种情况,还是没有办法实现最佳的解决方案的。现在对于非预期的异常产生的时候,我们只能够让当前请求超时,然后让这个进程停止服务,之后重新启动。graceful模块配合cluster就可以实现这个解决方案。

__domain十分强大,但不是万能的。__希望在看过这篇文章之后,大家能够正确的使用domian,避免踩坑。:)


题外推荐

在整个问题排查与代码研究过程中,有一个工具起了巨大的作用:node-inspector,它可以让node代码在chrome下进行单步调试,能够跟进到node源码之中,@goddyzhao的文章使用node-inspector来调试node详细介绍了如何使用node-inspector

31 回复

好文,支持,收藏了。

非常精彩的文章,domain运行机制剖析很明了。domain很美好,但要成长为一个成熟稳定的node模块,还有一段很长的路呢。

好东西 !~~~~~~~~~~~~

您好,DeNA在招聘资深Node.js的职位,您有兴趣了解一下吗?

可不可以这么说 目前的domain配合最后的退出进程再重启是现行最好的方案?

domain 不能捕获全部异常,还得配合 process.on("uncaughtException") 和 优雅退出。

学习了,支持好文章

收藏了。。

我郁闷呀 ,我怎么发布不了话题啊 我有问题 没办法问啊

有没有交流nodejs的QQ群哦

去 nodeclub 的 github 上描述一下问题,提个issue

@suqian 我很认同你说的话,process.on("uncaughtException")在servers中可以进行整个错误的抓包处理,domain我觉得楼主给的方案还不是很成熟,另外在写代码的时候应封装好,对错误做回调处理,如: var login = function(fn) { var results = 1 + 2; return fn(err,results);} 调用代码:

login(function(err, cookie) {});

之前nodejs官网并未发布异步回调所产生错误的解决方案我想应该是本身nodejs语言所致,所以我觉得能利用本身的语言特点去解决才最好

@ym1623 所有的异步调用的异常,都是通过回调函数的第一个参数返回的。我们需要domain并不是因为想要去代替这种形式。尽管domain的api里面有这样的用法:

var d = domain.create();

function readSomeFile(filename, cb) {
  fs.readFile(filename, 'utf8', d.intercept(function(data) {
    // note, the first argument is never passed to the
    // callback since it is assumed to be the 'Error' argument
    // and thus intercepted by the domain.

    // if this throws, it will also be passed to the domain
    // so the error-handling logic can be moved to the 'error'
    // event on the domain instead of being repeated throughout
    // the program.
    return cb(null, JSON.parse(data));
  }));
}

d.on('error', function(er) {
  // an error occurred somewhere.
  // if we throw it now, it will crash the program
  // with the normal line number and stack message.
});

这可能是node尝试通过domain来统一处理嵌套异步回调中的异常。 但是由于文章中所描述的domain的一些问题,这个暂时还不是很好用。不过异步流程工具已经很多了。只要按照约定,把错误放在callback的第一个参数返回。可以有很多种工具来优化代码。

我们使用domain的原因,只是为了捕获那些意料之外的错误,例如:

if (!data.length) {
  //do something
}

类似这种不小心出现的错误,如果真的在线上出现了,我们想要把这个错误记录下来以便之后的修改,同时可以给前端一个友好的500响应,而不是只能超时返回(因为用uncaughtException捕捉到的异常,我们也没有办法知道这个异常是在哪个请求出现的)。

@dead_horse 我一般都是这样做的:process.on(‘uncaughtException’, console.dir);直接把错误信息打印在控制台中或者写在日志中

@ym1623 嗯, 为什么我们想要用domain替代这种方案的原因在node文档中提到了。

Note that uncaughtException is a very crude mechanism for exception handling and may be removed in the future. Don’t use it, use domains instead. If you do use it, restart your application after every unhandled exception!

所以我们最终在domain有缺陷的情况下,只能使用graceful的这个并不完美的解决方案。

@dead_horse 我觉得还是写测试代码好点,在把项目推上去的时候先运行npm test一次,保证代码质量不出错,node服务抓错防止服务不挂掉也是不错的,但是现在没有一个成熟的方案出来,先写个process.on(‘uncaughtException’, console.dir)用着也可以.

@ym1623 那肯定是要自己代码保证不出错啊。domain和uncaoughtException都只是预防意外而已。:)

@dead_horse 我之前无意间用forever watch一个文件作为项目目录时,在出错的时候我将错误log指定到该项目中,这样的话项目一报错的话就会在在该项目下的错误log中写入错误日志,而forever watch此时就起作用了,以为项目有改动自动重启,不知道这个算不算一个取巧.

node-inspector貌似现在已经无法使用了,没人维护了

node@v0.8.x下应该还是能用的吧?

这几天也在研究domain,其实lz提到使用add()的例子,改成如下即可: var e = new events.EventEmitter();

e.on(‘data’, function(err) { if(err) throw err; });

var d = domain.create();

d.on(‘error’, function(err) { console.error(‘Error caught by domain:’, err); });

d.run(function() { e.emit(‘data’, new Error(‘Handle data error!’)); });

这也符合一般人写代码的习惯,先创建EventEmitter,绑定事件然后再在其他地方emit即可。 为啥要在run里头绑定事件? 看下events模块的代码,它会在事件触发的时候检查当前活动的domain的: https://github.com/joyent/node/blob/v0.10.4/lib/events.js#L53, 蛋是在绑定事件的时候却未做任何的检查。

最近在研究node中的详细错误记录处理(记录stack信息),在官网上找到了process的uncaughtException,但是这个不怎么友好,于是在研究domain,之后看到楼主的这篇好文章,顶 :)

对于最后那个示例代码,我将

var d = domain.create();
d.on('error', function () {
  console.log('cache by domain');
});

d.run(next);

这段放在 new EventEmitter上方执行也是可以捕获的

回到顶部