在NodeJS中使用domain模块捕获异步异常

背景

最近,线上有一个重要服务发生了宕机,导致上课中的教师大面积的断线。我们通过错误日志,查到一个 socket hang up 报错。但是无法根据错误定位到异常的代码。通过查询访问日志,也无法定位到宕机前发生错误的接口。我们期望能在代码里对异常进行捕获,这样不会导致服务挂掉,还希望获取异常的上下文,以便定位错误。

events.js:183
      throw er; // Unhandled 'error' event
      ^

Error: socket hang up
    at TLSSocket.onHangUp (_tls_wrap.js:1137:19)
    at Object.onceWrapper (events.js:313:30)
    at emitNone (events.js:111:20)
    at TLSSocket.emit (events.js:208:7)
    at endReadableNT (_stream_readable.js:1064:12)
    at _combinedTickCallback (internal/process/next_tick.js:138:11)
    at process._tickCallback (internal/process/next_tick.js:180:9)
npm ERR! code ELIFECYCLE
npm ERR! errno 1
npm ERR! lesson@1.0.0 start: `node ./app/bin/www;`
npm ERR! Exit status 1
npm ERR!
npm ERR! Failed at the lesson@1.0.0 start script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.

npm ERR! A complete log of this run can be found in:
npm ERR!     /root/.npm/_logs/2018-09-30T08_22_32_703Z-debug.log

使用uncaughtException捕获异步异常

首先,我们想到了 process上的 uncaughtException 事件。只要给 process 的uncaughtException事件注册了回调,服务就不会异常退出。看起来十分美好,起码我们的服务不会挂掉了。但是,带来了其他问题。

uncaughtException 事件

uncaughtException,是NodeJS进程(Process)中的一个事件。如果进程中发生了一个异常且异常没有被任何try...catch进行捕获就会触发这个事件。

下面以同步异常为例,介绍一下NodeJS对异常的默认处理。

process.on('uncaughtException', (error) => {
  console.log('call uncaughtException handle');
});

// 执行一个不存在的异常
nonexistentFunc();

首先,我们注册process的uncaughtException事件,然后执行了一个不存在的函数。当执行这段程序时,程序会因为函数nonexistentFunc不存在,而抛出异常。由于没有进行try...catch...处理,异常将会冒泡直到事件循环为止。NodeJS对于异常的默认处理,类似代码如下:

function _MyFatalException(err){
    if(!process.emit('uncaughtException',err)){
        console.error(err.stack);
        process.emit('exit',1);
    }
}

NodeJS对于异常的默认处理的顺序是,首先触发 uncaughtException事件,若事件没有被监听到,则打印堆栈的错误信息,最后调用进程的退出事件。若事件监听到,则处理uncaughtException的注册回调。

uncaughtException 事件发生的条件

  1. 当程序发生了异常
  2. 且异常未被 try...catch捕获

缺点与问题

  1. 无法获取异常的上下文。
  2. 无法给出友好的异常处理。例如,当接口发生 uncaughtException 时,无法获取到 response对象(已经丢失了上下文),告知调用方服务当前出现异常。
  3. 会导致内存泄露。uncaughtException事件发生后,会丢失当前环境的堆栈,可能导致Node不能正常进行内存回收,从而导致内存泄露。

由于使用 uncaughtException捕获异常,会导致内存泄露。建议的使用方式为,当 uncaughtException事件发生时,记录error,然后结束 Node 进程进行重启服务。(😭然后我们在项目中并没有这样做。。。业务不允许重启。。)

使用domain模块捕获异步异常

注意:domain模块将被弃用。

domain 模块,简化了异常的处理方式,可以处理try...catch..无法捕获的异常。且不会丢失上下文,也不会导致程序退出。

我们老项目使用的是 express 框架,因此可以使用中间件的方式,处理请求中的异步异常。代码如下:

const domain = require('domain');

app.use((req, res, next) => {
  const req_domain = domain.create();

  req_domain.on('error', (err) => {
    console.log(err);  // 打印错误日志
    res.send(500, err.stack);
  });

  req_domain.run(next);
});

在中间件中,首先创建一个 domain对象,然后注册 error 事件的回调,最后在创建域的上下文中执行next函数。

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

如果想要了解更多关于 domain 模块,可以阅读这篇文章——《Node.js 异步异常的处理与domain模块解析》

优化方案

domain虽然很好用,但不是万能的。有时,也无法捕获部分异常。为了保证服务不会挂掉。我可以使用 uncaughtException事件进行兜底。

基础版

domain + uncaughtException

domain 来捕获大部分异常和合理处理异常退出,uncaughtException 来避免服务挂掉。

升级版

domain + uncaughtException + Cluster

我们可以使用Cluster模块,开启多个工作线程。当遇到domain无法捕获的异常,在 uncaughtException 捕获到时,为了避免内存泄露,我们可以结束当前work进程。具体实现如下:

const cluster = require('cluster');

process.on('uncaughtException', (err) => {

  console.log(err); // 将异常写入日志

  server.close(); // 关闭服务连接

  // 通知 master 进程停止服务
  if (cluster.worker) {
      cluster.worker.disconnect();
  }

});

总结

尽管domain模块即将废弃,但是我们仍然可以使用它,发现和定位线上异常。

参考资料

推荐阅读更多精彩内容

  • 本文的主要内容是对nodejs提供的一些重要模块,结合官方API进行介绍,遇到精彩的文章,我会附在文中并标明了出处...
    艾伦先生阅读 840评论 0 13
  • 记得刚刚开始学Node.js时自己尝试着写了一个简单的http服务器,跟以前接触过的php相比感觉更自由,编起码来...
    淘小杰阅读 751评论 0 5
  • https://nodejs.org/api/documentation.html 工具模块 Assert 测试 ...
    KeKeMars阅读 4,180评论 0 6
  • JS常见错误 当 JavaScript 引擎执行 JavaScript 代码时,会发生各种错误,常见的错误类型有 ...
    Sunny_杰少阅读 757评论 0 1
  • process --进程 process对象是一个全局对象,他提供当前Node.js进程相关的有关信息,以及控制当...
    imakan阅读 1,131评论 0 2