守护请求边界:一款用于处理请求体、参数及查询的Zod中间件
了解如何使用一个可复用的 Zod 中间件来验证 Express 请求体、路由参数和查询字符串,以及它如何与 Sequelize 模型验证相辅相成。
没有任何东西能阻止客户端在 API 需要名称的位置输入数字,或在需要密码的位置输入 null。那些盲目信任 req.body 的代码最终会生成有问题的数据行,或抛出与实际原因无关的错误。本指南将展示如何使用 Zod 一次性定义有效的输入格式,在单个 Express 中间件中同时验证请求体、路由参数和查询字符串,从而让控制器专注于业务逻辑。
问题:请求数据缺乏类型定义
以下是一个完全合法但任何注册接口都不应接受的 HTTP 请求载荷:
{
"fullName": 123,
"email": "hello",
"password": null
}
每个字段的格式都不正确。Zod 是一个针对 JavaScript 和 TypeScript 的架构验证库,它能让你精确地指定期望的数据格式,并返回干净的数据或结构化的错误列表。
通过架构定义输入格式
假设注册需要一个 fullName 字符串、格式正确的 email、至少八位的 password,以及可选的整数型 age。在 Zod 中,这些要求几乎可以直接以代码形式体现:
const { z } = require('zod');
const registerSchema = z.object({
fullName: z.string().min(2),
email: z.string().email(),
password: z.string().min(8),
age: z.number().int().min(18).optional()
});
所有规则都集中在一个对象中,而非分散在多个 if 语句中。年龄规则还规定了最低年龄为18岁:未提供年龄则通过验证,16岁的年龄则无法通过。
安装与导入
Zod 是普通的 npm 依赖项:
npm install zod
在 CommonJS 环境中,可使用 require 导入 z 命名空间:
const { z } = require('zod');
在 ES 模块环境中,则使用带名称的导入方式:
import { z } from 'zod';
添加易于理解的错误信息
每个验证器都接受一个可选的消息参数,这便是客户端将看到的内容:
const registerSchema = z.object({
fullName: z.string().min(2, 'Full name is required'),
email: z.string().email('Invalid email'),
password: z
.string()
.min(8, 'Password must be at least 8 characters'),
age: z
.number()
.int()
.min(18)
.optional()
});
符合所有规则的负载会原样通过:
{
"fullName": "John Smith",
"email": "john@example.com",
"password": "password123",
"age": 25
}
这个数据的名称过短,地址没有域名,密码也只有三个字符:
{
"fullName": "J",
"email": "invalid-email",
"password": "123"
}
Zod会同时报告这三种问题,因此表单只需一次请求就能标出所有无效字段。
较新版本的Zod(v4及以后)还提供了如z.email()这样的顶级验证器,并已废弃链式z.string().email()写法。虽然链式写法仍然可用,但请查阅对应版本的文档。
parse()与safeParse()的选择
parse()会抛出异常
parse()会返回已验证的数据,或抛出ZodError异常:
const data = registerSchema.parse(req.body);
在Express处理程序中,你需要自行捕获该异常或使用next(err)将其传递下去。
safeParse() 返回结果
safeParse() 永不会抛出异常。它会返回一个包含 success 标志的对象,这更适用于请求处理,因为无效输入是预期结果而非异常情况:
const result = registerSchema.safeParse(req.body);
当处理失败时,error.issues 会列出每个问题及其路径和消息,便于返回 400 错误响应:
if (!result.success) {
return res.status(400).json({
success: false,
errors: result.error.issues
});
}
处理成功时,result.data 会存储解析后的值:
const data = result.data;
从现在开始请使用 result.data,而非 req.body:未知键会被自动移除,且已应用了类型转换和默认值处理。
从内联检查到可复用的中间件
最简单的集成方式是在处理函数内部调用 safeParse():
app.post('/register', (req, res) => {
const result = registerSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
success: false,
message: 'Validation failed',
errors: result.error.issues
});
}
const data = result.data;
console.log(data);
// Continue with registration logic...
return res.status(201).json({
success: true,
data
});
});
这种方法确实可行,但当端点数量达到20或50个时,相同的代码行会被复制到每个控制器中,逐渐变得难以维护。另外请注意,此示例会将已验证的对象(包括密码)原样返回给客户端;而真正的端点应仅返回非敏感字段。
validate()工厂函数
下面的工厂函数接收一个架构定义,然后返回一个Express处理程序。它会同时验证请求体、参数和查询字符串,验证失败时返回400错误码,否则在调用next()之前将解析后的结果存储到req.validated中:
const validate = (schema) => {
return (req, res, next) => {
const result = schema.safeParse({
body: req.body,
params: req.params,
query: req.query
});
if (!result.success) {
return res.status(400).json({
success: false,
message: 'Validation failed',
errors: result.error.issues
});
}
req.validated = result.data;
next();
};
};
module.exports = validate;
有两点需要注意。将数据写入独立的req.validated属性可以避免在Express 5中出现问题,因为在该版本中req.query仅为获取器,无法直接重新赋值。此外,由于中间件会将输入数据封装为{ body, params, query }格式,因此架构设计也必须遵循这一结构。如果直接使用扁平的registerSchema,它会试图在顶层查找fullName字段并拒绝所有请求,因此应将其封装为z.object({ body: registerSchema }),或者让中间件仅验证req.body内容。
将其集成到路由中
该中间件位于路径处理与控制器之间:
router.post(
'/register',
validate(registerSchema),
register
);
请求处理流程变为:
Request
↓
Express Router
↓
Zod Validation Middleware
↓
Controller
↓
Service
↓
Database
无效的输入会在中间件处被拦截,控制器不会被执行;而有效的输入则会继续处理,其数据格式必然符合预设的架构要求。
让控制器专注于业务逻辑
如果没有验证层,控制器会同时处理所有相关事务:
const register = async (req, res) => {
// validation
// check email
// validate password
// validate name
// business logic
// database operation
};
有了中间件之后,控制器只需读取已验证过的值:
const register = async (req, res) => {
const {
fullName,
email,
password
} = req.validated.body;
// Business logic
};
此外,还可以用普通对象对架构进行单元测试,控制器测试也不再需要为每种格式错误的请求数据都编写测试用例。
通过类型转换验证路由参数
同样的方法也适用于URL中的各个片段。以查询某个用户的请求为例:
GET /users/123
id参数的架构定义:
const userParamsSchema = z.object({
id: z.coerce.number().int().positive()
});
如前所述,会在(使用上述中间件时)的params键下附加该架构:
router.get(
'/users/:id',
validate(userParamsSchema),
getUser
);
关键在于类型转换:
z.coerce.number()
URL中的所有内容都是文本。其值
req.params.id
以字符串形式传递
"123"
而非数字形式
123
普通的 z.number() 会拒绝所有请求。z.coerce.number() 首先将输入传递给 Number(),然后再应用 .int() 和 .positive() 方法。有一个特殊情况:Number('') 的结果是 0,因此空值会被视为零。此时 .positive() 方法可以处理这种情况,但如果架构中没有设置下限,则空值仍会被允许通过。
使用默认值验证查询字符串
分页是典型的查询字符串应用场景:
GET /users?page=1&limit=10
通过强制转换和默认值处理,即使客户端未提供数值,也能得到安全的数字结果:
const userQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(10)
});
.max(100) 的限制还能防止客户端一次性请求一百万条记录。
常见的 Zod 构建模块
大多数架构都是由少量组件组合而成的:
z.string()、z.number()、z.boolean()用于检查原始类型。z.object()用于描述对象的结构;z.array()用于验证数组及其元素。z.enum()将值限制在固定的选项列表中。.min()和.max()用于限制数值或字符串/数组的长度。.email()用于检查电子邮件格式;.int()要求输入为整数;.positive()要求值大于零。.optional()允许字段缺失;.nullable()允许显式使用null值;.default()用于填充缺失的值。z.coerce是一个命名空间而非函数:z.coerce.number()及其相关函数会在验证之前对输入进行转换。
.refine()用于添加自定义规则;.transform()则在值通过验证后对其进行重新处理。.parse()在失败时会抛出异常;.safeParse()则返回成功或错误的结果。示例:包含角色的用户记录
在托儿所管理应用中,一个用户的信息可能如下所示:
const userSchema = z.object({
fullName: z.string().min(2),
email: z.string().email(),
role: z.enum([
'admin',
'teacher',
'parent'
]),
isActive: z.boolean().default(true)
});
z.enum()会拒绝任何其他角色,而当省略isActive时其默认值为true。该架构同时兼具文档功能。
Zod与Sequelize负责不同的验证层级
使用Sequelize和MySQL的团队常常会问,既然模型已有验证器,为何还需要Zod。实际上两者负责不同的验证范围。
Zod负责保护API接口
它在应用程序代码对其进行处理之前,会先检查通过HTTP传入的数据:
HTTP Request
↓
Zod
↓
Controller
Sequelize负责保护数据层
其验证器在模型被保存时于服务层深处运行:
Controller
↓
Service
↓
Sequelize
↓
MySQL
同时使用两者
它们共同构成了两个独立的层次结构:
Client
↓
Express
↓
Zod
↓
Controller
↓
Service
↓
Sequelize
↓
MySQL
Zod 能够快速返回对客户端友好的 400 错误响应;Sequelize 则能捕获应用程序内部产生的错误,比如后台任务生成的无效记录。而像 NOT NULL 这样的数据库约束以及唯一索引则仍是最后的保障。
在大型代码库中组织架构
在基于模块的项目中,每个模块的路由、控制器和服务文件旁边都会有一份验证文件,而通用的中间件则放在单独的文件夹中:
src/
├── modules/
│ └── users/
│ ├── user.controller.js
│ ├── user.service.js
│ ├── user.routes.js
│ └── user.validation.js
│
├── middleware/
│ └── validate.js
│
└── app.js
user.validation.js 用于导出该模块的架构定义:
const { z } = require('zod');
const createUserSchema = z.object({
fullName: z.string().min(2),
email: z.string().email(),
password: z.string().min(8)
});
module.exports = {
createUserSchema
};
这样一来,路由文件就能保持简洁:
router.post(
'/users',
validate(createUserSchema),
createUser
);
当字段发生变化时,控制器及其规则会一同被修改。若要在浏览器中重用相同的架构,请参阅在 React 和 Node 之间共享同一个 Zod 架构。
为何单一真实来源如此重要
如果没有架构定义,验证功能就会以临时性的检查形式混入控制器中:
if (!email) {
// ...
}
if (!password) {
// ...
}
if (password.length < 8) {
// ...
}
if (!['admin', 'teacher'].includes(role)) {
// ...
}
每个接口端点都存在略有不同的实现版本,因此没人能一目了然地看到完整的规范。而相应的架构定义只需几行文字就能清晰表述:
const userSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
role: z.enum(['admin', 'teacher'])
});
这就是 API 与客户端之间的约定,且会在一个地方得到统一执行。如需了解另一种流行方法的对比,请参阅Zod 与 express-validator 的对比。
核心要点
真正的价值在于 Zod 所规定的责任顺序:
Request
↓
Validation
↓
Controller
↓
Business Logic
↓
Database
- 使用
safeParse()在边界处进行验证,仅允许result.data传递给处理函数。 - 将验证功能集中到同一个中间件中,确保每个模式都与它所解析的数据结构相匹配。
- 对参数和查询字符串使用
z.coerce,并对页面大小等值设置上限。 - 将 ORM 验证器与数据库约束作为第二层防护,而非替代方案。
- 将模式与其对应的模块放在一起,使契约随代码一同变更。
相关阅读
- Zod与express-validator:Express验证的两种方法 —— 对比基于Zod的架构优先型请求验证与基于链式的express-validator中间件,涵盖配置、错误格式化及常见陷阱。
- 在React前端与Node后端之间共享同一个Zod架构 —— 了解如何利用单一的Zod架构对React表单、API响应、Express请求体及环境变量进行验证,同时生成对应的TypeScript类型。