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

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([...])

为什么有价值

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. 练习题

  1. 使用 oneOf() 实现邮箱登录或手机登录。
  2. 为创建愿望接口添加 checkExact(),测试额外字段。
  3. 比较只使用 matchedData 和同时使用 checkExact 的接口行为。
  4. 编写顺序执行、首错停止的手动 runner。
  5. 编写并行 runner,并说明什么情况下不能使用。
  6. 为正整数路径参数编写每次返回新链的函数工厂。

官方参考