首页 / 文章 / 守护请求边界:一款用于处理请求体、参数及查询的Zod中间件

守护请求边界:一款用于处理请求体、参数及查询的Zod中间件

了解如何使用一个可复用的 Zod 中间件来验证 Express 请求体、路由参数和查询字符串,以及它如何与 Sequelize 模型验证相辅相成。

1982 词

没有任何东西能阻止客户端在 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 验证器与数据库约束作为第二层防护,而非替代方案。
    • 将模式与其对应的模块放在一起,使契约随代码一同变更。

    相关阅读

  • Identify vs Shape:在 Express 中选择路由参数还是查询字符串 — 了解何时应将某个值放入 Express 路由参数中而非查询字符串,如何读取 req.params 和 req.query,以及如何安全地处理默认值和数据类型。
  • 前端团队适用的 HTTP QUERY 方法:带请求体的安全读取方式 — 了解在复杂过滤场景下为何 HTTP QUERY 方法优于 GET 和 POST,如何使用 fetch 调用该方法,以及需要哪些 CORS、缓存和基础设施支持。