引言
在 Node.js 开发中,错误处理往往是容易被忽视的环节。许多新手开发者习惯于用 try/catch 包裹所有代码,或者干脆忽略错误,导致生产环境出现未捕获异常时进程崩溃,用户体验极差。实际上,Node.js 的错误处理有一套成熟的模式,遵循这些最佳实践可以让你的应用在出现意外时优雅降级,而不是直接挂掉。
本文将从基础到进阶,带你掌握 Node.js 错误处理的完整体系。
1. 理解 Node.js 中的错误类型
Node.js 中的错误主要分为两类:编程错误(程序员 Bug)和 操作错误(运行时环境问题)。
- 编程错误:例如语法错误、类型错误、引用空值等。这类错误应该通过修复代码来避免。
- 操作错误:例如文件不存在、网络超时、数据库连接失败等。这类错误需要正确处理,以保证应用继续运行。
最佳实践:始终区分这两种错误,对操作错误进行优雅处理,对编程错误则尽快失败(fail-fast)并修复。
2. 同步代码的错误处理
最简单的错误处理是 try/catch,但要注意:try/catch 只能捕获同步代码中的异常。
// 同步错误处理示例
function parseJSON(str) {
try {
return JSON.parse(str);
} catch (err) {
// 记录错误日志
console.error('JSON 解析失败:', err.message);
// 返回默认值或抛出自定义错误
return null;
}
}
const result = parseJSON('invalid json');
console.log(result); // null
注意:不要滥用 try/catch 捕获所有错误。只捕获你预期会发生的操作错误,让编程错误暴露出来。
3. 异步代码的错误处理:回调、Promise 和 async/await
3.1 回调风格 (callback)
早期的 Node.js 使用回调函数处理异步,错误优先是约定俗成的模式。
const fs = require('fs');
fs.readFile('/path/to/file', (err, data) => {
if (err) {
// 处理错误
console.error('读取文件失败:', err.message);
return;
}
// 处理数据
console.log(data.toString());
});
坏味道:忘记检查 err 参数,或者回调嵌套过深导致难以追踪错误。
3.2 Promise 链
Promise 提供了 .catch() 方法统一处理错误,但要注意:如果 Promise 链中没有 .catch(),错误会被吞掉。
// 好的实践:始终在链尾添加 .catch()
fetchData()
.then(processData)
.then(saveData)
.catch(err => {
console.error('操作失败:', err);
// 可以在这里进行重试或回滚
});
常见坑:在 .then() 内部抛出的错误如果没有被捕获,会导致未处理的 Promise rejection。
3.3 async/await 与 try/catch
async/await 让异步代码看起来像同步,错误处理也回归 try/catch。
async function processUserData(userId) {
try {
const user = await fetchUser(userId);
const orders = await fetchOrders(user.id);
return { user, orders };
} catch (err) {
// 统一处理网络错误或数据库错误
console.error('获取用户数据失败:', err);
throw new AppError('USER_DATA_FETCH_FAILED', '无法获取用户数据');
}
}
// 调用方也需要处理
async function main() {
try {
const data = await processUserData(123);
console.log(data);
} catch (err) {
// 这里捕获到自定义错误
console.error(err.message);
}
}
最佳实践:在 async 函数内部使用 try/catch 捕获预期错误,并转换为自定义错误类型(如 AppError),方便上层统一处理。
4. 全局未捕获异常处理
即使我们尽力捕获所有错误,仍然可能出现未捕获的异常。Node.js 提供了两个全局事件:uncaughtException 和 unhandledRejection。
4.1 处理未捕获的同步异常
process.on('uncaughtException', (err) => {
console.error('未捕获的异常:', err);
// 记录日志后,优雅关闭应用
gracefulShutdown();
// 注意:不推荐在这里恢复运行,因为应用可能处于不一致状态
});
警告:uncaughtException 应该仅用于记录日志和清理资源,然后退出进程。不要试图让应用继续运行,否则可能导致内存泄漏或其他不可预知问题。
4.2 处理未处理的 Promise rejection
process.on('unhandledRejection', (reason, promise) => {
console.error('未处理的 Promise rejection:', reason);
// 同样,记录日志后退出
gracefulShutdown();
});
注意:从 Node.js 15 开始,未处理的 Promise rejection 会直接导致进程退出。因此务必确保所有 Promise 都有 .catch() 或 await 包裹。
5. 构建多层错误处理中间件(Express 示例)
在 Web 应用中,我们可以通过中间件分层处理错误。
5.1 自定义错误类
class AppError extends Error {
constructor(statusCode, message, isOperational = true) {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational; // 区分操作错误和编程错误
Error.captureStackTrace(this, this.constructor);
}
}
5.2 异步错误包装器
避免在每个 async 路由中重复写 try/catch,可以使用包装器。
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 getUserById(req.params.id);
if (!user) {
throw new AppError(404, '用户未找到');
}
res.json(user);
}));
5.3 全局错误处理中间件
app.use((err, req, res, next) => {
// 判断是否是自定义操作错误
if (err.isOperational) {
// 记录日志
console.warn('操作错误:', err.message);
return res.status(err.statusCode).json({
error: err.message
});
}
// 编程错误:记录详细日志,返回通用错误信息
console.error('编程错误:', err);
res.status(500).json({
error: '服务器内部错误'
});
});
优势:所有错误集中处理,代码干净,易于维护。
6. 日志记录与监控
错误处理的关键是记录足够的信息以便调试。推荐使用 winston 或 pino 等日志库。
const winston = require('winston');
const logger = winston.createLogger({
level: 'error',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'error.log' }),
new winston.transports.Console({ format: winston.format.simple() })
]
});
// 在错误处理中使用
logger.error('错误详情', { error: err.message, stack: err.stack, requestId: req.id });
注意:不要将敏感信息(如密码、Token)记录到日志中。
7. 重试与降级策略
对于临时性操作错误(如网络超时),可以实现重试机制。
async function fetchWithRetry(url, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url);
return response;
} catch (err) {
if (i === retries - 1) throw err; // 最后一次失败则向上抛出
console.warn(`重试第 ${i+1} 次`);
await sleep(1000 * (i+1)); // 指数退避
}
}
}
降级:当依赖服务不可用时,返回缓存数据或默认值。
总结
- 区分错误类型:编程错误尽快修复,操作错误优雅处理。
- 统一错误处理:使用自定义错误类和全局中间件。
- 捕获所有异常:包括 Promise rejection 和未捕获异常,但仅用于记录和退出。
- 记录日志:详细记录错误上下文,但避免敏感信息。
- 考虑重试与降级:提高系统韧性。
下一步,你可以探索如何结合 APM(如 Sentry)进行实时错误监控,或者学习分布式系统中的错误传播模式。