集合和模式

如何在 Meteor 中定义、使用和维护 MongoDB 集合。

阅读完本指南后,您将了解

  1. Meteor 中不同类型的 MongoDB 集合,以及如何使用它们。
  2. 如何为集合定义模式以控制其内容。
  3. 在定义集合模式时需要考虑的事项。
  4. 如何在写入集合时执行模式。
  5. 如何小心地更改集合的模式。
  6. 如何处理记录之间的关联。

Meteor 中的 MongoDB 集合

在核心上,Web 应用为其用户提供了查看和修改持久数据集的方式。无论是在管理待办事项列表还是叫车接送,您都在与一个永久但不断变化的数据层交互。

在 Meteor 中,该数据层通常存储在 MongoDB 中。MongoDB 中一组相关数据称为“集合”。在 Meteor 中,您可以通过 集合 访问 MongoDB,使其成为应用数据的首要持久化机制。

但是,集合不仅仅是保存和检索数据的方式。它们还提供了用户期望从最佳应用获得的交互式、连接式用户体验的核心。Meteor 使这种用户体验易于实现。

在本文中,我们将仔细研究集合在框架中各个位置的工作方式,以及如何充分利用它们。

服务器端集合

当您在服务器上创建集合时

Todos = new Mongo.Collection('todos');

您是在 MongoDB 中创建了一个集合,以及一个用于在服务器上使用该集合的接口。它是在底层 Node MongoDB 驱动程序之上相当简单的一层,但具有同步 API

// This line won't complete until the insert is done
Todos.insert({_id: 'my-todo'});
// So this line will return something
const todo = Todos.findOne({_id: 'my-todo'});
// Look ma, no callbacks!
console.log(todo);

客户端集合

在客户端,当您编写相同的代码行时

Todos = new Mongo.Collection('todos');

它执行的是完全不同的操作!

在客户端,没有直接连接到 MongoDB 数据库,实际上也不可能(也可能不是您想要的)使用同步 API 连接到它。相反,在客户端,集合是数据库的客户端缓存。这是通过 Minimongo 库实现的——这是一个内存中的、全 JS 的 MongoDB API 实现。

// This line is changing an in-memory Minimongo data structure
Todos.insert({_id: 'my-todo'});
// And this line is querying it
const todo = Todos.findOne({_id: 'my-todo'});
// So this happens right away!
console.log(todo);

将数据从服务器(和 MongoDB 支持的)集合移动到客户端(内存中)集合的方式是 数据加载文章 的主题。一般来说,您订阅一个发布,它将数据从服务器推送到客户端。通常,您可以假设客户端包含完整 MongoDB 集合某个子集的最新副本。

要将数据写回服务器,您需要使用方法,这是 方法文章 的主题。

本地集合

在 Meteor 中使用集合的第三种方式。在客户端或服务器上,如果您以以下两种方式之一创建集合

SelectedTodos = new Mongo.Collection(null);
SelectedTodos = new Mongo.Collection('selectedtodos', {connection: null});

这将创建一个本地集合。这是一个 Minimongo 集合,没有数据库连接(通常,集合要么直接连接到服务器上的数据库,要么通过客户端上的订阅连接)。

本地集合是使用 Minimongo 库的全部功能进行内存存储的便捷方式。例如,如果您需要对数据执行复杂的查询,则可以使用它代替简单的数组。或者,您可能希望利用其在客户端的反应性来以 Meteor 中自然的方式驱动某些 UI。

定义模式

尽管 MongoDB 是一个无模式数据库,允许在数据结构方面具有最大的灵活性,但通常最好使用模式来约束集合的内容以符合已知格式。如果不这样做,那么您最终需要编写防御性代码来检查和确认数据数据库中取出时的结构,而不是在数据写入数据库时。与大多数情况一样,您读取数据的频率通常高于写入数据的频率,因此,在写入时使用模式通常更容易且更不容易出错。

在 Meteor 中,主要的模式包是 npm simpl-schema 包。它是一个表达式的、基于 MongoDB 的模式,用于插入和更新文档。另一种选择是 jagi:astronomy,它是一个完整的对象模型 (OM) 层,提供模式定义、服务器/客户端端验证器、对象方法和事件处理程序。

