03 ValidationChain 进阶用法
1. .custom() 自定义 validator
内置 validator 无法表达所有业务格式,可以使用:
.custom((value, meta) => boolean | Promise<unknown>)
同步校验
body("startDate").isISO8601(),
body("endDate")
.isISO8601()
.bail()
.custom((endDate: string, { req }) => {
const startDate = String(req.body.startDate);
if (new Date(endDate) < new Date(startDate)) {
throw new Error("endDate 不能早于 startDate");
}
return true;
});
自定义 validator 可以:
- 返回 truthy 表示成功;
- 返回 falsy 表示失败;
- 抛出 Error;
- 返回 rejected Promise。
使用 .withMessage() 可以覆盖 custom 抛出的消息。
异步校验
body("email")
.isString()
.bail()
.isEmail()
.bail()
.custom(async (email: string) => {
const user = await User.findOne({ where: { email } });
if (user !== null) {
throw new Error("邮箱已经存在");
}
return true;
});
便宜的类型和格式校验应放在数据库查询前,并使用 .bail()。
但“先查不存在,再创建”存在并发竞态。MySQL 仍必须建立唯一约束,并在写入时处理唯一键冲突。
2. .customSanitizer()
body("displayName")
.customSanitizer((value: unknown) => String(value).trim());
自定义 sanitizer 返回的新值会替换原值。它不能用来表示“校验失败”,如果转换需要拒绝非法输入,应先使用 validator。
例如把逗号文本转换成数组:
body("tags")
.isString()
.bail()
.customSanitizer((value: string) =>
value
.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0),
)
.isArray({ max: 10 });
如果 API 本来约定接收 JSON 数组,最好直接要求数组,不要为了迁就错误格式做过多隐式转换。
3. .if() 条件校验
body("companyName")
.if(body("accountType").equals("company"))
.isString()
.trim()
.notEmpty();
也可以传函数:
body("reason")
.if((_value, { req }) => req.body.status === "rejected")
.isString()
.trim()
.isLength({ min: 1, max: 200 });
.if() 适合决定一条链是否运行。复杂状态机规则更适合放在 Service,而不是堆积成难以阅读的 validator。
4. .not() 反转下一项验证
body("username")
.not()
.isIn(["admin", "root", "system"])
.withMessage("该用户名不可使用");
.not() 只反转紧随其后的 validator,不会反转后面整条链。
5. .default() 与 .replace()
默认值
query("page")
.default(1)
.isInt({ min: 1 })
.toInt();
默认值属于 sanitizer,会改变请求数据。默认值是否属于 HTTP 层约定,需要在接口文档中明确。
替换指定值
body("nickname")
.replace(["N/A", "unknown"], null)
.optional({ values: "null" })
.isString();
不要用大量隐式替换掩盖客户端协议不一致。
6. .hide() 隐藏敏感值
body("password")
.isStrongPassword()
.hide();
错误对象通常包含非法值。对密码、Token、验证码等字段使用 .hide(),可以避免它们进入响应或日志。
也可以指定替代文本:
.hide("*****")
7. 嵌套字段
{
"profile": {
"name": "Tom",
"address": {
"city": "Shanghai"
}
}
}
body("profile.name").isString();
body("profile.address.city").isString();
字段名本身包含点号时,需要使用带引号的 bracket 语法:
body('settings["notification.email"]').isBoolean();
8. Wildcard:数组和动态对象
请求体:
{
"items": [
{ "productId": 1, "quantity": 2 },
{ "productId": 5, "quantity": 1 }
]
}
校验:
export const orderValidation = [
body("items")
.isArray({ min: 1, max: 50 })
.withMessage("items 必须包含 1 到 50 项"),
body("items.*.productId")
.isInt({ min: 1 })
.toInt(),
body("items.*.quantity")
.isInt({ min: 1, max: 100 })
.toInt(),
];
先限制数组长度,避免攻击者提交巨大数组,导致成千上万条校验和数据库查询。
自定义 validator 可以获得通配符对应值:
body("items.*.quantity")
.custom((quantity: number, { pathValues }) => {
console.log(pathValues);
return quantity <= 100;
});
9. Globstar
** 可以匹配任意深度的嵌套字段:
body("teams.**.name").isString();
它适合树形数据,但应限制树的深度和节点数量。对任意大型 JSON 递归校验可能消耗大量 CPU。
10. 校验整个请求体
省略字段名可以选择整个位置:
body().isArray();
例如接口直接接收 ID 数组:
[1, 2, 3]
body().isArray({ min: 1, max: 100 }),
body("*").isInt({ min: 1 }).toInt();
11. 复用规则的正确方式
推荐使用函数创建新的链:
import {
body,
param,
type ValidationChain,
} from "express-validator";
function positiveId(field: string): ValidationChain {
return param(field)
.isInt({ min: 1 })
.withMessage(`${field} 必须是正整数`)
.toInt();
}
function requiredShortText(
field: string,
max: number,
): ValidationChain {
return body(field)
.isString()
.bail()
.trim()
.isLength({ min: 1, max });
}
不要创建一条全局链后在多个地方继续追加方法,因为 ValidationChain 是可变对象。
12. 什么不应该放在 custom validator
不建议放入:
- 复杂权限判断;
- 多模型写入;
- 开启事务;
- 发送邮件或短信;
- 修改外部系统;
- 难以重复执行的副作用。
validator 应尽量是可重复执行的检查。权限、状态流转和事务属于 Service。
13. 练习题
- 校验“开始日期不得晚于结束日期”的请求体。
- 为订单 items 数组添加容器、productId、quantity 校验。
- 只有
accountType=company时才要求填写统一社会信用代码。 - 编写异步邮箱查重 validator,并说明为什么还需要 MySQL 唯一约束。
- 对 password 和 verificationCode 使用
.hide(),观察错误响应差异。 - 编写两个返回全新 ValidationChain 的复用函数,不复用可变链实例。