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

2020-08-20 10:33:48 浏览数 (1)

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

文档内嵌与引用模式

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

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

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

图片来源:mongoing[1]

引用模型示例

JSON 模型

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

  • Author
代码语言:javascript复制
{
  "bookIds":[
      26351021,
      26854244,
      27620408
  ],
  "authorId":1,
  "name":"Kyle Simpson"
}
  • Book
代码语言:javascript复制
[
  {
    "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

代码语言:javascript复制
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。

代码语言:javascript复制
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 和链接数据库。

代码语言:javascript复制
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 对象,关联查询的字段过滤可使用 别名.关联文档中的字段 进行指定。

代码语言:javascript复制
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));
})();

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

代码语言:javascript复制
[
  {
    "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 就只会返回一条数据
代码语言:javascript复制
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 方法设置。

代码语言:javascript复制
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 做关联查询。

代码语言:javascript复制
const { AuthorModel } = require('./model');
(async () => {
  const res = await AuthorModel.findOne({ authorId: 1 })
    .populate({
      path: 'bookList',
      select: 'bookId name -_id'
    });
})();

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

代码语言:javascript复制
// 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

- END -

0 人点赞