参数校验设计

要点

  • 校验的位置比校验的方式重要——在业务逻辑之前拦截非法数据,比在业务逻辑里做防御性判断成本低
  • 客户端校验解决的是用户体验问题,服务端校验解决的是安全问题,两者不能互相替代
  • 校验策略没有唯一正确答案:fail-fast 适合表单提交,collect-all-errors 适合批量导入
  • 白名单策略(只接受已知合法值)在安全边界上比黑名单(拒绝已知非法值)更可靠
  • 类型系统是编译期校验,Zod 是运行时校验,两者覆盖的时间窗口不同,需要同时存在
  • 校验规则的粒度和 API 的演进节奏有关——过严的版本会让客户端频繁收到 breaking change

1. 先看一个不校验会发生什么

01 里展示了 zValidator 的基本用法,这篇先退一步,不聊框架,先看一个真实场景。

假设有一个用户注册接口,前端已经做了表单校验:

// src/index.ts
app.post('/api/users', async (c) => {
  const body = await c.req.json()
  const user = await db.user.create({
    data: {
      name: body.name,
      email: body.email,
      age: body.age,
    },
  })
  return c.json(user, 201)
})

前端有校验,所以「正常用户」不会提交脏数据。但这个接口上线一周后,可能会出现这些问题:

  1. 有人用 curl 直接发请求,age 传了 "abc",数据库里出现了一批年龄为 NaN 的用户
  2. 有人把 name 传成了 5000 个字符的字符串,数据库字段溢出,写入静默截断
  3. 有人传了 email: "",空字符串通过了 typeof === 'string' 检查,数据库里出现了一批无法联系的邮箱
  4. 有人传了 age: -1,业务上没有年龄为负数的概念,但代码里没有拦

这些问题的共同特征是:在数据进入系统之前,缺少一道检查。 等数据进了数据库再修,成本远高于在入口处拦住。

2. 校验在请求生命周期中的位置

一个 HTTP 请求到达业务逻辑之前,会经过几层处理。以 Hono 为例:

请求到达 → 路由匹配 → 中间件链 → 校验中间件 → 业务处理函数

校验应该放在中间件链里,在业务处理函数之前。这个位置选择背后的逻辑是:

  1. 路由匹配只关心 URL 模式,不关心数据内容
  2. 认证中间件关心请求者是谁,不关心请求体是否合法
  3. 校验中间件在认证之后、业务之前,确保进入业务逻辑的数据已经是合法的
  4. 业务处理函数拿到的一定是干净数据,不需要再做防御性检查
// src/index.ts
// 中间件的顺序很重要
app.post(
  '/api/users',
  authMiddleware,      // 1. 先确认请求者身份
  rateLimiter,         // 2. 再检查频率限制
  zValidator('json', createUserSchema),  // 3. 校验数据合法性
  (c) => {
    // 4. 到这里,数据一定是合法的
    const data = c.req.valid('json')
    return c.json(createUser(data), 201)
  }
)

如果把校验放到业务函数内部,校验逻辑和业务逻辑混在一起,业务函数需要关心数据是否合法。中间件方式把校验剥离出来,业务函数拿到的已经是干净数据。

3. 需要校验哪些维度

一个字段可能需要多个维度的校验。以一个创建文章的接口为例:

// src/schemas/article.ts
import { z } from 'zod'
 
export const createArticleSchema = z.object({
  // 存在性:字段必须出现
  title: z.string()
    .min(1, '标题不能为空')       // 非空
    .max(200, '标题不超过 200 字符'), // 范围
  // 格式:必须符合特定模式
  slug: z.string()
    .regex(/^[a-z0-9-]+$/, 'slug 只能包含小写字母、数字和连字符'),
  // 类型 + 范围
  wordCount: z.number()
    .int()
    .min(0)
    .max(1000000),
  // 枚举:只接受特定值
  status: z.enum(['draft', 'published', 'archived']),
  // 可选字段
  summary: z.string().max(500).optional(),
  // 数组
  tags: z.array(z.string().min(1)).max(10),
})

归纳起来,校验维度大致分为:

维度解决的问题Zod 写法
存在性字段是否必须出现z.string() vs z.string().optional()
类型值的数据类型是否正确z.string()z.number()z.boolean()
格式值是否符合特定模式.regex().email().url()
范围数值或字符串长度是否在合理区间.min().max().int()
枚举值是否属于允许的集合z.enum()
结构嵌套对象的层级是否正确z.object()z.array()
业务规则是否满足跨字段的业务约束.refine().superRefine()

