验证中间件
检查逻辑应该放在处理函数前面。要实现它,就得写一个对任何路由都通用的函数,无论那个路由需要什么样的 schema。
Express 中间件是一个在到达处理函数之前会被调用的函数,接收三个参数:请求、响应,以及把控制权继续往下传的 next。所以我们需要的是一个能产出这样一个函数的函数,并且把 schema 内置在里面。
validate(registerSchema) -> 根据 registerSchema 检查的中间件
validate(loginSchema) -> 根据 loginSchema 检查的中间件返回函数的函数叫高阶函数,这里它扮演的是一个工厂的角色:在设置路由时被调用一次,而它产出的中间件会在每次请求时运行。
工厂的基本结构
先在 back-end/middleware/validate.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。
外层那个只运行一次,在应用启动的时候,它的任务是记住 schema。内层那个在每一次请求时都会运行,它的任务是做检查。
拒绝一个有问题的请求
在内层函数里,用 schema 检查请求体:
const result = schema.safeParse(req.body)这里应该用 safeParse,因为校验失败是需要报告的情况,而不是需要中断执行流程的异常。
校验失败时,响应需要说明是哪个字段、出了什么问题:
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(),处理函数就会拿刚刚校验失败的数据去处理。
res.json() 只完成了第一件事。return 完成的是第二件事,漏掉它就是这类 bug 的经典写法。
把校验通过的数据传下去
return 之后的所有代码,只有在校验通过时才会执行:
;(req as any).validatedData = result.data
next()解析后的数据被挂载到请求对象上,然后 next() 把控制权交给路由处理函数,处理函数读取的是 req.validatedData,完全不会碰 req.body。
这里用 result.data 而不是 req.body,是有讲究的。Zod 可能把字符串转换成了数字、去掉了多余的空白、把邮箱转成了小写,或者丢弃了 schema 里没有描述的某个 key。解析后的值携带了这一切变化。原始请求体则什么都没有。
完整的文件是这样的:
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()
}
}next() 就是那个交接的动作。在它被调用之前,请求一直停留在这个函数手里,不会再往下走。 正是这一点让整套机制能够运转:校验失败的请求永远不会被传下去,所以后面的代码只会看到通过校验的数据。
动手试试
下面这个中间件有三个 bug。两个会让它直接跑不通;还有一个不声不响,但更麻烦。
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,直到注册表单上的每一项输入都被检查到。