假设我们有一个 Lists 集合。要使用 simpl-schema 为此集合定义模式,您可以创建一个 SimpleSchema 类的新的实例并将其附加到 Lists 对象

import SimpleSchema from 'simpl-schema';

Lists.schema = new SimpleSchema({
  name: {type: String},
  incompleteCount: {type: Number, defaultValue: 0},
  userId: {type: String, regEx: SimpleSchema.RegEx.Id, optional: true}
});

Todos 应用中的此示例定义了一个具有几个简单规则的模式

  1. 我们指定列表的 name 字段是必需的,并且必须是字符串。
  2. 我们指定 incompleteCount 是一个数字,在插入时,如果未另行指定,则将其设置为 0
  3. 我们指定 userId(可选)必须是一个看起来像用户文档 ID 的字符串。

我们正在将 SimpleSchema 用于 Meteor 相关的功能(如 ID),但我们鼓励您出于安全原因为 emailname 等字段创建自定义正则表达式。有关更多信息,请查看 Simple Schema 文档

我们将模式直接附加到 Lists 的命名空间,这使我们能够在需要时(例如在表单或 方法 中)直接根据此模式检查对象。在下一节 中,我们将看到如何在写入集合时自动使用此模式。

您可以看到,通过很少的代码,我们已经成功地显着限制了列表的格式。您可以在 Simple Schema 文档 中阅读有关模式可以执行的更复杂操作的更多信息。

根据模式进行验证

现在我们有了模式,如何使用它呢?

使用模式验证文档非常简单。我们可以编写

const list = {
  name: 'My list',
  incompleteCount: 3
};

Lists.schema.validate(list);

在这种情况下,由于列表根据模式有效,因此 validate() 行将顺利运行。但是,如果我们编写

const list = {
  name: 'My list',
  incompleteCount: 3,
  madeUpField: 'this should not be here'
};

Lists.schema.validate(list);

那么 validate() 调用将抛出一个 ValidationError,其中包含有关 list 文档错误的详细信息。

ValidationError

什么是 ValidationError?它是在 Meteor 中用于指示修改集合时基于用户输入的错误的特殊错误。通常,ValidationError 上的详细信息用于使用有关哪些输入与模式不匹配的信息标记表单。在 方法文章 中,我们将详细了解它是如何工作的。

设计数据模式

现在您已经熟悉了 Simple Schema 的基本 API,值得考虑 Meteor 数据系统的一些约束条件,这些约束条件可能会影响数据模式的设计。尽管通常情况下,您可以像任何 MongoDB 数据模式一样构建 Meteor 数据模式,但有一些重要细节需要注意。

最重要的考虑因素与 DDP(Meteor 的数据加载协议)在网络上传输文档的方式有关。需要认识到的关键一点是,DDP 在顶级文档字段级别发送文档更改。这意味着,如果您在文档上具有经常更改的大型复杂子字段,则 DDP 可能会在网络上传输不必要的更改。

例如,在“纯”MongoDB 中,您可能会设计模式,以便每个列表文档都有一个名为 todos 的字段,该字段是待办事项项目的数组

Lists.schema = new SimpleSchema({
  name: {type: String},
  todos: {type: [Object]}
});

此模式的问题是,由于刚才提到的 DDP 行为,对列表中任何待办事项项目的更改都需要通过网络发送该列表的整个待办事项集。这是因为 DDP 不了解“更改名为 todos 的字段中第 3 个项目的 text 字段”。它只能“将名为 todos 的字段更改为一个全新的数组”。

反规范化和多个集合

上述含义是我们需要创建更多集合来包含子文档。在 Todos 应用的情况下,我们需要一个 Lists 集合和一个 Todos 集合来包含每个列表的待办事项项目。因此,我们需要执行一些通常与 SQL 数据库关联的操作,例如使用外键 (todo.listId) 将一个文档与另一个文档关联起来。

在 Meteor 中,执行此操作通常比在典型的 MongoDB 应用中更容易,因为很容易发布重叠的文档集(我们可能需要一组用户来呈现应用的一个屏幕,另一组用户呈现另一个屏幕),这些文档集可能会在我们在应用中四处移动时保留在客户端。因此,在这种情况下,将子文档与父文档分离是有优势的。

