关注:一对多关系


NoSQL 数据库与 PostgreSQL、MYSQL 等 SQL 数据库不同,它们传统上是为数据关系管理而构建的,跨多个表进行索引和引用,在她的类似 JSON 的构建模式中对关系的支持很差或几乎不存在。 MongoDB 是一种流行的 NoSQL 数据库,与其他数据库一样,具有内置方法,开发人员可以利用这些方法在多个模式之间建立关系。

[SQL 与 NoSQL](https://res.cloudinary.com/practicaldev/image/fetch/s--N6IMAI9a--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://media.geeksforgeeks .org/wp-content/cdn-uploads/2019114165821/SQL-Vs-NoSQL1.png)

MongoDB 中的关系建立在 JOIN 功能和流行的 NPM 模块 Mongoose 库的基础上,开发人员可以利用它的原始功能,构建复杂的关系,更重要的是,设计高效的数据库以避免限制查询,如果它已经完成,如果使用 SQL 数据库。

[Mongoose、MongoDB、NodeJS关系图](https://res.cloudinary.com/practicaldev/image/fetch/s--0QbcuqxC--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https:// /s3.ap-south-1.amazonaws.com/afteracademy-server-uploads/mastering-mongoose-for-mongodb-and-node-js-mongoose-diagram-77560014632570f4.png)

在本教程中,我将详细介绍以下内容:

  • MongoDB 中的关系类型和对象引用类型

  • 猫鼬填充方法

  • Mongoose Virtuals


先决条件:

期望读者对 ExpressJS、Mongoose、ES6+ JS & Postman 有良好的基本掌握。

此外,以下内容应作为服务提供或在您的 PC 上本地安装和运行:

  • MongoDB或者你可以选择Atlas,MongoDB的云版。

  • 猫鼬 NPM。只需在项目文件夹的根目录下运行 [npm i mongoose ] 即可。

  • 邮递员,用于测试端点。

"npm i mongoose"

进入全屏模式 退出全屏模式

为了这篇文章的目的,我建立了一个小型的_“出版社”_项目,来引导你了解如何实现要讨论的任何方法。 Publishing House 项目假设出版商是注册用户,他们可以在其作品集中出版多本书。

  • MongoDB 作为数据库。

  • Mongoose 库,作为数据库对象文档管理器 (ODM)。

  • ExpressJS 使用 async/await ES6+ 创建我们的路由,因为我们将处理 Promise。

  • Postman 将用于测试我们的端点的响应。


Mongoose 表示使用两种主要设计模型的关系数据,在规划任何项目的数据库集合时选择要部署的模型主要取决于数据大小、数据准确性和访问频率。尽管如此,经验法则是,存储文档的大小与解决查询的速度成正比,最终与数据库的性能成正比。

两种型号如下:

  1. **Embedded Data Models [Denormalization]:**这是最不推荐的关系形式。通过将子(相关)文档直接嵌入到父(主)文档中,可以简单地对数据进行非规范化。以我们的“出版项目”为例,这意味着出版商将所有出版的书籍和相关信息直接存储在每个出版商的对象上。

在典型的一对少文档关系中,这将完美地工作,因为文档的预期大小不超过 20。但是,当处理较大大小的子文档时,这个大小会严重影响数据库性能,导致滞后,并且难以保持数据同步,最终带来糟糕的用户体验。

  1. Referenced Data Model [Normalization]: 当数据被规范化时,意味着文档被分成不同的集合,它们之间共享引用。在大多数情况下,在传递所有参数的情况下,对父文档的一次更新会更新直接引用它的子文档。本教程的其余部分将重点介绍此方法的最佳用例,以及如何以有效的方式最好地组织我们的数据库集合和文档。

集合之间的引用文档可以通过双重方法完成,如下所示:

  • 子引用: 当父文档存储对其子集合的引用时,文档被视为子引用,存储其标识符 - 在大多数情况下,id 存储在父文档上类似标识符的数组中。引用我们的“Publishing House”项目,这意味着让 Publishers 存储图书。_id 为每本创建的图书,在图书 id 数组中,在 Publisher 的 Schema 中预定义,并在需要时使用 populate 方法获取这些子文档.

在我们的项目中,请参阅下面的发布者架构:

const mongoose = require('mongoose');
const {Schema} = require('mongoose');

const publisherSchema = new Schema({
   name: String,
   location: String,
   publishedBooks: [{
      type: Schema.Types.ObjectId,
      ref: 'Book'
   }]
},
{timestamps: true});

module.exports = mongoose.model('Publisher', publisherSchema);

进入全屏模式 退出全屏模式

Publisher Schema [注意已出版书籍是一个数组]

这是我们的图书架构:

const mongoose= require('mongoose');
const {Schema} = require('mongoose');

const bookSchema = new Schema({
   name: String,
   publishYear: Number,
   author: String,
   publisher: {
      type: Schema.Types.ObjectId,
      ref: 'Publisher',
      required: true
   }
},
{timestamps: true});

module.exports = mongoose.model('Book', bookSchema);

进入全屏模式 退出全屏模式

图书架构

mongoose“填充”方法加载每个引用的子文档的详细信息,并将其与从数据库中获取的每个发布者文档一起返回。让我们看一个使用我们的项目的例子。

我们首先在下面创建一个新的发布者:

/***
 * @action ADD A NEW PUBLISHER
 * @route http://localhost:3000/addPublisher
 * @method POST
*/
app.post('/addPublisher', async (req, res) => {
   try {
      //validate req.body data before saving
      const publisher = new Publisher(req.body);
      await publisher.save();
      res.status(201).json({success:true, data: publisher });

   } catch (err) {
      res.status(400).json({success: false, message:err.message});
   }
});

进入全屏模式 退出全屏模式

创建一个新的发布者

{
    "success": true,
    "data": {
        "publishedBooks": [],
        "_id": "5f5f8ac71edcc2122cb341c7",
        "name": "Embedded Publishers",
        "location": "Lagos, Nigeria",
        "createdAt": "2020-09-14T15:22:47.183Z",
        "updatedAt": "2020-09-14T15:22:47.183Z",
        "__v": 0
    }
}

进入全屏模式 退出全屏模式

一个新的出版商

接下来,新创建的 Publisher 继续将即将发布的新书添加到它的 DB。发布者的 _id 在保存之前作为值传递给 Book 模式上的发布者键,并且在同一个请求循环中,在新书上调用 save 方法之后,从 Promise 返回的新创建的图书对象,必须作为参数传递给推送方法,在发布者的密钥上调用。这将确保 book 对象保存在 Publisher 的文档中。

这是神奇的分解:

/***
 * @action ADD A NEW BOOK
 * @route http://localhost:3000/addBook
 * @method POST
*/

app.post('/addBook', async (req, res)=>{

   /**
    * @tutorial: steps
    * 1. Authenticate publisher and get user _id.
    * 2. Assign user id from signed in publisher to publisher key.
    * 3. Call save method on Book.
   */

   try {
      //validate data as required

      const book = new Book(req.body);
      // book.publisher = publisher._id; <=== Assign user id from signed in publisher to publisher key
      await book.save();

      /**
       * @tutorial: steps
       * 1. Find the publishing house by Publisher ID.
       * 2. Call Push method on publishedBook key of Publisher.
       * 3. Pass newly created book as value.
       * 4. Call save method.
      */
      const publisher = await Publisher.findById({_id: book.publisher})
      publisher.publishedBooks.push(book);
      await publisher.save();

      //return new book object, after saving it to Publisher
      res.status(200).json({success:true, data: book })

   } catch (err) {
      res.status(400).json({success: false, message:err.message})
   }
})

进入全屏模式 退出全屏模式

一个出版商将一本新书添加到她的数据库中

这是在发布者的文档上保存子文档引用(id)的定义方式。创建成功后,查询Publisher id时返回以下内容。

PS: 下面的出版商创作了 3 本书。

{
    "publishedBooks": [
        {
            "_id": "5f5f8ced4021061030b0ab68",
            "name": "Learn to Populate virtuals Mongoose",
            "publishYear": 2019,
            "author": "Devangelist"
        },
        {
            "_id": "5f5f8d144021061030b0ab6a",
            "name": "Why GoLang gaining traction",
            "publishYear": 2020,
            "author": "John Doe"
        },
        {
            "_id": "5f5f8d3c4021061030b0ab6b",
            "name": "Developer Impostor syndrome",
            "publishYear": 2021,
            "author": "John Mark"
        }
    ],
    "_id": "5f5f8ac71edcc2122cb341c7",
    "name": "Embedded Publishers",
    "location": "Lagos, Nigeria",
    "createdAt": "2020-09-14T15:22:47.183Z",
    "updatedAt": "2020-09-14T15:33:16.449Z",
    "__v": 3
}

进入全屏模式 退出全屏模式

保存的对象返回子数组

但是,如果没有在 Publisher 的文档上调用 push 和 save 方法,则 Publisher 虽然存在,并且新书已创建,但在查询时将返回一个空的 publishedBooks 数组,如下所示。

{
    "success": true,
    "data": {
        "publishedBooks": [],
        "_id": "5f5f8ac71edcc2122cb341c7",
        "name": "Embedded Publishers",
        "location": "Lagos, Nigeria",
        "createdAt": "2020-09-14T15:22:47.183Z",
        "updatedAt": "2020-09-14T15:22:47.183Z",
        "__v": 0
    }
}

进入全屏模式 退出全屏模式

空数组,当对象没有被推送和保存时

尽管 Child Reference 方法取得了成功,但如上所示,它的局限性在于 Id 数组的大小会很快变得非常大,因此随着数组大小的增长,数据库会失去效率和性能超时。MongoDB 正式承认这是一种反模式,并强烈反对将其用于大规模运行的文档关系。


  • 父引用: 另一方面,父引用与前面描述的子引用略有不同,因为只有子文档保留对父文档的引用。此引用单独保存在创建的每个子文档上,定义为架构上的对象 ID。相反,父文档不保留直接引用,而是在称为 Virtuals 的 Mongoose 方法的帮助下构建一个。

Mongoose Virtual是一种更复杂的方法来获取引用的子文档,重要的是,它占用更少的内存用于数据存储,因为新的关键字段 Mongoose virtual 在运行查询时创建,不会在父文档。有时,Virtuals 也被称为“反向填充”,因此,当您听到人们提到这一点时,请不要担心!

说完了,让我们进入我们的项目代码。

首先,让我们看看我们的 Book Schema 如下所示:

const mongoose= require('mongoose');
const {Schema} = require('mongoose');

const bookSchema = new Schema({
   name: String,
   publishYear: Number,
   author: String,
   publisher: {
      type: Schema.Types.ObjectId,
      ref: 'Publisher',
      required: true
   }
},
{timestamps: true})

module.exports = mongoose.model('Book', bookSchema);

进入全屏模式 退出全屏模式

接下来是我们的父文档,这是棘手的部分所在。请注意 virtuals 是如何定义的,其中一个关键部分是我们必须在 Schema 上设置的额外选项,否则不会返回任何结果。这些额外的选项是 toJSONtoObject 选项。它们都默认为 false,并且是确保每当在这些选项设置为 True 时查询父文档时,将结果传递给响应调用的 .json() 方法的核心。

const mongoose = require('mongoose');
const {Schema} = require('mongoose');

const publisherSchema = new Schema({
   name: String,
   location: String
},
   {timestamps: true}
);

/**
 * @action Defined Schema Virtual
 * @keys 
 *    1.   The first parameter can be named anything.
 *          It defines the name of the key to be named on the Schema
 * 
 *    2. Options Object
 *       ref: Model name for Child collection
 *       localField: Key for reference id, stored on Child Doc, as named on Parent Doc.
 *       foreignField: Key name that holds localField value on Child Document
 */
publisherSchema.virtual('booksPublished', {
   ref: 'Book', //The Model to use
   localField: '_id', //Find in Model, where localField 
   foreignField: 'publisher', // is equal to foreignField
});

// Set Object and Json property to true. Default is set to false
publisherSchema.set('toObject', { virtuals: true });
publisherSchema.set('toJSON', { virtuals: true });


module.exports = mongoose.model('Publisher', publisherSchema);

进入全屏模式 退出全屏模式

请注意,我们在 Schema 上不再有 publishedBooks 数组


接下来是定义虚拟对象,轻松记住如何定义它的最佳方法(如果您来自 SQL 背景更容易)是;

SELECT “name for the virtual field” FROM “ref – Child collection name”, WHERE “localField – parent key 存储在 child collection 中,主要是 id” EQUALS “_foreignField – Child schema key 的 name,存储 parent id,作为它的价值。


定义了上述两个选项后,每当我们在调用 GET 方法后填充我们的 Publisher 时,我们都会保证检索每个出版商出版的所有书籍,并且为了进一步明确,因为并非需要有关书籍的所有信息,请选择所需的键从每本书中提取并在响应正文中返回。

在下面的项目中查看它是如何完成的:

/***
 * @action GET ALL PUBLISHERS
 * @route http://localhost:3000/publishers
 * @method GET
 */
app.get('/publishers', async (req, res) => {
   try {
      const data = await Publisher.find()
                                 .populate({path: 'booksPublished', select: 'name publishYear author'});
      res.status(200).json({success: true, data});
   } catch (err) {
      res.status(400).json({success: false, message:err.message});
   }
})

进入全屏模式 退出全屏模式

获取所有发布者

{
    "success": true,
    "data": [
        {
            "_id": "5f5f546e190dff51041db304",
            "name": "Random Publishers",
            "location": "Kigali, Rwanda",
            "createdAt": "2020-09-14T11:30:54.768Z",
            "updatedAt": "2020-09-14T11:30:54.768Z",
            "__v": 0,
            "booksPublished": [
                {
                    "_id": "5f5f548e190dff51041db305",
                    "name": "Mastering Mongoose with Javascript",
                    "publishYear": 2020,
                    "author": "Devangelist",
                    "publisher": "5f5f546e190dff51041db304"
                },
                {
                    "_id": "5f5f55ca190dff51041db307",
                    "name": "Learning Mongoose Populate method",
                    "publishYear": 2019,
                    "author": "Devangelist",
                    "publisher": "5f5f546e190dff51041db304"
                }
            ],
            "id": "5f5f546e190dff51041db304"
        }
}

进入全屏模式 退出全屏模式

获取所有出版商的查询结果[注意 booksPublished 数组]

总而言之,在使用归一化模型方法和处理大型数据集时,父引用是最佳的引用方法。

如果你做到了这一点,感谢你的通读,我希望你学到了一些东西-[new]。我很高兴进一步讨论新知识、机会和可能的更正。可以通过 @oluseyeo_或通过电子邮件sodevangelist@gmail.com在 Twitter 上与我联系。

快乐黑客💥💥


TL:博士;

  1. 有两种建模方法,嵌入式和引用。

  2. 仅当您的数据访问频率较低且您主要只读取数据时才嵌入。

  3. 对于较大的 IOPS,使用引用模型。

  4. 引用可以通过两种方式完成,子引用和父引用。

  5. 如果子文档尺寸较小,小于 100,请使用子参考。这使用 push 方法将子引用键直接存储在父文档上。

  6. 如果 Child 文档的大小很大,使用 parent 引用选项,使用 mongoose virtual 反向填充 Parent 文档。


推荐阅读:

数据访问模式

猫鼬文档

非规范化

Logo

MongoDB社区为您提供最前沿的新闻资讯和知识内容

更多推荐