第六章:路由设计与数据验证

第六章:路由设计与数据验证

摘要: 随着应用功能的扩张,将所有路由逻辑堆砌在主文件 app.js 中将导致代码混乱,难以维护。同时,API 的一个核心职责是确保数据的完整性和安全性,这意味着我们绝不能信任任何来自客户端的输入。本章,我们将解决这两个核心问题。首先,我们将学习使用 express.Router 将路由按功能模块(如用户、产品)进行拆分和管理。接着,我们将引入强大的数据验证库 zod,学习如何定义数据“契约”,并创建一个可复用的验证中间件来自动化地清洗和校验输入。最后,我们将正式实战 Express 的 全局错误处理中间件,学习如何优雅地捕获验证失败等错误,并向客户端返回清晰、规范的响应。


在本章,我们将为应用构建坚固的“骨架”和可靠的“免疫系统”:

  1. 首先,我们将使用 PowerShell 快速搭建一个符合最佳实践的 Express 项目目录结构。
  2. 接着,我们将回顾 RESTful API 设计原则,确保我们的 API 设计是规范和易于理解的。
  3. 然后,我们将深入学习 express.Router,将庞杂的路由体系拆解为独立的、可维护的模块。
  4. 之后,我们将引入 zod,为我们的 API 定义严格的数据输入规范。
  5. 最后,我们将把所有知识点融会贯通:创建一个 通用的验证中间件,并结合 全局错误处理中间件 来处理验证失败的情况,提供完整的、可验证的实战闭环。

6.1. 初始化标准项目结构 (PowerShell)

承上启下: 在编写代码之前,一个清晰的目录结构是成功的一半。我们将使用 PowerShell 命令,一键生成我们在第三章讨论过的标准项目结构,为后续的路由模块化做好准备。

第一步:创建项目并初始化
打开 PowerShell 终端,执行以下命令:

1
2
3
4
5
6
7
8
9
10
11
12
# 创建项目根目录并进入
New-Item -ItemType Directory -Name "my-structured-api"
Set-Location "my-structured-api"

# 初始化 package.json
npm init -y

# 安装核心依赖
npm install express zod
npm install nodemon --save-dev

# 在 package.json 中设置 "type": "module"

第二步:创建目录和文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 创建源代码目录结构
New-Item -ItemType Directory -Path "src/api", "src/middlewares", "src/utils", "src/controllers"

# 创建核心文件
$files = @(
"src/app.js",
"src/api/index.js",
"src/api/users.routes.js",
"src/controllers/users.controller.js",
"src/middlewares/errorHandler.js",
"src/middlewares/validate.js"
)
foreach ($file in $files) {
New-Item -ItemType File -Path $file
}

执行完毕后,你就拥有了一个整洁的、随时可以开始编码的项目骨架。


6.2. RESTful API 设计原则回顾

痛点背景: API 的设计如同城市的交通规划,如果毫无章法(例如 POST /getUserGET /createUser),将会导致使用者(前端开发者或其他服务)的困惑和误用。

解决方案: 遵循 REST (Representational State Transfer) 设计原则,它提供了一套广为接受的、基于 HTTP 协议的最佳实践。核心思想是:将 URL 视为资源(名词),将 HTTP 方法视为对资源的操作(动词)

资源 (名词)GET (读取)POST (新建)PUT / PATCH (更新)DELETE (删除)
/users获取所有用户列表新建一个用户(不常用) 批量更新(不常用) 删除所有用户
/users/:id获取指定 ID 的用户(不适用)更新指定 ID 的用户删除指定 ID 的用户

坚持 RESTful 风格能让你的 API 具有更好的可读性、可预测性和可维护性。


6.3. express.Router: 模块化你的 API

痛点背景: 当我们的应用增长时,app.js 文件会充斥着大量的 app.get, app.post, app.put… 这使得文件臃肿,且不同业务模块的路由逻辑耦合在一起。

解决方案: express.Router 是一个可插拔的、迷你的 Express “子应用”。我们可以为每个资源(如 users, products)创建一个独立的路由文件,然后在主应用 app.js 中将它们“挂载”到指定的路径前缀下。

第一步: 在 users.routes.js 中定义路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// file: src/api/users.routes.js
import { Router } from 'express';

// 1. 创建一个新的路由实例
const router = Router();

