☰
express-validator 命令式校验:深入掌握 run(req) 手动运行验证链
2026/10/10 1:28:37 网站建设 项目流程
  • 后端

【免费下载链接】express-validator

An express.js middleware for validator.js.

项目地址:https://gitcode.com/gh_mirrors/ex/express-validator
点击查看免费下载

express-validator 的核心设计是"声明式"的——把校验链当作中间件直接挂进 Express 路由即可自动执行。但现实业务中,我们常常需要把验证的执行时机和流程控制握在自己手里。本文以 v6.5.0 官方指南《Running validations imperatively》为骨架,系统讲解如何通过校验链与净化链上统一的run(req)方法手动触发验证、如何用validationResult(req)收集错误,并结合仓库源码剖析其底层实现,帮助你写出可复用的自定义校验中间件、按条件触发的动态校验等实战方案。

从声明式到命令式:为什么需要run(req)

express-validator 默认推崇声明式用法,也就是把校验链直接作为 Express 中间件传入路由处理器,框架会在请求到达时自动运行这些验证:

const { body } = require('express-validator'); app.post('/api/create-user', [ body('email').isEmail(), body('password').isLength({ min: 6 }), ], (req, res) => { // 请求到这里时,校验已经自动完成 });

这种模式简洁高效,大多数 API 在"直接传给路由处理器"时表现最佳。但有些场景需要开发者自己掌握校验的运行时机:

  • 希望复用同一套校验逻辑于多个路由,并统一封装错误响应格式;
  • 校验规则依赖运行时条件(例如"只有提交了密码,才校验确认密码");
  • 需要控制校验的并发/串行顺序,或提前短路后续校验。

为此,express-validator 提供了一条命令式入口:校验链和净化链上都有run(req)方法。官方文档指出,该方法同时存在于验证链与净化链上。调用它,等于把"运行验证"的控制权从框架手里交还给你的中间件或路由处理器。

从仓库源码看,run(req)并非校验链独有的能力,而是通过一个名为ContextRunner的接口抽象出来的统一行为。当前版本文档中这样定义它:

interface ContextRunner { run(req: Request, options?: { dryRun: boolean }): Promise<Result>; }

ContextRunner是"所有会执行某种校验/净化逻辑的中间件"共同实现的接口,返回一个专属于该验证链/中间件的Result对象(详见 docs/api/misc.md)。ValidationChain、checkExact()、checkSchema()、oneOf()返回的中间件都实现了该接口,因此它们的run(req)语义完全一致。

run(req)的底层执行机制

理解了接口定义后,再看它的实现,能帮助你准确预判调用run(req)后的行为。核心实现位于 src/chain/context-runner-impl.ts 的ContextRunnerImpl.run():

  1. 构建上下文(Context):如果持有的是ContextBuilder,先调用.build()生成校验上下文;校验上下文里记录了目标字段、请求位置(body/query/params/headers/cookies)、字段实例以及一串待执行的校验/净化条目(context items)。
  2. 短路检查:如果当前请求上已有的上下文中存在bail且已有错误(即请求级.bail()生效),直接返回空结果,不再执行本链(对应ValidationHalt机制)。
  3. 字段选择:通过selectFields()按字段路径与位置从req中取出待校验的值,挂到上下文上。
  4. 逐条目执行:遍历上下文栈中的每个校验/净化条目,对每个字段实例并行执行。若某个条目抛出ValidationHalt(例如.bail()),该字段实例后续条目被跳过。
  5. 结果持久化:除非传入了dryRun: true,否则会将这条上下文的执行结果追加到请求对象上,并同步净化产生的字段新值。

关键点在于第 5 步:源码中run()结束时会执行

internalReq[contextsKey] = (internalReq[contextsKey] || []).concat(context);

其中contextsKey是'express-validator#contexts'(见 src/base.ts)。这正是"校验结果被持久化到req"的实现证据,它决定了两个重要行为:

  • 之后调用validationResult(req)会包含这次手动运行的验证结果;
  • 链中的净化器(如body('message').trim())会直接更新req.body.message,与声明式中间件行为一致。

如果传入options.dryRun: true,则只执行校验并返回结果,不写入req,也不影响validationResult(req)的返回内容。官方示例展示了 dryRun 的精确语义:

const usernameResult = await check('username').notEmpty().run(req, { dryRun: true }); const passwordResult = await check('password').notEmpty().run(req, { dryRun: false }); const result = validationResult(req); // `result` 包含 passwordResult 的错误,但不包含 usernameResult 的错误

示例一:标准化的验证错误响应

原文档给出的第一个典型场景,是把一组校验链封装成可复用的自定义中间件validate,统一输出400错误响应。这也是命令式校验最常见的价值所在——用一份代码统一所有路由的校验入口和错误格式:

// 可被多个路由复用的校验中间件 const { validationResult } = require('express-validator'); const validate = validations => { return async (req, res, next) => { await Promise.all(validations.map(validation => validation.run(req))); const errors = validationResult(req); if (errors.isEmpty()) { return next(); } res.status(400).json({ errors: errors.array() }); }; }; app.post('/api/create-user', validate([ body('email').isEmail(), body('password').isLength({ min: 6 }) ]), async (req, res, next) => { // 走到这里,说明请求没有任何校验错误 const user = await User.create({ ... }); });

逐行拆解这段代码的要点:

  • validations.map(validation => validation.run(req)):对传入的每个校验链调用run(req)。这里用Promise.all让所有校验链并行执行,互不阻塞,适合互相独立的字段校验。
  • validationResult(req):从请求中提取全部已验证字段的错误,包装成Result对象(见下文)。因为run(req)已把上下文持久化到req,这里才能拿到完整错误集。
  • errors.isEmpty():判断请求是否有效;为空则调用next()放行,否则返回400和统一的 JSON 错误结构。
  • res.status(400).json({ errors: errors.array() }):.array()返回错误对象数组,便于前端直接展示。

进阶:串行执行与失败短路

原文档的并行版本适合大多数场景,但当校验链之间存在强依赖、或某个字段校验失败后不希望继续执行后续链(例如避免对格式错误的输入再发数据库查询)时,可以改为串行执行并在首个失败处中断。更新的官方指南 docs/guides/manually-running.md 提供了等价思路:

const validate = validations => { return async (req, res, next) => { for (const validation of validations) { const result = await validation.run(req); if (!result.isEmpty()) { return res.status(400).json({ errors: result.array() }); } } next(); }; };

串行版直接利用run(req)的返回值Result(而非重新调用validationResult(req))逐条判断是否失败,一旦失败立即返回。值得注意的是,该版本的错误响应直接来自当前这条链的Result,而非全量validationResult(req)——两条策略各有取舍:并行版一次性返回所有字段的错误,用户体验更好;串行版尽早短路、开销更小,更利于控制数据库/外部 API 等重操作的触发次数。

示例二:带条件的验证

第二个官方示例展示了命令式校验的另一大优势——在路由处理器内部按请求内容动态决定是否追加校验:

app.post('/update-settings', [ body('email').isEmail(), body('password').optional().isLength({ min: 6 }) ], async (req, res, next) => { // 如果提交了密码,则必须同时提供确认密码 if (req.body.password) { await body('passwordConfirmation') .equals(req.body.password).withMessage('passwords do not match') .run(req); } // 检查校验错误,然后更新用户设置 const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ errors: errors.array() }); } // ...更新设置 });

这里body('passwordConfirmation')这条链并非静态注册的中间件,而是在if分支里临时构建并立即执行。由于run(req)会把执行结果写入请求,随后validationResult(req)能一并收集这条动态链的错误。withMessage('passwords do not match')则把默认错误信息替换为业务友好的提示。

官方建议:优先使用.if()

不过,官方指南在 docs/guides/manually-running.md 末尾附了一条重要提醒:这只是一个演示"手动运行校验"能力的示例。如果只是想做条件校验,更推荐用声明式的.if()修饰符,让校验规则保持静态、可读、集中:

body('newPassword') // 仅当提供了旧密码时才校验 .if((value, { req }) => req.body.oldPassword) // 也可以传入另一个校验链作为条件 .if(body('oldPassword').notEmpty()) .isLength({ min: 6 });

两种方式都能实现"条件触发",取舍原则是:规则本身固定不变时优先用.if()(声明式、易审查);规则依赖复杂运行时逻辑、或需要在请求处理中段动态追加校验时,才用手动run(req)。关于.if()的完整语义可参考 docs/api/validation-chain.md。

结果对象:validationResult与ResultAPI

命令式校验的最后一步几乎总是"检查结果",因此必须掌握validationResult与Result的完整 API(详见 docs/api/validation-result.md)。

validationResult(req)

validationResult(req: Request): Result<ValidationError>

它从请求中提取全部已验证字段的错误并包装成Result对象。实现上(见 src/validation-result.ts),它读取req[contextsKey]上累积的所有校验上下文,把每个上下文的errors扁平拼接起来——这解释了为什么手动run(req)与声明式中间件的错误会汇总到同一个结果里。

Result的常用方法

方法签名说明
.isEmpty()isEmpty(): boolean是否没有任何错误,即请求是否有效
.array()array(options?: { onlyFirstError?: boolean }): T[]返回错误数组;onlyFirstError: true时每个字段只保留首个错误
.mapped()mapped(): Record<string, T>返回"字段路径 → 错误"的映射对象(每字段仅首个错误)
.formatWith()formatWith<T>(formatter): Result<T>用自定义格式化函数重包结果,返回新的Result
.throw()throw(): void有错误时抛出一个带Result方法的错误对象,便于转发给 Express 错误处理中间件

一个实用的组合:用validationResult.withDefaults预设全局错误格式,让所有命令式校验的输出风格一致:

const myValidationResult = validationResult.withDefaults({ formatter: error => error.msg, }); // 之后统一使用 const errors = myValidationResult(req).array(); // => ['Invalid value', ...]

Result的错误对象带有type判别字段('field'/'alternative'/'alternative_grouped'/'unknown_fields'),type: 'field'时可通过error.path、error.location、error.value、error.msg拿到字段级细节,便于定制响应结构。

何时该用命令式校验:场景清单

综合原文档与仓库现状,以下场景优先考虑run(req)手动运行:

  1. 多路由复用校验并统一错误格式:封装validate(validations)中间件,一处定义、处处复用(示例一)。
  2. 请求中段的动态条件校验:校验规则依赖请求其他字段的值或外部状态(示例二)。
  3. 精细控制执行顺序与短路:串行遍历校验链、首个失败即停止,或配合Result返回值逐条决策。
  4. 隔离"仅执行、不持久化"的校验:通过dryRun: true先试跑校验、观察结果而不污染req,例如在真实保存前做一次预检。

反之,如果只是静态规则校验,直接挂中间件或使用.if()条件链即可,代码更简洁、更易被静态审查。

注意事项与常见误区

  • run(req)返回的是Result,不是布尔值:判断成败请用result.isEmpty(),不要依赖await本身是否抛错——校验失败不会抛出异常,只有执行异常(如自定义校验器内部抛错)才会中断。
  • 结果持久化是默认行为:只要没传dryRun,手动运行的链就会写入req并被后续validationResult(req)收集。若在多个中间件中重复运行同一批链,错误会累积,注意去重或使用dryRun。
  • 净化也会生效:run(req)不仅执行校验,链上的净化器(如trim()、toLowerCase())同样会更新请求字段,行为与声明式中间件完全一致。
  • bail 与短路语义:请求级.bail({ level: 'request' })会影响后续所有链;并行Promise.all下各链仍会执行,但结果中会体现中断,设计错误响应时需留意。
  • TypeScript 类型:若要封装自己的校验器,可用import { ValidationChain } from 'express-validator'标注参数类型;更通用的做法是标注为ContextRunner,这样checkExact()、checkSchema()、oneOf()的产物也能传入。

小结

命令式校验run(req)是 express-validator 从"声明式中间件"走向"可编程校验流程"的关键接口:它统一实现了ContextRunner,底层通过把执行上下文持久化到req来与validationResult(req)无缝协作。掌握它,你就能写出可复用的标准化校验中间件、按条件动态追加的校验逻辑,以及精细控制执行顺序与开销的自定义校验流程——同时记得官方建议:能用.if()表达的条件,优先保持声明式。

  • 后端

【免费下载链接】express-validator

An express.js middleware for validator.js.

项目地址:https://gitcode.com/gh_mirrors/ex/express-validator
点击查看免费下载
上一篇:【热门开源项目下载】<MusicFree> 小白级图文教程
下一篇:【Sa-Token】开源下载和安装教程

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询