利用asyncHandler去除Express路由中的try/catch样板代码。
了解错误中间件、asyncHandler封装以及自定义的AppError类如何将Express的错误响应从每个路由中移出,集中到统一的位置。
来源: Stop Repeating try/catch: A Cleaner Error Handling Pattern in Express.js
大多数 Express API 会在每个异步路由中加入 try/catch 结构,将错误转换为 JSON 响应。随着 API 规模的扩大,这段代码会被复制到每个处理函数中。在这里,我们可以将错误响应集中到同一个中间件中,用一个简单的封装结构替代重复的 try/catch 代码,并通过自定义错误类来表示状态码。对于进程级故障,可参阅我们的指南 Node.js 中的生产级错误处理。
为何按路由处理的 try/catch 无法扩展
最初的版本通常只是将相关逻辑封装起来,失败时返回 500 错误码。
app.get("/users", async (req, res) => {
try {
const users = await getUsers();
res.json(users);
} catch (err) {
res.status(500).json({ message: err.message });
}
});
在代码结构没有在数十个路由中重复出现之前,其实并没有什么问题。
app.get("/users", async (req, res) => {
try {
// ...
} catch (err) {
res.status(500).json({ message: err.message });
}
});
app.get("/products", async (req, res) => {
try {
// ...
} catch (err) {
res.status(500).json({ message: err.message });
}
});
app.post("/orders", async (req, res) => {
try {
// ...
} catch (err) {
res.status(500).json({ message: err.message });
}
});
现在的每个处理函数都承担两项任务:执行业务逻辑以及决定错误在网络传输时的呈现方式。要更改错误格式,就需要编辑每一条路由。
Express中的错误处理中间件
Express通过中间件的参数个数来识别它:一个带有四个参数的函数,其中第一个参数即为错误对象。
(err, req, res, next)
最简版本会记录日志并返回通用的500错误响应。应在所有路由之后注册该中间件,因为Express是按顺序执行中间件的。即使不使用next函数,也必须保留全部四个参数,否则Express会将其视为普通中间件。
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({
message: "Internal Server Error"
});
});
使用next(err)转发错误
无需在catch块中构建响应,只需将错误传递给Express即可。
app.get("/users", async (req, res, next) => {
try {
const users = await getUsers();
res.json(users);
} catch (err) {
next(err);
}
});
next(err)会让Express跳过常规中间件,直接进入错误处理函数:
Request
↓
Route
↓
Business logic
↓
Error?
↓
next(err)
↓
Centralized error handler
↓
HTTP Response
移除剩余的 try/catch 代码
每个路由仍然包含 try/catch 语句。asyncHandler 会包裹路由函数,通过 Promise.resolve 执行它,并将任何拒绝值传递给 next。
const asyncHandler = (fn) => {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
路由简化为仅包含其实际逻辑:
app.get(
"/users",
asyncHandler(async (req, res) => {
const users = await getUsers();
res.json(users);
})
);
当 getUsers() 发生拒绝时,包装函数会调用:
next(err);
在 Express 5 中,异步操作的拒绝值会自动传递给 next,因此该包装函数主要在 Express 4 中起作用。
使用 AppError 传递状态码
当错误附带 HTTP 状态码时,其作用会更显著:
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
}
}
路由会抛出有意义的错误,而非直接格式化响应:
app.get(
"/users/:id",
asyncHandler(async (req, res) => {
const user = await getUser(req.params.id);
if (!user) {
throw new AppError("User not found", 404);
}
res.json(user);
})
);
处理程序会读取 statusCode,若无法获取则默认使用 500:
app.use((err, req, res, next) => {
console.error(err);
const statusCode = err.statusCode || 500;
res.status(statusCode).json({
success: false,
message: err.message || "Internal Server Error"
});
});
当用户不存在时,会返回404错误:
{
"success": false,
"message": "User not found"
}
注意:对每个错误都输出err.message可能会泄露数据库消息等内部信息。仅对AppError实例返回该信息,其他情况则返回通用文本。关于标准的响应格式,请参阅RFC 9457相关说明。
完整的项目结构
简单的结构如下:
src/
├── controllers/
│ └── user.controller.js
├── middleware/
│ ├── asyncHandler.js
│ └── errorHandler.js
├── errors/
│ └── AppError.js
├── routes/
│ └── user.routes.js
└── app.js
包装模块
const asyncHandler = (fn) => {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
module.exports = asyncHandler;
错误类模块
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
}
}
module.exports = AppError;
错误处理模块
const errorHandler = (err, req, res, next) => {
console.error(err);
res.status(err.statusCode || 500).json({
success: false,
message: err.message || "Internal Server Error"
});
};
module.exports = errorHandler;
同时使用两者的控制器
控制器负责获取数据,必要时抛出AppError异常,并返回成功的响应格式。
const asyncHandler = require("../middleware/asyncHandler");
const AppError = require("../errors/AppError");
const getUsers = asyncHandler(async (req, res) => {
const users = await userService.getUsers();
res.json({
success: true,
data: users
});
});
const getUser = asyncHandler(async (req, res) => {
const user = await userService.getUser(req.params.id);
if (!user) {
throw new AppError("User not found", 404);
}
res.json({
success: true,
data: user
});
});
module.exports = {
getUsers,
getUser
};
在app.js中的配置
错误处理逻辑放在最后。
const express = require("express");
const userRoutes = require("./routes/user.routes");
const errorHandler = require("./middleware/errorHandler");
const app = express();
app.use(express.json());
app.use("/users", userRoutes);
// Must be after routes
app.use(errorHandler);
module.exports = app;
该模式带来的好处
减少代码重复
控制器不再需要重复编写:
try {
// ...
} catch (err) {
res.status(500).json(...);
}
统一的错误格式
客户端可以依赖统一的结构:
{
"success": false,
"message": "User not found"
}
专注的控制器
控制器的职责变得清晰明确:
Get data
↓
Validate
↓
Process
↓
Return result
统一的可观测性处理方式
日志记录、请求ID和错误跟踪都集中在同一个中间件中。
顺序很重要:最后注册处理程序
它必须放在其所覆盖的每个路由之后:
app.use("/users", userRoutes);
app.use("/products", productRoutes);
// Error handler LAST
app.use(errorHandler);
如果提前注册,它将无法获取后续路由产生的错误信息。
总结
与其这样:
Route
└── try/catch
└── send error response
不如改为:
Route
└── business logic
└── throw error
↓
asyncHandler
↓
centralized error handler
↓
consistent HTTP response
真正的目标是将业务逻辑与错误展示分离:
- 错误中间件需要四个参数,且必须最后注册。
asyncHandler;Express 5 会自动处理异步拒绝情况。AppError,并隐藏意外错误的消息。