本文源自工作中的一个问题,在使用 Mongoose 做关联查询时发现使用 populate() 方法不能直接关联非 _id 之外的其它字段,在网上搜索时这块的解决方案也并不是很多,在经过一番查阅、测试之后,有两种可行的方案,使用 Mongoose 的 virtual 结合 populate 和 MongoDB 原生提供的 Aggregate 里面的 $lookup 阶段来实现。
文档内嵌与引用模式
MongoDB 是一种文档对象模型,使用起来很灵活,它的文档结构分为 内嵌和引用 两种类型。
内嵌是把相关联的数据保存在同一个文档内,我们可以用对象或数组的形式来存储 ,这样好处是我们可以在一个单一操作内完成,可以发送较少的请求到数据库服务端,但是这种内嵌类型也是一种冗余的数据模型,会造成数据的重复,如果很复杂的一对多或多对多的关系,表达起来就很复杂,也要注意内嵌还有一个最大的单条文档记录限制为 16MB。
引用模型是一种规范化的数据模型 ,通过主外键的方式来关联多个文档之间的引用关系,减少了数据的冗余,在使用这种数据模型中就要用到关联查询,也就是本文我们要讲解的重点。
图片来源:mongoing [1]
引用模型示例
JSON 模型
我们通过作者和书籍的关系,一个作者对应多个书籍这样一个简单的示例来学习如何在 MongoDB 中实现关联非 _id 查询。
{ "bookIds" :[ 26351021 , 26854244 , 27620408 ], "authorId" :1 , "name" :"Kyle Simpson" }
[ { "bookId" :26351021 , "name" :"你不知道的JavaScript(上卷)" , }, { "bookId" :26854244 , "name" :"你不知道的JavaScript(中卷)" , }, { "bookId" :27620408 , "name" :"你不知道的JavaScript(下卷)" , } ]
定义 Schema
使用 Mongoose 第一步要先定义集合的 Schema。
创建 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;
创建 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;
创建 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]
参考资料
[1] 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