// 2. 在这个 router 实例上定义路由
// 注意:这里的路径是相对于它被挂载的路径
router.get('/', (req, res) => {
res.json({ message: 'GET /users - 获取所有用户' });
});

router.post('/', (req, res) => {
res.status(201).json({ message: 'POST /users - 创建新用户' });
});

router.get('/:id', (req, res) => {
res.json({ message: `GET /users/${req.params.id} - 获取单个用户` });
});

// 3. 导出 router
export default router;

第二步: 在 app.js 中挂载路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// file: src/app.js
import express from 'express';
import usersRouter from './api/users.routes.js'; // 导入用户路由

const app = express();
const PORT = 3000;

app.use(express.json());

// 3. 将用户路由挂载到 /api/v1/users 前缀下
// 所有来自 './api/users.routes.js' 的路由都会自动加上这个前缀
// 例如: router.get('/') 实际对应的 URL 是 GET /api/v1/users
app.use('/api/v1/users', usersRouter);

// ...可以继续挂载其他路由,如 productsRouter

app.listen(PORT, () => {
console.log(`🚀 服务器已启动,正在监听 http://localhost:${PORT}`);
});

验证环节:

1
2
# 请求的是 /api/v1/users/:id
curl http://localhost:3000/api/v1/users/123

通过这种方式,app.js 变得极其整洁,只负责加载全局中间件和挂载各个模块的路由。


6.4. Zod 入门:定义数据的“契约”

痛点背景: 用户的输入是不可预测且不可信任的。他们可能会忘记填写必填项、输入错误的数据类型(如年龄输入 “abc”),甚至尝试通过恶意的输入来攻击你的系统。如果在接收到数据后不加验证就直接存入数据库,轻则导致脏数据,重则引发程序崩溃或安全漏洞。

解决方案: 使用模式验证 (Schema Validation) 库,在数据进入你的业务逻辑之前,强制对其进行检查。zod 是 2025 年 TypeScript/JavaScript 生态中的首选库,它类型推断能力强,API 链式调用清晰易懂。

第一步:安装 Zod

1
npm install zod

第二步:创建数据契约 (Schema)
我们可以在 users.routes.js (或专门的 users.schemas.js 文件) 中定义创建用户时所期望的数据结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// file: src/api/users.routes.js (文件顶部)
import { Router } from 'express';
import { z } from 'zod'; // 导入 zod

// 定义创建用户时的验证 schema
const createUserSchema = z.object({
body: z.object({
// username 必须是字符串,且最小长度为 3
username: z.string().min(3, {
message: "用户名长度不能少于3个字符"
}),
// email 必须是字符串,且符合 email 格式
email: z.string().email({
message: "请输入有效的邮箱地址"
}),
// password 必须是字符串,最小 6 位,最大 100 位
password: z.string().min(6).max(100),
// age 是数字,且是可选的
age: z.number().optional(),
}),
query: z.object({}).optional(),
params: z.object({}).optional(),
});

6.5. 实战:创建验证中间件与全局错误处理

现在,我们将所有知识点串联起来,构建一个完整的、健壮的创建用户 API。

第一步:创建可复用的验证中间件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// file: src/middlewares/validate.js
import { ZodError } from 'zod';

// 这是一个高阶函数:它接收一个 schema,返回一个中间件函数
const validate = (schema) => (req, res, next) => {
try {
// 使用 zod 的 parse 方法进行验证
// 如果验证通过,什么都不会发生,请求会继续
schema.parse({
body: req.body,
query: req.query,
params: req.params,
});
next();
} catch (error) {
// 如果验证失败, zod 会抛出一个 ZodError
// 我们将这个错误传递给 next(),由全局错误处理器来处理
next(error);
}
};

export default validate;

第二步:创建全局错误处理中间件
这是我们上一章承诺要演示的功能,它是一个拥有 4 个参数的特殊中间件。

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
// file: src/middlewares/errorHandler.js
import {
ZodError
} from 'zod';

