Dark Dwarf Blog background

Express 中间件与控制器

Express 中间件与控制器

1. 中间件

a.a. 中间件概念

中间件 (Middleware) 是 Express 框架的基石和灵魂。它本质上是一个函数,可以访问请求对象 (req)响应对象 (res) 以及请求-响应周期中的下一个中间件函数 (next)

中间件可以执行以下核心任务:

  • 执行任何代码。
  • 修改请求和响应对象(例如,为 req 对象附加用户信息)。
  • 结束请求-响应周期(例如,通过 res.send() 发送响应)。
  • 调用栈中的下一个中间件(通过 next())。

b.b. 中间件的执行顺序

Express 会按照我们使用 app.use()app.METHOD() 定义它们的顺序,依次执行它们。

const express = require('express');
const app = express();

// 中间件 1: 日志记录
app.use((req, res, next) => {
  console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
  next(); // 传递给下一个中间件
});

// 中间件 2: 身份验证
app.use((req, res, next) => {
  console.log('Authentication check...');
  // 实际应用中这里会有复杂的验证逻辑
  next();
});

app.get('/', (req, res) => {
  res.send('Hello World!');
});

// 请求到达时,控制台会先打印日志,然后打印身份验证信息,最后才由路由处理器发送响应。

c.c. next() 函数

next() 函数是连接中间件链条的关键。调用它会将控制权传递给下一个中间件。如果不调用 next() 也不发送响应,请求将被“挂起”,客户端会一直等待直到超时。

next() 的调用方式决定了控制权的流向:

  1. next(): 不带参数调用,将控制权传递给下一个非错误处理的中间件。
  2. next(error): 传递一个 Error 对象或其他值,将跳过所有后续的常规中间件,直接将控制权交给第一个错误处理中间件
  3. next('route'): 仅在 router.METHOD()app.METHOD() 加载的中间件中有效。它会跳过当前路由实例中所有剩余的处理器,将控制权交给下一个匹配的路由。

下面是使用 next('route') 的一个例子:

const express = require("express");
const app = express();

// 请求 GET /user/123
app.get(
  "/user/:id",
  (req, res, next) => {
    console.log("第一条流水线,工位A: 检查ID");
    if (req.params.id === "0") {
      console.log("ID是0,这是特殊产品,转交下一条流水线处理");
      next("route"); // 跳出当前 app.get 的处理链
    } else {
      console.log("ID有效,在本流水线继续");
      next(); // 正常传递到工位B
    }
  },
  (req, res, next) => {
    // 这个处理器是 工位B
    console.log("第一条流水线,工位B: 处理常规用户");
    res.send(`Regular User: ${req.params.id}`);
  }
);

对于请求 GET /user/123req.params.id 不等于 “0”,next 正常调用,控制权交给同一路由内的 “工位B”。

对于请求 GET /user/0req.params.id 是 “0”。调用 next(‘route’)。Express 立即跳过 “工位B”。Express 继续向后查找,发现还有另一个 app.get('/user/:id', ...) 也能匹配该请求。于是控制权交给第二个 app.get 的处理器。

2. 中间件类型

a.a. 应用层中间件

应用层中间件通过 app.use()app.METHOD() 绑定到 app 对象实例上。如果没有指定路径,它会对应用的每一个请求生效。

// 这个中间件会对所有请求生效
app.use((req, res, next) => {
  console.log('This runs for every request!');
  next();
});

// 这个中间件仅对路径以 /api 开头的请求生效
app.use('/api', apiSpecificMiddleware);

b.b. 路由层中间件

路由层中间件绑定到 express.Router() 的实例上。它只对通过该路由的请求生效,是实现模块化的基础。

const express = require('express');
const router = express.Router();

// 这个中间件仅对 user-router 中的所有路由生效
router.use((req, res, next) => {
  console.log('Accessing user section...');
  next();
});

// ... router.get(), router.post() 等

module.exports = router;

c.c. 内置中间件

Express 提供了一些开箱即用的关键中间件:

  • express.json(): 解析 Content-Typeapplication/json 的请求体,并将解析后的数据填充到 req.body
  • express.urlencoded({ extended: true }): 解析 Content-Typeapplication/x-www-form-urlencoded 的请求体(即表单提交的数据)。
  • express.static('public'): 托管静态文件。例如,public 目录下的 style.css 文件可以通过 http://localhost:3000/style.css 访问。

d.d. 第三方中间件

Express 拥有庞大的生态系统,可以通过 npm 安装各种第三方中间件来快速添加功能:

  • cors: 处理跨域资源共享(CORS)。
  • helmet: 通过设置各种 HTTP 头来提高应用的安全性。
  • morgan: 强大的 HTTP 请求日志记录工具。
  • cookie-parser: 解析 Cookie 头,并通过 req.cookies 对象提供。

