Skip to content
This page has been auto-translated and may contain errors.View in English

验证中间件

检查逻辑应该放在处理函数前面。要实现它,就得写一个对任何路由都通用的函数,无论那个路由需要什么样的 schema。

Express 中间件是一个在到达处理函数之前会被调用的函数,接收三个参数:请求、响应,以及把控制权继续往下传的 next。所以我们需要的是一个能产出这样一个函数的函数,并且把 schema 内置在里面。

text
validate(registerSchema)  ->  根据 registerSchema 检查的中间件
validate(loginSchema)     ->  根据 loginSchema 检查的中间件

返回函数的函数叫高阶函数,这里它扮演的是一个工厂的角色:在设置路由时被调用一次,而它产出的中间件会在每次请求时运行。

工厂的基本结构

先在 back-end/middleware/validate.ts 里搭出大致框架:

ts
import type { Request, Response, NextFunction } from 'express'
import * as z from 'zod'

export function validate(schema: z.ZodType) {
  return (req: Request, res: Response, next: NextFunction): void => {
    // 根据 schema 校验 req.body。
  }
}

两层结构,做的是两件事,发生在两个不同的时间点。

外层的 validate 只在应用启动时运行一次,它唯一的参数就是 schema。z.ZodType 是所有 Zod schema 都继承的基类,所以这个参数可以接受对象 schema、字符串 schema,任何类型都行。

内层函数才是 Express 真正持有并在每次请求时调用的东西。它返回 void,因为它不需要返回什么值,它要么直接响应请求,要么调用 next

Juno工厂的基本结构 两个函数叠在一起,是需要花点时间消化的部分。把它们理解成两个不同的时间点,会更容易一些。

外层那个只运行一次,在应用启动的时候,它的任务是记住 schema。内层那个在每一次请求时都会运行,它的任务是做检查。

Juno工厂的基本结构 之所以必须写成工厂,是因为中间件的签名是 Express 决定的。它会用 request、response 和 next 来调用你的函数,没有第四个位置可以传 schema 进去。

通过闭包捕获 schema,就是把额外参数塞进去的办法。几乎你遇到的每一个可配置中间件背后都是这个模式,这也是为什么 cors()helmet() 是被调用而不是直接传入的原因。

Juno工厂的基本结构 把参数类型定为 z.ZodType,能让工厂函数保持通用性,但代价是放弃了具体类型,于是 result.data 拿到的类型会比较宽泛。让工厂对 schema 泛型化,写成 <T extends z.ZodType>,就能把 z.infer<T> 一路带到挂载在请求上的数据类型上,处理函数拿到的就是真实类型,不需要做类型断言。

当你的路由超过两三个之后,这样做是值得的;但在你学习这个模式的阶段,可以先跳过,因为泛型版本会更难读——而这恰恰是你正试图理解这个两层结构的时候。

拒绝一个有问题的请求

在内层函数里,用 schema 检查请求体:

ts
const result = schema.safeParse(req.body)

这里应该用 safeParse,因为校验失败是需要报告的情况,而不是需要中断执行流程的异常。

校验失败时,响应需要说明是哪个字段、出了什么问题:

ts
if (!result.success) {
  const errors = result.error.issues.map((issue) => ({
    field: issue.path[0],
    message: issue.message,
  }))

  res.status(400).json({ success: false, errors })
  return
}

这里发生了三件事。issues 被转换成字段和消息成对的形式,响应以 400 状态码把它们发回去,return 让函数停下来。

这个 return 就是整段逻辑的安全保障。res.json() 只是发送响应,函数本身并不会停止执行,所以如果没有这个 return,函数会继续往下走到 next(),处理函数就会拿刚刚校验失败的数据去处理。

Juno拒绝一个有问题的请求 发送响应和让函数停下来,是两个各自独立的动作,第一次踩到这个坑的时候会觉得很意外。

res.json() 只完成了第一件事。return 完成的是第二件事,漏掉它就是这类 bug 的经典写法。

Juno拒绝一个有问题的请求 这一步映射的作用,是把 Zod 的输出转换成一份 API 契约。原始的 issue 携带了期望的类型和不满足的约束条件,这些信息相当于把你的 schema 描述给了请求方。

在这里定好返回格式,也意味着所有路由的响应格式都是一致的,前端只需要写一个函数来渲染错误,完全不用管这个错误是哪个端点产生的。

Juno拒绝一个有问题的请求 对于扁平的请求体,issue.path[0] 是没问题的,但一旦出现嵌套结构就会出错,因为同一个父字段下的所有字段都会折叠成同一个 key,错误消息会对应到错误的输入上。issue.path.join('.') 能始终保持唯一,而且现在就写并不会多花什么成本。

