学习Mongoose的关系教程

313 阅读7分钟

像MongoDB这样的NoSQL数据库与MySQL、Oracle、Microsoft SQL等老牌关系型数据库的工作方式不同。传统意义上的关系在MongoDB中并不像在MySQL中那样真正存在。在本教程中,我们将看看你如何处理相关数据,尽管MongoDB没有明确强制执行。我们将看看基于引用的关系(规范化)以及嵌入式文档关系(反规范化)。


基于引用的关系(规范化)

在这种方法中,假设我们有两个集合。一个是出版商的,另一个是游戏的。因此,首先我们会有一个像这样的出版商对象。

let publisher = {
    companyName: 'Nintendo'
}

然后,我们将有另一个集合来代表一个游戏。所以在这里的对象中,我们有一个游戏,它引用了一个出版商文件的ID。

let game = {
    publisher: 'id'
}

这就是引用的方法。它感觉类似于在关系型数据库中可能做的事情,但有一个区别。在MongoDB中,这种关系是*不*强制执行的--不像关系型数据库那样强制执行跨关系的数据完整性。即使游戏文档通过一个id引用到出版商文档,在MongoDB中,这两个文档之间没有实际的关系。


嵌入文档的关系(反规范化)

另一种处理关系的方法是将一个相关的文档嵌入到另一个文档中。例如,我们可以将一个出版商文档嵌入到一个游戏文档里面。

let game = {
    publisher: {
        companyName: 'Nintendo'
    }
}

那么你应该使用哪种方法呢?嗯,规范化确实是一种关系数据库类型的方法。如果你要严格关注规范化,关系型数据库可能是更好的选择。MongoDB不支持服务器端的外键关系,规范化通常不被鼓励。如果可能的话,更常见的做法是将子对象嵌入到父对象中,因为这可以提高性能,并使外键成为不必要的。


正常化 -> 更好的一致性

  • 需要额外的查询
  • 提供一致性

去规范化 -> 更好的性能

  • 可以对相关文件使用一个查询
  • 一致性会随着时间的推移而降低

你也可以在你的应用程序中使用这两种方法的组合,但一般来说,如果可能的话,你会把一个子对象嵌入到一个父对象中。


在另一个文档中引用一个文档

我们将从下面这段代码开始,让事情开始。

const mongoose = require('mongoose');

mongoose.connect('mongodb://localhost/mongo-games')
    .then(() => console.log('Now connected to MongoDB!'))
    .catch(err => console.error('Something went wrong', err));

const Publisher = mongoose.model('Publisher', new mongoose.Schema({
    companyName: String,
    firstParty: Boolean,
    website: String
}));

const Game = mongoose.model('Game', new mongoose.Schema({
    title: String,
}));

async function createPublisher(companyName, firstParty, website) {
    const publisher = new Publisher({
        companyName,
        firstParty,
        website
    });

    const result = await publisher.save();
    console.log(result);
}

async function createGame(title, publisher) {
    const game = new Game({
        title,
        publisher
    });

    const result = await game.save();
    console.log(result);
}

async function listGames() {
    const games = await Game
        .find()
        .select('title');
    console.log(games);
}

createPublisher('Nintendo', true, 'https://www.nintendo.com/');

所以首先在这里,我们在数据库中创建一个发布者。出版商是任天堂,他们是第一方出版商是真的,网站地址也是提供的。还要注意的是,一旦出版商被插入数据库,我们就为该文件提供了一个唯一的ID。5b2bdc233e939402b41d90bf

mongo-crud $node index.js
Now connected to MongoDB!
{ _id: 5b2bdc233e939402b41d90bf,
  companyName: 'Nintendo',  firstParty: true,
  website: 'https://www.nintendo.com/',
  __v: 0 }

现在我们有了这个特定发行商的唯一ID,我们可以在指定发行商的同时向数据库中插入一个新游戏,在这里使用5b2bdc233e939402b41d90bf的唯一ID作为第二个参数。

createGame('Super Smash Bros', '5b2bdc233e939402b41d90bf')

现在我们的目标是在数据库中创建一个新的游戏,这个游戏与一个出版商有关。看起来我们在这里得到的只是游戏标题的输出,出版商似乎没有。

mongo-crud $node index.js
Now connected to MongoDB!
{ _id: 5b2bdca63c61aa362c25dc7b,
  title: 'Super Smash Bros',  __v: 0 }

我们可以通过修改我们的游戏模型来解决这个问题,包括一个发行商,其类型设置为mongoose.Schema.Types.ObjectId,像这样。

