第六章:路由设计与数据验证
第六章:路由设计与数据验证
Prorise第六章:路由设计与数据验证
摘要: 随着应用功能的扩张,将所有路由逻辑堆砌在主文件 app.js
中将导致代码混乱,难以维护。同时,API 的一个核心职责是确保数据的完整性和安全性,这意味着我们绝不能信任任何来自客户端的输入。本章,我们将解决这两个核心问题。首先,我们将学习使用 express.Router
将路由按功能模块(如用户、产品)进行拆分和管理。接着,我们将引入强大的数据验证库 zod
,学习如何定义数据“契约”,并创建一个可复用的验证中间件来自动化地清洗和校验输入。最后,我们将正式实战 Express 的 全局错误处理中间件,学习如何优雅地捕获验证失败等错误,并向客户端返回清晰、规范的响应。
在本章,我们将为应用构建坚固的“骨架”和可靠的“免疫系统”:
- 首先,我们将使用 PowerShell 快速搭建一个符合最佳实践的 Express 项目目录结构。
- 接着,我们将回顾 RESTful API 设计原则,确保我们的 API 设计是规范和易于理解的。
- 然后,我们将深入学习
express.Router
,将庞杂的路由体系拆解为独立的、可维护的模块。 - 之后,我们将引入
zod
,为我们的 API 定义严格的数据输入规范。 - 最后,我们将把所有知识点融会贯通:创建一个 通用的验证中间件,并结合 全局错误处理中间件 来处理验证失败的情况,提供完整的、可验证的实战闭环。
6.1. 初始化标准项目结构 (PowerShell)
承上启下: 在编写代码之前,一个清晰的目录结构是成功的一半。我们将使用 PowerShell 命令,一键生成我们在第三章讨论过的标准项目结构,为后续的路由模块化做好准备。
第一步:创建项目并初始化
打开 PowerShell 终端,执行以下命令:
1 | # 创建项目根目录并进入 |
第二步:创建目录和文件
1 | # 创建源代码目录结构 |
执行完毕后,你就拥有了一个整洁的、随时可以开始编码的项目骨架。
6.2. RESTful API 设计原则回顾
痛点背景: API 的设计如同城市的交通规划,如果毫无章法(例如 POST /getUser
,GET /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 | // file: src/api/users.routes.js |
第二步: 在 app.js
中挂载路由
1 | // file: src/app.js |
验证环节:
1 | # 请求的是 /api/v1/users/:id |
1
{"message":"GET /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 | // file: src/api/users.routes.js (文件顶部) |
6.5. 实战:创建验证中间件与全局错误处理
现在,我们将所有知识点串联起来,构建一个完整的、健壮的创建用户 API。
第一步:创建可复用的验证中间件
1 | // file: src/middlewares/validate.js |
第二步:创建全局错误处理中间件
这是我们上一章承诺要演示的功能,它是一个拥有 4 个参数的特殊中间件。
1 | // file: src/middlewares/errorHandler.js |
第三步:在 app.js
中应用错误处理器
1 | // file: src/app.js |
第四步:在路由中使用验证中间件
1 | // file: src/api/users.routes.js |
验证环节
场景一:发送有效数据
1 | # 注意在 PowerShell 中,需要将 JSON 字符串转义 |
1
{"message":"用户 awakening 创建成功","data":{"username":"awakening","email":"architect@p.rise","password":"123456"}}
场景二:发送无效数据(用户名太短,邮箱格式错误)
1 | curl -X POST -H "Content-Type: application/json" -d "{\"username\":\"a\", \"email\":\"bad-email\", \"password\":\"123456\"}" http://localhost:3000/api/v1/users |
1
2
3
4
5
6
7
8
9
10
11
12
13
{
"error": "输入数据无效",
"issues": [
{
"path": "body.username",
"message": "用户名长度不能少于3个字符"
},
{
"path": "body.email",
"message": "请输入有效的邮箱地址"
}
]
}
这个结果完美地展示了我们的验证和错误处理流水线: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. 高频面试题与陷阱
Express 的错误处理中间件有一个很特别的地方,它有 4 个参数 (err, req, res, next)。你能解释一下为什么它必须有这 4 个参数,以及它必须放在代码的什么位置吗?
当然。这个特殊的设计是 Express 区分普通中间件和错误处理中间件的方式。当我们在代码中调用 next() 时,如果带有参数,比如 next(error),Express 会跳过所有后续的普通中间件,直接寻找第一个注册的、拥有这 4 个参数签名的错误处理中间件。
说得很清楚。那位置呢?为什么它必须放在所有 app.use() 和路由的后面?
这是因为它扮演的是“捕捞网”的角色。Express 的请求处理是一个线性的流水线。如果把错误处理中间件放在最前面,那么在它后面定义的路由或中间件中发生的任何错误,它都无法捕获到,因为请求的控制权已经经过了它。所以,它必须被放在流水线的末端,才能确保捕获到从它前面任何一个环节传递过来的错误。
那么,如果在一个路由处理器中,我使用了一个异步函数,比如查询数据库,然后在 .catch() 块里捕获了错误,我应该怎么把这个错误交给全局错误处理器?
在异步函数的 .catch(error) 块里,我应该显式地调用 next(error)。这样,Express 的机制就能确保这个异步操作中捕获到的错误,同样能被我们定义在末尾的全局错误处理器接收并进行统一处理。