前六种可以在 schema 声明中直接表达。第七种(跨字段业务规则)需要额外的 .refine() 回调:

// src/schemas/event.ts
const eventSchema = z.object({
  startDate: z.string().datetime(),
  endDate: z.string().datetime(),
}).refine(
  (data) => new Date(data.endDate) > new Date(data.startDate),
  { message: '结束时间必须晚于开始时间', path: ['endDate'] }
)

4. Fail-fast 与 Collect-all-errors

校验遇到第一个错误时,是立即返回还是继续收集所有错误再一起返回?这两种策略各有适用场景。

Fail-fast:遇到第一个错误就停止。

// src/index.ts
// Zod 默认行为:safeParse 遇到错误后继续解析全部字段
// 但 zValidator 中间件在第一个目标校验失败时就拦截请求
app.post('/api/users', zValidator('json', schema), (c) => {
  // ...
})

Zod 的 safeParse 默认会校验所有字段并收集全部错误。但 zValidator 中间件的行为是:对于同一个目标(比如 'json'),一次 safeParse 会收集该目标内的所有错误,但如果一个路由挂了多个 zValidator(比如先校验 param,再校验 json),前一个失败后后续不会执行。

Collect-all-errors:收集所有校验错误,一次性返回给客户端。

// src/index.ts
app.post(
  '/api/users',
  zValidator('json', createUserSchema, (result, c) => {
    if (!result.success) {
      // flatten() 会把所有字段的错误整理成 { field: [messages] } 的结构
      return c.json(
        {
          code: 'VALIDATION_ERROR',
          errors: result.error.flatten().fieldErrors,
        },
        400
      )
    }
  }),
  (c) => {
    return c.json(c.req.valid('json'), 201)
  }
)

返回的错误响应包含所有字段的错误:

{
  "code": "VALIDATION_ERROR",
  "errors": {
    "name": ["String must contain at least 1 character(s)"],
    "email": ["Invalid email"],
    "age": ["Expected number, received string"]
  }
}

选择策略的依据:

  • 表单提交(用户注册、创建文章):collect-all-errors 更合适。用户一次性看到所有需要修改的字段,不需要改完一个提交一次、再改下一个。
  • 流式处理 / 管道(消息队列消费、数据同步):fail-fast 更合适。数据不合法就直接丢弃或进入死信队列,不需要浪费资源继续处理。
  • 批量导入:两种策略可以组合——外层 fail-fast(文件格式不对就直接拒绝整个请求),内层 collect-all-errors(收集每行数据的校验错误,最后返回一份汇总报告)。

5. 客户端校验与服务端校验的关系

前端通常也会有表单校验。两套校验的关系值得理清。

客户端校验的目的是用户体验。用户在输入框旁边立刻看到「邮箱格式不对」,不需要等服务器返回错误。这种反馈速度快、交互体验好。

服务端校验的目的是安全。不管客户端做了什么校验,服务端都必须重新检查。原因包括:

  1. 客户端校验可以被绕过——用 curl、Postman 或浏览器 DevTools 直接发请求
  2. 客户端校验逻辑可能和服务器版本不一致——旧版客户端对接新版 API
  3. 不同的客户端(Web、iOS、Android)可能有不同的校验实现
// src/index.ts
// 服务端校验不能因为「前端已经校验了」就省略或放宽
app.post('/api/users', zValidator('json', createUserSchema), (c) => {
  const data = c.req.valid('json')
  return c.json(createUser(data), 201)
})

两层校验的规则应该保持一致,但实现是独立的。客户端校验不能替代服务端校验,服务端校验也不必重复客户端的交互逻辑。两者的职责边界是:客户端管体验,服务端管安全。

6. 白名单与黑名单

校验策略有两种基本思路:

白名单:只接受明确定义为合法的值。不在白名单里的一律拒绝。

// src/index.ts
// 只接受这三个值,其他全部拒绝
const statusSchema = z.enum(['draft', 'published', 'archived'])
 
// 只允许这些字段通过,多余的字段会被 strip
const createUserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
})
// Zod 默认行为:strip 掉 schema 中没有定义的字段
// createUserSchema.parse({ name: 'Alice', email: '[email protected]', admin: true })
// → { name: 'Alice', email: '[email protected]' }(admin 字段被丢弃)

