集合与架构
通过阅读本章,你可以了解到:
- 在流星中不同类型的MongoDB集合和如何使用它们。
- 如何为某个集合定义架构以控制它的内容。
- 当定义你的集合架构时,应该考虑什么。
- 当对一个集合进行写入时,如何强制写入数据符合它的架构。
- 如何小心地改变你的集合架构。
- 如何应对数据记录的关联性。
流星中的MongoDB集合
对于web应用的核心而言,是其用户提供视图,修改和持久化一组数据的方式。无论是管理todos的列表,或者是为你所挑选的车下订单,你都是在和一个固定的,但又持续变化的数据层在打交道。
在流星中,这个数据层通常会存储在MongoDB中。在MongoDB中一组相关联的数据是指某个 “集合”。在流星中,是通过 集合 来存取MongoDB中的数据,这还可以作为应用的主要持久化机制。
集合有很多方法来保存和获取数据。它们也提供核心的交互,连接用户体验,就是用户期待从最好的应用那里获得的那种。流星可以让这种用户体验很容易就得到实现。
在本文中,我们将紧密观察集合在该框架的不同位置上是如何工作的,并且,如何获得其中的大多数(实现的经验?)。
服务器端集合
当你在服务器端创建了某个集合:
Todos = new Mongo.Collection('Todos');
同时也在MongoDB中创建了一个集合和一个对于该集合如何在服务器端被使用的接口。这是一个相当明了的、构建在Node MongoDB驱动之上的层,只不过是同步API:
// 这行直到插入结束才会完成(译注:同步的区别Node JS中的异步)
Todos.insert({_id: 'my-todo'});
// 所以这行会返回些什么
const todo = Todos.findOne({_id: 'my-todo'});
// 看,没有回调!
console.log(todo);
客户端的集合
在客户端,当你写上相同的这一行时:
Todos = new Mongo.Collection('Todos');
它却做了完全不同的事情!
在客户端,并没有直接连接 MongoDB数据库,事实上,对于它的同步API也是不可能的(或者说,可能不是你想要的)。因此在客户端,某个集合是数据库在某个客户端的 缓存。达成这样的效果要感谢 Minimongo 库 — 一种内存型,全JS的 MongoDB API实现。这意味着,在客户端当你进行写入时:
// 这行改变了内存中Minimongo数据结构
Todos.insert({_id: 'my-todo'});
// 这行是进行查询
const todo = Todos.findOne({_id: 'my-todo'});
// 这里就立刻发生了!
console.log(todo);
从服务器端集合(由 MongoDB 所支持的)移动数据到客户端集合(内存中)的方式是 数据装载一章 的主题。通常而言,从某个 发布 来 订阅,将会从服务器端 推送 数据到客户端。通常能够假设客户端具有服务器端MongoDB集合的 全部副本的、最新数据的某些子集(译注:可以将客户端的内存中的数据想象为服务器端的数据的一部分子集)。
要将数据写回服务器,可以使用某个 Method,这是 Methods一文的主题。
本地集合
在流星中,还有第三种方法用来使用集合。在客户端或服务器端,如果创建某个集合时传递的是 null 而不是一个名字,那么:
SelectedTodos = new Mongo.Collection(null);
就会创建一个 本地集合。这是一个Minimongo集合,并不具有数据库连接(通常,有名字的集合要不就是直接连接到服务器端的数据库(服务器端),或者是通过订阅连接到服务器的数据库(客户端))。
一个本地集合是为了在内存中进行存储(数据),以方便使用Minimongo库的全部威力。举个例子,如果你需要对数据进行复杂查询,可以考虑使用它来替代一个简单的数据结构。或者可以考虑在客户端利用它的 响应性 来驱动某些用户界面,在流星中,这会让人感觉更自然。
定义架构
虽然MongoDB是一个无架构数据库,这样虽然允许最大限度数据结构的自由,但是,使用架构来约束集合数据内容以符合某个已知的格式通常却是较好的实践。如果不这么做,那么就需要去写防御式代码,用来检查和确认你的数据结构是否和其从 数据库 取出来的 一致,而不是在 访问 数据库时那么(检查和确认)做。 大多数时候,试图 读取的数据会多于要写入的数据,所以通常(使用架构)显得更容易,并且当写入时会产生更少的缺陷(bug)。
在流星中,名声显赫的架构包是 aldeed:simple-schema。这是一个生动的、基于 MongoDB 架构的用来插入和更新文档的包。
要使用 simple-schema 来定义架构,只需简单地创建一个新的、 SimpleSchema 类的实例:
Lists.schema = new SimpleSchema({
name: {type: String},
incompleteCount: {type: Number, defaultValue: 0},
userId: {type: String, regEx: SimpleSchema.RegEx.Id, optional: true}
});
本例是从Todos应用中而来,定义一个具有一些简单规则的架构:
- 我们指定某个列表的
name字段是必须的,而且必须是个字符串。 - 我们指定
incompleteCount是一个数字,而且在插入时,如果没有指定别的值的时候,插入0。 - 我们指定
userId是可选的,必须是字符串且符合用户文档的ID格式。
我们可以直接附加该架构到 Lists 命名空间,这可以使我们无论何时想要检测对象的架构成为可能,例如在某个表单或者 Methods中(我们要检测对象的架构)。在 下面 中我们将会看到在写入到集合时如何自动地使用架构。
可以看到,通过很少的代码,我们已经可以极大限制某个 Lists (集合)的格式。你可以在 Simple Schema 文档 中阅读更多复杂的、通过架构进行的实现。
通过架构进行验证
现在我们有了一个架构,接着该怎么使用它呢?
要通过某个架构去验证某个文档是很容易的,我们可以这么写:
const list = {
name: 'My list',
incompleteCount: 3
};
Lists.schema.validate(list);
本例中,因为Lists是通过了架构验证的,validate() 这行运行起来没有任何问题。但如果我们这么写:
const list = {
name: 'My list',
incompleteCount: 3,
madeUpField: 'this should not be here'
};
Lists.schema.validate(list);
那么 validate() 的调用就会抛出一个 ValidationError ,其中包含关于 list 文档出错的详细信息。
ValidationError
什么是 ValidationError?这是一个特殊的错误,被用来在流星中指代 在修改集合时,基于错误的某个用户输入。典型的,某个 ValidationError 的细节被用来标记出某个表单,具有输入不匹配架构的信息。在 Methods 一文 中,我们将会看到更多关于它是如何工作的(信息)。
设计你的数据架构
现在你已经熟悉了 Simple Schema 的基本API,那么值得考虑一些关于流星数据系统的约束,这会影响到你的数据架构的设计。虽然通常而言,你能够构建一个流星的数据架构和任何(普通)的MongoDB数据架构非常相似,但请记住一些重要的细节。
最重要的考量是关于DDP,流星的数据装载协议,通过互联与文档进行交互。关键是能够意识到DDP发送的 文档变化 是位于 文档 顶层的 字段。这就是说,如果在 文档 上有巨大而且复杂的子字段,而且经常改变,DDP会通过互联发送不必要的变化(到客户端)。
例如,在 “纯的” MongoDB中你也许会设计这样的架构,以便于每个列表文档都具有一个叫做 todos 的字段,其中存储着 todo项 的一个数组。
Lists.schema = new SimpleSchema({
name: {type: String},
todos: {type: [Object]}
});
这个架构的问题在于由于刚提到过的DDP行为,每次对于某个列表中 任意 的 todo项 发生变化,就会通过网络,发送该列表中 整个 一组 todos。这是因为DDP对于在被称为 todos 第三项中的 text 字段的 “变化“ 没有任何的概念,因此它简单地认为 todos 字段进行了一个完全的数组改变。
冗余化和多集合
标题预示我们需要创建多集合来包含子文档。在 Todos应用 示例中,我们需要一个 Lists 集合与一个 Todos 集合用来包含每个列表的todo项。因此我们需要去做一些典型的、类似SQL数据库事情,就好像使用外键(todo.listId)来关联某个 文档 一样。
在流星中,这种实现会比典型的MongoDB应用设计要少些问题,由于发布重叠的文档集(我们也许需要一组用户数据在应用中渲染为一个屏幕,而为另一组有交集的用户数据渲染另一个屏幕)是很容易做到的,这些数据可能留在客户端用来驱动我们应用。所以,在这样的场景里,将子文档从父文档中分离出来是有利的。
然而,对于给定MongoDB早于3.2版本而言,并不支持通过多集合的查询(join),相应地,我们需要冗余化数据到父级集合中去。冗余化是指在数据库中存储多次相同的信息的实践(作为与 “正常” 非冗余的形式的反例)。MongoDB是一种鼓励冗余的数据库,并且它为此进行过优化。
在 Todos应用 的例子中,因为我们想要在每个列表的旁边显示没有完成的 todos,我们就需要冗余化 list.incompleteTodoCount。这不太方便,但却是典型的、有理由的、容易做到的方法,我们可以在以下章节 抽象冗余器 中进行进一步的讨论。
另一个冗余化的做法是,数据架构有时需要能够从父级文档衍生到子级文档。例如,在 Todos 中,由于我们强制了todo列表的隐私,这是通过 list.userId 标签来达成的,但是由于我们分开发布todos,所以冗余 todo.userId 是有道理的。要完成这个,当创建todo的时候,我们需要从 list 中获得 userId ,并且无论何时某个列表的 userId 发生变化时,更新所有相关的 todos。
为将来而设计
一个应用,尤其是一个web应用,很少是完全完成的,当设计你的数据架构时,考虑潜在的、将来的变化是很有用的。因此,在多数情况下,在确实需要使用这些字段(毕竟,经常发生的状况是你所预期的并没有实际发生)之前就添加它们,显然并不是个好主意。
但是,提前考虑架构会如何随着时间而变化却是个好主意。例如,你可能在某个文档上有一组字符串字段(也许是一组tag)。虽然可以尝试让它们作为文档上的字段(假设它们并不会有太多的变化)是可以接受的,将来,它们一有机会就会变得相当的复杂(也许tag会有个创建者,或者子tag什么的),所以,一开始就将它们分到单独的集合中要比在运行很长的一段时间后再改要容易些。
要将这些前瞻性融入到你的架构设计中,将会依赖于应用的个性化约束,并且会需要你自己作出决定。
(译注:根据应用的对象属性和对象之间的关系来进行考虑这些前瞻性一种比较有效的方法)
写入时使用架构
虽然有不同的方法可以让你通过某个Simple Schema运行数据架构检测,在发送数据到你的集合之前(例如,可以在每个方法调用中检测架构),但是最简单和最可靠的方法就是使用 aldeed:collection2 包,通过该架构来运行每个 变型器(insert/update/upsert 的调用)。
要这么做,我们使用 attachSchema():
Lists.attachSchema(Lists.schema);
就是说从现在开始,每次我们调用 Lists.insert(), Lists.update(), Lists.upsert(), 文档或修饰器会自动根据该架构(这种不易察觉的不同方式依赖于实际实现的那个变型器)进行检测。
defaultValue 和数据清理
Collection2所做的一件事情是 “清理” 该数据 ,在数据发送到数据库之前。这包括但不限于:
- 修正类型 - 转换字符串到数字
- 移除不在该架构中的属性
- 根据架构定义中的
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 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(list, callback);
}
}
Lists = new ListsCollection('Lists');
在 insert/update/remove 上进行挂接
上述的技术也可以被用来在集合中提供一个位置以用来 “挂接” 额外功能。例如,当移除某个列表时,我们 一直 想要同时移除它的所有的todos。
我们也可以在这种情况下使用子类,覆写 remove() 方法:
class ListsCollection extends Mongo.Collection {
// ...
remove(selector, callback) {
Package.todos.Todos.remove({listId: selector});
return super(selector, callback);
}
}
该技术有些不好之处:
- 变型器(的代码)会变得非常长,当你想要挂接多次时。
- 有时,单一功能的片段会被传递到多个变形器上。
- 用完整地通用方式(穷尽每个可能的选择器和修改器)来完成挂接是很具有挑战性的一件事情,并且这么做对于你的应用也不是完全必要(因为也许你只是想要在每次调用变形器时都是用同样的方式)的。
有方法可以应对1和2,就是把一组挂接分离到它们自己的模块中,而且简单地使用变形器作为一个点来调用出那个模块,这是一种明智的方式。下面我们会看到一个例子。
第3点通常可以通过放置在调用该变形器中的方法中放置挂接,而不是放置在挂接的自身来获得解决。虽然这是一种有缺陷的妥协(因为我们会要很小心,如果将来要添加另一个方法来调用该变形器的话),但也好过写一堆从未被实际调用的代码(保证不会工作!)要来得强,或者说,给予一种你的挂接变得更通用(它实际上也是)的印象。
抽象化冗余器
冗余可能会发生在不同的几个集合的变形器上。因此,在一个地方定义该冗余,并用一行代码将它挂接到每个变形器中,是明智的选择。这种方法的好处是冗余逻辑集中在一个地方,而不是散落在很多文件中,但你仍然能够对每个集合进行代码检查以完全理解每个更新究竟发生了什么。
在 Todos示例应用 中,我们建立了一个 incompleteCountDenormalizer 来抽象列表中的未完成的 todos 的数量。这段代码需要在每个todo项被插入,更新(勾选或去除勾选),以及移除时进行运行。这段代码如下:
const incompleteCountDenormalizer = {
_updateList(listId) {
// 从 MongoDB 中直接重新计算正确的未完成的数量
const incompleteCount = Todos.find({
listId,
checked: false
}).count();
Lists.update(listId, {$set: {incompleteCount}});
},
afterInsertTodo(todo) {
this._updateList(todo.listId);
},
afterUpdateTodo(selector, modifier) {
// 我们在 todos 上仅支持非常有限的操作
check(modifier, {$set: Object});
// 我们只需要处理 $set 的修改器,这是我们在这个应用中所需要做的。
if (_.has(modifier.$set, 'checked')) {
Todos.find(selector, {fields: {listId: 1}}).forEach(todo => {
this._updateList(todo.listId);
});
}
},
// 这里我们需要在 list的todos 正被移除的时候,选择 *之前的* 更新
// 否则我们就不会知道相关的 list 的 id(s)(如果 todo 已经被删除了)
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(doc, callback);
incompleteCountDenormalizer.afterInsertTodo(doc);
return result;
}
}
注意我们只处理了那些我们在应用中实际用到的变形器 — 我们不需要应对某个列表上todo数量的所有可能的变化。例如,如果你改变了某个 todo项 的 listId,这会需要改变 两个 list 的 incompleteCount。既然我们的应用不做这些,就不需要在冗余器中处理它。
Dealing with every possible MongoDB operator is difficult to get right, as MongoDB has a rich modifier language. Instead we focus on just dealing with the modifiers we know we'll see in our app. If this gets too tricky, then moving the hooks for the logic into the Methods that actually make the relevant modifications could be sensible (although you need to be diligent to ensure you do it in all the relevant places, both now and as the app changes in the future).应对每个可能的MongoDB操作符是很困难的,这是由于MongoDB是具有相当多的修改符的语言。与其专注于应对修改符,不如我们在应用中进行查看。如果这变得太麻烦,那么移动对于这段逻辑的挂接到一些方法中,而这些方法是实际上产生相关修改,这样做很明智(虽然你需要很努力地确保在“所有”相关的位置都这么做,现在以及将来对于该应用的变化)。
能完整地抽象一些通用的冗余技术并实际地尝试去应对所有的可能的改变,对于这样的包的存在是很有意义的。如果你写了这样一个包,请让我们知道!
迁移到一个新的架构
就如上面所讨论的那样,试图预测对于你的数据架构的所有未来需求是不现实的。相反地,作为一个成熟的项目,有时需要去改变数据库的架构。你需要很小心,知道如何迁移到新的架构,以保证你的应用平滑地工作,在迁移期间以及在迁移之后。
写迁移
能用来写迁移的一个有用的包是percolate:migrations,它可以提供一种很好的框架用来在不同的架构之间进行切换。
假设,作为一个例子,我们想要添加一个 list.todoCount 字段,并且保证对于所有已经存在的列表中的该字段都得到设置。那么我们也许只需要在服务器端写下列代码(e.g. /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}});
}
});
这个迁移,是序列为第一个迁移。以便于在数据库中进行运行,当被调用时,为每个列表更新现有todo数量。
要发现关于迁移包API的更多信息,参见 它的文档
大量改变
如果迁移需要改变很多数据,并且尤其如果需要停止你的应用服务器,当其还在运行中,可能使用MongoDB 大量操作 是一个好主意。
一个大量操作的优势在于,它不仅只需要一个round trip到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._collection.rawCollection().initializeUnorderedBulkOp();
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}});
});
// We need to wrap the async function to get a synchronous API that migrations expects
const execute = Meteor.wrapAsync(batch.execute, batch);
return execute();
},
down() {
Lists.update({}, {$unset: {todoCount: true}});
}
});
注意我们能够通过使用一种聚合 来聚集todo数量的初始集合,来使得该迁移变得快速。
运行迁移
要在你的开发数据库上运行某个迁移,使用流星shell是很容易的:
// After running `meteor shell` on the command line:
Migrations.migrateTo('latest');
如果该迁移的日志打印到console上,你将会在流星服务器的终端窗口上看到它。
要在你的生产环境数据库上运行一个迁移,首先在本地运行你的应用,并使用生产环境模式(通过生产环境设定以及环境变量,包括数据库设定),然后同样适用流星的shell。这就是运行 up 函数在所有激活的迁移,在你的生产环境数据库上。在我们的示例中,它可以保证所有的列表具有一个 todoCount 字段集。
一个好的方式去做上述的事情就是,启动一个和你的数据库接近的虚拟机,而且在上面安装好流星和SSH(一个特殊的EC2实例,你可以启动和停止,为此目的设立的,是一个可以接受的选择),并且在shell进去之后运行命令。这种方式可以消除任何你的机器和数据库之间的延时,但你仍然需要非常小心的知道迁移是如何运行的。
注意你应该一直保持在运行任意迁移之前进行数据库备份
断裂性的架构改变
有时当我们改变了某个应用的架构,我们就处于一种断裂的状态 -- 以至于旧的架构不能够和新的代码很好地工作在一起。例如,如果我们的一些用户界面代码已经严重地依赖于所有的列表具有一个 todoCount 集,那么会有一段时间,在迁移运行之前,在部署之后,我们应用的界面处于断裂的状态。
一种使该问题能够得到缓解的简单方式是,将该应用在部署和完成迁移之间进行下线。但这非常的不理想,尤其考虑到某些迁移会要数个小时进行运行(即使使用巨量更新 会带来了很多帮助)。
一个更好的方式是进行多阶段的部署。基本的想法是:
- 部署一个你的应用的版本,其能够同时处理旧的和新的架构。在我们的例子中,就是代码不会要
todoCount(对于已经存在的列表),但可以正确的处理那些新创见的todos。 - 运行迁移。这个时候,你需要足够确信所有的列表都有了一个
todoCount - 部署新的代码依赖于新的架构,并且不在知道如何处理老的架构。现在我们可以很安全的依赖于
list.todoCount,在我们的用户界面里。
应该知晓另一件事情,尤其通过这么一种多阶段的部署,准备回滚是非常重要的!为了这个原因,该迁移包允许你能够指定某个 down() 函数并且在调用 Migrations.migrateTo(x) 来迁移 回 到版本 x。
所以如果我们想要回滚我们上面的迁移,我们可以运行
// The "0" migration is the unmigrated (before the first migration) state
Migrations.migrateTo(0);
如果你发现你需要回滚你的代码版本,你将会需要小心应对你的数据,并且很小心的反向你的部署步骤。
警告
上面列出来的一些迁移策略某些方面或许并不是最理想的方式(虽然在大多数的情况下是也许是适用的)。这里有一些其它需要知道的:
通常在迁移中最好不要依赖于你的应用的代码(因为应用会随着时间改变,但迁移不应该)。例如,让你的迁移通过的Collection2集合进行传递(那么就可以检查架构,设置自动值等等)可能随着时间的变化断裂它们,因为你的架构是随着时间变化的。
为了避免这样的问题,可以简单地不在你的数据上运行旧的迁移。这会有些限制但却能工作。
在你的本地机器上运行迁移可能需要花费更多的时间,因为你的机器不是和生产环境数据库相同的配置
Deploying a special "migration application" to the same hardware as your real application is probably the best way to solve the above issues. It'd be amazing if such an application kept track of which migrations ran when, with logs and provided a UI to examine and run them. Perhaps a boilerplate application to do so could be built (if you do so, please let us know and we'll link to it here!).部署一个特殊的 “迁移应用" 到某个和你真实应用一样硬件配置的机器可能是解决上面的问题的最好的方式。
集合之间的关联
如我们早些时候讨论的,在流星应用中,不同的集合中的文档之间具有关联是很常见的。因此,需要撰写获取关联的文档的查询也是很常见的,一旦某个文档是你想要的(例如获得一个List中所有的todos)。
为了让这些变得简单,我们能够为某个给定集合文档的原型链上附加函数,让我们可以在这些文档上具有 “方法” (从面向对象的角度考虑)。我们能够使用这些方法来创建新的查询用来找到关联的文档。
集合帮助器
我们可以使用 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}});
}
});
现在我们可以很容易地找到某个列表的所有todos了:
const list = Lists.findOne();
console.log(`The first list has ${list.todos().count()} todos`);