07 组合校验与手动执行
1. oneOf():多组规则至少满足一组
登录接口可能允许两种方式:
邮箱 + 密码
或
手机号 + 验证码
import {
body,
oneOf,
} from "express-validator";
export const loginValidation = oneOf(
[
[
body("email")
.isEmail()
.withMessage("邮箱格式不正确"),
body("password")
.isString()
.isLength({ min: 8 })
.hide(),
],
[
body("phone")
.isMobilePhone("zh-CN")
.withMessage("手机号格式不正确"),
body("code")
.isLength({ min: 6, max: 6 })
.withMessage("验证码必须为 6 位")
.hide(),
],
],
{
message: "请提供有效的邮箱密码或手机号验证码",
errorType: "grouped",
},
);
数组中的每一个元素是一种选择;元素本身又是数组时,该组中的所有链都必须成功。
errorType
grouped
least_errored
flat
grouped:按候选组保留错误,最适合解释哪一种登录方式失败;least_errored:只保留错误最少的一组;flat:把嵌套错误展平。
oneOf() 产生的不是普通 field 错误,统一 formatter 需要处理 alternative 类型。
2. 不要滥用 oneOf()
下面这种复杂业务状态不适合无限嵌套:
条件 A 或条件 B
其中 B 又要求 C 或 D
且角色为 E 时还要 F
如果规则描述的是业务状态机,而不是输入形状,应先校验基础格式,再交给 Service 做业务判断。
3. checkExact():拒绝未知字段
import {
body,
checkExact,
} from "express-validator";
export const createWishValidation = checkExact([
body("name")
.isString()
.trim()
.isLength({ min: 1, max: 20 }),
body("content")
.isString()
.trim()
.isLength({ min: 1, max: 200 }),
]);
如果客户端发送:
{
"name": "学习 Node.js",
"content": "完成教程",
"isAdmin": true
}
isAdmin 没有对应校验链,会产生 unknown_fields 错误。
如果不把已有链直接传给 checkExact(),就应把独立的 checkExact() 中间件放在相关校验链之后,确保它能够知道哪些字段已经被验证。最不容易写错的形式就是像上例一样把链传给 checkExact([...])。
为什么有价值
- 发现客户端拼错字段;
- 防止未声明字段静默进入 Service;
- 减少 Mass Assignment 风险;
- 让接口契约更严格。
locations
可以限制检查位置:
checkExact(chains, {
locations: ["body"],
});
如果不限制,某些框架或中间件自动加入的字段也可能被识别为未知字段。应根据接口协议明确检查范围。
4. checkExact() 与 matchedData()
| 工具 | 作用 |
|---|---|
checkExact() |
未声明字段直接报错 |
matchedData() |
忽略未匹配字段,只提取已验证数据 |
两者可以同时使用:
checkExact:告诉客户端请求包含多余字段
matchedData:确保业务层仍只接收白名单字段
如果 API 需要向前兼容,允许客户端携带服务端暂不认识的字段,可以只用 matchedData(),不使用 checkExact()。
5. 手动运行 ValidationChain
每条链都有:
await chain.run(req);
示例:
import {
body,
validationResult,
type ValidationChain,
} from "express-validator";
import type {
NextFunction,
Request,
Response,
} from "express";
const createWishChains: ValidationChain[] = [
body("name").isString().trim().notEmpty(),
body("content").isString().trim().notEmpty(),
];
export async function runWishValidation(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
for (const chain of createWishChains) {
const result = await chain.run(req);
if (!result.isEmpty()) {
break;
}
}
const errors = validationResult(req);
if (!errors.isEmpty()) {
res.status(400).json({ errors: errors.array() });
return;
}
next();
}
这个 runner 实现“第一条失败的链之后不再运行”。普通项目可以直接使用 .bail({ level: "request" }),手动 runner 更适合特殊流程或教学。
6. 并行手动执行
互相独立的链可以并行:
await Promise.all(
createWishChains.map((chain) => chain.run(req)),
);
如果链会修改同一个字段,或者后一个链依赖前一个 sanitizer 的结果,就不应并行。默认中间件顺序更容易推理。
7. 条件运行整组校验
const companyValidation = [
body("companyName").isString().notEmpty(),
body("creditCode").isString().notEmpty(),
];
if (req.body.accountType === "company") {
await Promise.all(
companyValidation.map((chain) => chain.run(req)),
);
}
简单字段条件优先使用 .if();只有整组规则和运行流程确实动态时才手动执行。
8. 自定义 Validation Runner
import type {
ContextRunner,
} from "express-validator";
export function validate(runners: ContextRunner[]) {
return async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
await Promise.all(runners.map((runner) => runner.run(req)));
const errors = validationResult(req);
if (!errors.isEmpty()) {
res.status(400).json({ errors: errors.array() });
return;
}
next();
};
}
它可以接收 ValidationChain、oneOf() 等实现了 ContextRunner 的对象。不要在已有中间件方式足够时创造过多 runner 抽象。
9. 校验链函数工厂
function idParam(field = "id"): ValidationChain {
return param(field)
.isInt({ min: 1 })
.withMessage(`${field} 必须是正整数`)
.toInt();
}
每次调用都会创建新链:
router.get("/:id", idParam(), validateRequest, handler);
router.delete("/:wishId", idParam("wishId"), validateRequest, handler);
10. 测试时手动运行
const req = {
body: {
name: "",
},
} as Request;
await body("name")
.isString()
.trim()
.notEmpty()
.run(req);
const errors = validationResult(req);
完整接口仍推荐使用 Supertest 测试中间件顺序、JSON 解析、状态码和响应结构。
11. 练习题
- 使用
oneOf()实现邮箱登录或手机登录。 - 为创建愿望接口添加
checkExact(),测试额外字段。 - 比较只使用 matchedData 和同时使用 checkExact 的接口行为。
- 编写顺序执行、首错停止的手动 runner。
- 编写并行 runner,并说明什么情况下不能使用。
- 为正整数路径参数编写每次返回新链的函数工厂。