Node.js 异常处理以及请求上下文隔离

1,200 阅读6分钟

成熟的Web Service技术必然会对代码异常有足够的保护,好的Web必然会在出错后给出友好的提示,而不是莫名其妙的等待504超时。
而node.js这里比较年轻,而开发人员就更年轻了,大家都没有太多的经验,也没有太多的参考。

1. 单进程 + PM2

最简单的方式http处理方式,可以经常见到这样的模式:

var http = require('http');

http.createServer(function (req, res) {
  res.end('hello');
}).listen(80);

简单处理请求,没有任何的全局异常处理。也就是说,只在业务逻辑中加一些try catch,信心满满的说,不会出现未捕获的异常。

  1. 一般来说,这样都没太多问题,正常返回的时候一切都好。
  2. 但如果,哪天,某个代码出现了点bug~~ 谁都不是神,总有bug的时候。
var http = require('http');

http.createServer(function (req, res) {
  var a;
  a.b();
  res.end('hello');
}).listen(80);

例如这样,node.js进程马上会因为报错而挂掉~~~

但是你也许早就听说世界上有forever、pm2等玩意。于是,服务启动就变成:

pm2 start index.js

这样的模式太常见,尤其是内部的小系统。pm2 监控 node.js 进程,一旦挂掉,就重启。
似乎这样也挺好的,简单易懂,整体的web服务不会受到太大影响。这种就是最最最简单的模式:单进程 + pm2

2. 第一个全局处理:process.on(‘uncaughtException’)

不过,哪里出错了,似乎都不知道,也不大好,总得记录一下错误在哪里吧?于是又找到了process.on(‘uncaughtException’)

process.on('uncaughtException', function(er){
  console.error("process.on('uncaughtException')", er);
});

这样通过log就可以发现哪里出错了。

而且因为截获了异常,所以进程也不会挂掉了~~~ 虽然按照官方的说法,一旦出现未处理的异常,还是应该重启进程,否则可能有不确定的问题。
好了,似乎这个方式已经很完美了~~~ 程序不挂掉,也能输出log。

3. 致命问题:出错后,没有任何返回

那么,我们的目标就是,要在出错后,能友好的返回,告诉用户系统出错了,给个有趣的小图,引导一下用户稍后重试。
但,单靠 process 不可能完成啊,因为错误来自五湖四海,单凭 error 信息不可能知道当时的 request 和 response 对象是哪个,此时只能翻书去。

在官网资料中,可以发现 domain 这个好东西。虽然官方说正在出一个更好的替代品,不过在这出来之前,domain 还是很值得一用的。

const domain = require('domain');
const http = require('http');

http.createServer(function (req, res) {
var d = domain.create();

d.on('error', function (er) {
console.error('Error', er);
try {
    res.writeHead(500);
    res.end('Error occurred, sorry.');
} catch (er) {
    console.error('Error sending 500', er, req.url);
}
});

d.run(handler);

function handler() {
    var a;
    a.b();
    res.end('hello');
}
}).listen(80);

上边的代码片段,在每次request处理中,生成了一个domain对象,并注册了error监听函数。
这里关键点是run函数,在d.run(handler)中运行的逻辑,都会受到domain的管理,简单理解,可以说,给每一个request创建了独立的沙箱环境。(虽然,事实没有这么理想)

request的处理逻辑,如果出现未捕获异常,都会先被domain接收,也就是on('error')。

由于每个request都有自己独立的domain,所以这里我们就不怕error处理函数串台了。加上闭包特性,在error中可以轻松利用res和req,给对应的浏览器返回友好的错误信息。

4. domain真的是独立的吗?

看官方的说法:

The enter method is plumbing used by the run, bind, and intercept methods to set the active domain. It sets domain.active and process.domain to the domain, and implicitly pushes the domain onto the domain stack managed by the domain module (see domain.exit() for details on the domain stack).

有兴趣的同学可以深入看看domain的实现,node.js维护一个domain堆栈。
这里有一个小秘密,代码中执行process.domain将获取到当前上下文的domain对象,不串台。
我们做个简单试验:

const domain = require('domain');
const http = require('http');