但是,鉴于 MongoDB 3.2 之前的版本不支持跨多个集合的查询(“联接”),我们通常最终需要将某些数据反规范化到父集合中。反规范化是在数据库中多次存储相同信息的做法(与非冗余的“正常”形式相反)。MongoDB 是一个鼓励反规范化的数据库,因此针对此实践进行了优化。

在 Todos 应用的情况下,因为我们想在每个列表旁边显示未完成的待办事项数量,所以我们需要反规范化 list.incompleteTodoCount。这虽然是一个不便之处,但通常很容易做到,正如我们将在下面关于 抽象反规范化器 的部分中看到的那样。

这种架构有时需要的另一种反规范化可以是从父文档到子文档。例如,在 Todos 中,我们通过list.userId属性强制执行待办事项列表的隐私,但我们单独发布待办事项,因此反规范化todo.userId也可能是有意义的。为此,我们需要小心地在创建待办事项时从列表中获取userId,并在列表的userId发生更改时更新所有相关的待办事项。

面向未来设计

应用程序,尤其是 Web 应用程序,很少会完成,在设计数据模式时考虑潜在的未来更改非常有用。与大多数事情一样,在您实际需要之前添加字段通常不是一个好主意(毕竟,您预期的结果通常最终不会发生)。

但是,提前考虑模式如何随时间变化是一个好主意。例如,您可能在文档上有一个字符串列表(可能是标签集)。虽然将它们作为文档的子字段(假设它们不会发生太大变化)很诱人,但如果它们将来很可能变得更复杂(也许标签稍后会有创建者或子标签?),那么从一开始就创建一个单独的集合在长远来看可能会更容易。

您在模式设计中融入的前瞻性将取决于您应用程序的个别约束,并且需要您做出判断。

写入时使用模式

虽然您可以通过多种方式在将数据发送到集合之前通过 Simple Schema 运行数据(例如,您可以在每个方法调用中检查模式),但最简单、最可靠的方法是使用aldeed:collection2包来通过模式运行每个修改器(insert/update/upsert调用)。

为此,我们使用attachSchema()

Lists.attachSchema(Lists.schema);

这意味着现在每次我们调用Lists.insert()Lists.update()Lists.upsert()时,我们的文档或修改器都会自动根据模式进行检查(根据修改器的具体情况,检查方式略有不同)。

defaultValue和数据清理

Collection2 做的一件事是在将数据发送到数据库之前“清理”数据。这包括但不限于

  1. 强制类型 - 将字符串转换为数字
  2. 删除模式中不存在的属性
  3. 根据模式定义中的defaultValue分配默认值

但是,有时在将文档插入集合之前,对文档进行更复杂的初始化会很有用。例如,在 Todos 应用程序中,我们希望将新列表的名称设置为List X,其中X是下一个可用的唯一字母。

为此,我们可以子类化Mongo.Collection并编写我们自己的insert()方法

class ListsCollection extends Mongo.Collection {
  insert(list, callback) {
    if (!list.name) {
      let nextLetter = 'A';
      list.name = `List ${nextLetter}`;

      while (!!this.findOne({name: list.name})) {
        // not going to be too smart here, can't go past Z
        nextLetter = String.fromCharCode(nextLetter.charCodeAt(0) + 1);
        list.name = `List ${nextLetter}`;
      }
    }

    // Call the original `insert` method, which will validate
    // against the schema
    return super.insert(list, callback);
  }
}

Lists = new ListsCollection('lists');

插入/更新/删除时的钩子

上述技术也可用于提供一个位置,将额外的功能“挂钩”到集合中。例如,在删除列表时,我们始终希望同时删除其所有待办事项。

我们也可以在这种情况下使用子类,覆盖remove()方法

class ListsCollection extends Mongo.Collection {
  // ...
  remove(selector, callback) {
    Package.todos.Todos.remove({listId: selector});
    return super.remove(selector, callback);
  }
}