黑名单:拒绝已知的非法值,其他都放行。

// src/index.ts
// 只排除了几个特定值,其他字符串都能通过
const nameSchema = z.string().refine(
  (val) => !['admin', 'root', 'system'].includes(val.toLowerCase()),
  { message: '不能使用保留名称' }
)

在安全边界上,白名单策略更可靠。黑名单的问题在于:你很难穷举所有非法值。比如限制用户名不能包含 <script>,但攻击者可以用 <img onerror=...> 或其他变体绕过。白名单用正则限定「只允许字母数字下划线」,就不存在这个问题。

// src/index.ts
// 白名单方式:只允许合法字符
const usernameSchema = z.string().regex(/^[a-zA-Z0-9_]{3,20}$/)
// 不管攻击者传什么,不匹配这个模式就一律拒绝

Zod 的 z.object() 默认就是白名单行为——只保留 schema 中声明的字段,未声明的字段会被丢弃(strip 模式)。如果需要严格拒绝未声明字段,可以切换为 strict 模式:

// src/schemas/user.ts
const strictSchema = z.object({
  name: z.string(),
  email: z.string().email(),
}).strict() // 传入未声明字段时直接报错,而不是静默丢弃
 
// strictSchema.safeParse({ name: 'Alice', email: '[email protected]', extra: 1 })
// → { success: false, error: { issues: [{ message: 'Unrecognized key(s)...' }] } }

strip 和 strict 的选择取决于 API 风格。对外 API 用 strip 更宽容(客户端多传字段不会报错),内部 API 用 strict 更早暴露前后端不一致的问题。

7. 类型强制转换与数据规范化

HTTP 请求中的数据天然都是字符串。查询参数 ?page=2 中的 2 是字符串,路由参数 /users/42 中的 42 也是字符串。校验之前或之中,通常需要把字符串转换成目标类型。

Zod 提供了 z.coerce 做隐式转换:

// src/index.ts
const querySchema = z.object({
  // 接收字符串 '2',自动转成数字 2
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().positive().max(100).default(20),
})
 
app.get('/posts', zValidator('query', querySchema), (c) => {
  const { page, limit } = c.req.valid('query')
  // page: number, limit: number
  return c.json({ page, limit })
})

z.coerce.number() 等价于先调 Number(value) 再用 z.number() 校验。如果传入的值无法转换成数字(比如 ?page=abc),Number('abc') 结果是 NaN,后续的 .int().positive() 校验会失败。

除了类型转换,还有一类操作叫数据规范化(normalization)——把合法但不统一的数据格式整理成统一格式:

// src/schemas/user.ts
const createUserSchema = z.object({
  // 转成小写,去掉首尾空格
  email: z.string().email().transform((val) => val.trim().toLowerCase()),
  // 去掉手机号中的空格和连字符
  phone: z.string().regex(/^[\d\s-]+$/).transform((val) => val.replace(/[\s-]/g, '')),
  // 统一日期格式
  birthday: z.string().transform((val) => new Date(val).toISOString().split('T')[0]),
})

规范化放在 transform() 里,好处是校验和转换在同一条链路中完成。schema 声明既是校验规则,也是数据清洗规则。下游代码拿到的数据已经是统一的格式,不需要到处写 trim()toLowerCase()

8. 校验与类型安全

TypeScript 的类型检查发生在编译期。它能在代码运行前捕获类型不匹配的问题。但 TypeScript 的类型系统有一个根本局限:它不检查运行时数据。

// src/index.ts
// TypeScript 认为 body 的类型是 Request 的 JSON 解析结果
// 但运行时 body 里实际有什么字段、什么类型,TypeScript 管不了
app.post('/api/users', async (c) => {
  const body = await c.req.json()
  // body.name — TypeScript 不知道这里有没有 name,也不知道它是不是 string
  // body 的类型实际上是 any(或 unknown)
})

c.req.json() 返回的类型不包含任何字段信息。即使你用 as 断言,也只是告诉 TypeScript「相信我」,运行时并不会做任何检查。

Zod 的 schema 补上了运行时这一环:

// src/index.ts
const createUserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
})
 
type CreateUserInput = z.infer<typeof createUserSchema>
// { name: string; email: string }
 
app.post('/api/users', zValidator('json', createUserSchema), (c) => {
  const data = c.req.valid('json')
  // data 的类型是 CreateUserInput
  // 这里既有运行时的校验保证(数据确实符合 schema)
  // 也有编译期的类型信息(TypeScript 知道 data 有哪些字段)
  return c.json(data, 201)
})

