我开发了一个基于 Beancount 的账本托管服务 HostedBeans,欢迎大家来了解纯文本复式记账并试用我的服务。
标签 #MongoDB

Mabolo: 轻量级的 MongoDB ORM

一开始我像很多人一样使用 Mongoose 作为 ORM, 但时间长了我发现了 Mongoose 的一些不理想的地方。

Mongoose 通过定义 Setter 的方式记录了对文档的每一次修改,以便可以用 save 方法将文档无冲突地储存在数据库中。但我在实际使用中发现,我很少会使用这个功能,每当对文档进行更新的时候,几乎都是直接使用 MongoDB 的原子性操作符($set 等)。Mongoose 在这个功能上下了很大功夫,也增加了很多额外的约束。例如它 使用了一些黑科技 来阻止用户修改从数据库查出的文档。而我希望从数据库中查出文档后进行一些加工,向文档上储存一些额外的数据来供渲染页面时使用(但不储存到数据库),本来我们在 JavaScript 这样的语言中是期待一个对象是可以被随意修改的,但在 Mongoose 中却不可以。

我发现我其实只需要 Mongoose 的一小部分功能,于是我自己编写了 Mabolo, 我对它的定位是一个轻量级、无黑科技的 ORM. 它完成于 2015 年初,目前已被使用到了我的大部分个人项目中。

Mabolo 用 300 行代码实现了一个 ORM 最核心的一些功能:为数据集合定义字段的类型、验证文档字段的合法性、定义类方法和实例方法、嵌入式的文档和数组、原子性地更新整个文档、同时兼容 Promise 和 callback 风格的 API.

Mabolo 几乎没有使用什么黑科技,每个 Model 都是一个普通的 JavaScript 构造函数,而每个文档则都是由这个构造函数生成的实例 —— 除了几个用来保存内部状态的不可枚举属性之外和普通的对象没有任何区别。

接下来我来谈一谈 Mabolo 中的几个实现细节。

定义 Model

在 Mongoose 中,要先创建 Mongoose 实例(即代表一个数据库连接)才能根据它来创建 Model, 但这样会造成 Model 定义依赖于这个全局的数据库连接。而在 Mabolo 中,可以先创建与实例无关的 Model, 然后再将其绑定到 Mabolo 实例上:

User.coffee:

Mabolo = reuqire 'mabolo'
module.exports = Mabolo.model 'User',
  name: String

app.coffee

Mabolo = reuqire 'mabolo'
mabolo = new Mabolo 'mongodb://localhost/test'
User = mabolo.bind require './User'

即使 Model 还没被绑定到 Mabolo 实例上,也是可以执行查询的,这些查询会被阻塞,直到 Model 被绑定到一个数据库连接上。

实现上,Mabolo.model 会创建一个继承(CoffeeScript 的 extends)自 AbstractModel 的类,作为 Model 来使用。在绑定时,Mabolo.bind 会调用 Model 上的 bindCollection 函数,这个函数会 resolve 一个内部的 Promise, 让对数据库的操作开始执行。

嵌入式文档

Mabolo 中 Model 的字段定义,既可以是基本类型,也可以是另一个 Model, 还可以是基本类型或 Model 的数组。

Token = mabolo.model 'Token',
  code: String

User = mabolo.model 'User',
  tokens: [Token]

在保存文档到数据库时,Mabolo 会调用 Model::transform 构造字段定义中的嵌入式文档,这样才可以运行定义在嵌入式文档上的字段验证。而在从数据库取出文档时,也会构造字段定义中所描述的嵌入文档, 以便用户调用嵌入文档上的实例方法。

下一步会支持在嵌入文档上运行 update 和 remove 方法。主要实现方法是 Model::transform 会在构造出的嵌入文档上储存父文档和在父文档中的位置,以便 update 时为查询和更新中的字段名加上前缀。

