错误处理
错误处理是 Web 应用开发中不可忽视的环节。良好的错误处理机制不仅能提升用户体验,还能帮助开发者快速定位和修复问题。Express 提供了一套灵活的错误处理机制,本章将深入讲解其原理和最佳实践。
错误处理基础
Express 如何捕获错误
Express 会自动捕获同步代码中抛出的错误,但异步代码中的错误需要手动传递给 Express。理解这一点是正确处理错误的关键。
同步错误:Express 自动捕获,无需额外处理。
// 同步错误会被 Express 自动捕获
app.get('/sync-error', (req, res) => {
throw new Error('同步错误示例');
// Express 会自动捕获这个错误并传递给错误处理中间件
});
异步错误:需要手动传递给 next() 函数。
// Express 4 中异步错误需要手动传递
app.get('/async-error', (req, res, next) => {
fs.readFile('/不存在的文件', (err, data) => {
if (err) {
return next(err); // 必须手动传递给 next()
}
res.send(data);
});
});
Express 5 的改进:自动捕获 Promise 错误
从 Express 5 开始,路由处理函数和中间件返回的 Promise 如果被拒绝(reject)或抛出错误,Express 会自动调用 next(err):
// Express 5 写法:无需 try-catch
app.get('/user/:id', async (req, res) => {
const user = await User.findById(req.params.id);
// 如果 findById 抛出错误或 Promise 被 reject
// Express 会自动捕获并传递给错误处理中间件
res.json(user);
});
// Express 5 中 throw 也会被自动捕获
app.get('/validate', async (req, res) => {
if (!req.body.name) {
throw new Error('名称不能为空'); // 自动被捕获
}
res.json({ success: true });
});
这与 Express 4 有显著区别。在 Express 4 中,必须使用 try-catch:
// Express 4 写法:需要 try-catch
app.get('/user/:id', async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
res.json(user);
} catch (err) {
next(err); // 必须手动传递
}
});
错误处理中间件
错误处理中间件与普通中间件的区别在于它有四个参数而不是三个。Express 通过参数数量来识别错误处理中间件。
基本结构
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).send('服务器出错了!');
});
参数说明:
err:错误对象,由前一个中间件通过next(err)传递req:请求对象res:响应对象next:传递给下一个错误处理中间件
错误处理中间件的执行时机
当发生以下情况时,Express 会跳过所有普通中间件,直接进入错误处理中间件:
- 同步代码中抛出错误(
throw new Error()) - 调用
next(err)传递错误对象 - Express 5 中异步函数返回 rejected Promise
// 普通中间件
app.use((req, res, next) => {
console.log('这是普通中间件');
next();
});
// 路由处理器抛出错误
app.get('/error', (req, res) => {
throw new Error('出错了!');
// 下面的普通中间件不会执行
});
// 这个普通中间件会被跳过
app.use((req, res, next) => {
console.log('这里不会执行');
next();
});
// 错误处理中间件会被执行
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});
默认错误处理器
Express 内置了一个默认错误处理器,当你没有定义自定义错误处理中间件时,它会处理所有错误。
默认行为
默认错误处理器会:
- 设置
res.statusCode为err.status或err.statusCode(如果不在 4xx 或 5xx 范围内,设为 500) - 设置
res.statusMessage对应状态码的消息 - 在开发环境下返回包含堆栈信息的 HTML
- 在生产环境下返回简单的状态码 HTML
- 添加
err.headers中指定的任何响应头
// 默认错误处理器会使用 err.status 设置状态码
app.get('/not-found', (req, res, next) => {
const err = new Error('页面未找到');
err.status = 404;
next(err);
});
// 默认错误处理器会使用 err.headers 设置响应头
app.get('/custom-headers', (req, res, next) => {
const err = new Error('需要认证');
err.status = 401;
err.headers = {
'WWW-Authenticate': 'Bearer'
};
next(err);
});
生产环境配置
设置 NODE_ENV=production 后,默认错误处理器不会暴露堆栈信息:
NODE_ENV=production node app.js
// 生产环境下的错误响应
// 开发环境: 返回完整的堆栈信息
// 生产环境: 返回简单的 "Internal Server Error"
自定义错误类
创建自定义错误类可以更好地组织错误信息,实现更精细的错误处理。
基础错误类
// errors/AppError.js
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
this.isOperational = true; // 标记为可操作的错误
// 捕获堆栈跟踪,排除构造函数调用
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = AppError;
特定错误类
// errors/index.js
const AppError = require('./AppError');
// 404 错误
class NotFoundError extends AppError {
constructor(message = '请求的资源不存在') {
super(message, 404);
}
}
// 400 验证错误
class ValidationError extends AppError {
constructor(message = '请求数据验证失败') {
super(message, 400);
}
}
// 401 未认证错误
class UnauthorizedError extends AppError {
constructor(message = '请先登录') {
super(message, 401);
}
}
// 403 权限不足
class ForbiddenError extends AppError {
constructor(message = '没有权限访问此资源') {
super(message, 403);
}
}
// 409 冲突错误
class ConflictError extends AppError {
constructor(message = '资源已存在') {
super(message, 409);
}
}
module.exports = {
AppError,
NotFoundError,
ValidationError,
UnauthorizedError,
ForbiddenError,
ConflictError
};
使用自定义错误类
const { NotFoundError, ValidationError, UnauthorizedError } = require('./errors');
// 用户不存在
app.get('/users/:id', async (req, res, next) => {
const user = await User.findById(req.params.id);
if (!user) {
return next(new NotFoundError('用户不存在'));
}
res.json(user);
});
// 验证失败
app.post('/users', async (req, res, next) => {
const { name, email } = req.body;
if (!name || !email) {
return next(new ValidationError('姓名和邮箱不能为空'));
}
const user = await User.create(req.body);
res.status(201).json(user);
});
// 未认证
app.get('/profile', (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return next(new UnauthorizedError());
}
// 验证 token...
});
全局错误处理中间件
基本全局错误处理
// middleware/error.js
const errorHandler = (err, req, res, next) => {
err.statusCode = err.statusCode || 500;
err.status = err.status || 'error';
if (process.env.NODE_ENV === 'development') {
// 开发环境:返回详细错误信息
res.status(err.statusCode).json({
status: err.status,
message: err.message,
stack: err.stack,
error: err
});
} else {
// 生产环境:只返回必要信息
if (err.isOperational) {
// 可操作的错误(如验证失败、未找到等)
res.status(err.statusCode).json({
status: err.status,
message: err.message
});
} else {
// 不可操作的错误(如程序 bug)
console.error('ERROR:', err);
res.status(500).json({
status: 'error',
message: '服务器内部错误'
});
}
}
};
module.exports = errorHandler;
404 处理中间件
// middleware/notFound.js
const { NotFoundError } = require('../errors');
const notFound = (req, res, next) => {
next(new NotFoundError(`路由 ${req.originalUrl} 不存在`));
};
module.exports = notFound;
在应用中使用
const express = require('express');
const app = express();
const errorHandler = require('./middleware/error');
const notFound = require('./middleware/notFound');
// 路由
app.use('/api', require('./routes'));
// 404 处理(放在所有路由之后)
app.use(notFound);
// 全局错误处理(放在最后)
app.use(errorHandler);
多个错误处理中间件
Express 允许定义多个错误处理中间件,它们会按顺序执行。这对于分离不同类型的错误处理很有用。
分层错误处理
// 1. 日志记录中间件
const logErrors = (err, req, res, next) => {
console.error('[Error]', new Date().toISOString(), err.stack);
next(err); // 传递给下一个错误处理中间件
};
// 2. 客户端错误处理(针对 AJAX 请求)
const clientErrorHandler = (err, req, res, next) => {
if (req.xhr) {
// AJAX 请求返回 JSON
res.status(500).json({ error: '请求失败,请稍后重试' });
} else {
next(err); // 非 AJAX 请求,继续传递
}
};
// 3. 数据库错误处理
const dbErrorHandler = (err, req, res, next) => {
// MongoDB 重复键错误
if (err.code === 11000) {
const field = Object.keys(err.keyValue)[0];
return res.status(409).json({
error: `${field} 已存在`
});
}
// Mongoose 验证错误
if (err.name === 'ValidationError') {
const messages = Object.values(err.errors).map(e => e.message);
return res.status(400).json({
error: messages.join(', ')
});
}
next(err);
};
// 4. 最终错误处理
const errorHandler = (err, req, res, next) => {
res.status(err.statusCode || 500).json({
status: 'error',
message: err.message || '服务器内部错误'
});
};
// 按顺序注册
app.use(logErrors);
app.use(clientErrorHandler);
app.use(dbErrorHandler);
app.use(errorHandler);
headersSent 检查
当响应头已经发送给客户端后,不能再发送响应。此时应该委托给默认错误处理器:
const errorHandler = (err, req, res, next) => {
// 如果响应头已经发送,委托给默认错误处理器
if (res.headersSent) {
return next(err);
}
res.status(err.statusCode || 500).json({
error: err.message
});
};
这种情况常见于流式响应或文件下载:
app.get('/download', (req, res) => {
const fileStream = fs.createReadStream('/path/to/file');
// 响应头已发送
res.setHeader('Content-Disposition', 'attachment; filename="file.pdf"');
fileStream.pipe(res);
fileStream.on('error', (err) => {
// 此时响应头已发送,不能再发送 JSON 响应
next(err); // 委托给默认错误处理器,它会关闭连接
});
});
异步错误处理的最佳实践
Express 4 中的方案
在 Express 4 中,可以使用包装函数或第三方库来简化异步错误处理:
方案一:包装函数
// utils/catchAsync.js
const catchAsync = (fn) => {
return (req, res, next) => {
fn(req, res, next).catch(next);
};
};
// 使用
app.get('/users/:id', catchAsync(async (req, res, next) => {
const user = await User.findById(req.params.id);
if (!user) {
return next(new NotFoundError('用户不存在'));
}
res.json(user);
}));
方案二:express-async-errors 库
npm install express-async-errors
// 在应用入口引入(必须在使用路由之前)
require('express-async-errors');
// 之后异步函数中的错误会被自动捕获
app.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
throw new NotFoundError('用户不存在'); // 自动被捕获
}
res.json(user);
});
Express 5 中的处理
Express 5 原生支持 Promise 错误捕获,无需额外处理:
// Express 5:直接使用 async/await
app.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
throw new NotFoundError('用户不存在');
}
res.json(user);
});
处理未捕获的 Promise 拒绝
无论 Express 版本如何,都应该处理全局未捕获的 Promise 拒绝:
// 捕获未处理的 Promise 拒绝
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection:', reason);
// 可以选择退出进程
// process.exit(1);
});
// 捕获未捕获的异常
process.on('uncaughtException', (err) => {
console.error('Uncaught Exception:', err);
// 未捕获的异常后应该退出进程
process.exit(1);
});
常见错误场景处理
数据库错误
app.post('/users', async (req, res, next) => {
try {
const user = await User.create(req.body);
res.status(201).json(user);
} catch (err) {
// 处理特定数据库错误
if (err.code === 11000) {
return next(new ConflictError('邮箱已被注册'));
}
if (err.name === 'ValidationError') {
return next(new ValidationError(err.message));
}
next(err);
}
});
JWT 认证错误
const jwt = require('jsonwebtoken');
const authMiddleware = (req, res, next) => {
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
throw new UnauthorizedError('请先登录');
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
if (err.name === 'JsonWebTokenError') {
return next(new UnauthorizedError('Token 无效'));
}
if (err.name === 'TokenExpiredError') {
return next(new UnauthorizedError('Token 已过期,请重新登录'));
}
next(err);
}
};
文件上传错误
const multer = require('multer');
const upload = multer({
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
fileFilter: (req, file, cb) => {
const allowedTypes = ['image/jpeg', 'image/png'];
if (!allowedTypes.includes(file.mimetype)) {
return cb(new ValidationError('只支持 JPEG 和 PNG 格式图片'), false);
}
cb(null, true);
}
});
// 错误处理包装器
const uploadMiddleware = (req, res, next) => {
upload.single('avatar')(req, res, (err) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return next(new ValidationError('文件大小不能超过 5MB'));
}
return next(err);
}
if (err) {
return next(err);
}
next();
});
};
app.post('/avatar', uploadMiddleware, (req, res) => {
res.json({ file: req.file });
});
请求验证错误
const { body, validationResult } = require('express-validator');
const validateUser = [
body('name').trim().notEmpty().withMessage('姓名不能为空'),
body('email').isEmail().withMessage('请输入有效的邮箱'),
body('password').isLength({ min: 6 }).withMessage('密码至少 6 位'),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
const messages = errors.array().map(e => e.msg);
return next(new ValidationError(messages.join(', ')));
}
next();
}
];
app.post('/users', validateUser, async (req, res) => {
const user = await User.create(req.body);
res.status(201).json(user);
});
错误日志记录
使用 Winston 日志库
npm install winston
const winston = require('winston');
const logger = winston.createLogger({
level: 'error',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: 'logs/error.log' }),
new winston.transports.Console()
]
});
// 错误处理中间件
const logErrors = (err, req, res, next) => {
logger.error({
message: err.message,
stack: err.stack,
method: req.method,
url: req.originalUrl,
body: req.body,
params: req.params,
query: req.query,
user: req.user?.id,
ip: req.ip
});
next(err);
};
使用 Morgan HTTP 日志
const morgan = require('morgan');
const fs = require('fs');
const path = require('path');
// 创建写入流
const accessLogStream = fs.createWriteStream(
path.join(__dirname, 'logs/access.log'),
{ flags: 'a' }
);
// 记录所有请求
app.use(morgan('combined', { stream: accessLogStream }));
// 只记录错误响应
app.use(morgan('combined', {
stream: accessLogStream,
skip: (req, res) => res.statusCode < 400
}));
错误处理最佳实践总结
1. 分离错误类型
区分"可操作的错误"(用户可理解的错误)和"程序错误"(bug):
// 可操作的错误:用户可以看到具体信息
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.isOperational = true;
}
}
// 程序错误:不应该向用户暴露详情
// 如 TypeError, ReferenceError, SyntaxError 等
2. 一致的错误响应格式
// 成功响应格式
{
"success": true,
"data": { ... }
}
// 错误响应格式
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "邮箱格式不正确",
"details": [...] // 可选,用于表单验证
}
}
3. 错误处理中间件顺序
// 正确的顺序
app.use(routes); // 1. 路由
app.use(notFound); // 2. 404 处理
app.use(logErrors); // 3. 日志记录
app.use(dbErrorHandler); // 4. 特定错误处理
app.use(errorHandler); // 5. 最终错误处理
4. 不要暴露敏感信息
// 错误:暴露数据库错误详情
app.use((err, req, res, next) => {
res.status(500).json({ error: err.stack }); // 危险!
});
// 正确:区分环境
app.use((err, req, res, next) => {
if (process.env.NODE_ENV === 'production') {
res.status(500).json({ error: '服务器错误' });
} else {
res.status(500).json({ error: err.message, stack: err.stack });
}
});
5. 优雅关闭
process.on('uncaughtException', (err) => {
console.error('Uncaught Exception:', err);
// 记录日志后退出
logger.error(err);
process.exit(1);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection:', reason);
logger.error(reason);
// 可选择继续运行或退出
});
小结
本章详细讲解了 Express 错误处理机制:
- 错误捕获:同步错误自动捕获,异步错误需要手动传递(Express 5 除外)
- Express 5 改进:自动捕获 Promise 错误,简化异步代码
- 错误处理中间件:四个参数,放在所有路由之后
- 默认错误处理器:内置处理机制,生产环境不暴露堆栈
- 自定义错误类:结构化错误信息,便于统一处理
- 多个错误处理中间件:分层处理,各司其职
- headersSent 检查:防止重复发送响应
- 最佳实践:统一格式、分离错误类型、保护敏感信息
良好的错误处理是应用稳定性的保障,值得投入时间精心设计。
练习
- 创建一套自定义错误类,覆盖常见 HTTP 错误状态码(400、401、403、404、409、500)
- 实现一个全局错误处理中间件,区分开发和生产环境返回不同的错误信息
- 为 Express 4 应用添加异步错误处理包装器
- 集成 Winston 日志库,记录所有错误到文件
- 实现请求验证错误处理,收集所有验证错误信息后统一返回