nodejs 错误异常的捕获
在 web 开发中,程序运行出现 bug 后都会把错误打印在浏览器的控制台上,其中,大部分的错误并不会导致 web 页面打不开,或者影响用户使用,所以很多前端开发人员对错误的捕获并不是很在意。
但是,如果用 nodejs 开发后台服务,就不能对错误不闻不问了,因为这直接会导致服务崩溃,用户访问不了应用。所以,对于开发后台服务就必须要对异常提前做预防处理,尽量保证在异常出现时,给用户一个友好的提示,不至于服务挂起导致请求超时,并且能将异常信息做记录上报,方便后期排查解决。
同步与异步代码的错误捕获
先看一段同步代码:
try {
throw new Error('这是一个错误')
} catch (e) {
console.error(e.message) // 这是一个错误
}
可以看到控制台打印了这是一个错误
,说明同步代码的错误被try/catch
捕获了。
如果把同步代码改为异步代码会怎么样呢?
try {
setTimeout(() => {
throw new Error('这是一个错误')
})
} catch (e) {
console.error(e.message)
}
console.error(e.message)
不会执行,即异步代码中的错误没有被捕获。
uncaughtException
捕获错误
那异步错误该怎么处理呢?
可以这么理解,异常并不是事先准备好的,不能控制其到底在哪儿发生,所以需要站在更高的角度,如监听整个应用进程的错误异常,从而捕获不能预料的错误异常,保证应用不至于崩溃。
try {
setTimeout(() => {
throw new Error('这是一个错误')
})
} catch (e) {
console.error(e.message)
}
process.on('uncaughtException', e => {
console.error('uncaughtException:', e.message)
})
可以看到在uncaughtException
事件中就能捕获到这个错误了,它可以捕获到整个进程包含异步中的错误信息。
但是,uncaughtException
事件捕获错误存在两个重大问题。
第一,uncaughtException
错误会导致当前的所有的用户连接都被中断,甚至不能返回一个正常的 HTTP 错误码,用户只能等到浏览器超时才能看到一个no data received
错误。
这是由于uncaughtException
丢失了当前环境的上下文,无法拿到res
,执行res.send
来结束此次请求。最终,出错的用户只能等待浏览器超时,这是非常不友好的。
app.get('/', function (req, res) {
setTimeout(function () {
throw new Error('async error');
// uncaughtException, 导致 res 的引用丢失
res.send(200);
}, 1000);
});
process.on('uncaughtException', function (err) {
// 拿不到当前请求的 res 对象
res.send(500);
});
第二,uncaughtException
会影响整个进程的健康运转。当 Node 抛出uncaughtException
异常时就会丢失当前环境的堆栈,导致 Node 不能正常进行内存回收。也就是说,每一次 uncaughtException
都有可能导致内存泄露。
uncaughtException
捕获错误是一种非常野蛮粗暴的异常处理机制,任何线上服务都不应该因为 uncaughtException
导致服务器崩溃。一个友好的错误处理机制应该满足三个条件:
- 对于引发异常的用户,返回 500 页面;
- 其他用户不受影响,可以正常访问;
- 不影响整个进程的正常运行;
既然如此,退而求其次,我们可以在满足前两个条件的情况下退出进程,然后重启服务,这个时候就需要采用多进程部署。
domain
模块捕获异步错误
为了解决这个问题,Node 0.8 之后的版本新增了 domain
模块,它可以用来捕获回调函数中抛出的异常。
domain
主要的 API 有 domain.run
和 error
事件。简单的说,通过 domain.run
执行的函数中引发的异常都可以通过 domain
的 error
事件捕获,例如:
var domain = require('domain');
var d = domain.create();
d.run(function () {
setTimeout(function () {
throw new Error('async error'); // 抛出一个异步异常
}, 1000);
});
d.on('error', function (err) {
console.log('catch err:', err); // 这里可以捕获异步异常
});
通过 domain
模块,可以很轻易的为引发异常的用户返回 500 页面。以 express 为例:
const express = require('express')
var app = express()
var domain = require('domain')
app.use(function (req, res, next) {
var reqDomain = domain.create()
reqDomain.on('error', function (err) {
// 下面抛出的异常在这里被捕获,使用闭包,保留了对res的访问
res.status(500).send(err.stack) // 成功给用户返回了 500
})
reqDomain.run(next)
})
app.get('/', function () {
setTimeout(function () {
throw new Error('async exception') // 抛出一个异步异常
}, 1000)
})
app.listen('9000', () => {
console.log('server run at 9000')
})
上面的代码将 domain 作为一个中间件来使用,保证之后 express 所有的中间件都在 domain.run
函数内部执行。这些中间件内的异常都可以通过 error
事件来捕获。
在domain.on('error')
捕获到错误,同时给用户一个友好的提示,结束本次请求,这样错误就不会被uncaughtException
事件捕获。
打印结果:
Error: async exception at Timeout._onTimeout (/Users/pengchangjun/Documents/node/express-middleware/domain.js:39:11) at listOnTimeout (node:internal/timers:569:17) at process.processTimers (node:internal/timers:512:7)
尽管借助于闭包,我们可以拿到res
对象,正常的给用户返回 500 错误,但是 domain
捕获到错误时依然会丢失堆栈信息,此时已经无法保证程序的健康运行,必须退出。因此,需要执行process.exit(1)
来结束进程。
另外,domain
有个最大的问题,它不能捕获所有的异步异常。也就是说,即使用了 domain
,程序依然有因为 uncaughtException
崩溃的可能。
在官方文档上,domain
模块处于废弃状态,但是现在也没有其他方案可以完全代替domain模块。
多进程模式 + domain + uncaughtException
如果在单进程单实例的部署下,杀掉进程后然后重启这一段时间内服务是不能访问的,这显然是不合理的。
因此,部署的时候需要采用多进程(cluster)的模式去部署应用,当某一个进程被异常捕获后,可以做一下打点上报后,开始重启释放内存,此时其他请求被接受后,其他进程依旧可以对外提供服务。
const cluster = require('cluster');
const os = require('os');
const http = require('http');
const domain = require('domain');
const d = domain.create();
if (cluster.isMaster) {
const cpuNum = os.cpus().length;
for (let i = 0; i < cpuNum; ++i) {
cluster.fork()
};
// fork work log
cluster.on('fork', worker=>{
console.info(`${new Date()} worker${worker.process.pid}进程启动成功`);
});
// 监听异常退出进程,并重新fork
cluster.on('exit',(worker,code,signal)=>{
console.info(`${new Date()} worker${worker.process.pid}进程启动异常退出`);
cluster.fork();
})
} else {
http.createServer((req, res)=>{
d.add(res);
d.on('error', (err) => {
console.log('记录的err信息', err.message);
console.log('出错的 work id:', process.pid);
// uploadError(err) // 上报错误信息至监控
res.end('服务器异常, 请稍后再试');
// 将异常子进程杀死
cluster.worker.kill(process.pid);
});
d.run(handle.bind(null, req, res));
}).listen(8080);
}
function handle(req, res) {
if (process.pid % 2 === 0) {
throw new Error(`出错了`);
}
res.end(`response by worker: ${process.pid}`);
};
express
中异常的处理
使用 express
时记住一定不要在 controller 的异步回调中抛出异常,例如:
app.get('/', function (req, res, next) {
mysql.query('SELECT * FROM users', function (err, results) {
// 不要这样做
if (err) throw err;
// 应该将 err 传递给 errorHandler 处理
if (err) return next(err);
});
});
app.use(function (err, req, res, next) {
// 带有四个参数的 middleware 专门用来处理异常
res.render(500, err.stack);
});
总结
nodejs 在异步错误的捕获是无能为力的,对于大部分的一部错误可以使用domain
模块来处理,但是它并不能捕获所有的一部错误,因此,还需要在整个应用进程的层面上对错误进行兜底,但是uncaughtException
事件捕获会导致整个服务无法接受新的请求而崩溃。
所以,最后采用多进程+domain
+uncaughtException
的方式来进行错误的捕获。
转载自:https://juejin.cn/post/7249991926307799077