资讯详情 express-validator 6 输入清洗与净化迁移实战
📅 2026/10/29 15:17:58
上周帮一位做后端的朋友排查线上问题升级依赖之后注册接口直接 500报错就一行sanitizeBody is not a function。他翻了几页搜索结果教程清一色是旧版本写法照着改当然跑不起来。这个场景我最近半年碰到不止五次干脆把 express-validator 6 的净化体系从头到尾捋一遍包括sanitize、sanitizeBody、sanitizeQuery这些名字在 v6 里到底变成了什么、底层是怎么把请求对象改掉的、哪些参数看着无害实际会把数据改坏。如果你刚把项目从 v5 升上来或者正准备给接口加一层输入清洗这篇可以直接拿去对照。文章里的代码都按 CommonJS 写Express 4 和 5 的差异我会在踩坑部分单独说明。1. 升级后满屏报错v6 的净化 API 到底变了什么1.1 sanitizeBody 为什么找不到了先把结论摆出来v6 把sanitize、sanitizeBody、sanitizeQuery、sanitizeParam、sanitizeParams、sanitizeCookie、sanitizeHeader这一整批独立函数全部移除了。你在旧文章里看到的sanitizeBody(email).trim()在 v6 的入口文件里根本导不出来require拿到的就是undefined调用时自然抛is not a function。旧版本的设计是“验证一个入口、净化一个入口”check系列负责验证sanitize系列负责净化两边各有一套位置参数。问题是这两套 API 长得像但行为不完全对称字段路径、通配符、可选修饰符的支持程度也有差异维护成本高使用者还容易混。v6 的做法是把它们合并到同一套链式 API 上body()、query()、param()、params()、cookie()、header()返回的都是同一个ValidationChain净化方法直接挂在链上。所以 v5 的sanitizeBody(email).trim()在 v6 里等价写法是body(email).trim()sanitizeQuery(page).toInt()则是query(page).toInt()。这个改动带来的一个直接好处是同一字段的净化和验证可以写在同一行不用再维护两个数组。比如body(email).trim().normalizeEmail().isEmail()读起来就是“先去掉首尾空白、再标准化邮箱、最后判断格式”顺序和语义都在一起。坏处是升级时必须全局搜一遍sanitize开头的导入逐个替换漏一个就是一个运行时错误。1.2 新旧写法对照表我整理了一张迁移对照表基本覆盖常见场景。注意最右边一列v6 里没有“通用 sanitize 入口”这个概念了所有净化都要落到具体的位置函数上。v5 写法v6 等价写法说明sanitizeBody(email).trim()body(email).trim()位置由函数名决定sanitizeQuery(page).toInt()query(page).toInt()同上sanitizeParam(id).toInt()param(id).toInt()单数 param 对应路由参数sanitizeParams(id).toInt()params(id).toInt()复数形式仍可用sanitizeCookie(token).trim()cookie(token).trim()Cookie 场景sanitizeHeader(x-trace-id).trim()header(x-trace-id).trim()自定义请求头sanitize(email).trim()body(email).trim()v6 无通用入口按实际位置选sanitizeBody(tags).toArray()body(tags).toArray()数组转换sanitizeBody(user.name).trim()body(user.name).trim()嵌套路径sanitizeBody(items.*.id).toInt()body(items.*.id).toInt()通配符路径补充一个容易忽略的点check()在 v6 里仍然存在默认作用位置是 body但我建议新代码一律显式写body()、query()别用check()。原因很简单半年后回头看代码check(id)你根本不知道它查的是 body 还是 query尤其是同一个字段名在两个位置都出现的时候排查起来非常难受。1.3 官方重构的三个动机第一个动机是减少概念数量。v5 有check、sanitize两套入口每套又有若干位置变体文档里光函数名就占半屏。v6 统一成一套链式 API 之后学习成本明显下降你只需要记住“先选位置再挂方法”。第二个动机是让净化器能复用验证链上的能力。v6 的ValidationChain支持optional()、bail()、withMessage()、通配符路径、自定义错误消息这些特性。净化器挂在同一条链上就能共享这些能力。比如body(nickname).optional().trim()字段不存在时直接跳过不会因为undefined调用trim而报错。v5 的sanitizeBody虽然也支持optional但两个体系的实现是分开的行为细节容易对不齐。第三个动机是执行顺序可控。v6 明确了一条规则同一条 chain 内所有 sanitizer 先执行所有 validator 后执行跟你声明的先后顺序无关。这个设计有它的道理净化是把输入变成“可验证的规范形态”验证是判断这个形态是否合法逻辑上净化必须在前。但它也带来一个反直觉的结果后面讲执行原理时会展开。2. 链式净化的工作机制从中间件注册到 req 被改写2.1 一条 chain 从创建到执行的完整路径body(email)这一行代码做的事情比看起来多。它内部会创建一个ValidationChain实例构造时传入上下文信息字段路径email、位置body、以及默认的错误消息配置。这个 chain 对象同时是一个 Express 中间件因为它实现了run(req)方法并且在被app.use或路由注册时会以中间件形式挂载。执行时Express 按注册顺序调用每一条 chain。chain 的run方法会做三件事先从req上按字段路径取值然后把值交给净化函数处理最后把处理结果写回req的对应位置。取值和写回都由内部的getValue、setValue完成支持点号路径、方括号路径和通配符。写回这一步是关键它意味着后续的中间件、路由处理函数拿到的已经是净化后的值你不需要手动接收返回值再赋值。这里有个细节值得注意净化函数拿到的值可能是undefined。如果字段在请求里根本不存在getValue返回undefined此时直接调用trim()会抛类型错误。v6 的处理方式是没有加optional()的 chain遇到undefined仍然会把undefined传给净化器所以字符串类净化器要配合optional()使用或者自己用customSanitizer做空值判断。这一点在官方文档里写得比较散实际写代码时很容易漏。2.2 sanitizer 与 validator 的执行顺序这是 v6 最容易被误解的地方。同一条 chain 内净化器的执行时机跟声明顺序无关永远是先净化、后验证。你写body(age).isInt().toInt()实际执行的是先toInt()再isInt()isInt看到的是转换后的数字不是原始字符串。官方这样设计有它的逻辑净化负责把输入整理成标准形态验证负责判断标准形态是否合法。如果反过来你先验证原始字符串再净化验证通过的值和最终入库的值可能不是同一个东西中间会出现“验证的是 A、存的是 B”的缝隙。先净化后验证至少保证验证的对象就是最终要用的对象。但这个设计有一个明显的副作用它会掩盖原始输入的非法性。举个例子body(age).toInt().isInt({ min: 1 })用户传18abctoInt按parseInt语义得到18isInt判断18是整数验证通过。可用户传的明明是脏数据却被“洗白”了。要避免这种情况要么先用isInt()验证原始字符串、再用toInt()转换要么在customSanitizer里做严格格式判断。我个人的习惯是对数字类字段验证和净化分成两条 chain 写顺序明确排查时也清楚是哪一步出的问题。跨 chain 的顺序则是按 Express 中间件顺序来的。你把body(email).trim()和body(email).isEmail()写成两个 chain谁先注册谁先执行这个顺序是确定的。利用这一点可以把“净化链”和“验证链”拆成两个数组前者放前面后者放后面结构比堆在一行里清楚得多。2.3 字段路径、通配符与嵌套赋值express-validator 的字段路径解析支持三种写法。点号路径user.profile.name对应嵌套对象方括号路径items[0].name对应数组下标通配符items.*.name对应数组里每个元素的name字段。这三种可以混用比如orders.*.items.*.sku。通配符的底层实现是getValue遇到*时会把当前数组展开对每个元素分别取值净化后再逐个写回。这意味着body(items.*.id).toInt()会遍历items数组把每个元素的id转成整数。这里有个边界如果items不是数组或者某个元素不是对象通配符路径可能取不到值净化器就被跳过不会报错。所以用通配符之前最好先用isArray()验证外层结构。嵌套赋值还有一个坑setValue在写回时会尝试创建中间对象。比如body(a.b.c).trim()如果req.body.a不存在它会创建{ b: { c: ... } }但如果req.body.a是一个字符串写回a.b.c就可能失败或者产生奇怪的结构。实际项目里嵌套路径尽量配合对象类型的验证一起用别让净化器去猜结构。3. 常用净化器逐个拆解参数、边界与真实效果3.1 空白与字符集处理trim、ltrim、rtrim、blacklist、whitelisttrim()是最常用的一个去掉字符串首尾的空白字符包括空格、制表符、换行。它接受一个可选参数chars指定要剔除的字符集合。比如trim(-_)会把首尾的减号和下划线也去掉。这个参数在清理用户手动输入的“分隔线”时挺有用但要注意它只处理首尾不会动中间的内容。ltrim()和rtrim()分别是只处理左侧和右侧。什么时候用比如处理金额字符串¥ 128.00 你想保留右侧可能的单位、只去掉左侧符号就用ltrim(¥ )。这类需求不多但遇到了能省不少自定义代码。blacklist(chars)按字符黑名单删除whitelist(chars)按字符白名单保留。这两个的参数会被拼进字符类正则所以a-zA-Z0-9这种范围写法是有效的能保留所有字母数字。但特殊字符要小心blacklist([])里的方括号会破坏正则结构得写成blacklist(\\[\\])。我的经验是blacklist适合做简单的字符剔除比如blacklist()去掉尖括号whitelist适合做严格格式约束比如用户名只允许字母数字下划线。但whitelist千万别用在昵称、备注这类允许中文的字段上whitelist(a-zA-Z0-9)会把中文全部删掉用户提交完发现名字没了。还有一个容易忽略的点blacklist和whitelist对非字符串值的处理。如果字段是数字内部会先转成字符串再处理结果是字符串。也就是说body(age).blacklist(abc)会把数字18变成字符串18。如果你后面还要用isInt()记得这个类型变化。3.2 HTML 与控制字符escape、unescape、stripLowescape()把 HTML 特殊字符转成实体变amp;变lt;变gt;双引号变quot;单引号变#x27;斜杠变#x2F;反引号变#x60;。unescape()是反向操作。这两个方法的使用场景非常明确数据要输出到 HTML 模板时在输出前转义。很多旧教程会建议在入库前就escape()我不推荐。原因有两个一是转义后的数据在非 HTML 场景比如 JSON 接口、日志、导出文件里会变成乱码一样的实体字符读起来很难受二是二次转义问题用户输入lt;你escape()之后变成amp;lt;展示时再转义一次就彻底坏了。正确做法是存原始数据输出层按上下文转义。express-validator 的escape()适合放在返回给页面的那一步而不是注册接口的入库链上。stripLow(keepNewLines)删除 ASCII 控制字符码点小于 32 和等于 127 的字符都会被清掉。第二个参数传true时保留换行符\n和\r。这个净化器适合处理从表单、文本域来的内容防止用户粘贴进不可见控制字符导致数据库存储或日志展示异常。它不会删 emoji因为 emoji 的码点远高于 127这点可以放心。但要注意stripLow对\t也会删掉如果你需要保留制表符得自己写customSanitizer。3.3 类型转换toInt、toFloat、toBoolean、toDate、toArraytoInt(radix)底层是parseInt第二个参数是进制默认十进制。它的行为跟parseInt完全一致遇到非数字字符就截断18abc得到18abc得到NaN。toFloat()同理底层是parseFloat。这两个的坑我在 2.2 节提过这里再强调一次它们不是严格解析而是“能解析多少算多少”。如果你的业务要求“必须是纯数字”别指望toInt帮你把关得先用isInt()或者matches(/^\d$/)验证原始值。toBoolean(strict)的行为分两种模式。非严格模式下字符串0、false、空字符串会转成false其他非空字符串一律true。也就是说no、off、false 带空格都会变成true前端如果按 HTML 表单的习惯传no后端会收到true这种 bug 排查起来很费劲。严格模式下只认true和false其他值原样返回。我的建议是布尔字段一律用严格模式并且在前端明确传true/false别用0/1或者yes/no混着来。toDate()底层是new Date(value)。它能解析 ISO 字符串、时间戳、部分日期格式但解析规则依赖运行时不同 Node 版本对非标准格式的处理可能不一致。更麻烦的是非法日期new Date(2024-02-30)在 V8 里得到Invalid Date而new Date(2024/02/30)可能滚动到 3 月 1 日。所以toDate()之后一定要跟一个日期有效性验证比如custom(v !Number.isNaN(v.getTime()))。另外toDate()写回req.body的是Date对象JSON 序列化时会变成 ISO 字符串如果你直接把这个对象丢给数据库驱动记得确认驱动接受Date类型。toArray()是 express-validator 自己实现的不是 validator.js 的函数。它的逻辑是如果值已经是数组原样返回如果是字符串按逗号切分成数组其他类型包成单元素数组。适合处理tagsa,b,c这种查询参数。但要注意它只按逗号切不会去重、不会去空白a, b ,c得到的是[a, b , c]中间那个带空格的元素需要你再map(trim)一次。我一般会跟一个customSanitizer做二次清理。3.4 邮箱标准化与自定义净化器normalizeEmail(options)是争议最大的一个净化器。它默认会做这些事把域名转小写、把 Gmail 地址里的点去掉、把后面的子地址去掉、把googlemail.com统一成gmail.com。也就是说Foo.BarnewsletterGmail.com会被标准化成foobargmail.com。这个行为在“防止用户用多个变体重复注册”的场景下有用但在“用户用邮箱登录”的场景下就是灾难用户注册时用的是foo.bargmail.com登录时输入同一个地址标准化后变成foobargmail.com如果数据库里存的是标准化后的值登录能过但如果用户之前用别的系统注册、存的是原始地址就对不上了。更常见的问题是用户自己都不记得注册时有没有加点标准化之后反而找不回账号。我的建议是除非业务明确需要去重否则把gmail_remove_dots关掉只保留域名小写。配置写成normalizeEmail({ gmail_remove_dots: false, gmail_remove_subaddress: false })这样只做最基本的规范化不会把用户地址改得面目全非。如果你确实需要去重标准化后的值单独存一个字段用于查重原始地址保留用于登录和展示两边别混。customSanitizer(fn)是兜底方案接收(value, meta)两个参数meta里有req、location、path三个字段。你可以返回任何类型的值也可以返回 Promiseexpress-validator 会 await 它。这个能力很强但别滥用能在内置净化器里找到对应方法的优先用内置的自定义逻辑越多测试成本越高。我一般只在三种情况下用它需要同时读多个字段做联合处理、需要做严格格式判断后抛错、需要对数组做 map/filter 这类内置方法覆盖不到的操作。4. 从零搭一个注册接口净化规则完整落地4.1 依赖安装与中间件顺序先建项目、装依赖npm init -y npm i express express-validator入口文件里express.json()和express.urlencoded()必须挂在验证链之前。这是最常见的“净化不生效”原因body 解析中间件在后面req.body还是undefined净化器拿不到值自然什么都不会发生。顺序应该是日志、body 解析、express-validator 链、路由处理函数、错误处理。const express require(express); const { body, query, param, validationResult, matchedData } require(express-validator); const app express(); app.use(express.json()); app.use(express.urlencoded({ extended: true }));如果你用的是 Express 5req.query是只读 getter直接往上面写值可能不生效。这个问题不是 express-validator 独有的任何试图修改req.query的中间件都会遇到。稳妥做法是把净化后的查询参数写到req.validatedQuery之类的自定义属性上路由里读这个属性别依赖req.query被改写。Express 4 下没有这个问题但升级前最好实测一遍。4.2 规则集与路由代码下面是一套完整的注册接口规则涵盖字符串、数字、数组、可选字段、自定义净化。我把它拆成数组方便复用和单测。const registerRules [ body(email) .trim() .normalizeEmail({ gmail_remove_dots: false, gmail_remove_subaddress: false }) .isEmail().withMessage(邮箱格式不正确) .isLength({ max: 254 }).withMessage(邮箱过长), body(nickname) .trim() .blacklist() .isLength({ min: 2, max: 20 }).withMessage(昵称需要 2 到 20 个字符), body(age) .isInt({ min: 1, max: 120 }).withMessage(年龄必须是 1 到 120 的整数) .toInt(), body(tags) .optional() .toArray() .customSanitizer((value) { if (!Array.isArray(value)) return []; return [...new Set(value.map((item) String(item).trim()).filter(Boolean))]; }), body(profile.homepage) .optional() .trim() .isURL({ require_protocol: true }).withMessage(主页必须是完整 URL), ]; app.post(/register, registerRules, (req, res) { const errors validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ errors: errors.array() }); } const data matchedData(req); res.json({ ok: true, data }); });这段代码里有几个有意为之的设计。age字段把isInt()放在toInt()前面虽然实际执行时toInt仍然先跑但至少声明顺序上表达了“先验证原始值”的意图。更严格的做法是拆成两条 chainbody(age).isInt()和body(age).toInt()这样执行顺序完全按中间件顺序来不会受“sanitizer 先执行”规则的影响。tags用了optional()字段不存在时直接跳过避免toArray对undefined做处理。profile.homepage用了嵌套路径配合optional()父级对象不存在也不会报错。matchedData(req)是我强烈建议加的一步。它只返回经过验证链处理过的字段自动过滤掉请求里多传的键。直接拿req.body入库等于把客户端传什么就存什么多出来的字段可能覆盖你不希望被覆盖的列。用matchedData相当于多了一层白名单。4.3 请求实测与结果对照发一个带脏数据的请求curl -X POST http://localhost:3000/register \ -H Content-Type: application/json \ -d { email: Foo.BartestGmail.com , nickname: 张三script , age: 18, tags: node, express, node, , profile: { homepage: https://example.com }, isAdmin: true }响应里的data大概是这样{ ok: true, data: { email: foo.bartestgmail.com, nickname: 张三script, age: 18, tags: [node, express], profile: { homepage: https://example.com } } }逐项对照邮箱首尾空白被trim掉域名转小写但点和子地址保留了因为配置里关掉了昵称的空白和尖括号被清掉script变成script这里没用escape避免入库变成实体字符年龄从字符串18变成数字18标签按逗号切分、去空白、去重主页 URL 的空白被去掉isAdmin没有出现在结果里因为规则集里没声明这个字段matchedData把它过滤掉了。如果传age: 18abc因为isInt()在声明上先于toInt()但实际执行时toInt()先跑值是18验证通过。这正是 2.2 节说的“洗白”问题。想拦住它把规则改成先isInt()再单独一条toInt()或者用matches(/^\d$/)验证原始字符串。这个细节建议你在自己的项目里实际跑一遍感受一下执行顺序和声明顺序的差异。4.4 规则复用与测试写法规则集写成数组之后单测可以直接把数组传给一个假的 req调用每条 chain 的run方法断言req.body的变化。比起整个 HTTP 服务再发请求快得多也更容易定位是哪条规则改坏了数据。const { body } require(express-validator); async function runChain(chain, req) { await chain.run(req); return req; } test(tags 去重并去空白, async () { const req { body: { tags: a, b ,a, } }; await runChain(body(tags).toArray().customSanitizer((v) { return [...new Set(v.map((s) String(s).trim()).filter(Boolean))]; }), req); expect(req.body.tags).toEqual([a, b]); });这种写法有个前提chain.run(req)需要完整的 req 对象吗大多数净化器只读req.body给个普通对象就够。但customSanitizer的meta.req会拿到这个对象如果你的自定义逻辑依赖req.headers之类的东西测试里记得补上。规则集抽成独立模块之后路由文件只负责组装测试文件只负责断言两边不会互相污染。5. 踩坑实录净化不生效与数据被改坏的典型场景5.1 净化器不执行的四个原因排在第一位的永远是中间件顺序。express.json()挂在验证链后面req.body