再之后会支持文档中的引用关系,在从数据库中取出文档时,Mabolo 会自动取出被引用到的文档,这个过程被称为「填充」,用户也可以自己定义更复杂的填充规则。

原子性地更新文档

Mabolo 使用了和 Mongoose 类似的技术来原子性地更新整个文档,即在每次更新时都为文档设置一个版本号(在 Mabolo 中是一个随机的字符串),在进行原子更新时会将当前版本号作为一个查询条件来运行更新,如果没有成功(版本号被另一个操作修改了),会从数据库中查出最新的文档,重放修改然后再一次尝试提交。

user.modify (user) ->
  Q.delay(1000).then ->
    user.age = 19
.then ->

Mabolo 使用了一种更简单的方式实现重放修改 —— 即要求用户传入一个无副作用的修改函数,这个函数会在每次重放修改的时候被调用一次。应该说这只是一种不推荐大量使用的备选方案,更好的做法是直接使用 MongoDB 的原子操作符:

user.update
  $set:
    age: 19

MongoDB 使用经验

最初听说 MongoDB 的时候,我总是觉得它的稳定性堪忧,后来用了差不多一年的时间,其实也没有遇到过什么问题,反而是 MySQL 出现过几次丢失数据的情况。配合 Node.js 使用 MongoDB 是一件非常舒畅的事情,从前端,到后端,再到数据库,统统全是 JSON.

本文的定位是一篇对 MongoDB 的一个概览性的介绍,告诉你 MongoDB 的特点和功能,以及如果需要了解某个功能,应当搜索什么关键词,并不直接涉及技术细节。

特点

  • 无模式

    MongoDB 中的每一条文档,都是一个 JSON 对象,因此你无需预定义一个集合的结构,集合中的每个文档也可以有不同的结构。

  • 异步写入

    MongoDB 默认所有的写操作都是『不安全』的,即当请求被 MongoDB 收到时,不等写入操作完成,就返回一个『成功』的响应。
    这是默认的行为,当然你设置一些选项,让操作等待等待写入完成后再返回响应。不过对于大多数应用,这种『不安全』已经足够安全了。

  • 简单查询

    MongoDB 只支持简单的查询,MongoDB 只储存数据,更多的逻辑应该在应用中解决。因此 MongoDB 有着简单的且在各编程语言下高度一致的 API 接口。

  • 无需运维

    MongoDB 几乎没有什么选项可以设置,集群也是设置一次之后就可以自动地解决故障,极少需要维护。

安装和备份

MongoDB 的安装很简单,直接在官网下载二进制版本,运行其中的 mongod 即可启动数据库,运行 mongo 即可启动客户端 Shell, 或者你也可以从软件源中安装。
MongoDB 被设计运行于 64bit 的操作系统,在 32bit 的情况下,数据文件最大限制为 2GiB.

MongoDB 在运行时并不会实时地将修改写入磁盘,因此在关闭服务器时需要给 MongoDB 时间将所有数据写入磁盘。当出现服务器突然掉电的情况时,MongoDB 的数据库文件会损坏,需要进行修复才能重新运行,这个过程中会丢失掉电时正在进行的写入操作。在对运行中的 MongoDB 进行备份时,需要使用自带的 mongodump 工具,不能直接复制其数据库文件。

设计文档结构

  • ObjectID

    MongoDB 会为每一个文档默认添加一个名为 _id, 类型为 Object 的字段,这个字段用来唯一地标识每一个文档。这个 ID 通过时间,服务器,进程号被生成,甚至可以认为是全世界唯一的。
    除了 MongoDB 默认为 _id 添加 ObjectID, 你也可以自己在文档中创建 ObjectID, 来起到唯一地标识某个对象的功能。

引用关系

例如我们有一个 topic 集合,topic 都是 account 创建的,所以 topics 中要引用来自 accounts 中的 account.

// accounts
{
    _id: <ObjectID 1>,
    name: "jysperm"
}