const Game = mongoose.model('Game', new mongoose.Schema({
    title: String,
    publisher: {
        type: mongoose.Schema.Types.ObjectId,
        ref: 'Publisher'
    }
}));

现在,当我们在数据库中插入一个游戏时,我们看到标题和发行商都被显示出来。

mongo-crud $node index.js
Now connected to MongoDB!
{ _id: 5b2bdd5fd056be34c08986c2,
  title: 'Super Smash Bros',  publisher: 5b2bdc233e939402b41d90bf,
  __v: 0 }

如果我们在Compass中检查它,我们也会看到这个。
games in mongodb

现在我们想查询数据库,看看我们的游戏。这里的查询说要找到所有的游戏并选择它们的标题。


async function listGames() {
    const games = await Game
        .find()
        .select('title');
    console.log(games);
}

listGames();

我们看到我们已经插入的两个游戏。

mongo-crud $node index.js
Now connected to MongoDB!
[ { _id: 5b2bdca63c61aa362c25dc7b, title: 'Super Smash Bros' },  { _id: 5b2bdd5fd056be34c08986c2, title: 'Super Smash Bros' } ]

我们还可以选择出版商,只需在查询的选择部分包括它。

async function listGames() {
    const games = await Game
        .find()
        .select('title publisher');
    console.log(games);
}

listGames();

现在,当运行该程序时,我们看到第一个游戏没有发行商,而第二个游戏由于我们上面的代码更新而与一个发行商相关联。请注意,发行商只是5b2bdc233e939402b41d90bf 的唯一ID。

mongo-crud $node index.js
Now connected to MongoDB!
[ { _id: 5b2bdca63c61aa362c25dc7b, title: 'Super Smash Bros' },
  { _id: 5b2bdd5fd056be34c08986c2,    title: 'Super Smash Bros',
    publisher: 5b2bdc233e939402b41d90bf } ]

所以,最好能看到发行商的实际数据,而不是仅仅是识别性的id。我们可以用populate()方法做到这一点。

async function listGames() {
    const games = await Game
        .find()
        .populate('publisher')
        .select('title publisher');
    console.log(games);
}

listGames();

啊哈!现在来看看我们得到的数据吧。

mongo-crud $node index.js
Now connected to MongoDB!
[ { _id: 5b2bdca63c61aa362c25dc7b, title: 'Super Smash Bros' },
  { _id: 5b2bdd5fd056be34c08986c2,    title: 'Super Smash Bros',
    publisher:
     { _id: 5b2bdc233e939402b41d90bf,       companyName: 'Nintendo',
       firstParty: true,
       website: 'https://www.nintendo.com/',       __v: 0 } } ]

所以,假设你只想看到出版商的公司名称,而不想看到出版商的所有其他相关数据。很好,只要像这样更新你的populate()调用。

async function listGames() {
    const games = await Game
        .find()
        .populate('publisher', 'companyName')
        .select('title publisher');
    console.log(games);
}

listGames();

现在我们得到了我们想要的东西。

mongo-crud $node index.js
Now connected to MongoDB!
[ { _id: 5b2bdca63c61aa362c25dc7b, title: 'Super Smash Bros' },
  { _id: 5b2bdd5fd056be34c08986c2,    title: 'Super Smash Bros',
    publisher: { _id: 5b2bdc233e939402b41d90bf, companyName: 'Nintendo' } } ]

为了把事情做得更干净一些,让我们在查询中加入-_id ,把_id从输出中删除。

async function listGames() {
    const games = await Game
        .find()
        .populate('publisher', 'companyName -_id')
        .select('title publisher');
    console.log(games);
}

listGames();

很好!看起来它工作得很好。

mongo-crud $node index.js
Now connected to MongoDB!
[ { _id: 5b2bdca63c61aa362c25dc7b, title: 'Super Smash Bros' },
  { _id: 5b2bdd5fd056be34c08986c2,    title: 'Super Smash Bros',
    publisher: { companyName: 'Nintendo' } } ]


在MongoDB中嵌入文档

现在我们想看看如何将一个文档嵌入到另一个文档中,而不是使用引用方法。在上面的章节中,我们有一个游戏文档,它引用了一个单独的出版商文档。现在,我们要改变代码,以便当游戏文档被保存时,我们也将同时嵌入一个出版商文档。你可以在下面看到,我们现在是在gameSchema中嵌入了publisherSchema。

// Publisher Schema
const publisherSchema = new mongoose.Schema({
    companyName: String,
    firstParty: Boolean,
    website: String
})

// Publisher Model
const Publisher = mongoose.model('Publisher', publisherSchema);