3. 控制器

a.a. 基本概念

MVC (Model-View-Controller) 设计模式中,控制器是核心协调者。它的职责是:

  1. 接收请求: 作为路由的直接处理器,接收来自客户端的请求。
  2. 与模型交互: 调用模型(Model,通常是数据库操作层)来获取或修改数据。
  3. 准备响应: 处理业务逻辑,并将最终结果传递给视图(View)进行渲染,或直接作为 API 响应(如 JSON)发送回客户端。

b.b. 在 Express 中实现控制器

在 Express 中,控制器就是作为路由处理器的中间件函数。最佳实践是将这些控制器函数从路由定义中分离出来,放到单独的文件中,以保持代码的清晰和可维护性。

  1. 创建控制器文件:
// controllers/authorController.js
const db = require('../db'); // 假设这是我们的模拟数据库模块

// 控制器函数:根据 ID 获取作者
const getAuthorById = async (req, res, next) => {
  const { authorId } = req.params;
  const author = await db.getAuthorById(Number(authorId));

  if (!author) {
    // 如果找不到作者,可以发送 404 响应
    return res.status(404).json({ message: "Author not found" });
  }

  // 发送 JSON 格式的响应
  res.status(200).json(author);
};

module.exports = { getAuthorById };
  1. 在路由文件中使用控制器:
// routes/authorRouter.js
const { Router } = require('express');
const { getAuthorById } = require('../controllers/authorController');

const authorRouter = Router();

// 将路由和控制器清晰地绑定在一起
authorRouter.get('/:authorId', getAuthorById);

module.exports = authorRouter;

c.c. 响应客户端

在 Express 中,每个请求处理函数的 res 对象代表了服务器对客户端请求的响应。控制器必须调用 res 上的某个方法来结束请求——响应周期,否则客户端将一直等待直到超时。

下面是常用的 res 方法:

  • res.send(): 通用方法,可以发送字符串、Buffer 或对象。它会根据内容自动设置 Content-Type
  • res.json(): API 开发首选。它确保响应是 application/json 格式,并能正确处理 nullundefined 等值。
  • res.status(code): 设置 HTTP 状态码,可以链式调用,如 res.status(404).send('Not Found')
  • res.redirect([status,] path): 重定向请求到另一个 URL。
  • res.sendFile(path): 读取文件并将其作为响应发送。

4. 优雅的错误处理

a.a. 异步错误处理

从 Express v5 开始,框架原生支持异步错误处理。如果在一个 async 的路由处理器或中间件中抛出错误,Express 会自动捕获它并将其传递给错误处理中间件。这极大地简化了代码,我们不再需要在每个异步函数中都写 try...catch 块来调用 next(error)

// Express 5+ 中,这个错误会被自动捕获
app.get('/async-error', async (req, res, next) => {
  throw new Error('Something went wrong in async land!');
});

b.b. 错误处理中间件

错误处理用于集中处理应用中发生的所有错误。它有以下特点:

  1. 它的函数签名必须是 (err, req, res, next),多出来的第一个参数 err 用于接收错误对象。
  2. 它必须在所有其他 app.use() 和路由调用之后定义,否则它无法捕获在它之后定义的路由中发生的错误。
// ... 其他所有路由和中间件

// 错误处理中间件
app.use((err, req, res, next) => {
  console.error(err.stack); // 在服务器控制台记录完整的错误堆栈

  // 向客户端发送一个通用的、对用户友好的错误消息
  res.status(500).json({ message: 'Internal Server Error' });
});

c.c. 创建自定义错误

为了在错误处理器中能根据错误类型做出不同响应(例如,返回 404 而不是 500),我们可以创建自定义错误类。

// errors/NotFoundError.js
class NotFoundError extends Error {
  constructor(message) {
    super(message);
    this.name = 'NotFoundError';
    this.statusCode = 404;
  }
}

module.exports = NotFoundError;

d.d. 使用自定义错误处理逻辑

现在,我们可以在控制器中抛出自定义错误,并在错误处理器中检查它:

  • 控制器
const NotFoundError = require('../errors/NotFoundError');

const getAuthorById = async (req, res, next) => {
  const author = await db.getAuthorById(Number(req.params.authorId));
  if (!author) {
    // 抛出自定义错误,Express 5 会自动捕获并传递给 next()
    throw new NotFoundError('Author not found');
  }
  res.json(author);
};
  • 增强的错误处理器
app.use((err, req, res, next) => {
  console.error(err);
  
  // 检查我们自定义的 statusCode,如果没有则默认为 500
  const statusCode = err.statusCode || 500;
  const message = err.message || 'Internal Server Error';

  res.status(statusCode).json({ message });
});