从零开始写一个 Node.js 的 MongoDB 驱动库

哈哈,有点标题党。我在大半年前写了一个 Node.js 的 MongoDB 驱动库——Mongolass,趁着当时造轮子的过程没有忘干净,在这里整理并记录下来。

为什么要造个轮子

我想大多数 Noder 都用过 Mongoose(如果你用的数据库是 MongoDB 的话),没用过也听说过,还有一部分人用的官方的 node-mongodb-native,少部分人则用的其他的。Mongoose 功能确实比较强大,也有很多优秀的设计在里面,我自己也用了挺久的 Mongoose,为什么我还要去造个轮子?主要以下几点考虑:

  1. 设计复杂。新手比较容易迷惑于一些概念,比如:
    1. 难以理解的 Schema、Model、Entity 之间的关系,在 Mongoose 中,Schema 不仅用来定义文档结构,还可以用来定义 Model 的静态方法(Static methods)和实例方法(Instance methods),甚至可以定义索引。Model 用来查询,也可以创建一个 Entity,Entity 又可以对数据做些修改然后 save 回数据库。
    2. 虚拟属性(Virtual attributes),以及 Entity 调用 toJSON 还是 toObject?
    3. 使用的 mpromise 不支持 .catch,后来可以自定义了。
  2. 插件系统不够灵活。Mongoose 的插件已经比较强大了,但还是有几点不太满意的地方,比如:
    1. 插件的定义顺序决定了执行顺序。
    2. 插件一旦引入,就一定会被使用。
    3. pre 函数内 this 一会是要更新的文档(如: save)一会是 Query 的实例(如: find),需要自己判别;post 函数第一个参数是 result,只能通过修改这个对象修改返回值而不能通过返回一个新的对象覆盖。
  3. 错误不够详细。用过 Mongoose 的人一定碰到过:
    CastError: Cast to ObjectId failed for value "xxx" at path "_id"
    
    错误栈也看不出啥来,只知道一个期望是 ObjectId 的字段传入了非期望的值,通常很难定位出错的代码,即使定位到也得不到错误现场。
  4. 接口与官方驱动不一致。Mongoose 封装并扩展了 node-mongodb-native 的 API,Mongoose 文档不是很详细,官方文档则十分详细。Mongoose 改动:
    1. API 改动。如:Mongoose 是 findOneAndRemove;node-mongodb-native 是 findOneAndDelete 等等。
    2. API 参数改动。

目标

既然知道了痛点,那造轮子的时候就要考虑如何解决这些问题。针对以上几点,经过一段时间考虑后,制定以下目标:


  1. 简化设计。
    1. Schema 只用来做参数校验及格式化,可通过 Schema 生成 Model(Schema 是可选的,不使用也没有关系),Model 负责所有对数据库的增删改查的操作(包括建立索引),去掉 Entity 的概念,Mongoose 中可以对 Entity 做些修改后调用 save 方法更新数据库,我们设计只能通过调用 Model 的 update 等方法更新数据库。
    2. 没有静态方法,没有实例方法,没有虚拟属性。血的教训告诉我,混用静态方法+实例方法+虚拟属性+自定义插件+自己写的 services/models 方法简直就是作死,一团糟。
  2. 灵活的插件系统。
    1. 可定义全局插件或某个 Model 上的插件,Model 级插件的优先级大于全局插件,跟 Mongoose 一样。
    2. 定义了插件后期望:①按需使用 ②顺序可随意组合 ③一个插件针对不同的操作可实现不同的行为,如:beforeInsert、afterFind 等等。
    3. beforeInsert 会在 Model 的 insert 前被调用,用来:①格式化查询参数 ②修改要插入或更新的文档;afterFind 会在 Model 的 find 后被调用,用来处理查询结果。beforeXxx 和 afterXxx 都支持 Function/GeneratorFunction/AsyncFunction。
  3. 详细的错误信息。
    1. 正确的错误栈,至少让前几行是正确并能定位到出错的代码行。
    2. 更多的错误信息,而不是只有一个 error.message。
  4. 与官方驱动相同的接口。接口和参数都保持一致,好处:
    1. 直接复用官方文档,也不用自己写文档。
    2. 学习成本低,也方便 node-mongodb-native 用户迁移。