此技术有一些缺点

  1. 当您想要多次挂钩时,修改器可能会变得非常长。
  2. 有时单个功能可能会分散在多个修改器中。
  3. 编写完全通用的钩子(涵盖所有可能的选择器和修改器)可能具有挑战性,并且对于您的应用程序而言可能没有必要(因为也许您只以一种方式调用该修改器)。

解决第 1 点和第 2 点的一种方法是将钩子集分离到它们自己的模块中,并将修改器用作以合理方式调用该模块的点。我们将在下面看到一个示例。

第 3 点通常可以通过将钩子放在调用修改器的方法中而不是钩子本身来解决。虽然这是一个不完美的折衷方案(因为如果我们将来添加另一个以这种方式调用该修改器的方法,我们需要小心),但它比编写一堆从未实际调用的代码(这保证不会工作!)或给人的印象更好你的钩子比它实际的更通用。

抽象反规范化器

反规范化可能需要在多个集合的各种修改器上发生。因此,在一个地方定义反规范化逻辑并在每个修改器中使用一行代码将其挂钩是明智的。这种方法的优点是反规范化逻辑在一个地方而不是分散在多个文件中,但您仍然可以检查每个集合的代码并完全了解每次更新时会发生什么。

在 Todos 示例应用程序中,我们构建了一个incompleteCountDenormalizer来抽象列表上未完成待办事项的计数。此代码需要在插入、更新(选中或取消选中)或删除待办事项时运行。代码如下所示

const incompleteCountDenormalizer = {
  _updateList(listId) {
    // Recalculate the correct incomplete count direct from MongoDB
    const incompleteCount = Todos.find({
      listId,
      checked: false
    }).count();

    Lists.update(listId, {$set: {incompleteCount}});
  },
  afterInsertTodo(todo) {
    this._updateList(todo.listId);
  },
  afterUpdateTodo(selector, modifier) {
    // We only support very limited operations on todos
    check(modifier, {$set: Object});

    // We can only deal with $set modifiers, but that's all we do in this app
    if (_.has(modifier.$set, 'checked')) {
      Todos.find(selector, {fields: {listId: 1}}).forEach(todo => {
        this._updateList(todo.listId);
      });
    }
  },
  // Here we need to take the list of todos being removed, selected *before* the update
  // because otherwise we can't figure out the relevant list id(s) (if the todo has been deleted)
  afterRemoveTodos(todos) {
    todos.forEach(todo => this._updateList(todo.listId));
  }
};

然后,我们可以将反规范化器连接到Todos集合的变异中,如下所示

class TodosCollection extends Mongo.Collection {
  insert(doc, callback) {
    doc.createdAt = doc.createdAt || new Date();
    const result = super.insert(doc, callback);
    incompleteCountDenormalizer.afterInsertTodo(doc);
    return result;
  }
}

请注意,我们只处理了应用程序中实际使用的修改器——我们没有处理列表上待办事项计数可能发生的所有可能方式。例如,如果您更改了待办事项的listId,则需要更改两个列表的incompleteCount。但是,由于我们的应用程序没有这样做,因此我们不会在反规范化器中处理它。

处理每个可能的 MongoDB 运算符都很难做到正确,因为 MongoDB 具有丰富的修改器语言。相反,我们专注于处理我们知道将在我们的应用程序中看到的修改器。如果这变得太棘手,那么将逻辑的钩子移动到实际进行相关修改的方法中可能是明智的(尽管您需要勤奋地确保您在所有相关位置都这样做,现在和应用程序将来发生变化时)。

对于包来说,完全抽象一些常见的反规范化技术并尝试处理所有可能的修改可能是合理的。如果您编写了这样的包,请告诉我们!

迁移到新的模式

正如我们上面讨论的那样,试图提前预测数据模式的所有未来需求是不可能的。不可避免地,随着项目的成熟,您将需要更改数据库的模式。您需要小心如何迁移到新模式,以确保您的应用程序在迁移期间和之后都能顺利运行。

编写迁移

一个用于编写迁移的有用包是percolate:migrations,它为在模式的不同版本之间切换提供了一个很好的框架。

例如,假设我们想添加一个list.todoCount字段,并确保它已为所有现有列表设置。然后我们可能会在仅限服务器的代码(例如/server/migrations.js)中编写以下内容