还有两个细节留给以后。数组的索引在 path 里是以数字形式出现的,所以列表里的一项校验失败会得到类似 items.2.quantity 的路径,而大多数表单库期望的是 items[2].quantity。另外,只校验 req.body 会让 params 和 query string 处于未检查状态,而数据库查询用到的 id 通常就藏在那里。把工厂扩展成接受 { body, params, query },改动不大,却能补上一个实实在在的漏洞。

把校验通过的数据传下去

return 之后的所有代码,只有在校验通过时才会执行:

ts
;(req as any).validatedData = result.data
next()

解析后的数据被挂载到请求对象上,然后 next() 把控制权交给路由处理函数,处理函数读取的是 req.validatedData,完全不会碰 req.body

这里用 result.data 而不是 req.body,是有讲究的。Zod 可能把字符串转换成了数字、去掉了多余的空白、把邮箱转成了小写,或者丢弃了 schema 里没有描述的某个 key。解析后的值携带了这一切变化。原始请求体则什么都没有。

完整的文件是这样的:

ts
import type { Request, Response, NextFunction } from 'express'
import * as z from 'zod'

export function validate(schema: z.ZodType) {
  return (req: Request, res: Response, next: NextFunction): void => {
    const result = schema.safeParse(req.body)

    if (!result.success) {
      const errors = result.error.issues.map((issue) => ({
        field: issue.path[0],
        message: issue.message,
      }))

      res.status(400).json({ success: false, errors })
      return
    }

    ;(req as any).validatedData = result.data
    next()
  }
}
Juno把校验通过的数据传下去next() 就是那个交接的动作。在它被调用之前,请求一直停留在这个函数手里,不会再往下走。

正是这一点让整套机制能够运转:校验失败的请求永远不会被传下去,所以后面的代码只会看到通过校验的数据。

Juno把校验通过的数据传下去 忘了调用 next(),你就会得到一个既没有响应也没有报错、一直挂着的请求,直到客户端超时为止。这种失败特别让人困惑,因为日志里什么都不会显示。

as any 是因为 Express 的 Request 类型上本来就没有 validatedData 这个属性。这样写是能跑通的,但也关掉了这次赋值的类型检查,于是属性名如果写错了,代码照样能编译通过,处理函数里读到的就是 undefined。

Juno把校验通过的数据传下去as any 更有类型安全的做法是声明合并:在一个 .d.ts 文件里扩展 Express 的 Request 接口,让 validatedData 在任何地方都是一个真实存在的属性。只需要多写几行代码,就能从你写的每一个中间件里去掉类型断言。

直接用解析后的值覆盖 req.body 是另一种做法,看起来很诱人,因为处理函数还是读它们原本就在读的那个属性。但这样一来,后面的中间件就无法分辨这是校验过的数据还是原始数据,而且类型标注仍然声称它是 body 解析器输出的那个类型。用一个独立的属性名,这点额外开销是值得的。

值得了解的一点是,带参数调用 next(err) 会跳过其余所有处理函数,直接进入错误处理中间件。如果你的应用有一个统一格式化响应的中央错误处理器,把 ZodError 传给它,能让格式化逻辑集中在一处,而不是分散在每个中间件里。

动手试试

下面这个中间件有三个 bug。两个会让它直接跑不通;还有一个不声不响,但更麻烦。

ts
export function validate(schema: z.ZodType) {
  return (req: Request, res: Response, next: NextFunction): void => {
    const result = schema.safeParse(req.body)

    if (!result.success) {
      res.status(400).json({ success: false, errors: result.error.issues })
    }

    ;(req as any).validatedData = req.body
    next()
  }
}
对照一下你的答案

1. 400 之后没有 return 响应发出去了,但函数继续执行,next() 照样被调用,处理函数会处理一个校验没通过的请求。客户端看到的是拒绝,但后台的处理还是照样做了。

2. 该用 result.data 的地方用了 req.body 就算加上了 return,处理函数拿到的还是原始的请求体。schema 里做的所有类型转换、去空白、转小写全部作废,schema 本该剔除的字段也依然留在里面。

3. 响应里直接返回了原始的 result.error.issues 因为它能正常运行,所以是最不容易被发现的一个。Zod 的 issue 对象把你的 schema 细节都暴露给了客户端,包括期望的类型和没满足的约束条件,而且它们的格式根本不是给人读的。应该把它们映射成字段和消息成对的形式。

第 2 个 bug 特别值得多想一想。这个中间件表面上看起来是正常工作的:合法请求能通过,非法请求被拒绝,测试也全绿。但悄悄消失的是 schema 原本要做的每一次转换,这个问题会在很久之后,以数据库里数据不一致的形式冒出来。

接下来往哪走

这个中间件已经写完了,能对任何交给它的 schema 做校验。它现在还没拿到 schema,也还没有路由让它站在前面把关。

构建一个带校验的端点会把这两样都写出来,然后一个字段一个字段地扩充 schema,直到注册表单上的每一项输入都被检查到。