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

校验应该放在哪里

前面三章的 schema,都是手动运行在上面几行现写的对象上。而真实的输入是通过网络到达的,schema 只有落在这种输入上才有意义。

问题在于把检查放在哪里,而这个位置会改变代码其余部分的写法。

一次提交经过的路径

一个注册表单向服务器发起 POST 请求。从请求到达到你的代码运行之间,会发生一件事:

text
form  ──POST──▶  validation  ──▶  route handler  ──▶  response

                     └──▶  400 with the errors

校验位于中间,由它决定请求会走向两种结局中的哪一种。

失败。 schema 发现了问题,请求就此止步,一个 400 Bad Request 会带着错误列表返回。路由处理函数根本不会运行。

通过。 解析后的数据继续流向路由处理函数,由它完成真正的工作,并以 201 Created 作答。

处理函数收到的数据,永远只会是已经通过 schema 校验的数据。

Juno一次提交经过的路径 之前扫描仪的比喻依然适用,只是现在这台机器不再放在长椅上,而是立在了门口。

任何东西想进入门后的房间都必须经过它,房间也就不必再操心东西有没有被检查过。

Juno一次提交经过的路径 这个 400 做的事不只是报告一个问题。前端正是靠它来判断该标记哪些字段——这也是为什么响应里带的是一份"字段+消息"的列表,而不是一句话。

状态码本身也很重要。400 表示客户端发送的内容有问题,应该修改后再重试;500 表示服务器出了故障。如果校验失败也返回 500,客户端就会陷入反复重试的死循环,而这个请求本来就注定不会成功。

Juno一次提交经过的路径 值得认真考虑一下 400 的响应体里应该放什么。字段名和你自己写的消息没问题,但收到的原始值不该放进去,因为一个被拒绝的注册请求里可能就带着密码。

错误响应正是人们最容易忘记"自己在记录数据"的地方,你在这条路径上记的日志也是同样的道理。

图里还藏着一个决定:转换发生在哪里。继续往下走的是解析后的值,所以在这里做去空格、转小写这类处理,就意味着下游看到的都是清理过的版本,不需要每个处理函数都记得去做归一化。

这正是把转换逻辑放进 schema 的理由,而这个理由只有在处理函数不再读取原始请求体时才成立。

为什么检查要放在前面

另一种做法是在每个路由处理函数的开头做校验,这样能跑通,但撑不住。

js
// 会逐渐走样的版本
import { registerSchema } from './schemas/userSchema.js'

app.post('/api/register', (req, res) => {
  const result = registerSchema.safeParse(req.body)
  if (!result.success) {
    return res.status(400).json({ success: false, errors: result.error.issues })
  }

  // ……真正的业务逻辑,早晚会写在这里
})

每个路由都要重复这段代码。而重复的代码会逐渐走样:一个路由对错误做了映射,另一个原样返回;一个忘了写 return,导致响应发出后处理函数还在往下执行;新的路由则随手复制了手边最近的那一份,不管它是不是最新的版本。

把检查放在前面,就能让所有路由共用同一份代码,也能让一个路由的校验规则从它的定义就能一眼看出来,而不必翻看函数体。

Juno为什么检查要放在前面 两种写法运行的是同一个 schema,区别只在于周围那段代码存在了多少份。

一份代码,出了问题改一次就行;十二份代码,就得先找出哪一份出了问题。

Juno为什么检查要放在前面 漏写的那个 return 值得特别留意,因为它出错时很安静。res.status(400).json(...) 只是发送响应,并不会终止函数的执行,所以少了 return,处理函数会继续往下跑,照样处理那份无效数据。

结果就是浏览器那边收到 400,数据库里却多了个新建的用户。从外部看一切正常,而这恰恰是最糟糕的一种"正确"。

Juno为什么检查要放在前面 更深一层的好处是,校验变得声明式,因而也就可以被审查。当每个路由都在定义里点明自己用的 schema 时,"哪些接口对输入做了校验"这个问题,读一份列表就能回答,而一个没有 schema 的路由也就一眼可见,不再只是"没写文档"那么隐蔽。