借(chao)鉴(xi) Mongoose 一些优点:

  1. 伪同步建立连接。node-mongodb-native 需要在 connect 的回调函数里获取 db client,而 Mongoose 写法是同步 connect,然后直接使用 mongoose,如下:
    var mongoose = require('mongoose');
    mongoose.connect('mongodb://localhost/test');
    
    var Cat = mongoose.model('Cat', { name: String });
    
    var kitty = new Cat({ name: 'Zildjian' });
    kitty.save(function (err) {
      if (err) {
        console.log(err);
      } else {
        console.log('meow');
      }
    });
    
    其实建立连接的过程也是异步的,只不过在 save 的时候会等待连接成功后才执行插入。
  2. 链式调用。node-mongodb-native 的各种限制条件都放到了 options(如:skip, limit, fields),而 Mongoose 则可以链式调用,比较直观,最后会将参数组合到 options 里,见 mquery。如下:
    User
      .find()
      .skip(10)
      .limit(1)
      .select({ _id: 0 })
      .exec()
    
  3. 强大的 Schema。这里单纯指 Schema 定义文档结构和格式化的功能。
  4. 其他的如:
    1. 类型转换。如在 Mongoose 中定义了一个字段 type 是 ObjectId 后,ObjectId 的字符串形式也无缝使用,node-mongodb-native 则必须调用 ObjectId 函数生成一个 ObjectId 实例。
    2. 安全更新。update 等更新操作默认是 $set 等等。

简(kan)化(diao) Mongoose 一些功能:

  1. 去掉静态方法和实例方法,只有 Model 方法,如:find、insert 等等。
  2. 虚拟属性可用插件代替。

another-json-schema

目标定完了,那就开始造轮子吧。前面提到过,Mongoose 的 Schema 还是挺强大的,考虑是不是可以直接拿过来改改,看了下 Mongoose 的 Schema 源码,感觉耦合严重,搬过来改动大成本挺高,而且我只想要它的文档校验和格式化功能,于是寻找有没有其他开源库可以用。在找遍了 GitHub 上几乎所有的 JSON Schema 库后,感觉没有一个符合我的期望,那就再造个轮子吧。。

然后花了大约一周的业余时间,another-json-schema 诞生了,下面以 AJS 代称。

AJS 只有三个接口:

  1. AJS.register:注册 validator
  2. AJS.prototype.compile: 编译 Schema
  3. AJS.prototype.validate: 验证文档

AJS 内置了一些常用的 validator。一个简单的例子:

const util = require('util');
const AJS = require('another-json-schema');

const userSchema = AJS('userSchema', {
  name: { type: 'string' },
  age: { type: 'number', gte: 18 }
});

const user = {
  name: 'nswbmw',
  age: 17
};

console.log(util.inspect(userSchema, { depth: 5 }));
// AJS {
//   _name: 'userSchema',
//   _object: true,
//   _children:
//    { name:
//       AJS {
//         _leaf: true,
//         _children: { type: 'string' },
//         _parent: [Circular],
//         _path: '$.name',
//         _schema: { type: 'string' },
//         _name: 'userSchema' },
//      age:
//       AJS {
//         _leaf: true,
//         _children: { type: 'number', gte: 18 },
//         _parent: [Circular],
//         _path: '$.age',
//         _schema: { type: 'number', gte: 18 },
//         _name: 'userSchema' } },
//   _parent: null,
//   _path: '$',
//   _schema: { name: { type: 'string' }, age: { type: 'number', gte: 18 } } }
console.log(userSchema.validate({ name: 'nswbmw', age: 17 }));
// { valid: false,
//   error:
//    { Error: ($.age: 17) ✖ (gte: 18)
//      validator: 'gte',
//      actual: 17,
//      expected: { type: 'number', gte: 18 },
//      path: '$.age',
//      schema: 'userSchema' },
//   result: { name: 'nswbmw', age: 17 } }
console.log(userSchema.validate({ name: 'nswbmw', age: 18 }));
// { valid: true, error: null, result: { name: 'nswbmw', age: 18 } }

可以看出,AJS 的错误信息十分详细。格式化文档也很简单:

const validator = require('validator');
const toObjectId = require('mongodb').ObjectId;
const AJS = require('another-json-schema');

const commentSchema = AJS('commentSchema', {
  postId: {
    type: actual => {
      if (!actual || !actual.toString || !validator.isMongoId(actual.toString())) {
        throw new TypeError(`Wrong postId, expected ObjectId but got ${JSON.stringify(actual)}`);
      }
      return toObjectId(actual);
    }
  }
});

