第5章 5.3 MongoDB 与 mongoose
前置章节提示:上一章我们用 Sequelize 搞定了 MySQL,用它的「模型 + 关系」思维把数据库变成了代码里的对象。学会了用 findAll 查、create 增、destroy 删——但 Sequelize 本质是「表格思维」,每张表要有严格的字段定义。有没有一种数据库,可以让我们像写 JS 对象一样随便存、随便查、随便改?这一章我们来认识一下 MongoDB 和它的 Node.js 专用工具 mongoose。
配图说明:关系型数据库 vs 文档数据库的结构对比
🎯 开场 3 分钟:为什么要学这个?
场景引入
想象你是一个开网店的老板,商品数据五花八门:
- 服装:有颜色、尺码、款式
- 手机:有内存、颜色、配件列表
- 水果:有产地、重量、保质期
用传统 Excel 表格(MySQL)的话,每个商品都要塞进同一张表里,总有那么几列是空的或者不适用的。
用 MongoDB 就不一样了——每个商品可以有自己的「形状」,服装存服装的格式,手机存手机的格式,完全不冲突。
痛点问题
- MySQL 改表结构好麻烦:加个字段要
ALTER TABLE,历史数据还要迁移 - 存 JSON 数据太难受:比如订单里的商品列表,MySQL 要么拆成子表、要么序列化成字符串,查询起来特别别扭
- 快速迭代时期:产品需求天天变,数据库结构也要跟着快速调整
学完能解决
- 用 MongoDB 的「文档思维」存储灵活结构的数据
- 用 mongoose 在代码里定义数据模型,像写 JS 类一样简单
- 实现完整的增删改查 CRUD 操作
🧱 基础 25 分钟:核心概念
什么是 MongoDB?
生活类比:把 MongoDB 想象成一个超级文件柜。
- MySQL 像一个严格分类的档案室:每个抽屉只能放固定类型的文件(A抽屉放衣服档案、B抽屉放电器档案),每份文件格式必须一样
- MongoDB 像一个随意堆放的杂物间:你可以把衣服、电器、食物随便往里扔,每样东西有自己的「描述卡片」,格式随意
为什么用它:适合数据结构不固定、或者数据结构经常变化的场景。比如博客系统(文章、评论、标签结构各不相同)、用户画像、日志系统等。
一句话:MongoDB 是一个「文档型数据库」,它存的是 JSON 格式的文档,而不是行和列。
// MongoDB 里的文档长这样(就像一个 JS 对象)
{
name: "iPhone 15",
price: 6999,
colors: ["黑色", "白色", "蓝色"],
specs: {
memory: "256GB",
screen: "6.1寸"
}
}
什么是 mongoose?
生活类比:MongoDB 是一个没有说明书的乐高积木盒——你拿到了积木,但不知道怎么拼。mongoose 就是那个拼装说明书 + 教你规则的教练。
mongoose 帮你做三件事:
1. 定义数据长什么样(Schema)
2. 提供操作数据库的工具(Model)
3. 验证数据格式对不对(Validation)
安装 mongoose
npm install mongoose
第一个概念:Schema(数据结构定义)
是什么:Schema 就是告诉你「这个柜子里可以放什么东西」的说明书。
为什么要用:MongoDB 虽然灵活,但如果没有 Schema 检查,你可能会存一些乱七八糟的数据,后面查询和维护会抓狂。
怎么用:
const mongoose = require('mongoose');
// 定义一个「书籍」的 Schema
const bookSchema = new mongoose.Schema({
title: String, // 书名,是字符串
author: String, // 作者,是字符串
publishedYear: Number, // 出版年份,是数字
tags: [String], // 标签,是字符串数组
inStock: Boolean // 是否有货,是布尔值
});
// 这一步是把 Schema 编译成 Model
const Book = mongoose.model('Book', bookSchema);
解释:
- new mongoose.Schema({...}) 定义了书籍这个「东西」由哪些字段组成
- mongoose.model('Book', bookSchema) 把这个定义变成了一个可以操作的 Book 模型
- 模型名字会变成 MongoDB 里的集合名字(Book → books)
配图说明:Schema 定义与 Model 生成的关系
第二个概念:连接数据库
const mongoose = require('mongoose');
mongoose.connect('mongodb://localhost:27017/my_database')
.then(() => {
console.log('数据库连接成功!');
})
.catch(err => {
console.error('连接失败:', err);
});
解释:
- mongodb://localhost:27017/my_database 是数据库地址
- localhost:27017 是 MongoDB 默认端口
- my_database 是数据库名字(不存在会自动创建)
第三个概念:CRUD 操作
1️⃣ Create(新增)
const Book = require('./models/Book'); // 假设你把上面的代码存成了 models/Book.js
// 方法1:创建实例再保存
const newBook = new Book({
title: 'Node.js 入门教程',
author: '张三',
publishedYear: 2024,
tags: ['技术', '编程'],
inStock: true
});
newBook.save()
.then(book => console.log('创建成功:', book.title))
.catch(err => console.error('创建失败:', err));
// 方法2:直接用 Model.create(更简洁)
const created = await Book.create({
title: 'MongoDB 快速入门',
author: '李四',
publishedYear: 2023,
tags: ['数据库'],
inStock: true
});
2️⃣ Read(查询)
// 查询所有书籍
const allBooks = await Book.find();
// 按条件查询(找所有有货的书)
const inStockBooks = await Book.find({ inStock: true });
// 查询一条(找第一本「Node.js 入门教程」)
const nodeBook = await Book.findOne({ title: 'Node.js 入门教程' });
// 按 ID 查询
const byId = await Book.findById('65a1b2c3d4e5f6789...');
// 只查书名和作者(投影,只返回这两个字段)
const titles = await Book.find({}, 'title author');
// 按年份排序(1 是升序,-1 是降序)
const sorted = await Book.find().sort({ publishedYear: -1 });
// 分页(跳过前5条,取10条)
const paginated = await Book.find().skip(5).limit(10);
3️⃣ Update(更新)
// 根据条件找到并更新(返回更新后的文档)
const updated = await Book.findOneAndUpdate(
{ title: 'MongoDB 快速入门' },
{ author: '王五', publishedYear: 2024 },
{ new: true } // 加这个参数才会返回更新后的数据,不加返回更新前的
);
// 根据 ID 更新
const byIdUpdated = await Book.findByIdAndUpdate(
'65a1b2c3d4e5f6789...',
{ inStock: false },
{ new: true }
);
// 更新多条
await Book.updateMany(
{ publishedYear: { $lt: 2020 } }, // 年份小于2020的
{ inStock: false } // 标记为缺货
);
4️⃣ Delete(删除)
// 删除一条
const deleted = await Book.findOneAndDelete({ title: 'MongoDB 快速入门' });
// 根据 ID 删除
await Book.findByIdAndDelete('65a1b2c3d4e5f6789...');
// 删除多条(删除所有缺货的书)
const result = await Book.deleteMany({ inStock: false });
console.log('删除了', result.deletedCount, '本书');
第四个概念:数据校验(Validation)
mongoose 可以在保存数据之前自动检查格式,不符合就报错。
const userSchema = new mongoose.Schema({
name: {
type: String,
required: true, // 必填
minlength: 2, // 最小长度
maxlength: 20 // 最大长度
},
age: {
type: Number,
min: 0, // 最小值
max: 150 // 最大值
},
email: {
type: String,
match: /^\S+@\S+\.\S+$/ // 正则校验邮箱格式
},
hobby: {
type: String,
enum: ['足球', '篮球', '乒乓球'] // 只能在这些值里选
}
});
解释:当你要保存一个 User 时,如果 name 是空字符串、age 是负数、email 格式不对,mongoose 会直接报错,不会让你存进去。
🔥 实战 35 分钟:3 个递进的小项目
📦 项目 1:书籍管理系统(CRUD 基础版)
目标:学会用 mongoose 做最基本的增删改查。
预计时间:5 分钟
完整可运行代码:
const mongoose = require('mongoose');
// 1. 连接数据库
mongoose.connect('mongodb://localhost:27017/library')
.then(() => console.log('数据库连接成功'))
.catch(err => console.error(err));
// 2. 定义书籍 Schema
const bookSchema = new mongoose.Schema({
title: { type: String, required: true },
author: { type: String, required: true },
price: Number,
inStock: { type: Boolean, default: true }
});
const Book = mongoose.model('Book', bookSchema);
// 3. 增删改查演示
async function demo() {
// 清空之前的数据
await Book.deleteMany({});
// Create - 添加几本书
const book1 = await Book.create({
title: 'Node.js 实战',
author: '张三',
price: 59
});
const book2 = await Book.create({
title: 'MongoDB 入门',
author: '李四',
price: 49
});
// Read - 查询所有书
const allBooks = await Book.find();
console.log('所有书籍:', allBooks);
// Update - 给第一本书涨价
await Book.findByIdAndUpdate(book1._id, { price: 69 });
// Delete - 删除第二本书
await Book.findByIdAndDelete(book2._id);
// 最终结果
const finalBooks = await Book.find();
console.log('最终书籍:', finalBooks);
}
demo().then(() => {
console.log('演示完成!');
mongoose.disconnect();
});
预期输出:
数据库连接成功
所有书籍:[
{ _id:..., title: 'Node.js 实战', author: '张三', price: 59, inStock: true },
{ _id:..., title: 'MongoDB 入门', author: '李四', price: 49, inStock: true }
]
最终书籍:[
{ _id:..., title: 'Node.js 实战', author: '张三', price: 69, inStock: true }
]
演示完成!
解释:这个演示展示了完整的 CRUD 流程——先清空数据,然后添加、查询、更新、删除,最后验证结果。
📦 项目 2:学生成绩管理系统(带校验 + 统计)
目标:学会用 mongoose 的校验功能 + 聚合统计。
预计时间:15 分钟
场景:一个班级的成绩录入系统,需要录入学生姓名、分数、科目。
完整可运行代码:
const mongoose = require('mongoose');
mongoose.connect('mongodb://localhost:27017/school')
.then(() => console.log('数据库连接成功'))
.catch(err => console.error(err));
// 定义学生成绩 Schema
const scoreSchema = new mongoose.Schema({
studentName: {
type: String,
required: [true, '学生姓名不能为空'],
minlength: 2
},
subject: {
type: String,
required: true,
enum: ['数学', '语文', '英语', '物理', '化学']
},
score: {
type: Number,
required: true,
min: [0, '分数不能为负'],
max: [100, '分数不能超过100']
},
examDate: {
type: Date,
default: Date.now
}
});
const Score = mongoose.model('Score', scoreSchema);
async function scoreManagement() {
// 清空数据
await Score.deleteMany({});
// 批量添加成绩(模拟考试成绩)
const scores = await Score.insertMany([
{ studentName: '王小明', subject: '数学', score: 95 },
{ studentName: '王小明', subject: '语文', score: 88 },
{ studentName: '王小明', subject: '英语', score: 92 },
{ studentName: '李小红', subject: '数学', score: 78 },
{ studentName: '李小红', subject: '语文', score: 85 },
{ studentName: '李小红', subject: '英语', score: 81 },
{ studentName: '张大山', subject: '数学', score: 62 },
{ studentName: '张大山', subject: '语文', score: 70 },
{ studentName: '张大山', subject: '英语', score: 55 }
]);
console.log('录入完成,共', scores.length, '条成绩记录');
// 查询功能1:找出数学成绩 >= 90 的学生
const goodMathStudents = await Score.find({
subject: '数学',
score: { $gte: 90 }
});
console.log('\n数学 >= 90 的学生:');
goodMathStudents.forEach(s => console.log(` ${s.studentName}: ${s.score}分`));
// 查询功能2:统计每个学生的平均分
const avgByStudent = await Score.aggregate([
{ $group: {
_id: '$studentName',
avgScore: { $avg: '$score' },
totalScore: { $sum: '$score' }
}
},
{ $sort: { avgScore: -1 } }
]);
console.log('\n学生平均分排名:');
avgByStudent.forEach(s => console.log(` ${s._id}: 平均${s.avgScore.toFixed(1)}分`));
// 查询功能3:统计每个科目的平均分
const avgBySubject = await Score.aggregate([
{ $group: {
_id: '$subject',
avgScore: { $avg: '$score' },
maxScore: { $max: '$score' },
minScore: { $min: '$score' }
}
}
]);
console.log('\n各科目平均分:');
avgBySubject.forEach(s => console.log(` ${s._id}: 平均${s.avgScore.toFixed(1)}分 (最高${s.maxScore}, 最低${s.minScore})`));
// 测试校验:尝试添加一个无效分数
console.log('\n测试数据校验(尝试添加150分的成绩):');
try {
await Score.create({ studentName: '测试', subject: '数学', score: 150 });
} catch (err) {
console.log(' 校验失败:', err.errors.score.message);
}
}
scoreManagement().then(() => {
console.log('\n成绩管理系统演示完成!');
mongoose.disconnect();
});
预期输出:
数据库连接成功
录入完成,共 9 条成绩记录
数学 >= 90 的学生:
王小明: 95分
学生平均分排名:
王小明: 平均91.7分
李小红: 平均81.3分
张大山: 平均62.3分
各科目平均分:
数学: 平均78.3分 (最高95, 最低62)
语文: 平均81.0分 (最高88, 最低70)
英语: 平均76.0分 (最高92, 最低55)
测试数据校验(尝试添加150分的成绩):
校验失败: 分数不能超过100
解释:
- $group 是聚合管道,用来分组统计
- $avg、$sum、$max、$min 是聚合函数
- { $gte: 90 } 是查询运算符,表示「大于等于90」
📦 项目 3:个人任务清单(Todo)工具
目标:组合 CRUD + 关联查询,做一个有点真实用途的小工具。
预计时间:15 分钟
场景:一个命令行任务管理器,可以添加任务、标记完成、设置优先级、按状态筛选。
完整可运行代码:
const mongoose = require('mongoose');
mongoose.connect('mongodb://localhost:27017/todoapp')
.then(() => console.log('✅ 数据库连接成功'))
.catch(err => console.error('❌ 连接失败:', err));
// 定义任务 Schema
const todoSchema = new mongoose.Schema({
content: {
type: String,
required: [true, '任务内容不能为空']
},
priority: {
type: String,
enum: ['高', '中', '低'],
default: '中'
},
completed: {
type: Boolean,
default: false
},
createdAt: {
type: Date,
default: Date.now
},
completedAt: Date // 完成的时刻
});
const Todo = mongoose.model('Todo', todoSchema);
// 辅助函数:打印所有任务
async function printAllTodos(filter = {}) {
const todos = await Todo.find(filter).sort({ createdAt: -1 });
console.log('\n📋 当前任务列表:');
if (todos.length === 0) {
console.log(' (没有任务)');
return;
}
todos.forEach((t, i) => {
const status = t.completed ? '✅' : '⬜';
const done = t.completed ? ` [完成于: ${t.completedAt?.toLocaleDateString()}]` : '';
console.log(` ${i + 1}. ${status} ${t.content} (优先级: ${t.priority})${done}`);
});
console.log(`\n共 ${todos.length} 个任务,${todos.filter(t => t.completed).length} 已完成`);
}
// 任务管理器主函数
async function todoManager() {
console.log('=== 📝 个人任务管理器 ===\n');
// 清空数据(模拟全新开始)
await Todo.deleteMany({});
// 1. 添加一些初始任务
console.log('--- 添加任务 ---');
await Todo.create({ content: '学习 MongoDB', priority: '高' });
await Todo.create({ content: '完成作业', priority: '中' });
await Todo.create({ content: '给妈妈打电话', priority: '高' });
await Todo.create({ content: '整理房间', priority: '低' });
await Todo.create({ content: '看一本书', priority: '中' });
// 2. 查看所有任务
await printAllTodos();
// 3. 标记任务完成
console.log('\n--- 标记任务完成 ---');
const homework = await Todo.findOne({ content: '完成作业' });
if (homework) {
homework.completed = true;
homework.completedAt = new Date();
await homework.save();
console.log('✅ 完成了:「完成作业」');
}
// 4. 查看未完成的任务
console.log('\n--- 查看未完成任务 ---');
await printAllTodos({ completed: false });
// 5. 查看高优先级的任务
console.log('\n--- 高优先级任务 ---');
const highPriorityTodos = await Todo.find({ priority: '高', completed: false });
highPriorityTodos.forEach(t => console.log(` 🔥 ${t.content}`));
// 6. 删除已完成的任务
console.log('\n--- 清理已完成任务 ---');
const result = await Todo.deleteMany({ completed: true });
console.log(`🗑️ 删除了 ${result.deletedCount} 个已完成任务`);
// 7. 最终列表
await printAllTodos();
}
todoManager().then(() => {
console.log('\n=== 演示完成 ===');
mongoose.disconnect();
});
预期输出:
=== 📝 个人任务管理器 ===
✅ 数据库连接成功
--- 添加任务 ---
--- 查看未完成任务 ---
📋 当前任务列表:
1. ⬜ 看一本书 (优先级: 中)
2. ⬜ 整理房间 (优先级: 低)
3. ⬜ 给妈妈打电话 (优先级: 高)
4. ⬜ 完成作业 (优先级: 中)
5. ⬜ 学习 MongoDB (优先级: 高)
共 5 个任务,0 已完成
--- 标记任务完成 ---
✅ 完成了:「完成作业」
--- 查看未完成任务 ---
📋 当前任务列表:
1. ⬜ 看一本书 (优先级: 中)
2. ⬜ 整理房间 (优先级: 低)
3. ⬜ 给妈妈打电话 (优先级: 高)
4. ⬜ 完成作业 (优先级: 中)
共 4 个任务,1 已完成
--- 高优先级任务 ---
🔥 给妈妈打电话
🔥 学习 MongoDB
--- 清理已完成任务 ---
🗑️ 删除了 1 个已完成任务
📋 当前任务列表:
1. ⬜ 看一本书 (优先级: 中)
2. ⬜ 整理房间 (优先级: 低)
3. ⭐ 给妈妈打电话 (优先级: 高)
4. ⬜ 学习 MongoDB (优先级: 高)
共 4 个任务,0 已完成
=== 演示完成 ===
解释:这个小工具展示了 mongoose 的完整能力:创建任务、查询过滤、更新状态、删除清理。虽然是命令行版本,但逻辑和真实的 Web 应用一模一样。
💪 进阶 20 分钟:常见坑 + 性能小贴士
坑 1:忘记处理连接错误
// ❌ 错误:连接失败也不知道
mongoose.connect('mongodb://localhost:27017/test');
const data = await Book.find(); // 可能数据库还没连上就查了
// ✅ 正确:确保连接成功再操作
mongoose.connect('mongodb://localhost:27017/test')
.then(() => console.log('连接成功'))
.catch(err => { throw err; }); // 或者用 try-catch 包裹
坑 2:Schema 定义后修改
// ❌ 错误:模型编译后还在改 Schema
const userSchema = new mongoose.Schema({ name: String });
const User = mongoose.model('User', userSchema);
userSchema.add({ age: Number }); // 太晚了!
// ✅ 正确:编译前把字段定义完整
const userSchema = new mongoose.Schema({
name: String,
age: Number // 一次性定义完整
});
坑 3:异步操作忘了 await
// ❌ 错误:没等保存完成就返回了
function createBook(data) {
const book = new Book(data);
book.save(); // 没有 await!
return book; // 此时可能还没保存完
}
// ✅ 正确:async/await 用到底
async function createBook(data) {
const book = new Book(data);
await book.save();
return book;
}
坑 4:混用 findOneAndUpdate 和普通查询
// ❌ 错误:以为 update 会返回更新后的数据
const updated = await Book.findOneAndUpdate(
{ title: '旧书名' },
{ title: '新书名' }
);
console.log(updated.title); // 打印的是「旧书名」!
// ✅ 正确:加 new: true
const updated = await Book.findOneAndUpdate(
{ title: '旧书名' },
{ title: '新书名' },
{ new: true } // 返回更新后的文档
);
console.log(updated.title); // 打印的是「新书名」
坑 5:mongoose 版本 6/7 以后 API 变化
// ❌ 旧写法(mongoose 5):callback 风格
Book.find({ author: '张三' }, (err, books) => {
if (err) throw err;
console.log(books);
});
// ✅ 新写法(mongoose 6+):纯 async/await
const books = await Book.find({ author: '张三' });
性能小贴士:合理使用索引
如果你的查询经常按某个字段过滤,给这个字段加索引可以提速几十倍。
const userSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true }, // unique 本身就是索引
username: String,
createdAt: Date
});
// 手动加索引(针对经常查询的字段)
userSchema.index({ createdAt: -1 }); // 按创建时间倒序查询时很快
userSchema.index({ username: 1 }); // 按用户名查询时很快
调试技巧:开启 mongoose 的日志
// 开发环境开启详细日志
mongoose.set('debug', true);
mongoose.connect('mongodb://localhost:27017/test');
// 每次数据库操作都会打印出原生 MongoDB 命令
// Book.find({...}) 会打印:db.books.find({...})
✏️ 练习题 + 作业题
练习题(5 道,10 分钟内完成)
练习 1(2 分钟):改改名字
// 现有代码:创建一本书
await Book.create({
title: 'Node.js 入门',
author: '张三'
});
// 题目:改成创建一本「你的书」
- 输入:(直接运行上面的代码)
- 预期输出:数据库里多了一本你指定的书
- 提示:改
title和author的值
练习 2(2 分钟):加个条件判断
// 现有代码:查询所有有货的书
const books = await Book.find({ inStock: true });
// 题目:如果有货的书超过 3 本,才打印「库存充足」
- 输入:数据库里有 5 本有货的书
- 预期输出:打印「库存充足」
- 提示:用
if (books.length > 3)
练习 3(3 分钟):换一种数据
// 练习 2 用的是「书籍」数据
// 题目:模仿 Book Schema,创建一个「商品」Schema
// 字段:name(商品名)、price(价格)、category(分类)
// 用 create 添加一条商品数据
- 输入:一条商品记录
- 预期输出:控制台打印出创建的商品名
- 提示:复制
bookSchema的模板,改成商品相关的字段
练习 4(3 分钟):串起来用
// 练习 2 处理的是 Book(书籍)
// 练习 3 你创建了 Product(商品)
// 题目:写一个函数,同时查询书籍和商品,返回总数
- 输入:数据库里有 3 本书、2 个商品
- 预期输出:
共 5 个商品 - 提示:分别
find()然后books.length + products.length
练习 5(2 分钟):看图找错
// 以下代码运行时报错:
async function brokenCode() {
const book = await Book.findOne({ title: '不存在' });
book.completed = true; // 报错:Cannot set property...
await book.save();
}
- 输入:数据库里没有「不存在」这本书
- 预期输出:(报错)
- 提示:
findOne找不到会返回null,null.completed会报错
作业:做一个「个人图书收藏夹」
需求描述:做一个命令行版的图书收藏工具,可以录入书籍、给书籍打标签、按标签筛选、统计收藏情况。
功能点:
1. 添加书籍:输入书名、作者、出版社、标签列表(如 ["技术", "入门"])
2. 查看所有书籍:显示完整列表
3. 按标签筛选:输入标签名,只显示有该标签的书
4. 统计功能:显示收藏了多少本书、标签分布情况(哪个标签出现次数最多)
加分项:
1. 给书籍添加「评分」(1-5 星)
2. 按评分排序,最喜欢的书排在前面
验收标准:
- 能跑起来(连接 MongoDB 成功)
- 添加的书籍能在列表里显示出来
- 按标签筛选能正确过滤
- 统计功能输出合理
提交方式:评论区贴代码或 GitHub 链接
📚 总结 + 资源
本文学到的 3 个核心点
- MongoDB 是文档型数据库:存储 JSON 格式文档,不用预定义表结构,数据想怎么存就怎么存
- mongoose 是 Node.js 操作 MongoDB 的工具:通过 Schema 定义数据格式,通过 Model 执行 CRUD 操作
- mongoose 支持数据校验和聚合统计:可以限制字段格式(校验),也可以分组计算(aggregate)
推荐延伸资源
-
mongoose 官方文档(https://mongoosejs.com/docs/)
权威的学习资料,有每个 API 的详细说明和示例 -
MongoDB 官方教程(https://www.mongodb.com/docs/)
深入了解 MongoDB 本身的查询语法,从这里入手 -
《MongoDB 权威指南》
典书籍,想系统掌握 MongoDB 的同学可以啃一啃
互动钩子
学完了 MongoDB + mongoose,你现在手上有了一个「想存什么就存什么」的数据库。下 一章我们要用它来解决一个性能问题——数据越存越多,查询越来越慢怎么办?Redis 缓存登场!🎉
你有没有遇到过「数据库查得慢」的情况?当时是怎么解决的?评论区聊聊,老粉优先回复!
本文代码测试环境:Node.js 18+、mongoose 7.x、MongoDB 6.x。建议用 npx nodemon 启动,方便边改边跑。

评论(0)