Mongoose 实现关联查询和踩坑记录 - 图1

本文源自工作中的一个问题,在使用 Mongoose 做关联查询时发现使用 populate() 方法不能直接关联非 _id 之外的其它字段,在网上搜索时这块的解决方案也并不是很多,在经过一番查阅、测试之后,有两种可行的方案,使用 Mongoose 的 virtual 结合 populate 和 MongoDB 原生提供的 Aggregate 里面的 $lookup 阶段来实现。

文档内嵌与引用模式

MongoDB 是一种文档对象模型,使用起来很灵活,它的文档结构分为 内嵌和引用 两种类型。

内嵌是把相关联的数据保存在同一个文档内,我们可以用对象或数组的形式来存储,这样好处是我们可以在一个单一操作内完成,可以发送较少的请求到数据库服务端,但是这种内嵌类型也是一种冗余的数据模型,会造成数据的重复,如果很复杂的一对多或多对多的关系,表达起来就很复杂,也要注意内嵌还有一个最大的单条文档记录限制为 16MB。

引用模型是一种规范化的数据模型,通过主外键的方式来关联多个文档之间的引用关系,减少了数据的冗余,在使用这种数据模型中就要用到关联查询,也就是本文我们要讲解的重点。

Mongoose 实现关联查询和踩坑记录 - 图2

图片来源:mongoing

引用模型示例

JSON 模型

我们通过作者和书籍的关系,一个作者对应多个书籍这样一个简单的示例来学习如何在 MongoDB 中实现关联非 _id 查询。

  • Author
  1. {
  2. "bookIds":[
  3. 26351021,
  4. 26854244,
  5. 27620408
  6. ],
  7. "authorId":1,
  8. "name":"Kyle Simpson"
  9. }
  • Book
  1. [
  2. {
  3. "bookId":26351021,
  4. "name":"你不知道的JavaScript(上卷)",
  5. },
  6. {
  7. "bookId":26854244,
  8. "name":"你不知道的JavaScript(中卷)",
  9. },
  10. {
  11. "bookId":27620408,
  12. "name":"你不知道的JavaScript(下卷)",
  13. }
  14. ]

定义 Schema

使用 Mongoose 第一步要先定义集合的 Schema。

  • author.js

创建 model/author.js 定义作者的 Schema,代码中的 ref 表示要关联的 Model 是谁,在 Schema 定义好之后后面我会创建 Model

  1. const mongoose = require('mongoose');
  2. const Schema = mongoose.Schema;
  3. const AuthorSchema = new Schema({
  4. authorId: Number,
  5. name: String,
  6. bookIds: [{ type: Number, ref: 'Books' }]
  7. });
  8. AuthorSchema.index({ authorId: 1}, { unique: true });
  9. module.exports = AuthorSchema;
  • book.js

创建 model/book.js 定义书籍的 Schema。

  1. const mongoose = require('mongoose');
  2. const Schema = mongoose.Schema;
  3. const BookSchema = new Schema({
  4. bookId: Number,
  5. name: String,
  6. });
  7. BookSchema.index({ bookId: 1}, { unique: true });
  8. module.exports = BookSchema;
  • index.js

创建 model/index.js 定义 Model 和链接数据库。

  1. const mongoose = require('mongoose');
  2. const AuthorSchema = require('./author');
  3. const BookSchema = require('./book');
  4. const DB_URL = process.env.DB_URL;
  5. const AuthorModel = mongoose.model('Authors', AuthorSchema, 'authors');
  6. const BookModel = mongoose.model('Books', BookSchema, 'books');
  7. mongoose.set('useCreateIndex', true)
  8. mongoose.connect(DB_URL, {useNewUrlParser: true, useUnifiedTopology: true});
  9. module.exports = {
  10. AuthorModel,
  11. BookModel,
  12. }

使用 Aggregate 的 $lookup 实现关联查询

MongoDB 3.2 版本新增加了 $lookup 实现多表关联,在聚合管道阶段中使用,经过 $lookup 阶段的处理,输出的新文档中会包含一个新生成的数组列。

创建一个 aggregateTest.js 重点在于 $lookup 对象,代码如下所示:

  • $lookup.from: 在同一个数据库中指定要 Join 的集合的名称。
  • $lookup.localFiled: 关联的源集合中的字段,本示例中是 Authors 表的 authorId 字段。
  • $lookup.foreignFiled: 被 Join 的集合的字段,本示例中是 Books 表的 bookId 字段。
  • $as: 别名,关联查询返回的这个结果起一个新的名称。