http.createServer(function (req, res) {
  var d = domain.create();
  d.id = '123';
  d.res = res;
  d.req = req;

  d.on('error', function (er) {
    console.error('Error', er);
    var curDomain = process.domain;
    console.log(curDomain.id, curDomain.res, curDomain.req);
    try {
      curDomain.res.writeHead(500);
      curDomain.res.end('Error occurred, sorry.');
    } catch (er) {
      console.error('Error sending 500', er, curDomain.req.url);
    }
  });

  var reqId = parseInt(req.url.substr(1));

  setTimeout(function () {
  d.run(handler);
  }, (5-reqId)*1000);

  function handler() {
    if(reqId == 3){
      var a;
      a.b();
    }
    res.end('hello');
  }
}).listen(80);

我们用浏览器请求**http://localhost/1-5

node.js分别会等待5-1秒才返回,其中3号请求将会返回错误。
error处理函数中,没有使用闭包,而是使用process.domain,因为我们就要验证一下这个玩意是否串台。

根据fiddler的抓包可以发现,虽然3号请求比后边的4、5号请求更晚返回,但process.domain对象还是妥妥的指向3号请求自己。

5. domain的坑

domain管理的上下文,可以隐式绑定,也可以显式绑定。什么意思呢?

隐式绑定

If domains are in use, then all new EventEmitter objects (including Stream objects, requests, responses, etc.) will be implicitly bound to the active domain at the time of their creation.

在run的逻辑中创建的对象,都会归到domain上下文管理;

显式绑定

Sometimes, the domain in use is not the one that ought to be used for a specific event emitter. Or, the event emitter could have been created in the context of one domain, but ought to instead be bound to some other domain.

一些对象,有可能是在domain.run以外创建的,例如我们的httpServer/req/res,或者一些数据库连接池。
对付这些对象的问题,我们需要显式绑定到domain上,

也就是domain.add(req)

看看实际的例子:

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捕获,原因很清晰,因为timer和e都在domain.run之外创建的,不受domain上下文管理。需要解决这个问题,只需要简单的add一下即可。

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

例子终归是例子,实际项目中必然情况要复杂多了,redis、mysql等等第三方组件都可能保持长连,那么这些组件往往不在domain中管理,或者出一些差错。

所以,保底起见,都要再加一句process.on(‘uncaughtException’)

不过,如果异常真到了这一步,我们也没什么可以做的了,只能写好log,然后重启子进程了。

6. domain带来的额外好处:请求生命周期的全局变量

做一个webservice,一个请求的处理过程,往往会经过好几个js,接入、路由、文件读取、数据库访问、数据拼装、页面模版。。。等等
同时,有一些数据,也是到处需要使用的,典型的就是req和res。
如果不断在函数调用之间传递这些公用的数据,想必一定很累很累,而且代码看起来也非常恶心。
那么,能否实现request生命周期内的全局变量,存储这些公用数据呢?

这个全局变量,必须有两个特点:

  1. 全局可访问
  2. 跟request周期绑定,不同的request不串台

聪明的孩子应该想到了,刚才domain的特性就很吻合。
于是,我们可以借助domain,实现request生命周期内的全局变量。

简单代码如下:

const domain = require('domain');const http = require('http');

Object.defineProperty(global, 'window', {
  get : function(){
    return process.domain && process.domain.window;
  }
});

http.createServer(function (req, res) {
  var d = domain.create();
  d.id = '123';
  d.res = res;
  d.req = req;

  d.window = {name:'kenko'};

  d.on('error', function (er) {
      console.error('Error', er);
      var curDomain = process.domain;
      try {
        curDomain.res.writeHead(500);
        curDomain.res.end('Error occurred, sorry.');
      } catch (er) {
        console.error('Error sending 500', er, curDomain.req.url);
      }
    });
    d.add(req);
    d.add(res);
    d.run(handler);

    function handler() {
        res.end('hello, '+window.name);
    }
}).listen(80);

这里关键点是process.domainObject.defineProperty(global, 'window')
从此就能在任一个逻辑js中使用window.xxx来访问全局变量了。
更进一步,需要大家监听一下res的finish事件,做一些清理工作。