跳到主要内容

错误处理

错误处理是 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 会跳过所有普通中间件,直接进入错误处理中间件:

  1. 同步代码中抛出错误(throw new Error()
  2. 调用 next(err) 传递错误对象
  3. 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 内置了一个默认错误处理器,当你没有定义自定义错误处理中间件时,它会处理所有错误。

默认行为

默认错误处理器会:

  1. 设置 res.statusCodeerr.statuserr.statusCode(如果不在 4xx 或 5xx 范围内,设为 500)
  2. 设置 res.statusMessage 对应状态码的消息
  3. 在开发环境下返回包含堆栈信息的 HTML
  4. 在生产环境下返回简单的状态码 HTML
  5. 添加 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 错误处理机制:

  1. 错误捕获:同步错误自动捕获,异步错误需要手动传递(Express 5 除外)
  2. Express 5 改进:自动捕获 Promise 错误,简化异步代码
  3. 错误处理中间件:四个参数,放在所有路由之后
  4. 默认错误处理器:内置处理机制,生产环境不暴露堆栈
  5. 自定义错误类:结构化错误信息,便于统一处理
  6. 多个错误处理中间件:分层处理,各司其职
  7. headersSent 检查:防止重复发送响应
  8. 最佳实践:统一格式、分离错误类型、保护敏感信息

良好的错误处理是应用稳定性的保障,值得投入时间精心设计。

练习

  1. 创建一套自定义错误类,覆盖常见 HTTP 错误状态码(400、401、403、404、409、500)
  2. 实现一个全局错误处理中间件,区分开发和生产环境返回不同的错误信息
  3. 为 Express 4 应用添加异步错误处理包装器
  4. 集成 Winston 日志库,记录所有错误到文件
  5. 实现请求验证错误处理,收集所有验证错误信息后统一返回

参考资料