// Game Schema
const gameSchema = new mongoose.Schema({
    title: String,
    publisher: publisherSchema
})

// Game Model
const Game = mongoose.model('Game', gameSchema);

很好!让我们创建一个新的游戏,并将其嵌入到游戏中。让我们创建一个新的游戏,并一次性嵌入一个出版商。

async function createGame(title, publisher) {
    const game = new Game({
        title,
        publisher
    });

    const result = await game.save();
    console.log(result);
}

createGame('Rayman', new Publisher({ companyName: 'Ubisoft', firstParty: false, website: 'https://www.ubisoft.com/' }))

啊哈!注意,Publisher是一个对象,它包含Publisher的所有属性。

mongo-crud $node index.jsNow connected to MongoDB!
{ _id: 5b2bf100e588f40958a9b6e7,
  title: 'Rayman',  publisher:
   { _id: 5b2bf100e588f40958a9b6e6,
     companyName: 'Ubisoft',     firstParty: false,
     website: 'https://www.ubisoft.com/' },
  __v: 0 }

我们可以清楚地看到,在Compass中的Game文档内嵌入了Publisher文档。这些被称为嵌入式或 "子 "文档。
mongodb embedded document

因此,假设你想更新发布者,但它被嵌入到一个游戏中。我们怎样才能做到这一点呢?你必须先找到这个游戏,然后在游戏中更新发布者。最后,你要保存游戏。

async function updatePublisher(gameId) {
    const game = await Game.findById(gameId);
    game.publisher.companyName = 'Epic Games';
    game.publisher.website = 'https://epicgames.com/';
    game.save();
}

updatePublisher('5b2bf100e588f40958a9b6e7');

在终端运行该函数。

mongo-crud $node index.js

如果我们在Compass中查看该文件,我们可以看到它现在已经被成功更新了。
update embedded document mongodb

也可以直接更新一个子文件。下面是如何做到这一点的。

async function updatePublisher(gameId) {
    const game = await Game.update({ _id: gameId }, {
        $set: {
            'publisher.companyName': 'Bethesda Softworks',
            'publisher.website': 'https://bethesda.net/'
        }
    });
}

updatePublisher('5b2bf100e588f40958a9b6e7');

在终端运行该函数。

mongo-crud $node index.js

再一次,Compass显示我们成功地在数据库中直接更新了文档。
update sub document in mongodb


用unset删除一个子文档

要删除一个子文档,你可以像这样使用unset。

async function updatePublisher(gameId) {
    const game = await Game.update({ _id: gameId }, {
        $unset: {
            'publisher': ''
        }
    });
}

updatePublisher('5b2bf100e588f40958a9b6e7');

在终端运行该函数。

mongo-crud $node index.js

当然,这个子文档现在在Compass中已经消失了。
remove sub document with unset mongodb

这里有一些关于子文档的事情需要记住。

  • 你可以对子文档执行验证。
  • 你只能在父文档的上下文中保存一个子文档。
  • 你不能单独保存一个子文档。

Mongoose关系教程摘要

  • 为了建立连接数据之间的关系,你可以引用一个文档或将其作为一个子文档嵌入另一个文档中。

  • 引用一个文档并不像关系数据库那样在这两个文档之间建立一个 "真正的 "关系。

  • 引用文档也被称为规范化。它有利于数据的一致性,但会在你的系统中产生更多的查询。

  • 嵌入文档也被称为反规范化。这种方法的好处是只需一次查询就能得到你所需要的关于一个文档和它的子文档的所有数据。因此,这种方法是非常快的。缺点是,数据在数据库中可能不会保持一致。

  • ObjectIDs是由MongoDB驱动生成的,用于唯一地识别每个文档。ObjectIDs由12个字节组成

  • 4个字节:时间戳

  • 3个字节:机器标识符

  • 2个字节:进程标识符

  • 3个字节:计数器

要在Mongoose中引用一个文档,你可以像这样使用mongoose.Schema.Types.ObjectId。

const gameSchema = new mongoose.Schema({
    publisher: {
        type: mongoose.Schema.Types.ObjectId,
        ref: 'Publisher'
    }
})

对于NoSQL数据库来说,更常见的方法是像这样嵌入一个子文档。

const gameSchema = new mongoose.Schema({
    publisher: {
        type: new mongoose.Schema({
            companyName: String,
        })
    }
})

嵌入的文档没有一个保存方法。它们只能通过它们的父类来保存。

const game = await Game.findById(gameId);
game.publisher.companyName = 'New Company Name';
game.save();