第五章:Express 进阶:中间件的力量

第五章:Express 进阶:中间件的力量

摘要: 在上一章,我们学会了使用 Express 定义独立的路由。然而,一个真实的应用程序,需要在多个路由间共享通用的逻辑,例如:记录每个请求、验证用户身份、解析请求数据等。如果在每个路由处理器中重复编写这些代码,将是一场灾难。本章,我们将深入 Express 的核心设计哲学——中间件 (Middleware)。您将理解中间件是如何构成一个请求处理的“流水线”,学习如何使用内置、第三方及自定义中间件来优雅地组织和复用代码。这是从“会用 Express”到“精通 Express”最关键的一步,也是理解未来 Next.js 等框架中类似概念的基石。


在本章,我们将彻底解构 Express 的“魔法”核心:

  1. 首先,我们将深入理解 中间件的核心概念,建立一个清晰的“请求-响应管道”心智模型。
  2. 接着,我们将系统学习 中间件的分类与加载顺序,精确控制代码的执行流。
  3. 然后,我们将实战演练最常用的 内置及第三方中间件,并提供完整的 curl 命令来验证其效果。
  4. 最后,我们将亲手 编写自定义中间件,实现一个请求日志记录器和一个简单的 API 密钥认证,将理论知识转化为实践能力。

5.1. 中间件的核心概念:请求处理的“流水线”

深度讲解:
我们可以将 Express 的请求处理过程想象成一条 工业流水线。当一个 HTTP 请求(原材料)进入 Express 应用时,它会被放到这条流水线上。流水线由一系列的 中间件(工人/工站)组成。

每个中间件都是一个函数,它接收三个参数:req (请求对象)、res (响应对象) 和 next (一个回调函数)。

在这条流水线上,每个中间件(工人)都可以做三件事:

  1. 执行任何代码: 它可以读取或修改 reqres 对象。例如,记录请求信息,或者给 req 对象附加一些额外的数据(如用户信息)。
  2. 结束请求-响应周期: 它可以直接处理完这个请求,并向客户端发送响应(如 res.json(...))。这相当于一个工人完成了所有工序,直接将成品打包出厂。
  3. 调用 next() 将控制权传递给下一个中间件: 这是最常见的行为。它表示“我的工序处理完了,交给流水线上的下一个工人继续处理”。如果一个中间件既没有发送响应,也没有调用 next(),那么这个请求就会被“挂起”,客户端将永远等不到响应。

这个“流水线”模型,就是 Express 框架强大扩展性的根源。


5.2. 中间件的分类与加载顺序

深度讲解:
中间件的强大之处不仅在于其功能,还在于其灵活的加载方式。我们可以精确地控制一个中间件是全局生效,还是只对特定路由生效。而这一切的关键,在于 加载顺序

在 Express 中,中间件和路由的注册顺序至关重要。 请求会按照代码中定义的顺序,依次通过匹配的中间件。

5.2.1. 中间件的分类

  1. 应用级中间件: 通过 app.use()app.METHOD() 绑定到 app 对象上。这是最常见的类型,通常用于全局配置。
  2. 路由级中间件: 绑定到 express.Router() 实例上。当我们使用 express.Router 模块化路由时会用到(第六章将详述)。
  3. 错误处理中间件: 这是唯一一种有 4 个参数 (err, req, res, next) 的特殊中间件。它用于集中捕获和处理路由中发生的错误。
  4. 内置中间件: Express 官方提供的中间件,如 express.json()express.urlencoded()
  5. 第三方中间件: 由社区开发,通过 npm 安装的中间件,如 corsmorgan

5.2.2. 加载顺序实战验证

让我们通过一个例子,直观地感受加载顺序的重要性。

order-test.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import express from "express";
const app = express();

// 1. 应用级中间件 #1:全局日志记录器
// 它会对所有后续的请求生效
app.use((req, res, next) => {
console.log(`[${new Date().toISOString()}] 1. 全局中间件 #1 - 记录请求`);
next(); // 将请求传递给下一个中间件
});

// 2. 路由处理器 #A: 处理 /public 路径
app.get("/public", (req, res) => {
console.log("抵达 /public 路由处理器");
res.send("这是公开访问的页面");
});

