在現代 Web 開發中,特別是使用 Node.js 與 express 建立後端服務時,數據庫的操作是不可或缺的一環。然而,直接使用原生 MongoDB 驅動程式在 JavaScript/Node.js 環境中操作資料,雖然可行,但程式碼可能會顯得繁瑣且缺乏結構。為了解決這個問題,可透過 npm 安裝的 Mongoose 應運而生。這類工具統稱為 ODM/ORM。
Mongoose 是一個在 Node.js 環境下運行的 MongoDB 物件資料模型 (object data model, ODM) 函式庫。它扮演著應用程式程式碼與 MongoDB database 之間的橋樑,讓我們可以用更直觀、更貼近 JavaScript 物件導向的思維來操作資料。簡單來說,Mongoose 將數據庫中的「文件 (document)」對應到 JavaScript 中的「物件 (object)」,並提供了一套功能強大、基於結構 (Schema-based) 的解決方案來進行資料建模、驗證、查詢建構以及業務邏輯掛鉤等。這就是 object data model 概念的精髓 (the essence of the object data model concept)。
本文將深入探討 Mongoose 的核心概念與實用技巧,從基礎的 CRUD(新增、讀取、更新、刪除)操作,到進階的實例與靜態方法、中介軟體 (Middleware) 等,提供一份內容詳盡、結構完整的學習筆記。無論您是初學者還是希望深化對 Mongoose 理解的開發者,都能從這份線上課程般的指南中獲益。
Mongoose 核心概念 – Schema 與 Model
要掌握 Mongoose,首先必須理解兩個最核心的概念:Schema(綱要) 與 Model(模型)。
Schema (綱要)
可以將其想像成數據庫集合 (Collection) 中文件的「藍圖」或「骨架」模式。它定義了文件中應包含哪些欄位、每個欄位的數據類型 (如 String, Number, Date)、預設值、驗證規則(例如,某個欄位的 property 是否為必填)等。Schema 負責定義資料的結構與約束,確保存入數據庫的資料符合預期格式。這在概念上類似於關聯式資料庫 (SQL) 中的「資料表結構 (Table Schema)」或 object relational model。
Model (模型)
Model 是由 Schema 編譯產生的「建構函式 (Constructor)」。它包裝了 Schema,並提供了一系列豐富的介面,讓我們可以對其對應的 MongoDB 集合進行增、刪、查、改等操作。一個 Model 直接對應到數據庫中的一個集合。透過 Model,我們可以建立、查詢、更新和刪除文件。可以將 Model 視為操作特定數據集合的「入口」。
基本架構與實作步驟:
- 連接數據庫:使用 mongoose.connect() 方法連接到您的 MongoDB 實例。
- 建立 Schema:使用 new Schema() 來定義資料結構。這個 mongoose schema 是數據模型的基礎。
- 建立 Model:使用 mongoose.model() 方法,傳入模型名稱和對應的 Schema,來建立模型。
- 操作資料:使用建立好的 Model 來進行 CRUD 操作。
程式碼範例:
const express = require('express');
const app = express(); // 引入 const app express 結構
const mongoose = require('mongoose');
// 1. 連接數據庫
const dbConnection = mongoose.connect('mongodb://localhost:27017/myUniversityDB')
.then(() => console.log('成功 connect 到 MongoDB...'))
.catch(e => console.error('連接失敗...', e)); // 使用 catch e 捕獲錯誤
const db = mongoose.connection; // 這個 connection 物件代表了與數據庫的連線
// 2. 建立 Schema
const studentSchema = new mongoose.Schema({
name: {
type: String, // 這個 schema 讓 name 欄位的定義類型為 String
required: true, // 必填欄位
trim: true // 移除前後空白
},
age: {
type: Number,
min: [18, '年齡不能小於 18 歲'], // 最小值驗證與錯誤訊息
max: 100
},
major: String,
scholarship: {
merit: Number,
other: Number
},
entryDate: {
type: Date,
default: Date.now // 預設值為當前時間
}
});
// 3. 建立 Model
// Mongoose 會自動將 'Student' 轉為小寫複數 'students' 作為集合名稱,這是一個很方便的地方。
const Student = mongoose.model('Student', studentSchema);
// 4. 操作資料 - 建立一個新學生文件
async function createStudent() {
const student = new Student({
name: '陳大文',
age: 22,
major: 'Computer Science',
scholarship: {
merit: 5000,
other: 1500
}
});
try {
const result = await student.save();
console.log('學生數據已儲存:', result);
} catch (ex) {
// 捕捉驗證錯誤
for (const field in ex.errors) {
console.log('Validation error log:', ex.errors[field].message);
}
}
}
createStudent();
資料的增刪查改 (CRUD)
CRUD 是所有數據庫操作的基礎。Mongoose 提供了非常直觀的方法來執行這些操作。
1. 新增資料 (Create)
有兩種主要方式可以建立並儲存新文件:
- new Model() + .save():
- 先使用 new 關鍵字建立一個 model instance。
- 再呼叫該實例的 .save() 方法,將其存入 database。.save() 會回傳一個 Promise。
JavaScript
const newStudent = new Student({ name: '林小美', age: 20, major: 'Finance' }); newStudent.save() .then(doc => console.log('儲存成功:', doc)) .catch(err => console.error('儲存失敗:', err));
- Model.create():
- 這是一個更簡潔的方法,一步到位地建立並儲存文件。
- 它也回傳一個 Promise。
JavaScript
Student.create({ name: '王五', age: 25, major: 'Physics' }) .then(doc => console.log('直接建立成功:', doc)) .catch(err => console.error('建立失敗:', err));
2. 查詢資料 (Read)
Mongoose 的查詢語法非常靈活,可以處理簡單到複雜的各種查詢需求。在 Express 應用中,這些查詢方法通常會放在 app.get 路由處理器中,以回應前端的請求。
- Model.find(filter):查詢所有符合 filter 物件條件的文件,回傳一個文件 (文檔) 陣列 (Array of Documents)。如果 filter 為空物件 {},則回傳集合中的所有文件。
JavaScript
// 查詢所有主修為 'Computer Science' 的學生 const csStudents = await Student.find({ major: 'Computer Science' }); console.log(csStudents);
- Model.findOne(filter):查詢第一個符合 filter 物件條件的文件,回傳單一文檔或 null。
JavaScript
// 查詢第一位年齡大於 21 歲的學生 const olderStudent = await Student.findOne({ age: { $gt: 21 } }); // $gt: greater than console.log(olderStudent);
- Model.findById(id):透過文件的 _id property 進行查詢,非常常用。
JavaScript
const specificStudent = await Student.findById('60c72b2f9b1d8c001f8e4d3a'); console.log(specificStudent);
值得一提的是,Mongoose 回傳的查詢結果不是傳統的 Promise,而是一個 Query 物件。這個物件可以被鏈式呼叫以建構更複雜的查詢,最後使用 .exec() 方法執行並取得 Promise。當然,直接使用 await 也能達到同樣效果。
// 鏈式查詢範例
Student.find({ major: 'Geography' })
.limit(10) // 最多回傳 10 筆
.sort({ age: -1 }) // 根據年齡降序排列
.select({ name: 1, age: 1 }) // 只選擇 name 和 age 欄位
.exec()
.then(data => console.log(data))
.catch(err => console.log(err));
3. 更新資料 (Update)
更新資料時,需要特別注意驗證 (Validation) 的問題。
- Model.updateOne(filter, update, options):更新符合 filter 條件的第一筆文件。
- Model.updateMany(filter, update, options):更新所有符合 filter 條件的文件。
- Model.findByIdAndUpdate(id, update, options):透過 _id 查找並更新。
重要的 options 參數:
選項 | 描述 |
new: true | 在 findByIdAndUpdate 或 findOneAndUpdate 中使用,設定為 true 會回傳更新後的文件,預設為 false(回傳更新前的文件)。 |
runValidators: true | 非常重要! 在更新操作時,Mongoose 預設不會執行 Schema 中定義的驗證規則。必須將此選項設為 true,才能確保更新的資料也符合驗證條件。 |
程式碼範例:
// 將 name 為 '王五' 的學生的 age 更新為 26
const result = await Student.updateOne({ name: '王五' }, { age: 26 });
console.log(result); // 會顯示更新的統計資訊
// 查找 id 為 ... 的學生,將其 age 更新為 -5 (會觸發驗證失敗)
try {
const updatedStudent = await Student.findByIdAndUpdate(
'some_student_id',
{ age: -5 },
{ new: true, runValidators: true } // 啟用驗證
);
if (updatedStudent) {
console.log('更新後的學生資料:', updatedStudent);
} else {
console.log('找不到該學生');
}
} catch (ex) {
console.error('更新失敗,驗證錯誤:', ex.message);
}
4. 刪除資料 (Delete)
刪除操作相對直觀。
- Model.deleteOne(conditions):刪除符合 conditions 的第一筆文件。
- Model.deleteMany(conditions):刪除所有符合 conditions 的文件。
- Model.findByIdAndRemove(id):透過 _id 查找並刪除。
程式碼範例:
// 刪除所有主修為 'Art' 的學生
const deleteResult = await Student.deleteMany({ major: 'Art' });
console.log('已刪除', deleteResult.deletedCount, '筆資料');
進階功能 – 實例方法、靜態方法與中介軟體
除了基本的 CRUD,Mongoose 還提供了強大的擴充機制,讓你可以將業務邏輯封裝在 Model 中。
1. 實例方法 (Instance Methods)
實例方法是定義在 Schema 上的函式,Model 的每一個文件 (文檔) 實例都可以呼叫這些方法。這非常適合用來處理與單一文件相關的邏輯。在方法內部,this 關鍵字會指向當前的文件實例。
定義類型:
// 在 studentSchema 上定義一個計算總獎學金的實例方法
studentSchema.methods.getTotalScholarship = function() {
return this.scholarship.merit + this.scholarship.other;
};
使用方式:
const student = await Student.findOne({ name: '陳大文' });
if (student) {
// 接著使用 console 顯示結果
console.log(`${student.name} 的總獎學金為: ${student.getTotalScholarship()}`);
}
2. 靜態方法 (Static Methods)
靜態方法是直接定義在 Model 本身的方法,而不是在文件實例上。它適合用來處理與整個集合相關的操作,例如自訂的查詢。在靜態方法中,this 關鍵字指向 Model。
定義類型:
// 在 studentSchema 上定義一個查找特定主修所有學生的靜態方法
studentSchema.statics.findByMajor = function(majorName) {
return this.find({ major: majorName }); // 'this' 指向 Student Model
};
使用方式:
// 直接透過 Model 呼叫
const financeStudents = await Student.findByMajor('Finance');
console.log('所有主修金融的學生:', financeStudents);
實例方法 vs. 靜態方法總結:
特性 | 實例方法 (Instance Method) | 靜態方法 (Static Method) |
定義於 | schema.methods | schema.statics |
呼叫者 | 文件的實例 (e.g., student.doSomething()) | Model 本身 (e.g., Student.doSomething()) |
this 指向 | 當前的文件實例 | Model |
適用場景 | 針對單一文件的操作或計算 | 針對整個集合的查詢或操作 |
3. 虛擬屬性 (Virtuals)
虛擬屬性是可以 get 和 set,但不會被實際儲存到 MongoDB 的文件 property。它對於格式化或組合欄位非常有用。
// 建立一個 'fullName' 的虛擬屬性
studentSchema.virtual('fullName').get(function() {
return `${this.name} (主修: ${this.major})`;
});
// 使用
const student = await Student.findOne({ name: '陳大文' });
console.log(student.fullName); // 輸出: 陳大文 (主修: Computer Science)
4. 中介軟體 (Middleware / Hooks)
中介軟體 (也稱為 pre/post hooks) 是在執行非同步函式期間傳遞控制權的函式。你可以定義在 Schema 上,讓它在特定操作(如 save, validate, remove)之前 (pre) 或 之後 (post) 自動執行。
我們在許多場景下都認為這極為有用,例如:
- 在儲存使用者資料前,對密碼進行雜湊處理。
- 在刪除一篇文章前,先刪除所有相關的留言。
- 在儲存操作後,記錄一筆日誌 (log)。
程式碼範例:密碼雜湊
假設我們有一個 userSchema,我們希望在每次儲存使用者前都對密碼進行加密。
const bcrypt = require('bcrypt');
userSchema.pre('save', async function(next) {
// 只有在密碼被修改或新建時才執行
if (!this.isModified('password')) return next();
try {
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next(); // 繼續執行儲存操作
} catch (error) {
next(error); // 如果出錯,將錯誤傳遞下去
}
});
當你執行 user.save() 時,這個 pre(‘save’) 中介軟體會自動被觸發,完成密碼加密後再進行實際的儲存。
檔案結構與模組化
在一個典型的 project 中,我們通常會將 Mongoose models 分散在不同的檔案中,以保持程式碼的整潔。例如,我們可以建立一個 models 資料夾,並在其中為 Student 模型建立一個 student.js 檔案。
// ./models/student.js
const mongoose = require('mongoose');
const studentSchema = new mongoose.Schema({ /* ... schema definition ... */ });
// 使用 module.exports = mongoose.model 導出模型
module.exports = mongoose.model('Student', studentSchema);
這段程式碼使用 module.exports = mongoose.model(…) 的方式導出模型,而在其他檔案中就可以透過 require 來引用它。
常見問題 (FAQ)
Q1: Mongoose 和 MongoDB 原生驅動程式有什麼不同?我應該選擇哪個?
A1: MongoDB 原生驅動程式提供了最直接、最底層的數據庫存取方式,效能可能是最優的。而 Mongoose 是一個建立在原生驅動程式之上的 ODM,它提供了資料建模、Schema 驗證、中介軟體、查詢建構等豐富功能。對於大多數應用程式來說,Mongoose 帶來的開發便利性、程式碼組織性與資料一致性保障,遠遠超過其微小的效能開銷。除非你的 project 對效能有極致的要求,否則 Mongoose 通常是更好的選擇。
Q2: 為什麼我使用 updateOne 或 findOneAndUpdate 時,Schema 裡的 required 或 min 驗證沒有生效?
A2: 這是 Mongoose 一個常見的雷區。預設情況下,更新類型的操作 (updateOne, updateMany, findOneAndUpdate 等) 不會觸發 Schema 中定義的驗證器。你必須在操作的第三個參數 options 物件中明確設定 { runValidators: true },Mongoose 才會在更新時執行驗證。
Q3: 我應該在什麼時候使用「實例方法」,什麼時候使用「靜態方法」?
A3: 判斷的標準是:你的這個邏輯是針對單一一個文件,還是針對整個文件集合?
- 如果你的方法需要讀取或修改某一個特定文件的 property(例如,計算某個學生的 GPA、判斷某個使用者密碼是否正確),那就應該使用實例方法。
- 如果你的方法是為了從整個集合中查找符合特定條件的一批資料(例如,找出所有未畢業的學生、查找所有管理員帳號),那就應該使用靜態方法。
Q4: populate() 是做什麼用的?
A4: populate() 是 Mongoose 用來處理不同集合之間「關聯」查詢的利器。在 NoSQL 中,我們通常不使用像 SQL 的 JOIN。取而代之的是,我們在一個 Schema 中儲存另一個 Schema 文件的 id 作為參考 (reference)。當你需要查詢時,populate() 可以根據這個儲存的 id,自動去另一個集合中抓取對應的完整文檔,並替換掉原本的 _id 欄位。這讓處理關聯資料變得非常方便。
總結
Mongoose 為 Node.js 開發者提供了一個優雅且強大的方式來與 MongoDB 互動。透過本文的介紹,我們掌握了其核心概念與實用技巧:
- Schema 與 Model:定義了資料的結構與操作的入口,是 Mongoose 的基石。
- CRUD 操作:學習了如何使用直觀的 API 來新增、查詢、更新和刪除資料,並特別注意了更新時的 runValidators 選項。
- 進階功能:
- 實例方法和靜態方法讓我們能將業務邏輯優雅地封裝在 mongoose model 中,提升程式碼的組織性與可複用性。
- 虛擬屬性提供了不需存入數據庫的彈性欄位。
- 中介軟體則提供了強大的「掛鉤」能力,讓我們能在資料生命週期的關鍵節點執行自訂邏輯。
精通 Mongoose 不僅能大幅提升開發效率,更能幫助我們建立結構清晰、易於維護的後端應用程式。它將繁瑣的數據庫操作抽象化,讓開發者能更專注於實現核心的業務功能。
資料來源
- Mongoose 入門(簡介、CRUD、實例&靜態方法、中介軟體) | by 拉爾夫的技術隨筆 | Medium
- Mongoose 是什麼: 資料操作三步驟從 MongoDB 到 API | 前端三分鐘 | 一起用三分鐘分享技術與知識
- Web開發學習筆記23 — 開始使用數據庫(Mongoose)