MongoDB: mongoose数据校验与中间件
最后更新:2026-08-26
数据校验是应用层的第一道防线——mongoose 校验器能拦截 80% 的脏数据。
本课程学习 mongoose 内置 validators、自定义 validator、validateBeforeSave、中间件实战。
校验器在数据防护体系中的位置:完整的数据防护是三层结构——1. 数据库层(MongoDB Schema Validation/唯一索引):最后一道防线,防止应用层漏洞导致脏数据入库;2. 应用层(mongoose 校验器 + 中间件):主力防线,拦截大部分无效数据并给出友好错误提示;3. 前端层(表单验证):用户体验层,即时反馈但不安全(可绕过)。三层的关系:前端验证 ≠ 安全(可被跳过),mongoose 验证 = 安全基线(不可绕过),数据库验证 = 兜底(即使应用层出问题也不会入库脏数据)。永远不要只依赖前端验证。
mongoose 校验器的执行时机:mongoose 校验器在 document.save() 和 document.validate() 时自动执行(validateBeforeSave 默认 true)。注意:1. Model.updateOne()/updateMany() 等更新操作不触发校验器——它们直接操作数据库跳过 mongoose document 层;2. 如需在更新时校验,使用 Model.findOneAndUpdate + runValidators: true 选项;3. 校验器在中间件 pre('save') 之前执行——如果校验失败,pre save 中间件不会执行(密码哈希不会对无效数据执行)。理解这个执行顺序对调试校验问题至关重要。
1. 你将学到
- 内置 validators(required/min/max/enum/match)
- 自定义 validator 函数
- validateBeforeSave 选项
- async validator 异步验证
- 中间件实战:密码哈希、软删除、自动 populate
2. 内置 Validators
概念说明:mongoose 内置 validators 是 SchemaType 自带的验证规则,无需编写自定义函数即可声明常见约束。包括 required(必填)、min/max(数值范围)、minlength/maxlength(字符串长度)、enum(枚举值)、match(正则匹配)等。它们在 save() 和 validate() 时自动执行,是应用层数据质量的第一道防线。
工作原理:mongoose 在 Document 保存前(或显式调用 validate() 时)遍历所有字段的 SchemaType,依次执行内置验证器。每个验证器接收字段值,返回 boolean 或抛出错误。验证失败时,mongoose 收集所有错误到 ValidationError.errors 对象,包含字段路径、错误类型和自定义消息。
内置验证器的隐式行为:内置验证器有几个容易被忽视的隐式行为——1. required 检查的是字段是否存在且不为 undefined,但空字符串 '' 通过 required 检查(需额外用 minlength: 1 防止空字符串);2. min/max 只对 Number 类型有效(String 类型的 min/max 是按字典序比较而非数值大小);3. enum 对大小写敏感('Admin' 和 'admin' 是不同的值);4. match 只检查格式不检查内容(如 /\d+/ 匹配"abc123def",需用 /^d+$/ 严格匹配纯数字)。
验证器的自定义错误消息:每个内置验证器都支持自定义错误消息——required: [true, '用户名不能为空']、min: [0, '年龄不能为负数']、enum: {values: ['customer', 'admin'], message: '{VALUE} 不是有效的角色'}。消息模板支持 {VALUE}(当前值)、{PATH}(字段名)、{MIN}/{MAX}(边界值)等变量。好的错误消息应让前端开发者不看 Schema 就知道如何修复——"用户名必须3-30位字母数字下划线"比"Validator failed for path username"有用得多。
graph TD
A[doc.save] --> B[validate 阶段]
B --> C[检查 required]
B --> D[检查 min/max]
B --> E[检查 enum]
B --> F[检查 match]
B --> G[检查 minlength/maxlength]
C --> H{全部通过?}
D --> H
E --> H
F --> H
G --> H
H -->|Yes| I[pre save hooks]
H -->|No| J[ValidationError<br/>收集所有错误]
I --> K[MongoDB insertOne]
style K fill:#d4edda
style J fill:#f8d7da
| Validator | 适用类型 | 触发时机 | 错误消息模板 |
|---|---|---|---|
required |
全部 | 字段缺失时 | '{PATH} is required' |
min/max |
Number, Date | 值超范围时 | '{PATH} must be >= {MIN}' |
minlength/maxlength |
String | 长度不合规时 | '{PATH} must be >= {MINLENGTH} chars' |
enum |
String | 值不在枚举中时 | '{VALUE} is not valid' |
match |
String | 正则不匹配时 | '{PATH} is invalid' |
unique |
全部 | 索引冲突时 | E11000 duplicate key |
(1) 完整列表
validator 的优先级与执行顺序:mongoose 按固定顺序执行验证器——1. 内置类型转换(String→Number 等);2. required 检查;3. 内置范围检查(min/max/minlength/maxlength/enum/match);4. 自定义同步 validator;5. 自定义异步 validator。这意味着:即使自定义 validator 通过了,内置 validator 仍可能失败。设计原则:优先用内置 validator(性能好、错误消息标准),自定义 validator 只用于内置无法覆盖的场景。
自定义 validator 的错误消息设计:好的错误消息应包含三个要素——1. 哪个字段出错(mongoose 自动提供 path);2. 为什么出错(如"用户名必须以字母开头"而非"验证失败");3. 期望格式是什么(如"格式:字母开头,3-30 位字母数字下划线")。message 支持模板变量:{PATH}(字段名)、{VALUE}(当前值)、{MINLENGTH}(最小长度)等。生产环境的错误消息应让前端开发者不看文档就能修复——这比简短但模糊的消息更有价值。
| Validator | 适用类型 | 说明 |
|---|---|---|
required |
全部 | 字段必填 |
min/max |
Number, Date | 数值范围 |
minlength/maxlength |
String | 字符串长度 |
enum |
String | 枚举值 |
match |
String | 正则匹配 |
unique |
全部 | 唯一索引(数据库层) |
▶ 示例 1:内置 validator 实战
校验层策略分析:数据校验应在哪个层执行是架构设计的关键决策。应用层校验(mongoose validators)灵活可控——支持自定义逻辑、异步验证、友好错误消息,但仅对 Node.js 客户端有效。数据库层校验($jsonSchema)刚性可靠——对所有客户端生效,但仅支持静态规则。最佳策略是双重校验:mongoose 做主校验(业务规则、友好提示),$jsonSchema 做兜底(基础结构保护,防绕过应用层)。
校验执行顺序:mongoose 的校验按严格顺序执行:1. SchemaType 内置校验(required → type cast → min/max/minlength/maxlength/enum/match);2. 自定义同步 validator;3. 自定义异步 validator;4. pre validate 中间件;5. pre save 中间件。任何环节失败都会中断后续校验,抛出 ValidationError。
同步 vs 异步验证器的选择:mongoose 验证器分同步和异步两种——同步验证器返回 boolean(如 validator: v => v.length >= 3),异步验证器返回 Promise(如 validator: async function(v) { const existing = await User.findOne({email: v}); return !existing; })。选择原则:1. 不需要查询数据库的验证用同步(格式检查、范围检查、正则匹配);2. 需要查询数据库的验证用异步(唯一性检查、引用完整性检查)。注意:异步验证器比同步验证器慢 10-100 倍(每次验证都是一次数据库查询),应尽量减少使用——唯一性检查可用 unique 索引替代(数据库层保证,比应用层查询更可靠更高效)。
验证器的组合模式:多个验证器可以组合使用覆盖不同场景——1. 必填 + 格式:required: [true, '邮箱必填'] + match: [/^.+@.+$/, '邮箱格式不正确'](先检查存在性再检查格式);2. 范围 + 自定义:min: [0, '不能为负数'] + validator: v => v % 1 === 0(先检查范围再检查是否整数);3. 枚举 + 条件:enum: ['draft', 'published'] + 自定义验证器检查"从 draft→published 需要 content 不为空"。组合顺序很重要——required 放最前(为空时无需检查后续规则),格式检查放中间,业务逻辑检查放最后。
| 校验层 | 位置 | 优点 | 缺点 |
|---|---|---|---|
| mongoose 内置 | 应用层 | 零配置、自动执行 | 仅限常见规则 |
| mongoose 自定义 | 应用层 | 灵活、可异步 | 增加代码量 |
| $jsonSchema | 数据库层 | 全客户端生效 | 仅静态规则 |
const UserSchema = new mongoose.Schema({
email: {
username: {
type: String,
required: true,
unique: true,
minlength: [3, 'Username at least 3 chars'],
maxlength: [30, 'Username at most 30 chars'],
match: [/^[a-zA-Z0-9_]+$/, 'Only letters, numbers, underscores']
},
age: {
type: Number,
required: true,
min: [0, 'Age cannot be negative'],
max: [150, 'Age too large']
},
role: {
type: String,
enum: {
values: ['customer', 'admin', 'moderator'],
message: '{VALUE} is not a valid role'
},
default: 'customer'
},
passwordHash: {
type: String,
required: true,
minlength: 60 // bcrypt 哈希长度
}
});
输出:
// mongoose 操作成功执行
// 数据库查询/更新结果
sequenceDiagram
participant App as 应用代码
participant Schema as mongoose Schema
participant DB as MongoDB
App->>Schema: User.create({email, age})
Schema->>Schema: 验证 required
Schema->>Schema: 验证 match (email格式)
Schema->>Schema: 验证 min/max (age)
alt 验证通过
Schema->>DB: insertOne()
DB-->>Schema: 成功
Schema-->>App: 返回 User 对象
else 验证失败
Schema-->>App: ValidationError
end
3. 自定义 Validator
概念说明:当内置 validators 无法满足业务规则时(如"用户名不能以数字开头"、"邮箱域名黑名单"),mongoose 允许在字段定义中编写自定义 validator 函数。自定义 validator 分为同步和异步两种:同步函数返回 boolean,异步函数返回 Promise<boolean>。两者都通过 validate 选项配置。
工作原理:自定义 validator 在内置验证之后执行。同步 validator 接收字段值,返回 true 通过、false 失败。异步 validator 接收字段值,返回 Promise,resolve(true) 通过、resolve(false) 失败。失败时使用 message 选项的模板生成错误消息。注意:异步 validator 会增加每次保存的延迟。
validator 组合模式:多个 validator 可组合使用——mongoose 按声明顺序依次执行,全部通过才验证成功。常见组合:1. required + match(必填且格式正确);2. minlength + 自定义密码强度验证(长度和复杂度双检);3. min + 自定义范围验证(如"折扣率在 0-1 之间"用 min:0 + max:1 即可,但"金额不能为 0"需自定义 validator: v => v !== 0)。组合时注意错误消息的区分——每个 validator 独立的消息模板帮助前端精确定位问题字段。
graph LR
A[doc.save] --> B[内置 validators<br/>required/min/max/enum...]
B --> C{内置通过?}
C -->|No| D[ValidationError]
C -->|Yes| E[自定义 validators<br/>同步/异步]
E --> F{自定义通过?}
F -->|No| D
F -->|Yes| G[pre save hooks]
G --> H[MongoDB write]
style D fill:#f8d7da
style H fill:#d4edda
| 对比维度 | 同步 validator | 异步 validator |
|---|---|---|
| 定义方式 | validator: v => v >= 18 |
validator: async v => await check(v) |
| 执行速度 | 快(无I/O) | 慢(可能查询数据库) |
| 典型用途 | 格式校验、范围检查 | 唯一性校验、黑名单检查 |
| 错误处理 | 返回 false | resolve(false) |
| 性能建议 | 优先使用 | 仅在必要时使用 |
(1) 同步 validator
同步 validator 的设计模式:同步 validator 适合纯逻辑校验——不涉及数据库查询的规则。常见模式:1. 格式校验(邮箱不含+号、用户名不以数字开头);2. 范围校验(年龄>=18、折扣率0-1之间);3. 长度校验(密码>=8字符、手机号11位);4. 组合校验(endDate >= startDate)。设计原则:validator 函数应只返回 boolean(true 通过 false 失败),不做副作用操作(不修改 this、不抛异常)。
自定义验证器的错误消息定制:好的错误消息应让开发者一眼知道问题所在——validator: {validator: v => /^[a-z]/.test(v), message: '用户名必须以小写字母开头(当前值: {VALUE})'}。消息模板支持 {VALUE}(当前值)、{PATH}(字段名)、{MIN}/{MAX}(边界值)、{LENGTH}(当前长度)。中文错误消息示例:'年龄必须在{MIN}到{MAX}之间,当前值{VALUE}'、'密码长度至少{MINLENGTH}位,当前{LENGTH}位'。消息越具体,前端联调效率越高——"用户名格式不正确"比"Validator failed"有用100倍。
验证器与业务逻辑的边界:mongoose validator 应只验证数据本身的合法性("数据是否正确"),不应包含业务逻辑("操作是否允许")。区分标准:1. 数据验证(属于 Schema):邮箱格式、密码长度、金额范围——这些规则不随业务场景变化;2. 业务逻辑(属于 Controller/Service):用户是否有权限修改、余额是否充足、库存是否够——这些规则因场景而异。混在一起的后果:密码修改和重置需要不同的验证规则,但 Schema 只有唯一一套 validator,导致重置密码时密码长度验证被跳过或硬编码。
const UserSchema = new mongoose.Schema({
email: {
type: String,
validate: {
validator: function(v) {
// 邮箱不能包含 + 号(不接受带标签的邮箱)
return !v.includes('+');
},
message: 'Email cannot contain + character'
}
},
age: {
type: Number,
validate: {
validator: function(v) {
return v >= 18;
},
message: 'Must be at least 18 years old'
}
}
});
(2) async validator(异步)
同步 vs 异步验证器的选择:同步验证器适合纯内存计算(数值比较、正则匹配、枚举检查),异步验证器需要查数据库或调用外部 API。选择原则——1. 能用同步就用同步(性能好、无副作用);2. 需要查数据库才用异步(如检查用户名唯一性、敏感词检测);3. 异步验证器的性能代价:每次 save 都要 await 数据库查询,批量操作时 N 次验证 = N 次数据库查询;4. 异步验证器的替代方案:将唯一性检查放在 pre save 中间件(可批量优化),而非单个字段的 async validator。
异步 validator 的风险与应对:异步 validator 每次保存都会触发数据库查询,在高并发场景下可能成为性能瓶颈。应对策略:1. 仅在必要时使用(如唯一性校验、黑名单检查);2. 用 unique 索引替代异步唯一性校验(数据库层更高效);3. 对非关键校验降级为定时批量检查;4. 缓存频繁查询的结果(如黑名单列表可缓存 5 分钟)。
validator 组合模式:复杂业务规则往往需要多个 validator 组合——用户名需同时满足:不含敏感词(异步)、不以数字开头(同步)、长度 3-30(内置)。mongoose 按顺序执行所有 validator,第一个失败即中断。建议将轻量同步校验放在前面(快速失败),重量级异步校验放在后面(避免不必要的 I/O)。
const UserSchema = new mongoose.Schema({
username: {
type: String,
validate: {
validator: async function(v) {
// 检查用户名是否包含敏感词
const banned = await BannedWords.findOne({ word: v });
return !banned;
},
message: 'Username contains banned word'
}
},
email: {
type: String,
validate: {
validator: async function(v) {
// 检查邮箱域名是否被禁止
const domain = v.split('@')[1];
const blocked = await BlockedDomains.findOne({ domain });
return !blocked;
},
message: 'Email domain is blocked'
}
}
});
(3) validateBeforeSave 选项
// === 默认行为:save 前自动 validate ===
const user = new User({ email: 'invalid' });
await user.save(); // ValidationError
// === 跳过 validate(不推荐)===
const user = new User({ email: 'invalid' });
await user.save({ validateBeforeSave: false });
// === 手动 validate ===
const user = new User({ email: 'invalid' });
try {
await user.validate();
} catch (err) {
console.error(err.message); // ValidationError
}
4. 中间件实战
概念说明:mongoose 中间件(middleware)是在数据操作生命周期中自动触发的钩子函数。本节聚焦实际开发中最常用的中间件模式:密码哈希(pre save)、时间戳管理(pre save / 内置 timestamps)、软删除(pre find 过滤 + 实例方法)、自动填充(pre find populate)。这些模式覆盖了 80% 的中间件使用场景。
工作原理:中间件注册在 Schema 上,mongoose 在执行对应操作时自动调用。pre('save') 在写入前执行,可修改 Document 数据;pre(/^find/) 在查询前执行,可修改查询条件;post('save') 在写入后执行,可触发副作用(日志、通知)。中间件链式执行,每个必须调用 next() 传递控制权。
graph TB
A[中间件模式] --> B[密码哈希<br/>pre save<br/>isModified检测]
A --> C[时间戳<br/>pre save / timestamps选项]
A --> D[软删除<br/>pre find过滤<br/>softDelete方法]
A --> E[自动填充<br/>pre find populate]
B --> F["仅密码变更时<br/>重新哈希"]
C --> G["自动维护<br/>createdAt/updatedAt"]
D --> H["查询自动排除<br/>isDeleted: true"]
E --> I["查询自动填充<br/>关联文档"]
style B fill:#d4edda
style C fill:#cce5ff
style D fill:#fff3cd
style E fill:#e2d5f1
| 中间件模式 | 触发时机 | 核心API | 典型用途 |
|---|---|---|---|
| 密码哈希 | pre save | isModified('password') |
仅密码变更时哈希 |
| 时间戳 | pre save / timestamps | isNew, Date.now |
自动维护时间字段 |
| 软删除 | pre /^find/ | this.find({isDeleted:{$ne:true}}) |
查询自动排除已删除 |
| 自动填充 | pre find | this.populate(path) |
查询自动关联加载 |
| 级联删除 | post findOneAndDelete | Model.deleteMany() |
删除主文档时清理关联 |
(1) 密码哈希中间件
密码安全设计原则:密码哈希是安全系统的基石。bcrypt 是行业标准选择——它内置盐值(salt)、可调工作因子(cost factor 10-12)、抗 GPU/ASIC 破击。关键设计要点:1. 用 isModified() 检测避免每次保存都重新哈希;2. 密码字段用 select: false 默认不返回;3. 哈希前验证明文长度(防空密码哈希后变 60 字符绕过 minlength);4. 使用异步版本避免阻塞事件循环。
UserSchema.pre('save', async function(next) {
// 仅在密码字段被修改时重新哈希
if (!this.isModified('passwordHash')) return next();
try {
const salt = await bcrypt.genSalt(10);
this.passwordHash = await bcrypt.hash(this.passwordHash, salt);
next();
} catch (err) {
next(err);
}
});
(2) 时间戳中间件
timestamps 选项 vs 手动中间件:mongoose 的 timestamps: true 选项自动管理 createdAt/updatedAt,无需手动写 pre save 中间件——这是推荐方式。手动中间件仅在有特殊需求时使用:1. 自定义时间戳字段名(如 created_at 而非 createdAt);2. 时间戳需要关联用户 ID(如 updatedBy);3. 需要在时间戳更新时触发额外逻辑。95% 的场景直接用 timestamps: true 即可。
// === mongoose 内置 timestamps 选项 ===
const schema = new mongoose.Schema({...}, { timestamps: true });
// 自动添加 createdAt/updatedAt
// === 自定义时间戳中间件 ===
schema.pre('save', function(next) {
this.updatedAt = new Date();
if (this.isNew) {
this.createdAt = new Date();
}
next();
});
(3) 软删除中间件
软删除架构决策:物理删除(hard delete)不可恢复,违反数据合规要求(如 GDPR 的"被遗忘权"可匿名化而非删除)。软删除通过 isDeleted 标记实现逻辑删除——pre find 中间件自动过滤已删除文档,对业务代码透明。选择软删除的核心原因:1. 数据可恢复(误删可 restore);2. 审计需求(保留操作历史);3. 引用完整性(其他文档的引用不会断裂)。
软删除的存储成本与清理策略:软删除的代价是"僵尸数据"持续占用存储和索引空间——1. 存储膨胀:假设 30% 的数据被软删除,主集合体积膨胀 43%(100 / 70 ≈ 1.43),索引同样膨胀;2. 查询影响:pre find 中间件为每个查询添加 isDeleted: {$ne: true} 条件,虽然可用索引但增加查询复杂度;3. 清理策略:定时任务(cron job)将软删除超过 90 天的数据迁移到归档集合(物理删除主集合中的记录),归档集合保留 1 年后彻底删除;4. GDPR 合规:用户请求删除时,将个人信息字段替换为 [REDACTED](匿名化),而非仅标记 isDeleted——这满足"无法识别个人"的法律要求,同时保留数据用于统计分析。
软删除实现模式对比:
| 模式 | 实现 | 查询影响 | 恢复难度 |
|---|---|---|---|
| 布尔标记 | isDeleted: Boolean | pre find 自动过滤 | 简单设 false |
| 时间戳 | deletedAt: Date | deletedAt: {$ne: null} | unset 字段 |
| 内容替换 | content: '[已删除]' | 无需过滤 | 不可恢复原文 |
// === 软删除字段 ===
const schema = new mongoose.Schema({
isDeleted: { type: Boolean, default: false },
deletedAt: Date,
deletedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }
});
// === pre find 过滤已删除 ===
schema.pre(/^find/, function(next) {
this.find({ isDeleted: { $ne: true } });
next();
});
// === softDelete 实例方法 ===
schema.methods.softDelete = async function(deletedBy) {
this.isDeleted = true;
this.deletedAt = new Date();
this.deletedBy = deletedBy;
return await this.save();
};
// === restore 实例方法 ===
schema.methods.restore = async function() {
this.isDeleted = false;
this.deletedAt = undefined;
this.deletedBy = undefined;
return await this.save();
};
(4) 自动 populate 中间件
自动填充的设计权衡:pre find 中自动 populate 提升开发体验——每次查询都自动带出关联数据,无需在每个 Controller 手动调用 populate。但这种便利有代价:1. 每次查询都执行额外 I/O(即使不需要关联数据);2. 嵌套 populate 导致 N+1 查询问题;3. 难以按场景选择性 populate。推荐方案:用 setOptions() 条件触发,而非无条件自动填充。
自动 populate 的性能控制:自动 populate 的性能问题可以通过以下方式控制——1. 稀疏 populate:只在需要时 populate(用 req.query.populate=true 触发,而非默认自动填充);2. 字段白名单:自动 populate 只填充关键字段(author: 'username avatar',而非全部用户信息);3. lean + 手动 $lookup:对性能敏感的列表查询用 lean() + 聚合管道替代 populate;4. 缓存 populate 结果:对不常变更的关联数据(如用户头像/角色),用 Redis 缓存 populate 结果。生产环境的经验——"自动 populate 只用于开发效率,不用于生产性能"。
// === 默认 populate 关联字段 ===
UserSchema.pre('find', function(next) {
this.populate({
path: 'profileId',
select: 'avatar bio'
});
next();
});
// === 条件 populate ===
UserSchema.pre('find', function(next) {
if (this.options.includeOrders) {
this.populate('orders');
}
next();
});
// 使用:
const user = await User.findById(userId); // 自动 populate profileId
const userWithOrders = await User.findById(userId).setOptions({ includeOrders: true });
▶ 示例 2:完整中间件实战
中间件链组合模式:实际项目中,一个 Schema 通常注册多个中间件形成完整的"处理链"。组合原则:1. 数据转换类中间件放最前(邮箱小写、字符串 trim),确保后续校验拿到标准化数据;2. 校验类中间件居中(密码哈希前的明文长度检查);3. 副作用类中间件放最后(审计日志、通知推送),此时数据已确保合法。错误处理中间件(4 参数版本)作为安全网兜底所有异常。
// === 用户模型完整中间件 ===
UserSchema.pre('save', async function(next) {
if (this.isModified('passwordHash') && !this.passwordHash.startsWith('$2b$')) {
this.passwordHash = await bcrypt.hash(this.passwordHash, 10);
}
next();
});
UserSchema.pre(/^find/, function(next) {
this.where({ isDeleted: { $ne: true } });
next();
});
UserSchema.post('save', function(doc, next) {
if (this.wasNew) {
logger.info(`New user: ${doc.email}`);
}
next();
});
UserSchema.post('findOneAndDelete', function(doc) {
if (doc) {
// 级联删除关联数据
Session.deleteMany({ userId: doc._id });
Cart.deleteMany({ userId: doc._id });
}
});
输出:
// 执行成功
5. 自定义错误消息
概念说明:mongoose 允许为每个 validator 自定义错误消息,支持模板变量(如 {VALUE}、{PATH}、{MIN}),使错误信息对用户更友好。自定义消息有两种方式:(1) 数组语法 [validator, message];(2) 对象语法 { validator, message }。推荐使用对象语法,更灵活且可包含模板变量。
错误消息的多语言支持:生产应用的错误消息需要支持多语言——1. 消息模板化:将错误消息定义为模板字符串(如 'validation.{PATH}.min'),而非硬编码中文/英文;2. 运行时替换:Controller 层根据 Accept-Language 头选择语言包,替换模板变量;3. 字段级自定义:每个 Schema 字段的 message 用函数而非字符串——message: (props) => i18n.t('validation.age.min', { value: props.value });4. 统一错误格式化中间件:在错误处理中间件中统一将 Mongoose ValidationError 转换为 i18n 格式。这套机制让同一个 API 服务全球用户。
工作原理:验证失败时,mongoose 用模板变量替换消息中的占位符。{VALUE} 替换为实际值,{PATH} 替换为字段路径,{MIN}/{MAX} 替换为约束值。这些信息帮助前端精准展示错误原因。
| 模板变量 | 含义 | 示例输出 |
|---|---|---|
{VALUE} |
实际传入值 | 'Age must be at least 18, got 15' |
{PATH} |
字段路径 | 'email is required' |
{MIN} / {MAX} |
约束边界值 | 'Age must be >= 0' |
{MINLENGTH} / {MAXLENGTH} |
长度约束 | 'Username must be >= 3 chars' |
const UserSchema = new mongoose.Schema({
email: {
type: String,
required: [true, 'Email is required'],
match: [/\S+@\S+\.\S+/, 'Invalid email format: {VALUE}'],
unique: true
},
age: {
type: Number,
min: [18, 'Age must be at least 18, got {VALUE}'],
max: [150, 'Age cannot exceed 150']
},
password: {
type: String,
minlength: [8, 'Password must be at least 8 characters'],
validate: {
validator: function(v) {
return /[A-Z]/.test(v) && /[0-9]/.test(v);
},
message: 'Password must contain uppercase and digit'
}
}
});
6. validate 错误处理
错误处理架构原则:ValidationError 包含所有字段的错误信息(而非仅第一个),这允许前端一次性展示所有验证问题。遍历 err.errors 对象获取每个字段的错误详情——field(字段路径)、message(错误消息)、value(实际值)、kind(验证器类型)。生产环境应将 ValidationError 转换为统一错误响应格式,而非直接暴露 mongoose 内部结构。
错误分类与恢复策略:三类错误的处理方式截然不同——ValidationError(4xx)是用户输入问题,应提示具体错误字段;CastError(4xx)通常是 ID 格式错误,提示"无效的资源标识符";E11000(4xx)是唯一约束冲突,提示具体重复字段和值。5xx 错误不应暴露技术细节,统一返回"服务暂时不可用"并触发告警。
graph TD
A[ValidationError] --> B[errors 对象]
B --> C["errors.email<br/>ValidatorError<br/>message: 'Invalid email'"]
B --> D["errors.age<br/>ValidatorError<br/>message: 'Age must be >= 18'"]
B --> E["errors._id<br/>CastError<br/>message: 'invalid ObjectId'"]
F[MongoServerError] --> G["code: 11000<br/>唯一索引冲突"]
style A fill:#f8d7da
style F fill:#fff3cd
| 错误类型 | 触发条件 | 检测方式 |
|---|---|---|
ValidationError |
验证器失败 | err.name === 'ValidationError' |
CastError |
类型转换失败 | err instanceof mongoose.Error.CastError |
E11000 |
唯一索引冲突 | err.code === 11000 |
验证错误的统一处理策略:生产应用应统一处理 Mongoose 验证错误,而非在每个 Controller 中重复 try-catch——1. 错误中间件:在 Express 错误处理中间件中统一转换 ValidationError 为 400 响应,提取每个字段的错误信息为友好提示;2. CastError 处理:CastError(如无效 ObjectId)转为 400 而非 500——用户传了格式错误的 ID 是客户端错误;3. E11000 处理:唯一索引冲突转为 409 Conflict + 友好提示("该邮箱已注册"),而非暴露 MongoDB 原始错误;4. 未知错误:其他错误统一返回 500 + 通用消息(不暴露内部细节)。统一处理的好处——前端只需一套错误解析逻辑,后端 Controller 无需关心错误格式化。
验证错误的前端展示:验证错误应精确到字段级别——1. 字段级错误:err.errors 中每个字段有独立的 message,前端可在对应输入框下方显示红色错误提示;2. 错误优先级:required 错误 > type 错误 > custom 验证错误(先提示"必填"再提示"格式不正确");3. 实时验证:前端用 joi 预验证(输入时即时反馈),后端 Mongoose 验证作为最终防线(前端可能被绕过);4. 错误信息本地化:err.errors[field].message 用中文/英文模板,根据 Accept-Language 返回对应语言。精确的错误展示让用户快速定位并修正问题,而非面对一个模糊的"输入有误"提示。
// === 捕获验证错误 ===
try {
await User.create({ email: 'invalid', age: 200 });
} catch (err) {
if (err.name === 'ValidationError') {
// 处理字段错误
for (const field in err.errors) {
console.error(`${field}: ${err.errors[field].message}`);
}
}
}
// === mongoose 错误类型 ===
const mongoose = require('mongoose');
if (err instanceof mongoose.Error.ValidationError) {
// 验证错误
}
if (err instanceof mongoose.Error.CastError) {
// 类型转换错误(如 ObjectId 格式错误)
}
if (err.code === 11000) {
// 唯一索引冲突
}
▶ 示例 3:async validator 远程校验 + 统一错误处理
异步验证的典型场景:某些业务规则需要查询数据库或外部服务才能判定——如"用户名唯一性"、"手机号已验证"、"邀请码有效"等。mongoose async validator 让这类校验直接嵌入 Schema,配合统一错误处理中间件,前端只需一套错误解析逻辑。
// === async validator:用户名唯一性 + 邀请码验证 ===
const UserSchema = new mongoose.Schema({
username: {
type: String,
required: [true, '用户名不能为空'],
minlength: [3, '用户名至少3个字符'],
validate: {
// 异步验证器:查询数据库确认唯一性
validator: async function(value) {
const count = await this.constructor.countDocuments({
username: value,
_id: { $ne: this._id } // 排除自身(更新场景)
});
return count === 0;
},
message: '用户名 "{VALUE}" 已被占用'
}
},
inviteCode: {
type: String,
validate: {
// 异步验证:检查邀请码是否在有效列表中
validator: async function(value) {
if (!value) return true; // 非必填字段,空值跳过
const InviteCode = mongoose.model('InviteCode');
const doc = await InviteCode.findOne({
code: value,
used: false,
expiresAt: { $gt: new Date() }
});
return !!doc;
},
message: '邀请码无效或已过期'
}
}
});
// === Express 统一错误处理中间件 ===
app.use((err, req, res, next) => {
if (err.name === 'ValidationError') {
// 提取所有字段错误
const errors = Object.values(err.errors).map(e => ({
field: e.path,
message: e.message,
value: e.value
}));
return res.status(400).json({ errors });
}
if (err.code === 11000) {
// 唯一索引冲突 → 409
const field = Object.keys(err.keyPattern)[0];
return res.status(409).json({
errors: [{ field, message: `该${field}已被占用` }]
});
}
res.status(500).json({ message: '服务暂时不可用' });
});
输出:
// mongoose 操作成功执行
// 数据库查询/更新结果
❓ 常见问题
message 选项。📖 小节
- 内置 validators:required/min/max/enum/match/minlength/maxlength
- 自定义 validator:同步函数或 async 函数
- validateBeforeSave 默认 true,可关闭(不推荐)
- pre 中间件:save/find/validate/remove
- post 中间件:save/findOneAndDelete
- 软删除:isDeleted + pre find 过滤
- 错误处理:ValidationError、CastError、E11000
📝 作业
- 基础题(⭐):定义 User Schema,应用所有内置 validators(required/email/age min-max/role enum)。
- 基础题(⭐):添加自定义 validator:用户名不能以数字开头。
- 进阶题(⭐⭐):用 pre save 中间件实现密码哈希(isModified 检测)。
- 进阶题(⭐⭐):实现软删除(pre find 过滤 + softDelete 实例方法)。
- 挑战题(⭐⭐⭐):完整用户注册模型(5+ validators + 密码哈希 + 时间戳 + 软删除 + 错误处理)。