Migrations.add({
  version: 1,
  up() {
    Lists.find({todoCount: {$exists: false}}).forEach(list => {
      const todoCount = Todos.find({listId: list._id}).count();
      Lists.update(list._id, {$set: {todoCount}});
    });
  },
  down() {
    Lists.update({}, {$unset: {todoCount: true}}, {multi: true});
  }
});

此迁移被排序为第一个在数据库上运行的迁移,在被调用时,会将每个列表更新到当前待办事项计数。

要详细了解 Migrations 包的 API,请参阅其文档

批量更改

如果您的迁移需要更改大量数据,尤其是在需要停止应用程序服务器运行时,最好使用MongoDB 批量操作

批量操作的优点是它只需要对 MongoDB 进行一次往返写入,这通常意味着它快得多。缺点是,如果您的迁移很复杂(如果您无法执行.update(.., .., {multi: true}),通常就是这种情况),则准备批量更新可能需要大量时间。

这意味着如果用户在准备更新时访问该站点,它可能会停止服务!此外,批量更新将在应用过程中锁定整个集合,如果花费一段时间,这可能会导致用户体验出现重大波动。由于这些原因,您通常需要停止服务器并让用户知道您在更新期间正在执行维护。

我们可以这样编写上面的迁移(请注意,您必须使用 MongoDB 2.6 或更高版本才能使用批量更新操作)。我们可以通过Collection#rawCollection()访问本地 MongoDB API

Migrations.add({
  version: 1,
  up() {
    // This is how to get access to the raw MongoDB node collection that the Meteor server collection wraps
    const batch = Lists.rawCollection().initializeUnorderedBulkOp();

    //Mongo throws an error if we execute a batch operation without actual operations, e.g. when Lists was empty.
    let hasUpdates = false;
    Lists.find({todoCount: {$exists: false}}).forEach(list => {
      const todoCount = Todos.find({listId: list._id}).count();
      // We have to use pure MongoDB syntax here, thus the `{_id: X}`
      batch.find({_id: list._id}).updateOne({$set: {todoCount}});
      hasUpdates = true;
    });

    if(hasUpdates){
      // We need to wrap the async function to get a synchronous API that migrations expects
      const execute = Meteor.wrapAsync(batch.execute, batch);
      return execute();
    }

    return true;
  },
  down() {
    Lists.update({}, {$unset: {todoCount: true}}, {multi: true});
  }
});

请注意,我们可以通过使用聚合收集初始待办事项计数集来加快此迁移速度。

运行迁移

要对开发数据库运行迁移,最简单的方法是使用 Meteor shell

// After running `meteor shell` on the command line:
Migrations.migrateTo('latest');

如果迁移将任何内容记录到控制台,您将在运行 Meteor 服务器的终端窗口中看到它。

要对生产数据库运行迁移,请在生产模式下在本地运行您的应用程序(使用生产设置和环境变量,包括数据库设置),并以相同的方式使用 Meteor shell。这样做会对您的生产数据库运行所有未完成迁移的up()函数。在我们的例子中,它应该确保所有列表都设置了todoCount字段。

执行上述操作的一种好方法是在靠近数据库的虚拟机上启动并安装 Meteor,并具有 SSH 访问权限(您出于此目的启动和停止的特殊 EC2 实例是一个合理的选择),并在登录后运行命令。这样,您机器和数据库之间的任何延迟都将消除,但您仍然可以非常小心地运行迁移。

请注意,您应该始终在运行任何迁移之前备份数据库!

破坏性模式更改

有时当我们更改应用程序的模式时,我们会以破坏性的方式进行——以便旧模式无法与新的代码库正常工作。例如,如果我们有一些 UI 代码严重依赖于所有列表都设置了todoCount,那么在迁移运行之前,在部署之后,我们的应用程序的 UI 将会有一段时间处于损坏状态。

解决此问题的简单方法是在部署和完成迁移之间的一段时间内停止应用程序。这远非理想,特别是考虑到一些迁移可能需要几个小时才能运行(尽管使用批量更新可能在这里有很大帮助)。

