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 个字符");
解释:
- 先确保能按字符串处理;
- 类型错误后停止;
- 去除业务不需要的首尾空格;
- 检查最终保存值的长度。
但密码通常不应 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 中:
- 创建数据库记录;
- 扣减库存;
- 发送验证码;
- 写审计日志;
- 调用无法安全重试的外部 API。
校验器可能因测试、组合规则或程序流程被重复执行。
10. 错误响应规范
推荐稳定结构:
{
"code": "VALIDATION_ERROR",
"message": "请求参数不合法",
"errors": [
{
"code": "validation.wish.name.length",
"field": "name",
"message": "name 必须是 1 到 20 个字符"
}
]
}
code给程序使用;message给用户或开发者阅读;- 不返回内部堆栈;
- 不返回密码、Token、验证码原值;
- 不把数据库错误全部包装成验证错误。
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 日期");
格式合法仍不等于业务语义正确。还要明确:
- 纯日期还是时间点;
- 是否允许未来时间;
- 使用哪个时区;
- 日期范围是否左闭右开;
- MySQL DATE、DATETIME、TIMESTAMP 对应关系。
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. 测试策略
格式测试
- 缺失字段;
- 错误类型;
- 边界长度;
null、空字符串、空格;- 数组过大;
- 多余字段。
Sanitizer 测试
- page 是否变成 number;
- name 是否 trim;
- email 是否按约定规范化;
- matchedData 是否只含允许字段。
集成测试
使用 Supertest 经过真实 Express 中间件链,验证:
express.json()顺序;- HTTP 状态码;
- 错误响应;
- Controller 是否不会在校验失败时执行;
- 数据库唯一约束冲突是否正确映射。
17. 性能经验
- 类型和长度检查放在数据库查询前;
- 异步 custom 前使用 chain-level bail;
- 避免多个 validator 重复查询同一数据;
- 不在 validator 中调用慢速、不稳定外部服务;
- 限制数组、字符串和请求体大小;
- 日志记录校验类型,不记录敏感原值;
- 不要为了“收集所有错误”继续执行昂贵校验。
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. 上线检查清单
- 是否在 body 校验前解析请求体?
- 是否为分页和数组设置上限?
- 是否区分 POST 和 PATCH?
- 是否使用 matchedData 或显式字段白名单?
- 是否隐藏密码和 Token?
- 是否避免在 validator 中产生副作用?
- 是否保留 MySQL 唯一键和外键约束?
- 是否统一错误 code 和消息格式?
- 是否测试边界值、null、空格和额外字段?
- 是否避免 request-level bail 带来的不必要顺序执行?
20. 练习题
- 重构愿望创建接口,让 Controller 只接收 matchedData DTO。
- 为 PATCH 愿望接口设计 optional 规则,并拒绝空更新。
- 设计统一错误 code 和中文消息格式。
- 编写 Supertest 测试,确认非法请求不会调用创建逻辑。
- 模拟两个并发注册请求,解释异步邮箱查重为什么不能代替唯一约束。
- 为包含 1000 个 items 的恶意请求设计请求体和数组大小保护。
- 比较 chain-level 和 request-level bail 对返回错误数及异步查询次数的影响。