08 Express 5 + TypeScript 最佳实践

1. 推荐工程结构

学习项目可以保持简单:

routes/
  wish.ts
  wish.validation.ts

middlewares/
  validate-request.ts

controllers/
  wish.controller.ts

services/
  wish.service.ts

职责:

文件 职责
wish.validation.ts 描述 HTTP 输入规则
validate-request.ts 统一读取和格式化错误
Controller 提取可信数据、设置 HTTP 响应
Service 业务规则、事务、权限
Model/MySQL 数据映射与最终约束

不要为了几个接口建立庞大的 validators 框架。

2. 推荐请求流程

请求体解析
  → 基础类型校验
  → 格式和范围校验
  → Sanitizer
  → 跨字段校验
  → 必要的数据库查询
  → validationResult
  → matchedData
  → Service

便宜、确定性的检查应放在昂贵检查之前。

3. 验证和清洗的顺序

推荐:

body("name")
  .isString()
  .withMessage("name 必须是字符串")
  .bail()
  .trim()
  .isLength({ min: 1, max: 20 })
  .withMessage("name 必须是 1 到 20 个字符");

解释:

  1. 先确保能按字符串处理;
  2. 类型错误后停止;
  3. 去除业务不需要的首尾空格;
  4. 检查最终保存值的长度。

但密码通常不应 trim,HTML 文本也不应无脑 escape。Sanitizer 是协议的一部分,不是越多越安全。

4. chain-level bail 通常更实用

.bail()

它能避免同一字段产生一串连锁错误,又允许其他字段继续检查,前端可以一次展示多个字段问题。

只有 API 明确只返回整个请求的第一个错误时才使用:

.bail({ level: "request" })

request-level bail 还可能使校验顺序化,尤其配合 oneOf()checkExact() 时应评估性能。

5. POST 与 PATCH 不应共用同一套必填规则

创建:

body("name")
  .isString()
  .trim()
  .notEmpty();

部分更新:

body("name")
  .optional()
  .isString()
  .trim()
  .notEmpty();

PATCH 还应检查至少有一个允许更新的字段。不能因为所有字段 optional 就允许空对象产生一次无意义 UPDATE。

6. 使用 matchedData 建立白名单

不推荐:

await Wish.create(req.body);

推荐:

interface CreateWishInput {
  name: string;
  content: string;
}

const input = matchedData<CreateWishInput>(req, {
  locations: ["body"],
});

await wishService.create(input);

Sequelize 的 fields 选项也可以建立第二道写入白名单:

await Wish.create(input, {
  fields: ["name", "content"],
});

7. checkExact 是否必须

严格内部 API 可以使用:

checkExact(chains, { locations: ["body"] });

它能及时发现拼错字段。但公开 API 的客户端升级节奏不同,拒绝所有未知字段可能降低向前兼容性。

无论是否 checkExact,都建议使用 matchedData。

8. Validation、Service、数据库约束

express-validator

email 是字符串且格式合理
page 是 1–10000 的整数
name 长度不超过 20

Service

当前用户是否有权限
愿望当前状态是否允许修改
余额是否足够
是否需要开启事务

MySQL

NOT NULL
UNIQUE
FOREIGN KEY
CHECK
事务隔离和锁

异步 validator 做邮箱预查可以给出友好消息,但并发下只有唯一约束能最终阻止重复记录。

9. 自定义 validator 避免副作用

推荐只做检查:

.custom(async (value) => {
  const exists = await repository.exists(value);
  return !exists;
})

不要在 validator 中:

校验器可能因测试、组合规则或程序流程被重复执行。

10. 错误响应规范

推荐稳定结构:

{
  "code": "VALIDATION_ERROR",
  "message": "请求参数不合法",
  "errors": [
    {
      "code": "validation.wish.name.length",
      "field": "name",
      "message": "name 必须是 1 到 20 个字符"
    }
  ]
}

11. i18n

规则中保存 key:

.withMessage("validation.wish.name.length")

统一错误中间件根据 Accept-Language、账号设置或 Cookie 翻译。express-validator 不内置完整 i18n 系统,可以配合 i18next。

不要在模块定义阶段尝试读取当前请求语言;那时还没有 req

12. TypeScript 类型策略

HTTP 输入在验证前应被视为不可信数据。不要用 Request 泛型制造虚假的安全感:

type CreateWishRequest = Request<
  object,
  object,
  CreateWishInput
>;

这只影响编译器。

推荐在验证成功后创建 DTO:

const input = matchedData<CreateWishInput>(req);

再让 Service 只接收 DTO,不接收整个 Request

await wishService.create(input);

13. 日期校验

body("startDate")
  .isISO8601({ strict: true })
  .withMessage("startDate 必须是 ISO 日期");

格式合法仍不等于业务语义正确。还要明确:

14. 数组和对象输入限制规模

body("items").isArray({ min: 1, max: 50 });
body("items.*.productId").isInt({ min: 1 });

先限制容器大小。否则一个包含十万项的数组即使最终失败,也可能消耗大量 CPU、内存和数据库连接。

嵌套对象还要考虑深度限制和请求体大小限制:

app.use(express.json({ limit: "100kb" }));

15. 分页保护

query("page")
  .default(1)
  .isInt({ min: 1, max: 10_000 })
  .toInt(),
query("size")
  .default(20)
  .isInt({ min: 1, max: 100 })
  .toInt();

size 上限既是输入规则,也是资源保护。数据量大时还应考虑游标分页,不能只依赖限制 page。

16. 测试策略

格式测试

Sanitizer 测试

集成测试

使用 Supertest 经过真实 Express 中间件链,验证:

17. 性能经验

  1. 类型和长度检查放在数据库查询前;
  2. 异步 custom 前使用 chain-level bail;
  3. 避免多个 validator 重复查询同一数据;
  4. 不在 validator 中调用慢速、不稳定外部服务;
  5. 限制数组、字符串和请求体大小;
  6. 日志记录校验类型,不记录敏感原值;
  7. 不要为了“收集所有错误”继续执行昂贵校验。

18. 当前愿望接口的推荐形式

export const createWishValidation = [
  body("name")
    .isString()
    .withMessage("name 必须是字符串")
    .bail()
    .trim()
    .isLength({ min: 1, max: 20 })
    .withMessage("name 必须是 1 到 20 个字符"),

  body("content")
    .isString()
    .withMessage("content 必须是字符串")
    .bail()
    .trim()
    .isLength({ min: 1, max: 200 })
    .withMessage("content 必须是 1 到 200 个字符"),
];

这里使用默认 chain-level bail:某字段类型错误时停止该字段,但仍检查另一个字段。若接口协议明确只返回整个请求的一个错误,再使用 request-level bail。

19. 上线检查清单

20. 练习题

  1. 重构愿望创建接口,让 Controller 只接收 matchedData DTO。
  2. 为 PATCH 愿望接口设计 optional 规则,并拒绝空更新。
  3. 设计统一错误 code 和中文消息格式。
  4. 编写 Supertest 测试,确认非法请求不会调用创建逻辑。
  5. 模拟两个并发注册请求,解释异步邮箱查重为什么不能代替唯一约束。
  6. 为包含 1000 个 items 的恶意请求设计请求体和数组大小保护。
  7. 比较 chain-level 和 request-level bail 对返回错误数及异步查询次数的影响。

官方参考