首页 / 文章

利用asyncHandler去除Express路由中的try/catch样板代码。

了解错误中间件、asyncHandler封装以及自定义的AppError类如何将Express的错误响应从每个路由中移出,集中到统一的位置。

发布者:REACTAPP.TOP阅读约 5 分钟1231 词

来源: 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

真正的目标是将业务逻辑与错误展示分离:

  • 错误中间件需要四个参数,且必须最后注册。
  • 在 Express 4 中使用 asyncHandler;Express 5 会自动处理异步拒绝情况。
  • 对于预期会出现的错误,抛出带有对应状态码的 AppError,并隐藏意外错误的消息。