02 ValidationChain 高频 API
1. ValidationChain 是什么
调用 body()、query()、param() 等函数会返回一个 ValidationChain:
const chain = body("email")
.isString()
.trim()
.isEmail()
.normalizeEmail();
它同时具有两种身份:
- Express 中间件;
- 可继续添加 validator、sanitizer 和 modifier 的链式对象。
链是可变的。向已有变量继续调用方法会修改同一个链,因此不要把一个共享链在不同路由中随意追加规则。
2. 链的执行顺序
下面的顺序不是随意的:
body("age")
.isString()
.bail()
.trim()
.isInt({ min: 18 })
.withMessage("age 必须是大于等于 18 的整数")
.toInt();
可以理解为:
验证原始类型
→ 失败则停止当前链
→ 清理空格
→ 验证整数文本
→ 转成 number
Sanitizer 会改变请求中的值,后续 validator 看到的是改变后的值。
3. 高频 validator
3.1 .isString()
body("name")
.isString()
.withMessage("name 必须是字符串");
它只检查类型,不检查字符串是否为空:
{ "name": "" }
空字符串仍然是字符串。
3.2 .notEmpty()
body("name")
.isString()
.bail()
.trim()
.notEmpty()
.withMessage("name 不能为空");
先 trim() 可以防止只包含空格的字符串通过检查。
3.3 .isLength()
body("name")
.isString()
.bail()
.trim()
.isLength({ min: 1, max: 20 });
常见 options:
{ min: 1 }
{ max: 200 }
{ min: 8, max: 64 }
密码长度、数据库列长度和用户可见字符限制是不同问题。不要只因为 MySQL 是 VARCHAR(255) 就允许用户输入 255 个字符。
3.4 .isInt()
query("page")
.isInt({ min: 1, max: 10_000 })
.withMessage("page 必须是有效正整数")
.toInt();
高频 options:
min
max
lt
gt
allow_leading_zeroes
.isInt() 只负责验证;需要 number 时再调用 .toInt()。
3.5 .isFloat()
body("price")
.isFloat({ min: 0 })
.withMessage("price 必须是非负数")
.toFloat();
金额不要因为通过了浮点校验就直接使用 JavaScript 浮点数完成精确财务计算。数据库通常使用 DECIMAL。
3.6 .isBoolean() 与 .toBoolean()
query("active")
.optional()
.isBoolean()
.withMessage("active 必须是布尔值")
.toBoolean();
查询字符串中的 "false" 本来仍是字符串,转换后才是布尔值。应测试客户端可能发送的具体形式,不要凭直觉理解所有 truthy/falsy 值。
3.7 .isEmail()
body("email")
.isString()
.bail()
.trim()
.isEmail()
.withMessage("邮箱格式不正确")
.normalizeEmail();
格式正确不代表邮箱存在,也不代表用户拥有该邮箱。账号验证仍需要验证码或确认邮件。
3.8 .isURL()、.isUUID()、.isISO8601()
body("website").optional().isURL();
param("id").isUUID("4");
body("birthday").isISO8601({ strict: true });
日期字符串通过格式校验后,还要明确业务时区和纯日期语义。
3.9 .isArray()、.isObject()
body("tags")
.isArray({ min: 1, max: 20 });
body("profile")
.isObject({ strict: true });
数组校验只检查容器,元素还需要通配符链:
body("tags.*")
.isString()
.trim()
.notEmpty();
4. 高频 sanitizer
4.1 .trim()
body("name").trim();
它会修改 req.body.name。不要对密码自动 trim(),否则会无声改变用户输入。
4.2 .toInt()、.toFloat()、.toBoolean()
query("page").isInt({ min: 1 }).toInt();
转换应该放在相应格式校验之后,避免把错误输入转换成意外值。
4.3 .normalizeEmail()
body("email")
.isEmail()
.normalizeEmail();
邮箱规范化规则可能影响账号身份。正式系统应确定规范化策略,并让登录、注册、唯一索引采用一致规则。
4.4 .toLowerCase()、.toUpperCase()
body("countryCode")
.isLength({ min: 3, max: 3 })
.toUpperCase();
适合具有明确大小写规范的枚举或编码。不要无条件修改用户展示名称。
4.5 .escape()
.escape() 会把 HTML 特殊字符转义。它不是 SQL 注入防护,也不是所有输出环境的通用 XSS 方案。
- SQL 使用 mysql2 占位符或 Sequelize 参数化查询;
- HTML 输出在正确的输出位置编码;
- JSON API 不应为了“安全”无条件把所有文本改成 HTML 实体。
5. .withMessage()
body("name")
.isLength({ min: 1, max: 20 })
.withMessage("name 必须是 1 到 20 个字符");
消息属于它前面的 validator。不要写成:
body("name")
.isString()
.isLength({ min: 1, max: 20 })
.withMessage("name 不合法");
此时 .isString() 失败仍可能产生默认英文消息。更清楚的写法是每个关键 validator 都配置消息。
多语言项目建议保存消息 key:
.withMessage("validation.wish.name.length")
最终在错误处理中间件中翻译。
6. .bail()
停止当前字段
body("email")
.isString()
.bail()
.isEmail()
.bail()
.custom(checkEmailExists);
默认等价于:
.bail({ level: "chain" })
类型已经失败时,不再执行格式和数据库校验。
停止整个请求
.bail({ level: "request" })
这会阻止该请求后续校验链继续运行,适合接口只返回第一个错误的约定。
但是 request-level bail 会影响原本可以并行执行的链,配合 oneOf()、checkExact() 时可能增加执行时间。普通表单通常更适合 chain-level bail,并一次返回多个字段错误。
7. .optional()
PATCH 接口经常使用:
body("name")
.optional()
.isString()
.trim()
.notEmpty();
默认只把 undefined 视为缺失:
.optional({ values: "undefined" })
.optional({ values: "null" })
.optional({ values: "falsy" })
values: "falsy" 会把 0、false、空字符串也当成未提供,容易掩盖错误,除非业务明确需要,否则不要随意使用。
.optional() 与调用位置无关,即使放在链尾也会影响整条链。为了可读性,建议紧跟字段选择器。
8. .exists()、.notEmpty()、.optional() 的区别
| API | 目的 |
|---|---|
.exists() |
要求字段存在 |
.notEmpty() |
要求字符串表示不为空 |
.optional() |
字段缺失时跳过整条链 |
创建接口:
body("name")
.exists()
.withMessage("必须提供 name")
.bail()
.isString()
.bail()
.trim()
.notEmpty();
如果 .isString() 已经足以表达字段缺失时失败,可以不额外使用 .exists()。只有需要区分“缺失”和“类型错误”的消息时才值得拆开。
9. 一个完整列表校验
export const listWishesValidation = [
query("page")
.default(1)
.isInt({ min: 1 })
.withMessage("page 必须是正整数")
.toInt(),
query("size")
.default(20)
.isInt({ min: 1, max: 100 })
.withMessage("size 必须是 1 到 100 的整数")
.toInt(),
query("keyword")
.optional()
.isString()
.bail()
.trim()
.isLength({ max: 50 })
.withMessage("keyword 最多 50 个字符"),
];
限制 size 不只是格式校验,也是保护数据库和服务资源。
10. 练习题
- 为注册接口编写用户名、邮箱、密码、年龄校验链。
- 对比
.optional()三种values配置对undefined、null、""、0的处理。 - 编写一个只返回第一个错误的校验数组,再改成每个字段返回一个错误。
- 为
tags编写 1–10 个非空字符串的数组校验。 - 验证分页参数并转换为 number,限制最大每页数量为 100。
- 设计一条密码校验链,确保不会调用
.trim()或.escape()修改密码。