
本文源自工作中的一個問題,在使用 Mongoose 做關聯查詢時發現使用 populate() 方法不能直接關聯非 _id 之外的其它欄位,在網上搜索時這塊的解決方案也並不是很多,在經過一番查閱、測試之後,有兩種可行的方案,使用 Mongoose 的 virtual 結合 populate 和 MongoDB 原生提供的 Aggregate 裡面的 $lookup 階段來實現。
文件內嵌與引用模式
MongoDB 是一種文件物件模型,使用起來很靈活,它的文件結構分為 內嵌和引用 兩種型別。
內嵌是把相關聯的資料儲存在同一個文件內,我們可以用物件或陣列的形式來儲存,這樣好處是我們可以在一個單一操作內完成,可以傳送較少的請求到資料庫服務端,但是這種內嵌型別也是一種冗餘的資料模型,會造成資料的重複,如果很複雜的一對多或多對多的關係,表達起來就很複雜,也要注意內嵌還有一個最大的單條文件記錄限制為 16MB。
引用模型是一種規範化的資料模型,通過主外來鍵的方式來關聯多個文件之間的引用關係,減少了資料的冗餘,在使用這種資料模型中就要用到關聯查詢,也就是本文我們要講解的重點。

圖片來源:mongoing[1]
引用模型示例
JSON 模型
我們通過作者和書籍的關係,一個作者對應多個書籍這樣一個簡單的示例來學習如何在 MongoDB 中實現關聯非 _id 查詢。
-
Author
{
"bookIds":[
26351021,
26854244,
27620408
],
"authorId":1,
"name":"Kyle Simpson"
}
-
Book
[
{
"bookId":26351021,
"name":"你不知道的JavaScript(上卷)",
},
{
"bookId":26854244,
"name":"你不知道的JavaScript(中卷)",
},
{
"bookId":27620408,
"name":"你不知道的JavaScript(下卷)",
}
]
定義 Schema
使用 Mongoose 第一步要先定義集合的 Schema。
-
author.js
建立 model/author.js 定義作者的 Schema,程式碼中的 ref 表示要關聯的 Model 是誰,在 Schema 定義好之後後面我會建立 Model
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const AuthorSchema = new Schema({
authorId: Number,
name: String,
bookIds: [{ type: Number, ref: 'Books' }]
});
AuthorSchema.index({ authorId: 1}, { unique: true });
module.exports = AuthorSchema;
-
book.js
建立 model/book.js 定義書籍的 Schema。
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const BookSchema = new Schema({
bookId: Number,
name: String,
});
BookSchema.index({ bookId: 1}, { unique: true });
module.exports = BookSchema;
-
index.js
建立 model/index.js 定義 Model 和連結資料庫。
const mongoose = require('mongoose');
const AuthorSchema = require('./author');
const BookSchema = require('./book');
const DB_URL = process.env.DB_URL;
const AuthorModel = mongoose.model('Authors', AuthorSchema, 'authors');
const BookModel = mongoose.model('Books', BookSchema, 'books');
mongoose.set('useCreateIndex', true)
mongoose.connect(DB_URL, {useNewUrlParser: true, useUnifiedTopology: true});
module.exports = {
AuthorModel,
BookModel,
}
使用 Aggregate 的 $lookup 實現關聯查詢
MongoDB 3.2 版本新增加了 $lookup
實現多表關聯,在聚合管道階段中使用,經過 $lookup
階段的處理,輸出的新文件中會包含一個新生成的陣列列。
建立一個 aggregateTest.js 重點在於 $lookup 物件,程式碼如下所示:
-
$lookup.from: 在同一個資料庫中指定要 Join 的集合的名稱。 -
$lookup.localFiled: 關聯的源集合中的欄位,本示例中是 Authors 表的 authorId 欄位。 -
$lookup.foreignFiled: 被 Join 的集合的欄位,本示例中是 Books 表的 bookId 欄位。 -
$as: 別名,關聯查詢返回的這個結果起一個新的名稱。
如果需要指定哪些欄位返回,哪些需要過濾,可定義 $project 物件,關聯查詢的欄位過濾可使用 別名.關聯文件中的欄位 進行指定。
const { AuthorModel } = require('./model');
(async () => {
const res = await AuthorModel.aggregate([
{
$match: { authorId: 1 }
},
{
$lookup: {
from: 'books',
localField: 'bookIds',
foreignField: 'bookId',
as: 'bookList',
}
},
{
$project: {
'_id': 0,
'authorId': 1,
'name': 1,
'bookList.bookId': 1, // 指定 books 表的 bookId 欄位返回
'bookList.name': 1
}
}
]);
console.log(JSON.stringify(res));
})();
執行以上程式,將得到以下結果:
[
{
"authorId":1,
"name":"Kyle Simpson",
"bookList":[
{
"bookId":26351021,
"name":"你不知道的JavaScript(上卷)"
},
{
"bookId":26854244,
"name":"你不知道的JavaScript(中卷)"
},
{
"bookId":27620408,
"name":"你不知道的JavaScript(下卷)"
}
]
}
]
關於 $lookup 更多操作參考 MongoDB 官方文件 #lookup-aggregation[2]
Mongoose Virtual 和 populate 實現
Mongoose 的 populate 方法預設情況下是指向的要關聯的集合的 _id 欄位,並且在 populate 方法裡無法更改的,但是在 Mongoose 4.5.0 之後增加了虛擬值填充[3],以便實現文件中更復雜的一些關係。
在我們本節示例中 Authors 集合會關聯 Books 集合,那麼我們就需要在 Authors 集合中定義 virtual, 下面的一些引數和 $lookup 是一樣的,個別引數做下介紹:
-
ref: 表示的要 Join 的集合的名稱,同 $lookup.from -
justOne: 預設為 false 返回多條資料,如果設定為 true 就只會返回一條資料
AuthorSchema.virtual('bookList', {
ref: 'Books',
localField: 'bookIds',
foreignField: 'bookId',
justOne: false,
});
之前在這樣設定之後,發現沒有效果,這裡還要注意一點: 虛擬值預設不會被 toJSON() 或 toObject 輸出。
如果你需要填充的虛擬值的顯示是在 JSON 序列化中輸出,就需要設定 toJSON 屬性,例如 console.log(JSON.stringify(res))。如果是直接顯示的物件,就需要設定 toObject 屬性,例如直接列印 console.log(res)。
可以在建立 Schema 時在第二個引數 options 中設定,也可以使用建立的 Schema 物件的 set 方法設定。
const AuthorSchema = new Schema({
authorId: Number,
name: String,
bookIds: [{ type: Number, ref: 'Books' }]
}, {
toJSON: { virtuals: true },
toObject: { virtuals: true },
});
// 或以下方式
// AuthorSchema.set('toObject', { virtuals: true });
// AuthorSchema.set('toJSON', { virtuals: true });
經過以上設定之後就可以使用 populate 做關聯查詢。
const { AuthorModel } = require('./model');
(async () => {
const res = await AuthorModel.findOne({ authorId: 1 })
.populate({
path: 'bookList',
select: 'bookId name -_id'
});
})();
Mongoose 的虛擬值填充,還可以對匹配的文件數量進行計數,使用如下:
// model/author.js
AuthorSchema.virtual('bookListCount', {
ref: 'Books',
localField: 'bookIds',
foreignField: 'bookId',
count: true
});
// populateTest.js
const res = await AuthorModel.findOne({ authorId: 1 }).populate('bookListCount');
console.log(res.bookListCount); // 3
總結
本文主要是介紹了在 Mongoose 關聯查詢時如何關聯一個非 _id 欄位,一種方式是直接使用 MongoDB 原生提供的 Aggregate 聚合管道的 $lookup
階段來實現,這種方式使用起來靈活,可操作的空間更大,例如通過 as 即可對欄位設定別名,還可以使用 $unwind
等關鍵字對資料做二次處理。另外一種是 Mongoose 提供的 populate 方法,這種方式寫起來,程式碼會更簡潔些,這裡需要注意如果關聯的欄位是非 _id 欄位,一定要在 Schema 中設定虛擬值填充,否則 populate 關聯時會失敗。
Github 獲取文中程式碼示例 mongoose-populate[4]
參考資料
mongoing: https://mongoing.com/docs/core/data-modeling-introduction.html#references
[2]#lookup-aggregation: https://docs.mongodb.com/v4.2/reference/operator/aggregation/lookup/index.html
[3]虛擬值填充: http://www.mongoosejs.net/docs/populate.html#populate-virtuals
[4]mongoose-populate: https://github.com/qufei1993/Examples/tree/master/code/database/mongoose-populate
敬請關注「Nodejs技術棧」微信公眾號,獲取優質文章,如需投稿可在後臺留言與我取得聯絡。

本文分享自微信公眾號 - Nodejs技術棧(NodejsRoadmap)。
如有侵權,請聯絡 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。