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

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

哆啦 A 梦:伴我同行

其实我对这部电影的评价不是很高,剧情低于主线剧场版的平均水准,我相信也不是同一个制作团队。明摆着就是骗钱的,不然也不可能在中国上映,但作为脑残粉就是乐意上这个当。出乎我的意料,一同去看的朋友觉得这电影还不错。

作为一个哆啦 A 梦的脑残粉是怎样一种感受呢?每当有人说自己喜欢哆啦 A 梦的时候,我都会在心里说你凭什么说喜欢。45 册单行本你看过几遍?35 个大长篇你看过几个?每周都在追新的短篇动画么?除了任意门和竹蜻蜓还知道其他的道具么?

很多人都说这部电影感人,但我觉得像我这种铁杆粉丝是不会被感动的,因为每个情节都看过十几遍了。不过我也理解普通观众的感受,这部电影将上千个短篇故事中最催泪的八集拼在了一起,每十分钟有一个泪点和高潮——让人觉得电影应该已经结束了吧。

所以我相信这部电影瞄准的应该是小时候曾经看过哆啦 A 梦,但长大以后已经很多年没有再看过了的人。而不是从未看过哆啦 A 梦的人,也不是铁粉,更不是给小孩子看的。

如果说有一个片段感动了我,应该要算是和职员表一同出现的「幕后花絮」,这短短一分钟的花絮给人一种哆啦 A 梦不再是虚构人物的感觉,不由得感慨制作团队几十年如一日地维护一个虚构世界真的是不容易啊。

有一幕是成年后的大雄远远地望着哆啦 A 梦,还是决定不要去和他打招呼了。未来世界的大雄身边是没有哆啦 A 梦的,但关于哆啦 A 梦究竟是何时真正地离开大雄的,却从未被提到过。不过无所谓,时间已经定格在了五年级,每一集都是一个平行世界,哆啦 A 梦的离开和大雄的婚礼永远悬而未决。哆啦 A 梦于我的意义就是一个随时回到美好的理想世界的方式,剧情即在意料之内,偶尔又会出乎意料。

关于哆啦 A 梦和大雄之间感情我就不写了,推荐一篇文章:http://www.zhihu.com/question/24548572/answer/28430735

Cichorium: 基于 Promise 的中间件路由框架

Cichorium 的代码仅有 130 行,用 CoffeeScript 风格实现了一个简单的基于中间件的路由框架,其中的异步操作都是以 Promise 风格提供的。

Cichorium 有一个路由表和一个错误路由表。路由表是一个数组,其中每个元素可以是一个子路由表(数组),或一个中间件(函数)。

中间件会被按顺序地执行,中间件可以返回一个 Promise 表示这是一个异步中间件,Cichorium 会等待这个 Promise 被 resolve 再执行下一个中间件。在中间件中,可以用 nextRoute 来跳过当前路由表上的其他路由,直接进入父级的下一个路由,条件路由(例如匹配 HTTP 方法或 URL 前缀)就是这么实现的。

如果执行过程中抛出了异常(或 Promise 被 reject),就会进入错误处理,错误路由表中的路由会按照同样的规则被逐个调用,如果错误处理过程中抛出了新的异常,那么新的异常会替换掉之前的异常,错误处理中间件可以用 errorResolved 来解决这个异常,剩余的中间件就不会再执行了(但如果有多个异常则下个中间件会以之前的一个异常被继续调用)。


一开始创建这个项目只是想造一个 express 的轮子,后来学习了 Promise 之后觉得 express 和 Promise 的配合有一些麻烦,express 不能识别中间件返回的 Promise, 而必须手动调用 next.

express 使用 next('route') 来跳过剩余的中间件,但我发现用抛出一个特殊的异常(Cichorium 的 nextRoute 就是这么实现的)效果会更好,而且因为有 Promise, 在回调函数中抛出的异常也会被正确地传递回 Cichorium.

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

订阅推送

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

王子亭的博客 @ Telegram


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

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