console.log(commentSchema.validate({ postId: 1 }));
// { valid: false,
//   error:
//    { Error: ($.postId: 1) ✖ (type: type)
//      validator: 'type',
//      actual: 1,
//      expected: { type: [Function: type] },
//      path: '$.postId',
//      schema: 'commentSchema',
//      originError:
//       TypeError: Wrong postId, expected ObjectId but got 1
//       ...
//    },
//   result: { postId: 1 } }
console.log(commentSchema.validate({ postId: '000000000000000000000000' }));
// { valid: true,
//   error: null,
//   result: { postId: 000000000000000000000000 } }

注意:AJS 限定只能在 type 这个 validator 的自定义函数里修改原来的值,其他的 validator 只用来验证是否合法,返回 true 则通过,否则不通过。

const AJS = require('another-json-schema');

AJS.register('in', function (actual, expected) {
  return expected.indexOf(actual) !== -1;
});

const productSchema = AJS('productSchema', {
  id: { type: 'string', in: ['A', 'B'] }
});

console.log(productSchema.validate({ id: 'A' }));
// { valid: true, error: null, result: { id: 'A' } }
console.log(productSchema.validate({ id: 'B' }));
// { valid: true, error: null, result: { id: 'B' } }
console.log(productSchema.validate({ id: 'C' }));
// { valid: false,
//   error:
//    { Error: ($.id: "C") ✖ (in: A,B)
//      validator: 'in',
//      actual: 'C',
//      expected: { type: 'string', in: ['A', 'B'] },
//      path: '$.id',
//      schema: 'productSchema' },
//   result: { id: 'C' } }

有兴趣的可以看下 AJS 源码,只有不到 240 行,欢迎 fork 与 pr。

Mongolass

在开发 Mongolass 之前,我大体翻了几个其他的 MongoDB 驱动库的源码看了下,最后参考了部分 mongoskin 的代码。Mongolass 的源码比较少,只有以下几个文件:

  1. index.js: 定义了 Mongolass 主类
  2. model.js: 定义了 Model 类
  3. query.js: 定义了 Query 类(包含插件系统)及将 Query 绑定到 Model 的函数
  4. plugins.js: 内置的插件
  5. schema.js: 定义了一些内置的 Schema,如给 _id 默认设置为 ObjectId 类型
  6. Types.js: 内置的 Schema Types

Mongolass 类、Model 类、Query 类的关系:

  1. Mongolass 类的实例用于:①创建与断开数据库的连接 ②定义 Schema ③生成 Model 实例 ④加载全局插件 ⑤对数据库(db 级)的操作,如: mongolass.listCollections()。
  2. Model 类的实例用于:①对数据库(collection 级)的增删改查,如: User.find() ②定义 Model 级的插件。
  3. Query 类的实例绑定到 Model 实例上的方法,即:Model 实例上的方法如 User.find() 就是一个 Query 实例。

插件系统是如何实现的?

Mongolass 类中有一个 _plugins 属性和一个 plugin 方法,源代码如下:

/**
 * add global plugin
 */
plugin(name, hooks) {
  if (!name || !hooks || !_.isString(name) || !_.isPlainObject(hooks)) {
    throw new TypeError('Wrong plugin name or hooks');
  }
  this._plugins[name] = {
    name: name,
    hooks: hooks
  };
  for (let model in this._models) {
    _.defaults(this._models[model]._plugins, this._plugins);
  }
  debug('Add global pulgin: %j', name);
}

Model 类也有一个 _plugins 属性和一个 plugin 方法,源代码如下:

/**
 * add model plugin
 */
plugin(name, hooks) {
  if (!name || !hooks || !_.isString(name) || !_.isPlainObject(hooks)) {
    throw new TypeError('Wrong plugin name or hooks');
  }
  this._plugins[name] = {
    name: name,
    hooks: hooks
  };
  debug('Add %s pulgin: %j', this._name, name);
}

Mongolass 类中 this._models 存储了所有定义的 Model 实例,可以看出:每次调用全局即 Mongolass 实例上的 plugin 方法,会遍历所有的 Model 实例,以 _.defaults 的形式合并到 Model 的 this._plugins 中。

也就是说,全局插件和 Model 插件没有定义顺序一说,因为全局插件的优先级总是低于 Model 插件,但同级的同名的插件后定义的会覆盖之前定义的。


hooks 是一个对象,举个栗子:

User
  .find({ name: 'haha' })
  .xx('A', { age: 18 })
  .exec()

User.plugin('xx', {
  beforeFind: (...args) {
    // args => ['A', { age: 18 }]
    // this._op => find
    // this._args => [{ name: 'haha' }]
  },
  afterFind: (result, ...args) {
    // result => 查询的结果
    // args => ['A', { age: 18 }]
  }
})

插件是如何使用的?