schema 同时产出了两样东西:运行时校验逻辑和编译期类型定义。改 schema 的时候,TypeScript 会自动提示所有受影响的使用点。

两层校验的分工可以这样理解:

层级检查什么什么时候检查工具
编译期代码中的类型使用是否一致tsc 编译时TypeScript
运行时外部输入的数据是否符合预期请求到达时Zod / zValidator

编译期校验保护的是代码内部的类型流转,运行时校验保护的是系统边界上的数据入口。两者覆盖的时间窗口和检查对象都不同,缺少任何一层都有盲区。

9. 校验规则的版本演进

API 的校验规则不是一成不变的。随着业务发展,字段会新增、约束会收紧、格式会变化。问题在于:收紧校验规则可能破坏现有客户端。

几个实用的策略:

新增字段用 optional,不要要求旧客户端立刻适配

// src/schemas/user-v2.ts
// v1 → v2:新增 phone 字段,但标记为 optional
const createUserSchemaV2 = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  phone: z.string().optional(), // 旧客户端不传这个字段也不会报错
})

收紧规则前评估影响面

如果一个字段原来是 z.string()(任意字符串),现在想收紧为 z.string().email(),需要先统计现有数据中有多少不符合新规则的。如果数据库里已经有大量非 email 格式的旧数据,直接收紧会让这些数据的读取或更新逻辑出问题。

用版本号或内容协商做平滑过渡

// src/routes/users-v1.ts
const createUserSchemaV1 = z.object({
  name: z.string(),
  email: z.string(), // 宽松校验
})
 
// src/routes/users-v2.ts
const createUserSchemaV2 = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(), // 严格校验
})
 
// 不同版本走不同的路由
app.post('/api/v1/users', zValidator('json', createUserSchemaV1), handler)
app.post('/api/v2/users', zValidator('json', createUserSchemaV2), handler)

对外 API 用 strip,对内 API 用 strict

对外 API 面对的是不可控的客户端,strip 模式忽略多余字段,兼容性更好。对内 API 面对的是自己的前端团队,strict 模式能更早发现前后端接口定义不一致的问题。

10. 不校验的代价

前面几个章节从不同角度讲了校验的设计考量。最后把这些角度汇总成一份代价清单,方便评估一个接口是否需要补校验:

  1. 数据损坏:非法数据写入数据库后,清洗成本远高于入口拦截成本。一个字段类型错误可能牵连聚合查询、报表统计、下游消费方。
  2. 安全漏洞:SQL 注入、XSS、路径遍历,这些攻击的本质都是未校验的外部输入被直接执行。校验是防御的第一层(不是唯一一层)。
  3. 调试成本:没有校验的接口,数据什么时候进来越界、从哪个客户端进来的,都需要逐条日志追溯。有了校验,错误在入口处就有明确的记录和响应。
  4. 级联失败:一个服务把未校验的数据传给下游服务,下游服务的校验也可能因此失败。错误会沿着调用链扩散,定位根因变得困难。

这些代价在项目初期可能不明显——数据量小、客户端可控、调用链路短。但随着系统规模增长,不校验的代价会以非线性方式放大。

延伸阅读

总结

参数校验的设计可以归纳为几组判断:

  1. 位置判断:校验放在中间件层,在认证之后、业务逻辑之前。业务函数拿到的一定是已校验数据。
  2. 维度判断:一个字段可能需要存在性、类型、格式、范围、枚举、结构、业务规则多个维度的校验。schema 声明覆盖了前六种,跨字段规则用 .refine()
  3. 策略判断:表单场景用 collect-all-errors 减少用户往返次数;流式处理用 fail-fast 节省资源;批量导入可以两者组合。
  4. 边界判断:客户端校验管体验,服务端校验管安全。两者独立实现,规则保持一致。
  5. 安全判断:白名单策略在安全边界上比黑名单更可靠。Zod 的 z.object() 默认就是白名单(strip 模式)。
  6. 演进判断:新增字段用 optional 保持向后兼容;收紧规则前评估存量数据影响;对外 API 宽松(strip),对内 API 严格(strict)。

下一篇进入 Body 校验的具体实现——JSON 请求体的解析细节、大体积 payload 的处理策略,以及 c.req.json()c.req.text() 在 Hono 内部的实现差异。