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. 你将学到


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"有用得多。

100%
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 数据库层 全客户端生效 仅静态规则
JAVASCRIPT
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 哈希长度
  }
});

输出:

TEXT 📖 仅展示
// mongoose 操作成功执行
// 数据库查询/更新结果

100%
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 独立的消息模板帮助前端精确定位问题字段。

100%
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,导致重置密码时密码长度验证被跳过或硬编码。

JAVASCRIPT
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)。

JAVASCRIPT
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 选项

JAVASCRIPT
// === 默认行为: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() 传递控制权。

100%
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. 使用异步版本避免阻塞事件循环。

JAVASCRIPT
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 即可。

JAVASCRIPT
// === 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: '[已删除]' 无需过滤 不可恢复原文
JAVASCRIPT
// === 软删除字段 ===
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 只用于开发效率,不用于生产性能"。

JAVASCRIPT
// === 默认 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 参数版本)作为安全网兜底所有异常。

JAVASCRIPT
// === 用户模型完整中间件 ===
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 });
  }
});

输出:

TEXT 📖 仅展示
// 执行成功

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'
JAVASCRIPT
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 错误不应暴露技术细节,统一返回"服务暂时不可用"并触发告警。

100%
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 返回对应语言。精确的错误展示让用户快速定位并修正问题,而非面对一个模糊的"输入有误"提示。

JAVASCRIPT
// === 捕获验证错误 ===
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,配合统一错误处理中间件,前端只需一套错误解析逻辑。

JAVASCRIPT
// === 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: '服务暂时不可用' });
});

输出:

TEXT 📖 仅展示
// mongoose 操作成功执行
// 数据库查询/更新结果

❓ 常见问题

Q required 和 default 哪个先执行?
A 先验证 required,再应用 default。如果未传值且有 default,使用 default;如果 required: true 且未传值,抛错。
Q 自定义 validator 抛出错误会怎样?
A 抛出的 Error.message 会成为该字段的错误消息。如需自定义格式,使用 message 选项。
Q validateBeforeSave: false 安全吗?
A 不安全。仅在数据可信场景(如脚本迁移)使用。生产环境禁用。
Q async validator 性能差吗?
A 相对差,因为每条记录都要异步查询。建议在 Schema 设计阶段做完整性约束,async validator 仅用于必要的远程校验。

📖 小节


📝 作业

  1. 基础题(⭐):定义 User Schema,应用所有内置 validators(required/email/age min-max/role enum)。
  2. 基础题(⭐):添加自定义 validator:用户名不能以数字开头。
  3. 进阶题(⭐⭐):用 pre save 中间件实现密码哈希(isModified 检测)。
  4. 进阶题(⭐⭐):实现软删除(pre find 过滤 + softDelete 实例方法)。
  5. 挑战题(⭐⭐⭐):完整用户注册模型(5+ validators + 密码哈希 + 时间戳 + 软删除 + 错误处理)。
Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