更好的方法是多阶段部署。基本思想是

  1. 部署可以处理旧模式和新模式的应用程序版本。在我们的例子中,它将是不会期望todoCount存在的代码,但在创建新待办事项时会正确更新它。
  2. 运行迁移。此时,您应该确信所有列表都具有todoCount
  3. 部署依赖于新模式并且不再知道如何处理旧模式的新代码。现在我们可以安全地在我们的 UI 中依赖list.todoCount

尤其是在多阶段部署中,需要注意的一点是,做好回滚准备非常重要!为此,迁移包允许您指定一个down()函数并调用Migrations.migrateTo(x)将数据库回滚到版本x

因此,如果我们想反转上面的迁移,我们会运行

// The "0" migration is the unmigrated (before the first migration) state
Migrations.migrateTo(0);

如果您发现需要回滚代码版本,则需要小心处理数据,并以相反的顺序仔细执行部署步骤。

注意事项

上面概述的迁移策略的某些方面可能不是最理想的方法(尽管在许多情况下可能适用)。以下是一些其他需要注意的事项

  1. 通常最好不要在迁移中依赖应用程序代码(因为应用程序会随着时间的推移而发生变化,而迁移不应该)。例如,让您的迁移通过您的Collection2集合(从而检查模式、设置自动值等)可能会随着时间的推移而导致它们失效,因为您的模式会随着时间的推移而发生变化。

    避免此问题的一种方法是在数据库上不运行旧的迁移。这有点限制性,但可以实现。

  2. 在本地机器上运行迁移可能会导致迁移时间更长,因为您的机器不像在生产数据库上那样靠近数据库。

将一个特殊的“迁移应用程序”部署到与您的真实应用程序相同的硬件上可能是解决上述问题的最佳方法。如果这样的应用程序能够跟踪哪些迁移在何时运行,并提供日志和 UI 来检查和运行它们,那将非常棒。也许可以构建一个用于此目的的样板应用程序(如果您这样做,请告诉我们,我们会在此处链接到它!)。

集合之间的关联

正如我们之前讨论的那样,在 Meteor 应用程序中,不同集合中的文档之间存在关联非常常见。因此,在获得您感兴趣的文档后(例如,单个列表中的所有待办事项),需要编写查询来获取相关文档也很常见。

为了简化此操作,我们可以将函数附加到属于给定集合的文档的原型上,以便在文档上提供“方法”(面向对象意义上的)。然后,我们可以使用这些方法创建新的查询来查找相关文档。

为了使事情变得更容易,我们可以使用cultofcoders:grapher包来关联集合并获取其关系。例如

// Configure how collections relate to each other
Todos.addLinks({
  list: {
    type: 'one',
    field: 'listId',
    collection: Lists
  }
});

Lists.addLinks({
  todos: {
    collection: Todos,
    inversedBy: 'list' // This represents the name of the link we defined in Todos
  }
});

这允许我们正确地获取列表及其所有待办事项

// With Grapher you must always specify the fields you want
const listsAndTodos = Lists.createQuery({
  name: 1,
  todos: {
      text: 1
  }
}).fetch();

listsAndTodos将如下所示

[
  {
    name: 'My List',
    todos: [
      {text: 'Do something'}
    ],
  }
]

Grapher 支持同构查询(反应式和非反应式),具有内置的安全功能,可与多种类型的关系一起使用,等等。有关更多详细信息,请参阅Grapher 文档

集合助手

我们可以使用dburles:collection-helpers包轻松地将此类方法(或“助手”)附加到文档。例如

Lists.helpers({
  // A list is considered to be private if it has a userId set
  isPrivate() {
    return !!this.userId;
  }
});

一旦我们将此助手附加到Lists集合,每次我们从数据库(客户端或服务器)获取列表时,它都将具有一个可用的.isPrivate()函数。

const list = Lists.findOne();
if (list.isPrivate()) {
  console.log('The first list is private!');
}

关联助手

现在我们可以将助手附加到文档,我们可以定义一个获取相关文档的助手。

Lists.helpers({
  todos() {
    return Todos.find({listId: this._id}, {sort: {createdAt: -1}});
  }
});

现在我们可以找到列表的所有待办事项

const list = Lists.findOne();
console.log(`The first list has ${list.todos().count()} todos`);
在 GitHub 上编辑
// 搜索框