MongoDB: mongoose模型与Schema设计
最后更新:2026-08-26
mongoose Schema 是 Node.js 生态最流行的 MongoDB ODM——掌握 Schema 设计是构建稳健应用的基础。
本课程系统讲解 mongoose Schema 的字段类型、Model vs Document、virtual、middleware 中间件。
ODM vs 驱动:为什么选 mongoose:MongoDB Node.js 生态有两个主流方案——1. 原生驱动(mongodb 包):轻量、灵活、无抽象,直接操作 BSON 文档,适合对性能和控制有极致要求的场景;2. mongoose(ODM):提供 Schema 定义、自动类型转换、校验器、中间件、populate 等高级功能,适合业务应用开发。选择 mongoose 的核心理由:1. Schema 即文档(字段类型和约束自描述);2. 自动校验拦截脏数据;3. 中间件机制实现横切关注点(密码哈希/软删除/日志);4. populate 替代 $lookup 简化关联查询。选择原生驱动的场景:1. 性能敏感(mongoose 有抽象开销);2. Schema 不固定(日志/物联网数据);3. 已有自己的校验/中间件体系。
1. 你将学到
- mongoose Schema 12+ 种字段类型
- Model 与 Document 的本质区别
- virtual 虚拟字段(不持久化但可查询)
- middleware 中间件(pre/post hooks)
- 实例方法与静态方法
- Schema 继承与插件机制
2. mongoose 入门回顾
mongoose = MongoDB + ORM 增强:
- Schema 定义数据结构(DDL 等价)
- Model 提供 CRUD API
- Document 实例方法(save、validate)
- middleware 在操作前后拦截
mongoose 的核心抽象层:mongoose 提供三层抽象——1. Schema(结构定义):声明字段类型、验证规则、默认值、索引、虚拟字段和中间件,相当于 SQL 的 DDL + 约束;2. Model(集合操作):由 Schema 编译生成,提供 find/create/update/delete 等类方法,相当于 SQL 的 CRUD 接口;3. Document(文档实例):Model 创建的实例对象,拥有 save/validate/remove 等实例方法和虚拟字段,相当于 ORM 的一行记录。三层抽象让开发者用面向对象的方式操作文档数据库。
mongoose vs 原生驱动的选择:何时用 mongoose、何时用原生 MongoDB 驱动?mongoose 适合——1. 业务逻辑复杂(需要验证、中间件、虚拟字段);2. 团队协作(Schema 即文档,类型安全减少 bug);3. 数据结构稳定(Schema 变更可控)。原生驱动适合——1. 追求极致性能(mongoose 的 Document 包装有 10-20% 性能开销);2. 数据结构高度动态(Schema 反而限制灵活性);3. 简单的读写操作(CRUD 无验证,直接操作 BSON)。大多数 Node.js 项目用 mongoose 是正确选择——开发效率比微小的性能差异更重要。
const mongoose = require('mongoose');
// 连接
await mongoose.connect('mongodb://localhost:27017/shopdb');
// 定义 Schema
const UserSchema = new mongoose.Schema({...});
// 创建 Model
const User = mongoose.model('User', UserSchema);
// 使用 Model CRUD
const user = await User.create({...});
graph TB
A[mongoose Schema] --> B[SchemaType<br/>字段类型]
A --> C[Model<br/>构造函数]
A --> D[Document<br/>实例]
A --> E[virtual<br/>虚拟字段]
A --> F[middleware<br/>中间件]
B --> B1[String/Number/Date]
B --> B2[ObjectId/Decimal128]
B --> B3[Mixed/Map/Array]
C --> C1[find/create]
D --> D1[save/validate]
F --> F1[pre/post hooks]
style C fill:#d4edda
style D fill:#cce5ff
3. Schema 字段类型
概念说明:Schema 是 mongoose 对 MongoDB 文档结构的定义层。它声明每个字段的类型、验证规则、默认值和索引策略。虽然 MongoDB 本身是 schema-less 的,但 mongoose 在应用层强制类型和验证,为 Node.js 应用提供类似传统 ORM 的数据安全保障。
工作原理:mongoose Schema 在应用内存中维护字段元数据(SchemaType 对象)。当创建或更新 Document 时,mongoose 按字段逐一执行类型转换和验证,不符合规则的值会在 save() 之前抛出 ValidationError。Schema 不影响 MongoDB 存储——旧文档缺少新字段时,mongoose 返回 undefined(可用 default 填充)。
Schema 的隐式行为:Schema 有几个容易被忽视的隐式行为——1. 每个文档自动包含 _id: ObjectId(即使 Schema 没声明);2. _id 默认不参与 toJSON 输出(除非设置 toJSON: {virtuals: true});3. 类型转换是隐式的——传入 '123' 给 Number 字段会自动转为 123(严格模式 strict: true 下),传入 null 给 required: true 字段不通过验证(null ≠ undefined,required 只检查 undefined);4. 嵌套对象的默认值需要用函数返回(default: () => ({})),否则所有文档共享同一个引用(经典的 JavaScript 陷阱)。
graph LR
A[Schema 定义] --> B[SchemaType<br/>字段元数据]
B --> C[类型转换<br/>String/Number/Date...]
B --> D[验证规则<br/>required/min/max/enum]
B --> E[默认值<br/>default/immutable]
B --> F[索引策略<br/>index/unique]
C --> G[Document.save]
D --> G
E --> G
G --> H{验证通过?}
H -->|Yes| I[MongoDB insertOne]
H -->|No| J[ValidationError]
style I fill:#d4edda
style J fill:#f8d7da
| 类型分类 | mongoose 类型 | 典型场景 |
|---|---|---|
| 基本类型 | String/Number/Boolean/Date | 名称、价格、开关、时间戳 |
| 二进制 | Buffer | 图片缩略图、文件内容 |
| 引用 | ObjectId + ref | 外键关联(如 categoryId → Category) |
| 精确数值 | Decimal128 | 货币金额(避免浮点误差) |
| 嵌套 | Schema 嵌套 | 地址、规格等固定结构 |
| 数组 | [Type] | 标签列表、图片集合 |
| 动态 | Mixed/Map | 不确定结构的元数据、多语言翻译 |
(1) 12+ 种 SchemaType
SchemaType 选择决策:选择正确的 SchemaType 是数据建模的第一步——错误的选择会导致数据质量问题和性能隐患。核心原则:1. 货币金额必须用 Decimal128 而非 Number(0.1+0.2≠0.3 的浮点陷阱);2. 外键关联用 ObjectId + ref 而非 String(mongoose populate 依赖 ObjectId);3. 不确定结构的字段用 Mixed 而非 Object(Mixed 允许任意值,Object 可能触发意外验证);4. 大量键值对用 Map 而非 Object(Map 的 key 可以是任意类型且支持 forEach/map)。
| 类型 | mongoose 定义 | BSON 类型 | 示例 |
|---|---|---|---|
| String | String |
String | String |
| Number | Number |
Double | Number |
| Boolean | Boolean |
Boolean | Boolean |
| Date | Date |
Date | Date |
| Buffer | Buffer |
Binary | Buffer |
| ObjectId | mongoose.Schema.Types.ObjectId |
ObjectId | ObjectId |
| Decimal128 | mongoose.Schema.Types.Decimal128 |
Decimal128 | Decimal128 |
| Map | Map |
Object | Map |
| Schema | new mongoose.Schema({...}) |
Object | embedded |
| Array | [Type] |
Array | [String] |
| Mixed | mongoose.Schema.Types.Mixed |
Object | Mixed |
设计原则:Schema 字段定义遵循"约束即文档"理念——每个字段的选项(required、min、max、enum、match)不仅是运行时校验规则,更是数据契约的显式声明。良好的字段定义让 Schema 自身成为可执行的文档规范,团队成员无需翻阅 Wiki 即可理解每个字段的约束。
架构决策:字段选项的选择需要在严格性和灵活性之间取平衡。过严的约束(如过多 required 字段)会降低系统演进能力——新增字段默认 optional,稳定后再收紧。过松的约束则积累技术债。建议策略:核心标识字段(email、sku)严格约束,辅助字段(nickname、avatar)宽松处理,业务状态字段(role、status)用 enum 限定。
(2) 字段定义选项
const UserSchema = new mongoose.Schema({
email: {
type: String,
required: [true, 'Email is required'],
unique: true,
lowercase: true,
trim: true,
match: [/^\S+@\S+\.\S+$/, 'Invalid email'],
minlength: 5,
maxlength: 100,
index: true
},
age: {
type: Number,
required: true,
min: [0, 'Age cannot be negative'],
max: 150,
default: 18
},
role: {
type: String,
enum: {
values: ['customer', 'admin', 'moderator'],
message: 'Invalid role: {VALUE}'
},
default: 'customer'
},
isActive: {
type: Boolean,
default: true
},
createdAt: {
type: Date,
default: Date.now,
immutable: true // 创建后不可修改
}
});
最佳实践:生产环境 Schema 类型选择指南——1. 货币金额务必用 Decimal128 而非 Number,浮点误差在财务计算中不可接受;2. 引用关系用 ObjectId + ref 而非嵌入完整文档,避免数据冗余和更新不一致;3. 不确定结构的元数据用 Mixed/Map,但需承担无验证风险;4. 日期字段始终用 Date 类型配合 timestamps: true,避免字符串日期的时区混乱。
Schema 选项的生产配置:mongoose Schema 的选项对象控制全局行为——1. timestamps: true:自动管理 createdAt/updatedAt, createdAt 设 immutable: true 防止误改;2. toJSON: {virtuals: true}:JSON 序列化包含虚拟字段(默认不包含);3. toObject: {virtuals: true}:toObject() 也包含虚拟字段;4. minimize: false:不压缩空对象(默认 mongoose 会移除空值字段,可能导致前端期望字段缺失);5. strict: true:拒绝未在 Schema 中定义的字段(默认开启,生产环境必须保持开启)。这些选项应在项目初期就确定,后期修改可能影响现有数据和行为。
Embedding vs Referencing 权衡:这是 MongoDB Schema 设计最核心的决策。嵌入式将关联数据存在同一文档中,查询一次获取所有数据,但面临文档膨胀(16MB 限制)和更新复杂性问题。引用式通过 ObjectId 关联,数据独立性强、无大小限制,但需额外 populate/$lookup 查询。决策依据:1. 数据是否总是一起读取?是→嵌入;2. 关联数据是否无限增长?是→引用;3. 关联数据是否需要独立更新?是→引用。
| 维度 | Embedding 嵌入 | Referencing 引用 |
|---|---|---|
| 查询性能 | 高(单次读取) | 低(需 populate) |
| 数据一致性 | 弱(冗余更新) | 强(单点更新) |
| 文档大小 | ⚠️ 可能超 16MB | ✅ 每文档独立 |
| 适用场景 | 1:N 少量且固定 | 1:N 大量或增长 |
▶ 示例 1:综合 Schema 类型实战
const ProductSchema = new mongoose.Schema({
// 基本类型
sku: { type: String, required: true, unique: true },
title: { type: String, required: true },
price: { type: mongoose.Schema.Types.Decimal128, required: true },
stock: { type: Number, default: 0, min: 0 },
isActive: { type: Boolean, default: true },
// 日期
releaseDate: { type: Date, required: true },
expiryDate: { type: Date },
// 二进制
thumbnail: { type: Buffer },
// 引用(外键)
categoryId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Category',
required: true
},
// 嵌套文档
specs: {
screen: String,
battery: String,
weight: Number
},
// 数组
tags: [String],
images: [{
url: String,
alt: String
}],
// Map(动态键值对)
translations: {
type: Map,
of: String
},
// Mixed(任意类型)
metadata: mongoose.Schema.Types.Mixed
}, { timestamps: true });
输出:
// mongoose 操作成功执行
// 数据库查询/更新结果
4. Model 与 Document
概念说明:Model 是 mongoose 对 MongoDB 集合的抽象——它是一个构造函数(class),提供 find、create、updateOne 等静态方法。Document 是 Model 的实例,代表数据库中的一条记录,提供 save、validate、remove 等实例方法。理解 Model 与 Document 的区别是正确使用 mongoose 的基础。
工作原理:mongoose.model('User', schema) 做了两件事:(1) 将 Schema 编译为 Model 构造函数;(2) 注册到 mongoose 连接,映射到 users 集合(自动复数化)。new User({...}) 创建一个 Document 实例,此时数据只在内存中,调用 save() 才写入 MongoDB。User.create({...}) 等价于 new User() + save()。
sequenceDiagram
participant App as 应用代码
participant Model as User Model
participant Doc as User Document
participant DB as MongoDB
App->>Model: User.create({email, age})
Model->>Doc: new User(data)
Doc->>Doc: validate()
Doc->>DB: insertOne()
DB-->>Doc: _id, createdAt
Doc-->>App: 返回 Document
App->>Model: User.find({role: 'admin'})
Model->>DB: find().toArray()
DB-->>Model: 原始文档数组
Model->>Doc: hydrate(docs)
Doc-->>App: Document 数组
style Model fill:#d4edda
style Doc fill:#cce5ff
(1) 核心区别
| 维度 | Model | Document |
|---|---|---|
| 本质 | 构造函数(class) | Model 实例 |
| 创建 | mongoose.model('User', schema) |
new User({...}) 或 User.create() |
| 数量 | 每个集合 1 个 | 每个文档 1 个 |
| 方法 | 静态方法(find、create) | 实例方法(save、validate) |
类型系统设计原则:mongoose 的类型系统在应用层提供了 MongoDB 原生不具备的类型安全。SchemaType 对象在 Document 创建时执行类型转换(如字符串 "42" 自动转 Number 42),转换失败则抛出 CastError。这种隐式转换虽方便,但也可能掩盖数据问题——生产环境建议在 Schema 中设置严格模式 strict: true(默认开启),拒绝未定义的字段。
隐式类型转换的风险与防范:mongoose 的隐式类型转换是双刃剑——1. 便利性:前端传 {age: "25"} 自动转为 Number 25,开发者无需手动转换;2. 风险:"abc" 转为 Number 时变成 NaN(CastError),但如果 Schema 定义是 String 而 MongoDB 存的是 Number,查询时可能"找不到"(类型不匹配);3. 防范策略:在 Schema 中明确类型(不为 String 存 Number)、在应用层先验证类型(joi 验证在 mongoose 转换之前)、在路由层做输入清洗(删除多余字段)。最危险的隐式转换:ObjectId 字段接收非 24 位十六进制字符串时抛 CastError(如 req.params.id 传入 "abc"),应在路由层验证 ObjectId 格式。
(2) Document 实例方法
// === 创建 Document ===
const user = new User({ email: 'alice@example.com' });
// === Document 属性 ===
user.email; // 'alice@example.com'
user._id; // ObjectId
user.createdAt; // Date
user.isNew; // true(未保存)
// === Document 实例方法 ===
await user.save(); // 保存
await user.validate(); // 验证(不保存)
user.toJSON(); // 转 JSON
user.toObject(); // 转普通对象
user.remove(); // 删除(已废弃,用 deleteOne)
await user.deleteOne(); // 删除(推荐)
await user.populate('orders'); // 关联填充
▶ 示例 2:Document 操作实战
Document 生命周期管理:Document 从创建到销毁经历四个阶段——1. new User(data) 创建内存实例(isNew: true,未写入数据库);2. await user.save() 持久化到 MongoDB(触发 pre save 中间件和验证);3. user.property = newValue 修改内存属性(dirty tracking 标记修改字段);4. await user.deleteOne() 删除文档。关键区别:new + save 是两步操作(可在 save 前修改数据),User.create() 是一步操作(直接写入)。
// === 创建并保存 ===
const user = new User({
email: 'alice@example.com',
username: 'alice',
passwordHash: '...'
});
await user.save();
// === 修改后保存 ===
user.lastLoginAt = new Date();
user.loginCount += 1;
await user.save();
// === 转换为对象 ===
const userObj = user.toObject();
delete userObj.passwordHash;
// === populate 关联 ===
const user = await User.findById(userId).populate({
path: 'orders',
options: { sort: { createdAt: -1 } }
});
输出:
// mongoose 操作成功执行
// 数据库查询/更新结果
5. virtual 虚拟字段
概念说明:virtual 是 mongoose 提供的"计算字段"机制——它在 Schema 上定义 getter/setter,但不会持久化到 MongoDB。virtual 字段在 Document 被 toJSON() 或 toObject() 转换时计算生成,非常适合派生属性(如 fullName = firstName + lastName)和关联统计(如 orderCount)。
工作原理:virtual getter 是一个函数,每次访问 doc.fullName 时执行计算。virtual setter 接收一个值,反向拆分到多个字段。关联型 virtual(ref + localField + foreignField)本质是 populate() 的快捷声明,查询时仍需调用 populate('orders') 触发关联填充。
graph TB
A[virtual 字段] --> B[计算型<br/>fullName = firstName + lastName<br/>discountedPrice = price * 1-discount]
A --> C[关联型<br/>orders: ref Order<br/>orderCount: count true]
B --> D[不持久化<br/>仅内存计算]
C --> E[需 populate<br/>触发关联查询]
D --> F[toJSON时输出<br/>需设置 virtuals true]
E --> F
style D fill:#d4edda
style E fill:#cce5ff
| virtual 类型 | 定义方式 | 是否持久化 | 是否需要 populate |
|---|---|---|---|
| 计算型 getter | schema.virtual('x').get(fn) |
否 | 否 |
| 计算型 setter | schema.virtual('x').set(fn) |
否 | 否 |
| 关联型(文档) | schema.virtual('x', {ref, localField, foreignField}) |
否 | 是 |
| 关联型(计数) | 同上 + count: true |
否 | 是 |
// === 定义 virtual ===
UserSchema.virtual('fullName').get(function() {
return `${this.firstName} ${this.lastName}`;
});
UserSchema.virtual('isAdult').get(function() {
return this.age >= 18;
});
// === virtual setter(反向设置)===
UserSchema.virtual('fullName').set(function(name) {
const parts = name.split(' ');
this.firstName = parts[0];
this.lastName = parts[1];
});
// === 启用 virtual ===
UserSchema.set('toJSON', { virtuals: true });
UserSchema.set('toObject', { virtuals: true });
▶ 示例 3:virtual 实战
virtual 的性能特征:virtual getter 在每次访问时计算(非缓存)——如果 virtual 依赖的字段被修改,下次访问自动返回新值。这意味着:1. 在列表查询中,对每个 Document 调用 virtual 都会执行一次计算函数;2. virtual 不能用于 $match 过滤(MongoDB 不知道 virtual 字段的存在);3. virtual 不能用于排序(同前);4. toJSON 中启用 virtuals: true 后,JSON 序列化时计算所有 virtual 并包含在输出中。
virtual vs 计算字段的选型:何时用 virtual、何时在 Schema 中存储计算字段——1. virtual 适用:计算结果依赖当前文档字段(如 fullName = firstName + lastName)、不需要用于查询/排序/聚合、计算代价低(简单字符串拼接/数学运算);2. 存储字段适用:需要用于查询/排序(如 discountedPrice 需要按折扣价排序)、计算代价高(如跨集合聚合)、需要持久化(如 commentCount 冗余计数)。选型原则——"如果需要在 MongoDB 查询中使用,就必须存储;如果只在应用层展示,用 virtual 更干净"。
// === 计算字段 ===
ProductSchema.virtual('discountedPrice').get(function() {
if (!this.discount) return this.price;
return this.price * (1 - this.discount);
});
// === 关联字段(不持久化)===
UserSchema.virtual('orders', {
ref: 'Order',
localField: '_id',
foreignField: 'userId'
});
// 使用:
const user = await User.findById(userId).populate('orders');
console.log(user.orders); // 关联的订单数组
// === 反向关联 ===
UserSchema.virtual('orderCount', {
ref: 'Order',
localField: '_id',
foreignField: 'userId',
count: true // 只统计数量,不返回文档
});
const user = await User.findById(userId).populate('orderCount');
console.log(user.orderCount); // 25
输出:
// 执行成功
6. middleware 中间件
概念说明:mongoose middleware(中间件)是在指定数据库操作前后自动执行的钩子函数。pre 中间件在操作前运行(如密码哈希、数据清理),post 中间件在操作后运行(如审计日志、通知推送)。中间件是 mongoose 最强大的扩展机制,让业务逻辑与数据操作解耦。
工作原理:mongoose 中间件采用"洋葱模型"——多个 pre 中间件按注册顺序依次执行,然后执行实际操作,最后 post 中间件按注册顺序执行。每个 pre 中间件必须调用 next() 或返回 Promise,否则操作挂起。post 中间件接收操作结果作为参数,不可修改操作行为。
sequenceDiagram
participant App as 应用代码
participant Pre1 as pre save #1<br/>密码哈希
participant Pre2 as pre save #2<br/>邮箱小写
participant DB as MongoDB
participant Post1 as post save #1<br/>审计日志
participant Post2 as post save #2<br/>欢迎邮件
App->>Pre1: doc.save()
Pre1->>Pre2: next()
Pre2->>DB: insertOne()
DB-->>Post1: 成功
Post1->>Post2: next(doc)
Post2-->>App: 返回 doc
(1) 中间件类型
中间件执行链设计:mongoose 中间件采用洋葱模型——请求从外层穿入内层,响应从内层穿出外层。pre 钩子按注册顺序依次执行,每个必须调用 next() 传递控制权;实际操作执行后,post 钩子按注册顺序执行。这种设计让关注点天然分离:密码哈希、数据清理、审计日志各占一个中间件,互不耦合。
| 类型 | 触发时机 | 用途 |
| pre('save') | 保存前 | 密码哈希、时间戳 |
| post('save') | 保存后 | 日志、通知 |
| pre('validate') | 验证前 | 数据清理 |
| pre('find') | 查询前 | 过滤条件 |
| pre('remove') | 删除前 | 清理关联数据 |
(2) pre save 中间件
最佳实践:pre save 中间件最常见的三个用途——密码哈希(isModified 检测避免重复哈希)、数据标准化(邮箱转小写、字符串 trim)、时间戳维护。关键点是 this 指向当前 Document 实例,因此 pre save 不能在 Model.updateOne() 等批量操作中触发——批量操作需用 Query 中间件 pre('updateOne') 替代。
// === 密码哈希(经典场景)===
UserSchema.pre('save', async function(next) {
if (!this.isModified('passwordHash')) return next();
// 密码已修改,重新哈希
this.passwordHash = await bcrypt.hash(this.passwordHash, 10);
next();
});
// === 时间戳 ===
UserSchema.pre('save', function(next) {
this.updatedAt = new Date();
next();
});
(3) pre find 中间件
Query 中间件 vs Document 中间件:pre find 是 Query 中间件(this 指向 Query 对象而非 Document),适用于全局查询行为控制——自动过滤已删除文档、默认 populate 关联、默认排序。正则形式 pre(/^find/) 同时匹配 find、findOne、findById 等所有查询操作,确保行为一致。注意:Query 中间件无法访问文档数据(因为查询尚未执行),只能修改查询条件。
// === 自动过滤已删除文档 ===
UserSchema.pre(/^find/, function(next) {
this.find({ isDeleted: { $ne: true } });
next();
});
// === 自动 populate 关联 ===
UserSchema.pre('find', function(next) {
this.populate('categoryId');
next();
});
// === 默认排序 ===
UserSchema.pre('find', function(next) {
this.sort({ createdAt: -1 });
next();
});
(4) post save 中间件
Post 中间件的副作用设计:post 中间件在操作完成后执行,不可修改文档数据(已写入数据库),适合触发副作用——审计日志、通知推送、缓存更新。关键特性:1. post save 接收 doc 参数(已保存的文档);2. this.wasNew 判断是新建还是更新;3. this.modifiedPaths() 获取被修改的字段列表;4. 错误处理中间件是 4 参数版本 (err, doc, next),专门捕获操作异常。
// === 保存后发送欢迎邮件 ===
UserSchema.post('save', function(doc, next) {
if (this.wasNew) {
sendWelcomeEmail(doc.email);
}
next();
});
// === 保存后记录审计日志 ===
UserSchema.post('save', function(doc) {
AuditLog.create({
action: 'user.updated',
userId: doc._id,
changes: this.modifiedPaths()
});
});
▶ 示例 4:综合中间件实战
// === 用户 Schema 中间件 ===
UserSchema.pre('save', async function(next) {
// 1. 密码哈希
if (this.isModified('passwordHash')) {
this.passwordHash = await bcrypt.hash(this.passwordHash, 10);
}
// 2. 邮箱小写
if (this.isModified('email')) {
this.email = this.email.toLowerCase();
}
next();
});
UserSchema.pre(/^find/, function(next) {
// 默认不返回已删除用户
this.find({ isDeleted: { $ne: true } });
next();
});
// === 错误处理中间件 ===
UserSchema.post('save', function(error, doc, next) {
if (error.name === 'MongoServerError' && error.code === 11000) {
next(new Error('Email already exists'));
} else {
next(error);
}
});
输出:
// mongoose 操作成功执行
// 数据库查询/更新结果
7. 实例方法与静态方法
概念说明:mongoose 允许在 Schema 上定义两种自定义方法:实例方法(methods)挂载在每个 Document 上,操作单个文档(如 user.comparePassword());静态方法(statics)挂载在 Model 上,操作整个集合(如 User.findByEmail())。这两种方法让业务逻辑内聚到数据模型中,遵循"胖模型"设计原则。
工作原理:实例方法通过 Schema.methods 定义,mongoose 在 new Model() 时将方法绑定到 Document 原型链上,方法内 this 指向当前 Document。静态方法通过 Schema.statics 定义,挂载到 Model 构造函数上,方法内 this 指向 Model 本身,可直接调用 this.find() 等。
graph TB
A[Schema 方法] --> B[实例方法<br/>Schema.methods]
A --> C[静态方法<br/>Schema.statics]
B --> D["user.comparePassword(pwd)<br/>this = 当前 Document"]
B --> E["user.generateToken()<br/>this = 当前 Document"]
B --> F["user.softDelete()<br/>this = 当前 Document"]
C --> G["User.findByEmail(email)<br/>this = User Model"]
C --> H["User.findActive()<br/>this = User Model"]
C --> I["User.getStatistics()<br/>this = User Model"]
style B fill:#cce5ff
style C fill:#d4edda
| 对比维度 | 实例方法 (methods) | 静态方法 (statics) |
|---|---|---|
| 定义位置 | Schema.methods |
Schema.statics |
| 挂载对象 | Document 原型 | Model 构造函数 |
| this 指向 | 当前 Document | Model 本身 |
| 调用方式 | doc.method() |
Model.method() |
| 典型用途 | 密码比对、软删除 | 按条件查找、聚合统计 |
方法设计的职责边界:实例方法和静态方法的划分遵循单一职责原则——1. 实例方法:操作单个文档自身的数据(comparePassword 比对密码、softDelete 标记删除、toJSON 脱敏输出),不需要查询数据库(或只查询自身关联数据);2. 静态方法:操作集合级别的数据(findByEmail 跨文档查询、getStatistics 聚合统计、bulkImport 批量导入),需要 Model 的查询能力。混淆边界会导致设计混乱——把 findByEmail 放在实例方法中(需要先有实例才能查找,逻辑矛盾),把 comparePassword 放在静态方法中(需要传入文档和密码,多此一举)。
(1) 实例方法
// === 定义实例方法 ===
UserSchema.methods.comparePassword = async function(candidatePassword) {
return await bcrypt.compare(candidatePassword, this.passwordHash);
};
UserSchema.methods.generateAuthToken = function() {
return jwt.sign({ id: this._id }, process.env.JWT_SECRET, { expiresIn: '7d' });
};
// === 使用 ===
const user = await User.findOne({ email: 'alice@example.com' });
const isValid = await user.comparePassword('password123');
const token = user.generateAuthToken();
(2) 静态方法
// === 定义静态方法 ===
UserSchema.statics.findByEmail = function(email) {
return this.findOne({ email: email.toLowerCase() });
};
UserSchema.statics.findActive = function() {
return this.find({ isActive: true });
};
// === 使用 ===
const user = await User.findByEmail('ALICE@example.com');
const activeUsers = await User.findActive();
▶ 示例 5:综合方法实战
胖模型 vs 瘦模型:mongoose 推崇"胖模型"设计——业务逻辑内聚到 Model/Document 方法中,Controller 只需调用 user.comparePassword() 而非自己写 bcrypt.compare。胖模型的优势:1. 逻辑复用(多个 Controller 共享同一方法);2. 封装性(外部不知道密码如何验证);3. 可测试性(Model 方法可独立单元测试)。瘦模型的 Controller 充满重复代码,改一个验证规则要改 N 个 Controller。
// === 完整 User 模型 ===
const UserSchema = new mongoose.Schema({...});
// 实例方法
UserSchema.methods = {
comparePassword: async function(candidate) {
return await bcrypt.compare(candidate, this.passwordHash);
},
softDelete: async function() {
this.isDeleted = true;
this.deletedAt = new Date();
return await this.save();
}
};
// 静态方法
UserSchema.statics = {
findByEmail: function(email) {
return this.findOne({ email: email.toLowerCase() });
},
getStatistics: async function() {
return await this.aggregate([
{ $group: { _id: '$role', count: { $sum: 1 } } }
]);
}
};
输出:
// mongoose 操作成功执行
// 数据库查询/更新结果
❓ 常见问题
virtuals: true 才能在 API 返回。async function 或返回 Promise。必须调用 next() 或返回 Promise,否则挂起。next(error) 抛出;在 post 中 next(error) 抛出错误会被传递到 Mongoose 错误处理。discriminators(鉴别器):const AdminUser = User.discriminator('admin', AdminSchema),所有 discriminator 共享同一集合。📖 小节
- mongoose 12+ 种 SchemaType:String/Number/Date/ObjectId/Decimal128/Map/Mixed
- Model 是构造函数(class),Document 是实例
- virtual 虚拟字段不持久化,但可在 toJSON 中返回
- middleware 中间件:pre(操作前)+ post(操作后)
- 实例方法:每个 Document 独享(如 comparePassword)
- 静态方法:每个 Model 独享(如 findByEmail)
📝 作业
- 基础题(⭐):定义 User Schema(含 email/username/age/role),创建 User Model。
- 基础题(⭐):用 virtual 定义 fullName 字段(firstName + lastName)。
- 进阶题(⭐⭐):用 pre save 中间件实现密码哈希(isModified 检测)。
- 进阶题(⭐⭐):用 pre find 中间件自动过滤已删除用户。
- 挑战题(⭐⭐⭐):实现完整的 User 模型(含密码哈希、虚拟字段、实例方法、静态方法),支持注册、登录、软删除、统计功能。