试试在四十个各自内部做校验的处理函数里回答同样的问题。你做不到,除非把四十个全部读一遍,而且每加一个新路由,答案就得重新算一遍。

把同一份 schema 也放在表单前面同样值得做,这样浏览器就能直接拦下一些明显的错误,不用往返服务器一趟。同一个文件,同一套规则,而真正说了算的仍然是服务端那次校验,因为浏览器端的那份代码,谁在用它就能改它。

处理函数收到的是什么

放在校验之后的处理函数可以写得很短,因为它可以直接假定拿到的数据是什么形状:

js
import { validate } from './middleware/validate.js'
import { registerSchema } from './schemas/userSchema.js'

app.post('/api/register', validate(registerSchema), (req, res) => {
  const userData = req.validatedData

  // 业务逻辑,处理的是已经通过 schema 校验的数据。

  res.status(201).json({ success: true, user: { email: userData.email } })
})

现在路径和处理函数之间有三样东西:validate(registerSchema) 就是那个检查,只有它通过了,处理函数才会运行。

处理函数读取的是 req.validatedData,而不是 req.body。这个区别就是整套安排的核心。req.body 是到达的数据;req.validatedData 是通过校验、并经过任何转换处理后留下的数据。

Juno处理函数收到的是什么 两个名字,看起来像是同一样东西,但区别很重要。

req.body 是发送过来的原始内容。req.validatedData 是通过检查后留下的内容。只读第二个,处理函数就永远不用猜。

Juno处理函数收到的是什么 这是代码审查中该坚持的一条习惯:一旦某个路由前面加上了校验,处理函数里就不该再出现 req.body。哪怕只有一处偷偷读了它,你设下的所有检查就都被绕开了。

这条规则的好处在于机械易查:在处理函数里搜 req.body,如果它出现在了 validate 调用之后,那就是问题所在。

Juno处理函数收到的是什么 在校验之后还去读 req.body,等于重新引入了批量赋值(mass assignment)漏洞,这点值得说清楚为什么。schema 会剔除它没有描述过的字段,所以解析出来的对象里只有你要求的内容;而原始请求体里仍然包含发送过来的一切,包括某个客户端满怀期望塞进来的 isAdmin

真正的解决办法是让数据只走一条路,而不是指望大家都记得这条规则。当处理函数唯一能拿到的就是解析后的值时,"忘记"这件事就不可能发生了,这比靠代码审查大部分时候都能抓出来要可靠得多。

动手试试

下面这个路由在处理函数内部做校验:

js
import * as z from 'zod'
import { saveSubscriber } from './services/subscribers.js'

const subscribeSchema = z.object({
  email: z.email('请输入有效的邮箱地址'),
})

app.post('/api/subscribe', (req, res) => {
  const result = subscribeSchema.safeParse(req.body)

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

  const { email } = req.body
  saveSubscriber(email)

  res.status(201).json({ success: true })
})

找出其中的三个问题。

对照一下你的答案

1. 漏写了 return res.status(400).json(...) 发送响应后不会停止执行,所以一个无效请求会既收到 400,被送进 saveSubscriber。客户端看到的是被拒绝,而订阅者却已经被保存了。

2. 读取的是 req.body 而不是 result.data 就算修好了 return,用的仍然是原始值,这意味着 schema 里做的任何去空格、转小写处理都被扔掉了,schema 剔除掉的字段也都又回来了。

3. 原样返回了 result.error.issues Zod 的 issue 对象带着内部细节,包括期望的类型和失败的约束条件,它们的形状是给代码看的。而客户端需要的是"字段+消息"这样的配对。

还有第四个问题,在这个路由里还算不上 bug,但规模一大就会变成问题:所有这些逻辑都写在处理函数内部,下一个路由就会复制一份,而复制出来的版本会逐渐走样。

接下来往哪走

计划已经定了:处理函数前面放一道检查,失败时返回带有可用错误信息的 400,成功时把解析后的数据挂在请求上。

校验中间件 会把它实现出来:一个函数,接收任意 schema,返回一个 Express 可以放在任何路由前面的东西。