// 3. 应用级中间件 #2:模拟认证检查
// 注意它的位置!它只对在它之后定义的路由生效
app.use((req, res, next) => {
console.log("2. 全局中间件 #2 - 模拟认证");
req.user = { name: "Admin" }; // 给 req 对象附加数据
next();
});

// 4. 路由处理器 #B: 处理 /private 路径
app.get("/private", (req, res) => {
console.log("抵达 /private 路由处理器");
res.send(`欢迎, ${req.user.name}!这里是私有页面`);
});

app.listen(3000, () => console.log("服务器启动,端口 3000"));

验证环节

打开两个终端。一个运行 node order-test.js,另一个用来发送 curl 请求。

场景一:请求 /public

1
curl http://localhost:3000/public

服务器终端输出:

1
2
[2025-09-14T01:30:15.123Z] 1. 全局中间件 #1 - 记录请求
抵达 /public 路由处理器

分析: 请求首先通过全局中间件 #1,然后直接匹配到了 /public 路由并结束,它永远不会经过全局中间件 #2

场景二:请求 /private

1
curl http://localhost:3000/private

服务器终端输出:

1
2
3
[2025-09-14T01:30:20.456Z] 1. 全局中间件 #1 - 记录请求
2. 全局中间件 #2 - 模拟认证
抵达 /private 路由处理器

分析: 请求依次通过了全局中间件 #1 和 #2,最终到达 /private 路由。因此,在 /private 处理器中可以成功访问到由中间件 #2 附加的 req.user


5.3. 常用内置与第三方中间件

5.3.1. express.json() - 解析 JSON 请求体

  • 痛点: 如果没有它,POST, PUT, PATCH 请求中 Content-Typeapplication/json 的请求体将无法被解析,req.body 将是 undefined
  • 安装: 内置,无需安装。
  • 使用: app.use(express.json());

json-test.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import express from 'express';
const app = express();

// 如果注释掉下面这行,req.body 将是 undefined
app.use(express.json());

app.post('/users', (req, res) => {
console.log('收到的请求体:', req.body);
const username = req.body.username;
if (username) {
res.status(201).json({ message: `用户 ${username} 创建成功` });
} else {
res.status(400).json({ error: '用户名是必需的' });
}
});

app.listen(3000);

验证环节:

1
curl -X POST -H "Content-Type: application/json" -d "{\"username\":\"awakening\"}" http://localhost:3000/users

服务器日志: 收到的请求体: { username: 'awakening' }

5.3.2. cors - 处理跨域资源共享

  • 痛点: 作为前端开发者,您对浏览器报的 CORS 错误再熟悉不过了。默认情况下,浏览器出于安全考虑,禁止脚本向不同源(域名、协议、端口)的服务器发起 HTTP 请求。
  • 解决方案: 在服务器端使用 cors 中间件,它会自动添加必要的 HTTP 响应头(如 Access-Control-Allow-Origin: *),告诉浏览器:“我允许来自任何源的请求”。
  • 安装: npm install cors
  • 使用: import cors from 'cors'; app.use(cors());

cors-test.js:

1
2
3
4
5
6
7
8
9
10
11
12
import express from 'express';
import cors from 'cors'; // 导入 cors
const app = express();

// 全局启用 CORS,允许所有跨域请求
app.use(cors());

app.get('/api/data', (req, res) => {
res.json({ data: '这份数据可以被任何前端页面获取' });
});

app.listen(3000);

验证环节:
我们可以用 curl -v 来查看响应头。

1
curl -v http://localhost:3000/api/data

5.3.3. morgan - HTTP 请求日志

  • 痛点: console.log 手动打印日志很原始,信息不全且格式不统一。我们需要一个专业的、生产级的请求日志记录器。
  • 解决方案: morgan 是一个广受欢迎的日志中间件,能以多种预设格式输出详细的请求日志。
  • 安装: npm install morgan
  • 使用: import morgan from 'morgan'; app.use(morgan('dev'));

morgan-test.js:

1
2
3
4
5
6
7
8
9
10
11
12
import express from 'express';
import morgan from 'morgan';
const app = express();

// 使用 'dev' 格式,它简洁且带有颜色,适合开发环境
app.use(morgan('dev'));

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

app.listen(3000);

验证环节:
启动服务器后,用 curl 请求。

