使用 express-validator Chains 在 Express 中实现服务器端输入验证
了解为何 Express API 必须在服务器端验证输入,express-validator 链如何为 validationResult 记录错误,以及如何构建生产环境下的路由。
来源: Stop Trusting User Input: A Beginner’s Guide to `express-validator` in Express.js
迟早,每一个接收输入的 Express API 都会收到类似 "age": "banana" 的数据,服务器要么崩溃,要么更糟糕的是将其存储下来。验证机制正是用来防止这种情况发生的,无论前端表单多么严谨,做好后端验证都至关重要。本指南将解释什么是验证以及为何必须由服务器来承担这一职责,展示手动验证的方法及其局限性,随后会详细说明 express-validator 的内部工作原理、最常用的方法,以及如何在一个实际的路由中结合验证、数据净化和基于数据库的检查。
验证的含义
验证是指在代码对数据进行处理之前,确认传入数据的格式和内容是否符合预期。一个恰当的类比是场所入口处的身份检查:无论某人看起来多么自信,工作人员都会核对其身份证件,若不符合要求则无法进入。验证会对请求中的每个字段进行同样的严格检查——这真的是电子邮件地址吗?这确实是数字吗?该字段是否存在?
以注册接口为例,一个格式正确的请求体可能如下所示:
{
"name": "Rahul Sharma",
"email": "rahul@example.com",
"age": 25,
"password": "supersecret123"
}
对其进行验证意味着要确认email的格式符合电子邮件地址的标准,age是数字而非文本,且password的长度足够长,而不仅仅是走形式。
为何后端不能依赖前端检查
一个常见的反对意见是,注册表单已经将某些字段标记为required,并且会在浏览器中验证电子邮件格式。但这还不够,因为从攻击者的角度来看,前端验证是可选的。任何人都可以使用Postman、curl或脚本直接调用您的API,从而绕过界面中的所有规则。如果服务器不自行验证数据,那就等于信任了任何发送请求的人。
服务器端验证的具体优势:
- 它能保护您的数据。当某个字段要求输入数字,却收到了
"twenty-five"这样的内容时,要么会出现错误,更糟糕的是会产生格式错误的记录,日后引发各种故障。
分工很简单:前端验证是为了帮助用户,而后端验证则是为了保护应用程序。两者都很重要,但只有后端验证是必须的。
手动验证及其阻碍扩展的原因
首先看看手动实现的版本。这是从解析后的请求体中提取字段的注册路由开头部分(这里假设已注册了如 express.json() 这样的 JSON 请求体解析器):
app.post("/register", (req, res) => {
const { name, email, age, password } = req.body;
每个字段都有对应的校验逻辑,一旦出错就会返回 400 响应:
if (!name || typeof name !== "string" || name.trim().length === 0) {
return res.status(400).json({ error: "Name is required" });
} if (!email || !email.includes("@")) {
return res.status(400).json({ error: "A valid email is required" });
} if (!age || typeof age !== "number" || age < 18) {
return res.status(400).json({ error: "Age must be a number and at least 18" });
} if (!password || password.length < 8) {
return res.status(400).json({ error: "Password must be at least 8 characters" });
} // If we made it here, the data is safe to use
res.json({ message: "User registered successfully" });
});
对于小型应用来说,这种方式完全可行。但随着代码库的扩大,问题会逐渐累积:
- 每个需要接收输入的路由都需要独立的
if语句块。 - 各种校验逻辑会在不同路由之间重复编写,对其中一个版本的修改很容易在其他版本中被忽略。
null值、错误类型以及长度过短或过长的值,很快就会变得十分繁琐。手动实现的方式还存在一些细微的缺陷。email.includes("@")会接受许多无效的邮箱地址,而对非字符串类型调用.includes方法则会抛出错误。这类重复且容易出错的检查工作正是库应该处理的。
express-validator 提供的功能
express-validator是一组基于validator.js库构建的Express中间件。无需为每个字段编写条件逻辑,只需声明规则,该库便会根据这些规则对请求数据进行检查。首先需要导入两个主要函数:
const { body, validationResult } = require("express-validator");
使用声明式规则重写的相同注册路由如下所示:
app.post(
"/register",
[
body("name").trim().notEmpty().withMessage("Name is required"),
body("email").isEmail().withMessage("A valid email is required"),
body("age").isInt({ min: 18 }).withMessage("Age must be at least 18"),
body("password").isLength({ min: 8 }).withMessage("Password must be at least 8 characters"),
],
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
} res.json({ message: "User registered successfully" });
}
);
这些规则现在用近乎通俗的语言表述:去除空格后名称不能为空,邮箱必须是有效的邮箱地址,年龄必须是至少18的整数,密码长度必须至少为8个字符。最后的处理函数只负责判断一件事:是否有任何错误发生?
当请求到达时会发生什么
初学者最常见的误解是认为express-validator会自动拒绝无效请求。实际上它并不会这样做,而是收集有关错误的信息,将响应的处理完全交由你决定。每个请求的处理流程如下:
- Express会启动为该路由定义的中间件链,并按顺序执行其中的每个中间件。
body("email").isEmail() 时,它会从 req.body 中读取 email 并对其进行验证。验证失败不会抛出异常或终止请求;该验证链会将错误记录在请求对象上,然后继续执行后续操作。validationResult(req) 用于读取已记录的错误。它只是一个普通函数,而非中间件,因此需要在处理程序中调用它。它本身不会进行任何验证操作,只是反馈各验证链已经收集到的错误信息。errors.isEmpty() 返回 false,则需返回包含详细信息的 400 状态码;否则即可继续执行实际任务。这种设计更注重灵活性而非便捷性。由于不会自动拒绝请求,你可以控制响应格式和时机,并能在检查与最终决策之间添加其他逻辑。相应的代价是,若忘记调用 validationResult(),该路由的验证功能就会悄然失效,这也是许多团队会将相关检查封装在可重用的小型中间件中的原因。
四个核心组成部分
了解了这些构成要素后,这个库的使用就相当简单了。
验证链
body()、query()、param() 和 check() 等函数构成了一条链:一系列用点号连接的规则,例如 body("email").trim().isEmail()。每个函数针对请求的不同部分:
body()用于读取req.body,即 JSON 或表单数据。query()用于读取req.query,即?后面的查询字符串,如?page=2。param()用于读取req.params,即路由参数,如/users/:id中的:id。check()会同时检查请求的所有部分。
验证器
验证器是用于检测值的函数:isEmail()、isInt()、isLength()、isAlphanumeric()等等。大多数验证器都会调用validator.js,该文件已包含针对常见格式的成熟且经过充分测试的逻辑。
净化器
净化器不仅会检查数据,还会对数据进行转换。trim()用于去除周围的空白字符,normalizeEmail()则将邮箱地址转换为标准格式,而escape()则会用实体编码替换那些在HTML中具有特殊意义的字符,从而防止它们被当作标记语言解析。净化器可以与验证器在同一处理链中一起使用,且顺序很重要:如果将trim()放在notEmpty()之前,那么仅包含空格的值就会被视为无效。
每次请求的结果存储
每当某个验证链被执行时,其检测到的错误都会关联到特定的 req 对象。validationResult(req) 只需从该存储中读取数据,因此它需要与验证链所处理的请求对象相同的对象。
整体流程如下:请求到达后,验证链开始运行并默默记录问题,处理程序随后获取结果,最后由你决定响应方式。如果你正在考虑这种方案与基于模式的验证方式,我们关于 Zod 和 express-validator 的对比文章详细分析了二者之间的权衡。
适用于大多数实际项目的验证方法
你并不需要完整的 API,只需一小部分功能就能处理大多数情况。
位置选择器
body(field):用于请求正文中的字段query(field):用于查询字符串参数
param(field) 用于路由参数常用验证器
.notEmpty():值不能为空.isEmail():值必须是有效的电子邮件地址.isInt({ min, max }):值必须是整数,可选地需在指定范围内.isLength({ min, max }):检查字符串的长度.isString():值必须是字符串类型.isBoolean():值必须是布尔值.matches(regex):值必须符合自定义模式.isIn([...values]):值必须是预定义集合中的某个值,非常适合用于只能是"active"或"inactive"这样的status字段
常用净化器
.trim():去除字符串开头和结尾的空白字符.escape():对HTML字符进行转义,降低注入风险.normalizeEmail():标准化电子邮件格式.toInt()和.toFloat():将字符串输入转换为数字
自定义规则
.custom((value, { req }) => { ... }):当内置验证规则不适用时实现自定义规则,例如确认用户名未被注册
错误报告
.withMessage("text"):为前面的规则设置提示信息validationResult(req):收集当前请求的所有错误信息errors.array():将这些错误转换为适合JSON响应的普通数组
生产环境风格的路由
以下示例更接近真实 API 的结构:一个用于创建博客文章的路由,其中包含了验证、数据净化以及数据库查询操作。首先是导入部分,这里的 Post 代表类似 Mongoose 模型这样的数据结构:
const express = require("express");
const { body, validationResult } = require("express-validator");
const Post = require("./models/Post"); // a Mongoose model, for example
接着是路由器以及包含规则数组的路由定义:
const router = express.Router();router.post(
"/posts",
[
body("title")
.trim()
.notEmpty().withMessage("Title is required")
.isLength({ max: 100 }).withMessage("Title cannot exceed 100 characters"), body("slug")
.trim()
.notEmpty().withMessage("Slug is required")
.matches(/^[a-z0-9-]+$/).withMessage("Slug can only contain lowercase letters, numbers, and hyphens")
.custom(async (slug) => {
const existing = await Post.findOne({ slug });
if (existing) {
throw new Error("This slug is already taken");
}
return true;
}), body("content")
.trim()
.notEmpty().withMessage("Content cannot be empty")
.isLength({ min: 20 }).withMessage("Content should be at least 20 characters"), body("tags")
.optional()
.isArray().withMessage("Tags must be an array")
.custom((tags) => tags.every((t) => typeof t === "string"))
.withMessage("Each tag must be a string"),
],
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
} try {
const post = await Post.create(req.body);
res.status(201).json(post);
} catch (err) {
res.status(500).json({ error: "Something went wrong while saving the post" });
}
}
);module.exports = router;
有几点细节值得研究:
- 基于数据库的规则验证。
slug字段使用异步的.custom()验证器来查询已存在的文章。正则表达式可以确认 slug 的格式,但只有通过数据库查询才能确定其唯一性。在自定义函数中抛出异常即可将该字段标记为无效,并附带相应错误信息。
tags 被标记为 .optional(),因此没有标签的帖子也能通过验证,但若提供了标签,则必须是字符串数组形式。在复制此模式之前需注意两点。首先,验证器中的唯一性检查可能存在竞态条件:两个具有相同标识符的请求都可能在数据被保存之前通过查询,因此需在对应列上维护唯一索引,并处理重复键错误。其次,直接将req.body传递给Post.create()会保存客户端发送的所有字段,而不仅仅是经过验证的那些。express-validator提供了matchedData(req)方法,仅返回你的验证链所覆盖的字段,使用该方法即可解决这种批量赋值的问题。
随着应用程序的发展,应将规则数组移至专用模块中,例如validators/postValidator.js,然后再将其导入到路由文件中,这样就能避免路由文件被大量验证代码占据。
总结
验证并非可选的附加功能,它决定了应用程序能否信任其处理的数据。对于少量字段而言,手写的if条件检查尚可,但一旦应用于众多路由,效率就会迅速下降。
express-validator并非魔法工具。它会以中间件的形式执行你定义的规则,将验证失败的情况记录在请求中,并通过validationResult()函数提供报告,让你据此决定响应方式。基于这样的工作流程,该库的其余部分则是一系列验证器和净化器工具,你可以根据需要选用。实际应用中最重要的几点是:
- 无论前端是否有验证,都应将后端验证视为必须。
- 务必调用
validationResult(),最好通过一个通用的中间件来调用,因为该库本身不会主动拒绝请求。 - 在处理流程中要有意识地安排净化器的执行顺序。
matchedData(req)而非原始请求体。