// topics
{
    _id: <ObjectID 2>,
    account_id: <ObjectID 1>
}

嵌入关系

每个 topic 会有一些 reply, 所以在 topic 中可以嵌入 reply.

// topics
{
    _id: <ObjectID 2>,
    account_id: <ObjectID 1>

    replies: [
        {
            _id: <ObjectID 3>,
            content: "xxoo"
        }
    ]
}

引用 Vs 嵌入

在上面第一个例子中,topic 其实也可以嵌入 account 中;而第二个例子中,reply 也可以使用一个新的集合,然后来引用 topic, 那么应该如何选择这两种关系呢。

我们主要从几个出发点来考虑:

  • 查询

    我们可以考虑查询 account 时,是否需要他所有的 topic, 或者查询 topic 时,是否需要它所有的 reply.
    从查询的角度来考虑,如果需要一同获得这两种数据,那么就应该嵌入,如果不需要,就应该引用。

  • 数据的增长性

    我们还可以考虑 account 的 topic 数量在今后会有怎样的增长,topic 的 reply 数量会有怎样的增长。
    如果增长是没有限度的,那么就应该引用,如果增长是有限的,那么就可以嵌入。

  • 对应关系

    如果是一对一关系,或者一对多关系,那么可以考虑嵌入,如果是多对多关系,那么应该引用。

  • 原子性

    MongoDB 仅在文档层面提供原子性,如果有两个非常敏感的数据需要同时被更新,那么他们有必要存在于同一个文档中。

查询和更新

所谓『增删查改』在 MongoDB 里对应:

  • find/findOne: 查询
  • update/save: 修改
  • insert/save: 新增
  • remove: 删除

在 MongoDB 中,操作符以 $ 开头,主要分为三类:查询操作符,更新操作符,聚合查询操作符。

  • 查询操作符

    用于 find 和 update 中的查询器,如 $gt(大于), $ne(不等于), $in(匹配几个值之一), 逻辑运算:$and, $not.

  • 更新操作符

    用于 update 中的更新器,如 $inc(对数字进行增量), $set(修改文档的一部分), $unset(删除一个字段).
    MongoDB 对嵌入式的文档和数组有非常好的支持:$addToSet(向集合中添加元素), $push(向数组添加元素), $pull(从数组移除元素).

  • 聚合查询(Aggregation)

    类似于 SQL 数据库中的 GROUP, 提供统计和计算的功能,要多强大有多强大,毕竟可以直接在数据库中运行 JavaScript 代码,不过因为性能的关系,不适合在应用中频繁调用。

查询命令还有几个选项:

  • limit: 限制返回的结果数
  • skip: 跳过一些结果
  • sort: 对结果进行排序
  • fields: 只返回指定的字段

副本集(Replica Set)和分片(Sharding)

MongoDB 的副本集采用一主多从的的集群方式。副本集中只有主节点可以写入,其他节点从主节点同步。当主节点故障时,从节点中会自动推选出一个新的主节点。当故障的节点恢复后,会向其他节点同步到最新的数据,然后成为一个从节点。

MongoDB 的分片是指,按照某个字段的值,将数据分为多份,储存在不同的服务器上。客户端会运行一个 mongos 代替 mongod, mongos 相当于一个路由,会根据请求和分片的设置,将请求拆分后发给不同的服务器,得到结果后再将结果组合起来发给客户端。

分片可以与副本集同时使用,此时,每个分片都是一个副本级,这样可以提供非常高的可用率和扩展性。

1

精子生于 1995 年,英文 ID jysperm.

订阅推送

通过 Telegram Channel 订阅我的博客日志、产品和项目的动态:

王子亭的博客 @ Telegram


通过邮件订阅订阅我的博客日志、产品和项目的动态(历史邮件):

该博客使用基于  Hexo  的  simpleblock  主题。博客内容使用  CC BY-NC-ND  授权发布。最后生成于 2023-12-20.