引言
在 Node.js 开发中,错误处理是构建可靠应用的关键,却常被忽视。许多开发者习惯用 try...catch 包裹一切,或忽略回调中的错误参数,导致应用崩溃、资源泄漏或难以调试的 Bug。本文将带你从基础到进阶,系统掌握 Node.js 错误处理的最佳实践,并给出可直接运行的代码示例。
1. 错误处理的基础:回调、Promise 与 async/await
Node.js 的错误处理模式经历了从回调到 Promise 再到 async/await 的演进。理解每种模式的正确处理方式是基础。
1.1 回调模式:错误优先(Error-First)
Node.js 的核心 API 大多遵循错误优先回调约定:回调的第一个参数是错误对象,第二个是结果。
const fs = require('fs');
fs.readFile('/path/to/file', (err, data) => {
if (err) {
// 处理错误
console.error('读取文件失败:', err);
return;
}
console.log(data.toString());
});
注意:永远不要忽略 err 参数,即使你认为不会出错。
1.2 Promise:使用 .catch() 或 try...catch
Promise 链式调用中,务必在链尾添加 .catch():
const fs = require('fs').promises;
fs.readFile('/path/to/file')
.then(data => console.log(data.toString()))
.catch(err => console.error('读取文件失败:', err));
1.3 async/await:同步风格的错误处理
async/await 让异步代码看起来像同步,可以用 try...catch 捕获错误:
const fs = require('fs').promises;
async function readFile() {
try {
const data = await fs.readFile('/path/to/file');
console.log(data.toString());
} catch (err) {
console.error('读取文件失败:', err);
}
}
💡 提示:在 async 函数中,未捕获的 Promise 拒绝会导致 unhandledRejection,应始终使用 try…catch 或 .catch()。
2. 创建自定义错误类
内置的 Error 类信息有限。通过继承 Error 创建自定义错误类,可以携带更多上下文,并便于区分错误类型。
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.isOperational = true; // 标记为可操作错误
Error.captureStackTrace(this, this.constructor);
}
}
class ValidationError extends AppError {
constructor(message) {
super(message, 400);
}
}
class NotFoundError extends AppError {
constructor(message) {
super(message, 404);
}
}
最佳实践:为可预见的错误(如参数校验失败、资源不存在)创建自定义错误类,并标记 isOperational 为 true。对于程序错误(如 TypeError),则不应标记,以便在全局处理中区分。
3. 集中式错误处理
在 Express 等框架中,集中处理错误可以避免重复代码,并确保一致性。
3.1 Express 错误处理中间件
// 错误处理中间件(4个参数)
app.use((err, req, res, next) => {
// 记录错误
console.error(err);
// 区分可操作错误与程序错误
if (err.isOperational) {
res.status(err.statusCode || 500).json({
status: 'error',
message: err.message
});
} else {
// 程序错误:不泄露细节,发送通用消息
res.status(500).json({
status: 'error',
message: '服务器内部错误'
});
}
});
3.2 使用 async/await 的包装函数
Express 不会自动捕获 async 函数中的错误,需要手动包装:
const asyncHandler = fn => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
// 使用
app.get('/user/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
throw new NotFoundError('用户不存在');
}
res.json(user);
}));
💡 提示:许多第三方库(如 express-async-errors)可以自动处理,但了解原理很重要。
4. 日志记录与监控
错误处理不仅仅是响应请求,还需要记录和监控。使用 Winston 或 Pino 等日志库,并区分错误级别。
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.Console({ format: winston.format.simple() })
]
});
// 在错误处理中间件中记录
app.use((err, req, res, next) => {
logger.error({
message: err.message,
stack: err.stack,
url: req.originalUrl,
method: req.method,
ip: req.ip
});
// ...
});
最佳实践:记录错误时包含请求上下文(URL、方法、IP、用户ID),便于排查。生产环境应将日志发送到集中式日志系统(如 ELK、Sentry)。
5. 优雅降级与进程管理
对于未捕获的异常和未处理的 Promise 拒绝,应记录并优雅关闭进程。
process.on('uncaughtException', (err) => {
logger.error('未捕获异常:', err);
// 优雅关闭:关闭服务器、数据库连接等
server.close(() => {
process.exit(1);
});
// 如果关闭失败,强制退出
setTimeout(() => process.exit(1), 10000);
});
process.on('unhandledRejection', (reason, promise) => {
logger.error('未处理的 Promise 拒绝:', reason);
// 根据情况决定是否退出
});
注意:uncaughtException 后进程状态可能不一致,建议记录后退出,由进程管理器(如 PM2)重启。
6. 常见坑与经验
- 坑1:在回调中抛出异常不会被捕获。应使用
process.nextTick或直接传递错误。 - 坑2:忘记
return导致后续代码执行。在错误处理中及时return。 - 坑3:在生产环境泄露错误堆栈。始终根据环境决定是否返回详细错误信息。
- 坑4:忽略
unhandledRejection事件。至少记录日志。
7. 总结
本文介绍了 Node.js 错误处理的核心实践:
- 根据异步模式选择合适的错误处理方式(回调、Promise、async/await)。
- 创建自定义错误类,区分可操作错误与程序错误。
- 使用集中式错误处理中间件,统一响应格式。
- 记录详细日志并监控错误。
- 处理未捕获异常,优雅降级。
下一步,你可以探索使用 Sentry 进行错误追踪,或使用 OpenTelemetry 实现分布式追踪。希望这些实践能帮助你构建更健壮的 Node.js 应用。