如果需要指定哪些字段返回,哪些需要过滤,可定义 $project 对象,关联查询的字段过滤可使用 别名.关联文档中的字段 进行指定。

  1. const { AuthorModel } = require('./model');
  2. (async () => {
  3. const res = await AuthorModel.aggregate([
  4. {
  5. $match: { authorId: 1 }
  6. },
  7. {
  8. $lookup: {
  9. from: 'books',
  10. localField: 'bookIds',
  11. foreignField: 'bookId',
  12. as: 'bookList',
  13. }
  14. },
  15. {
  16. $project: {
  17. '_id': 0,
  18. 'authorId': 1,
  19. 'name': 1,
  20. 'bookList.bookId': 1, // 指定 books 表的 bookId 字段返回
  21. 'bookList.name': 1
  22. }
  23. }
  24. ]);
  25. console.log(JSON.stringify(res));
  26. })();

运行以上程序,将得到以下结果:

  1. [
  2. {
  3. "authorId":1,
  4. "name":"Kyle Simpson",
  5. "bookList":[
  6. {
  7. "bookId":26351021,
  8. "name":"你不知道的JavaScript(上卷)"
  9. },
  10. {
  11. "bookId":26854244,
  12. "name":"你不知道的JavaScript(中卷)"
  13. },
  14. {
  15. "bookId":27620408,
  16. "name":"你不知道的JavaScript(下卷)"
  17. }
  18. ]
  19. }
  20. ]

关于 $lookup 更多操作参考 MongoDB 官方文档 #lookup-aggregation

Mongoose Virtual 和 populate 实现

Mongoose 的 populate 方法默认情况下是指向的要关联的集合的 _id 字段,并且在 populate 方法里无法更改的,但是在 Mongoose 4.5.0 之后增加了虚拟值填充,以便实现文档中更复杂的一些关系。

在我们本节示例中 Authors 集合会关联 Books 集合,那么我们就需要在 Authors 集合中定义 virtual, 下面的一些参数和 $lookup 是一样的,个别参数做下介绍:

  • ref: 表示的要 Join 的集合的名称,同 $lookup.from
  • justOne: 默认为 false 返回多条数据,如果设置为 true 就只会返回一条数据
  1. AuthorSchema.virtual('bookList', {
  2. ref: 'Books',
  3. localField: 'bookIds',
  4. foreignField: 'bookId',
  5. justOne: false,
  6. });

之前在这样设置之后,发现没有效果,这里还要注意一点: 虚拟值默认不会被 toJSON() 或 toObject 输出。

如果你需要填充的虚拟值的显示是在 JSON 序列化中输出,就需要设置 toJSON 属性,例如 console.log(JSON.stringify(res))。如果是直接显示的对象,就需要设置 toObject 属性,例如直接打印 console.log(res)。

可以在创建 Schema 时在第二个参数 options 中设置,也可以使用创建的 Schema 对象的 set 方法设置。

  1. const AuthorSchema = new Schema({
  2. authorId: Number,
  3. name: String,
  4. bookIds: [{ type: Number, ref: 'Books' }]
  5. }, {
  6. toJSON: { virtuals: true },
  7. toObject: { virtuals: true },
  8. });
  9. // 或以下方式
  10. // AuthorSchema.set('toObject', { virtuals: true });
  11. // AuthorSchema.set('toJSON', { virtuals: true });

经过以上设置之后就可以使用 populate 做关联查询。

  1. const { AuthorModel } = require('./model');
  2. (async () => {
  3. const res = await AuthorModel.findOne({ authorId: 1 })
  4. .populate({
  5. path: 'bookList',
  6. select: 'bookId name -_id'
  7. });
  8. })();

Mongoose 的虚拟值填充,还可以对匹配的文档数量进行计数,使用如下:

  1. // model/author.js
  2. AuthorSchema.virtual('bookListCount', {
  3. ref: 'Books',
  4. localField: 'bookIds',
  5. foreignField: 'bookId',
  6. count: true
  7. });
  8. // populateTest.js
  9. const res = await AuthorModel.findOne({ authorId: 1 }).populate('bookListCount');
  10. console.log(res.bookListCount); // 3

总结

本文主要是介绍了在 Mongoose 关联查询时如何关联一个非 _id 字段,一种方式是直接使用 MongoDB 原生提供的 Aggregate 聚合管道的 $lookup 阶段来实现,这种方式使用起来灵活,可操作的空间更大,例如通过 as 即可对字段设置别名,还可以使用 $unwind 等关键字对数据做二次处理。另外一种是 Mongoose 提供的 populate 方法,这种方式写起来,代码会更简洁些,这里需要注意如果关联的字段是非 _id 字段,一定要在 Schema 中设置虚拟值填充,否则 populate 关联时会失败

Github 获取文中代码示例 mongoose-populate