前面提到了定义的插件都放到了 Model 的 _plugins 属性中,那么该如何使用呢?Model 中执行了这样一行代码:
Query.bindQuery(this, mongodb.Collection);

query.js 中 bindQuery 做了以下几个操作:

  1. 将 mongodb.Collection 中所有的方法(如: insert, find),生成对应的 Query 实例绑定到 Model 实例上,这样就有 User.find() 这个方法了。再强调下:这里 User 是 Model 的实例,User.find() 是 Query 的实例。
  2. Query 实例在生成的时候,也有一个 _plugins 属性,但这个是数组用来存储调用的插件,因为数组可以保证顺序而对象则不能,同时做了以下操作:
    1. 添加内置的 schema 插件,用于设置 _id 默认为 ObjectId,更新时默认为 $set 等等操作:
      this._plugins = [{
        name: 'MongolassSchema',
        hooks: plugins(ctx._schema),
        args: []
      }];
      
    2. 遍历 Model 实例上的插件,定义 Query 实例上的方法:
      _.forEach(ctx._plugins, plugin => {
        this[plugin.name] = (...args) => {
          this._plugins.push({
            name: plugin.name,
            hooks: plugin.hooks,
            args: args
          });
          return this;
        };
      });
      
      可以看出,只有调用该插件后,才会将该插件 push 到 _plugins,后面才会执行。
    3. exec 方法调用后才真正执行插件和数据库查询:
      exec(cb) {
        return Promise.resolve()
          .then(() => execBeforePlugins.call(this))
          .then(() => ctx._connect())
          .then(conn => {
            let res = conn[this._op].apply(conn, this._args);
            if (res.toArray && (typeof res.toArray === 'function')) {
              return res.toArray();
            }
            return res;
          })
          .then(result => execAfterPlugins.call(this, result))
          .catch(e => addMongoErrorDetail.call(this, e))
          .asCallback(cb);
      }
      
      execBeforePlugins 和 execAfterPlugins 分别在数据库查询之前和之后执行,以 execBeforePlugins 为例:
      function execBeforePlugins() {
        let self = this;
        let hookName = 'before' + _.upperFirst(this._op);
        let plugins = _.filter(this._plugins, plugin => plugin.hooks[hookName]);
        if (!plugins.length) {
          return;
        }
        return co(function* () {
          for (let plugin of plugins) {
            debug('%s %s before plugin %s: args -> %j', self._model._name, hookName, plugin.name, self._args);
            try {
              let value = plugin.hooks[hookName].apply(self, plugin.args);
              yield (isGenerator(value)
                ? value
                : Promise.resolve(value));
            } catch (e) {
              e.model = self._model._name;
              e.plugin = plugin.name;
              e.type = hookName;
              e.args = plugin.args;
              throw e;
            }
            debug('%s %s after plugin %s: args -> %j', self._model._name, hookName, plugin.name, self._args);
          }
        });
      }
      
      如执行 User.find().mw1().mw2().exec() 则遍历这个 Query 实例上的 _plugins 数组,把所有的 beforeFind 方法放到一个数组里依次执行,execAfterPlugins 同理,只不过数组每一项的结果会作为数组下一项执行的输入。
    4. .cursor 用来返回游标;.then 方便结合 co 使用,可省略 .exec()。
  3. addMongoErrorDetail 用来给 MongoDB 查询出错后的 error 对象添加额外详细属性。以 User.find({ name: 'haha' }).select({ name: 1, age: 1 }).sort({ name: -1 }).exec() 为例:
    1. stack: 拼接了额外的错误栈信息
    2. op: 操作符,这里为:find
    3. args: 查询的条件,这里为:[{"name":"haha"},{"fields":{"name":1,"age":1},"sort":{"name":-1}}]
    4. model:Model 实例名,这里为:User
    5. schema:如果有,这个 Model 实例对应的 Schema 名

Mongolass 的插件有点 Koa 的中间件的概念但本质不同,通过链式调用并且在查询语句之前执行 beforeXxx 和之后执行 afterXxx,功能足够强大,所以说可以替代 Mongoose 中的虚拟属性和插件系统。

差不多就这些,虽然没有手把手从零开始,但也大体讲明白了写一个 Node.js 的 MongoDB 驱动的思路与过程。Mongolass 的代码还是比较少的,相信你读完这篇文章后再去看源码,会一目了然。

欢迎 fork 与 pr。

最后

我们正在招聘!

[北京/武汉] 石墨文档 做最美产品 - 寻找中国最有才华的工程师加入

编辑于 2017-02-07