const errorHandler = (err, req, res, next) => {
// 判断错误是否是 Zod 验证错误
if (err instanceof ZodError) {
console.error('Zod错误详情:', err.issues); // 打印Zod错误详情
// 如果是,返回 400 状态码和格式化后的错误信息
return res.status(400).json({
error: "输入数据无效",
issues: (err.issues || []).map(issue => ({
path: issue.path ? issue.path.join('.') : 'unknown',
message: issue.message || '未知错误',
})),
});
}

// 对于其他所有类型的错误,打印错误详情并返回一个通用的 500 服务器错误
console.error('服务器错误详情:', err);
res.status(500).json({
error: "服务器内部错误",
details: process.env.NODE_ENV === 'development' ? err.message : undefined
});
};

export default errorHandler;

第三步:在 app.js 中应用错误处理器

1
2
3
4
5
6
7
8
9
10
// file: src/app.js
// ... 其他 imports
import errorHandler from './middlewares/errorHandler.js'; // 导入错误处理器

// ... app.use(express.json()) 和 app.use('/api/v1/users', ...)

// !!! 关键:错误处理中间件必须在所有路由和普通中间件之后定义
app.use(errorHandler);

app.listen(PORT, ...);

第四步:在路由中使用验证中间件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// file: src/api/users.routes.js
// ... 其他 imports
import validate from '../middlewares/validate.js'; // 导入验证中间件

// ... 定义 createUserSchema 和 router

// 将 validate(createUserSchema) 作为路由的中间件
router.post('/', validate(createUserSchema), (req, res) => {
// 如果代码能执行到这里,说明数据验证已经通过
const { username } = req.body;
res.status(201).json({ message: `用户 ${username} 创建成功`, data: req.body });
});

export default router;

验证环节

场景一:发送有效数据

1
2
# 注意在 PowerShell 中,需要将 JSON 字符串转义
curl -X POST -H "Content-Type: application/json" -d '{"username":"awakening", "email":"architect@p.rise", "password":"123456"}' http://localhost:3000/api/v1/users

场景二:发送无效数据(用户名太短,邮箱格式错误)

1
curl -X POST -H "Content-Type: application/json" -d "{\"username\":\"a\", \"email\":\"bad-email\", \"password\":\"123456\"}" http://localhost:3000/api/v1/users

这个结果完美地展示了我们的验证和错误处理流水线:validate 中间件捕获了 zod 抛出的错误,通过 next(error) 传递给了 errorHandler,后者最终向客户端返回了一个结构清晰、信息明确的 400 错误响应。


6.6. 本章核心速查总结

分类关键项核心描述
路由设计express.Router()创建一个可模块化的路由处理器实例。
app.use('/prefix', router)将一个路由模块挂载到应用的主路径前缀下。
数据验证zod(推荐) 用于定义数据模式 (Schema) 和进行验证的库。
z.object({ key: rule })创建一个对象模式,定义其属性和验证规则。
schema.parse(data)使用模式验证数据,如果失败则会抛出 ZodError
错误处理next(error)在中间件中,将错误传递给下一个错误处理中间件。
app.use((err, req, res, next))(关键) 定义全局错误处理中间件,必须有 4 个参数,且放在路由之后。

6.7. 高频面试题与陷阱

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

Express 的错误处理中间件有一个很特别的地方,它有 4 个参数 (err, req, res, next)。你能解释一下为什么它必须有这 4 个参数,以及它必须放在代码的什么位置吗?

当然。这个特殊的设计是 Express 区分普通中间件和错误处理中间件的方式。当我们在代码中调用 next() 时,如果带有参数,比如 next(error),Express 会跳过所有后续的普通中间件,直接寻找第一个注册的、拥有这 4 个参数签名的错误处理中间件。

说得很清楚。那位置呢?为什么它必须放在所有 app.use() 和路由的后面?

这是因为它扮演的是“捕捞网”的角色。Express 的请求处理是一个线性的流水线。如果把错误处理中间件放在最前面,那么在它后面定义的路由或中间件中发生的任何错误,它都无法捕获到,因为请求的控制权已经经过了它。所以,它必须被放在流水线的末端,才能确保捕获到从它前面任何一个环节传递过来的错误。

那么,如果在一个路由处理器中,我使用了一个异步函数,比如查询数据库,然后在 .catch() 块里捕获了错误,我应该怎么把这个错误交给全局错误处理器?

在异步函数的 .catch(error) 块里,我应该显式地调用 next(error)。这样,Express 的机制就能确保这个异步操作中捕获到的错误,同样能被我们定义在末尾的全局错误处理器接收并进行统一处理。