1
curl http://localhost:3000/

服务器终端输出 (来自 morgan):

这行日志清晰地告诉我们:GET 请求 / 路径,响应状态码 404,耗时 1.487 毫秒,响应体大小 139 字节。


5.4. 编写你的第一个自定义中间件

现在,让我们亲手编写两个中间件来巩固所学。

5.4.1. 场景一:请求时间日志器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import express from "express";
const app = express();

// 自定义日志中间件
const requestLogger = (req, res, next) => {
const startTime = Date.now();
// 使用 res.on('finish', ...) 事件来确保在响应发送完毕后才计算耗时
res.on("finish", () => {
const endTime = Date.now();
const duration = endTime - startTime;
console.log(
`[自定义日志] ${req.method} ${req.originalUrl} - ${res.statusCode} [${duration}ms]`
);
});
next();
};
// 使用自定义日志中间件
app.use(requestLogger);


app.get("/", (req, res) => res.send("首页"));

app.listen(3000, () => console.log("服务器启动,端口 3000"));

5.4.2. 场景二:简单的 API 密钥认证

这是一个更实用的例子,它保护一个路由,只有提供了正确 API 密钥的请求才能访问。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import express from "express";
const app = express();

const MY_SECRET_API_KEY = "prorise-is-awesome";

// 自定义 API 密钥认证中间件
const apiKeyAuth = (req, res, next) => {
const provideKey = req.get("x-api-key"); // 从请求头获取 'x-api-key'
if (provideKey && provideKey === MY_SECRET_API_KEY) {
next();
} else {
res.status(401).json({ error: "无效的 API 密钥" });
}
};

app.get("/public", (req, res) => res.send("公开数据"));
// 将 apiKeyAuth 中间件应用到这个特定的路由上
app.get("/private/data", apiKeyAuth, (req, res) => {
res.json({ secret: "这是只有通过认证才能看到的核心数据" });
});

app.listen(3000, () => console.log("服务器启动,端口 3000"));

验证环节:
请求一:没有提供 API 密钥

1
curl http://localhost:3000/private/data

请求二:提供了正确的 API 密钥

1
curl -H "x-api-key: prorise-is-awesome" http://localhost:3000/private/data

这个例子完美地展示了中间件作为“守卫”的强大能力。


5.5. 本章核心速查总结

分类关键项核心描述
核心签名(req, res, next)标准中间件的函数签名。
(err, req, res, next)错误处理中间件的特殊签名,必须有 4 个参数。
核心函数next()将控制权传递给流水线中的下一个中间件。
加载方式app.use(middleware)将中间件应用到所有后续的路由和中间件。顺序很重要
app.get(path, middleware, ...)将中间件仅应用于特定的路由。
常用中间件express.json()(内置) 解析 application/json 格式的请求体。
cors()(第三方) 轻松解决浏览器跨域请求问题。
morgan('dev')(第三方) 添加专业的、彩色的 HTTP 请求日志。

5.6. 高频面试题与陷阱

面试官深度追问
2025-09-14

在 Express 中,中间件的加载顺序非常重要。你能举一个例子来说明,如果顺序错误会导致什么问题吗?

当然。一个最经典的例子就是 express.json() 和需要解析请求体的路由处理器的顺序。

假设我先定义了 app.post(‘/users’, …) 这个路由,在它的处理函数内部我尝试去读取 req.body。然后,在这段路由代码的后面,我才写 app.use(express.json())。

那么当一个 POST 请求发送到 /users 时,会发生什么?

当请求到达时,它会首先匹配到 /users 的路由处理器。在这个处理器内部,代码尝试访问 req.body,但因为负责解析 JSON 请求体的 express.json() 中间件还没有被执行,所以此时 req.body 还是 undefined。代码如果尝试从 undefined 上解构或读取属性,就会直接抛出一个 TypeError,导致程序出错。

非常准确。那正确的做法是什么?

正确的做法是始终将像 express.json()、cors、morgan 这类全局性的中间件,放在所有业务路由定义之前。这样就能确保,当请求到达任何一个具体的业务路由处理器时,日志记录、CORS 检查、请求体解析等所有“预处理”工作都已经完成了。

很好。这说明你深刻理解了 Express 的“流水线”模型。