进击的猫鼬 (1)
2014-10-28

猫鼬是一种看似温顺,但实际上很厉害的小动物,据说性情凶暴起来足以杀死一条眼镜蛇。在程序员的世界里,猫鼬竟然也占有重要一席。最近接触的两个开源项目,都以 mongoose(猫鼬的英文)命名,可见其受喜爱的程度。

这两只猫鼬聪明敏捷,执行任务非常出色,已经是我不可或缺的小伙伴。我打算分两次来介绍一下它们,首先要出场的这只猫鼬,是为使用 NodeJS+MongoDB 的程序猿准备的。

mongoose 官网首页的一句话把 mongoose 的用途交代的很清楚:

Let’s face it, writing MongoDB validation, casting and business logic boilerplate is a drag. That’s why we wrote Mongoose.

说白了 mongoose 就是一个 ORM,如果你要做的东西很简单,就别用 mongoose 折腾了,推荐使用更加简易的monk

不过就 GitHub 上 star 的数量来说,mongoose 是 monk 十倍有余,流行程度遥遥领先,所以本文就聊一聊这个了不起的 mongoose。

mongoose 有三个最重要的概念:Schema, Model, instance.

因为 Schema 和 Model 用作类型(可理解为 class),为了强调这一点,我给 Schema 和 Model 的首字母用了大写,instance 是对象实例,所以首字母小写。

下面用一个例子串起它们三个的用法。我用关系型数据库的语言描述一下这个例子,先有个了解,以便更有目的性地看代码:

我们数据库里保存的,是菜市场里的菜价信息。有两个表,一个表是 markets(菜市场),字段包含市场的名字和地理位置。另一个表是 prices(菜价),字段包含某天某种菜在某个市场的价格,其中表示市场的_market 字段指向 markets 表中的某一条记录,可理解为外键。

下面逐块代码进行讲解:

var mongoose = require("mongoose");
var events = require("events");

var emitter = new events.EventEmitter();
var Schema = mongoose.Schema;

function handleError(err) {
  console.log("error: ", err);
}

mongoose.connect("mongodb://localhost/test");

我们的例子由一连串增删改查(CRUD)的操作组成,首先是 create,等到 create 成功以后才进行下一步 read,然后才 update 和 delete。由于这些数据库操作都是异步返回的,所以要等前一个操作完成后再进行下一个操作。以上代码中的 events,就是为了串行化而引入的。在下面的例子中,由 EventEmitter 来负责串行化的事件触发。

除了 events 和 emitter 这两行,剩下的几乎是使用 mongoose 所必备的开篇代码,无需多言。

Schema

MongoDB 是 document 数据库,本质上不对数据格式做限制。但是这不妨碍我们人为地去规范格式,比如我们希望某个 collection 中的元素都拥有相同的字段和数据类型,单纯而美好的愿望。

Schema 就是干这用的。除了限制类型,Schema 还可以定义 method。所有脱胎于这个 Schema 的对象,都有了相同的格式和 method。

// 定义Schema
var priceSchema = new Schema({
  date: { type: Date, index: true },
  name: String,
  _market: { type: Schema.Types.ObjectId, ref: "Market" },
  min: Number,
  max: Number,
  avg: Number,
  unit: { type: String, default: "元/千克" },
  tags: [String],
});

var marketSchema = new Schema({
  name: String,
  lat: Number,
  lng: Number,
  province: String,
  city: String,
  district: String,
  street: String,
  StreetNumber: String,
});

priceSchema 有一个_market 字段值得注意,使用下划线开头是为了强调它的特殊性。ref: ‘Market’表示它引用的类型,也就是说这个字段是一个以 Market 为 model 生成的 instance。 上面的例子里,所有字段的类型都是基础类型,嵌套类型如何表达呢?

nestedTypeField: {
    x: {
        y: Number,
        z: [String]
    }
}

其实这个字段的类型是匿名的 Model,对已有的 Model,直接把 Model 作为类型即可。

Model

按照习惯上对 ORM 的理解,不就只有类和对象吗?类对应着表定义,对象对应着表中的记录。但是 mongoose 却整出来三个概念,Schema 对应着表定义,instance 对应着表记录,Model 是干啥的?

可将 Model 理解成 Schema 和 instance 之间的粘合剂,Schema 生 Model,Model 生 instance。然而从日常用途来看,主要是通过 Model 来增删改查。

首先需要以一个 Schema 为模板生成 Model,然后拿这个 Model 去 create, find, remove, update.

// 由Schema创建Model
var Price = mongoose.model("Price", priceSchema);
var Market = mongoose.model("Market", marketSchema);

instance

一个 instance 就是一条数据,也就是 collection 中的一个 document。

CRUD 操作

create

向数据库插入数据,一种方法是生成 instance,然后调用 instance 上的 save 方法。另一种是直接调用 Model 上的 create 方法。

// create的示例代码
function create() {
  // 准备数据
  var marketJson = {
    name: "抚顺路蔬菜副食品批发市场",
    lat: 36.103964,
    lng: 120.38536,
    province: "山东省",
    city: "青岛市",
    district: "市北区",
    street: "哈尔滨路",
    streetNumber: "13号",
  };

  // 由Market Model创建一个instance
  var market = new Market(marketJson);

  var priceJson = {
    date: new Date("2014-10-16"),
    name: "大白菜",
    _market: market._id, // 拿_market的id作为ref
    min: 0.9,
    max: 1.1,
    avg: 1,
  };

  // instance直接save的方式添加document
  market.save(function (err) {
    if (err) return handleError(err);

    console.log("create market success", market);
    // 使用Model的create方法添加document
    Price.create(priceJson, function (err, price) {
      if (!err) {
        console.log("create success", price);
        emitter.emit("startQuery"); // 触发下一步的查找操作
      }
    });
  });
}

query

增删改查这四个里面,查是永远的主角。MongoDB 不是关系型数据库,没有多表联合查询这一说,因此需要转换一下思维。

首先,在 MongoDB 里尽量不要拆表,能用一个 collection 表示的,就别再拆出一个 collection 来,这样做的好处是减少多表查询,提高性能。

如果实在不能忍了,比如就是有一个字段,不仅数据量大,而且重复特别多,那就拆表。由于 MongoDB 不支持多表联合查询,所以要通过写程序来实现。自己写程序,就要避开坑,比如

where A.a = ‘XXX’ and B.b = ‘YYY’

A 和 B 是不同的 collection,正确做法是把 A 和 B 中所有满足条件的先都取出来,再写代码求交集。可千万别在取交集的时候,每做一次比较都去 query 数据库,那样性能就完蛋了。

mongoose 提供了一个 populate 方法,可以简化联合查询的写法,即将外键字段直接填充成对象,而不仅仅是一个外键 id,请看下面例子:

// 查询示例代码
function query() {
  // findOne查一个
  var findPromise = Price.findOne({ name: "大白菜" }).exec();

  // exec的返回值,同时继承了Promise和EventEmitter
  // 因此既可以then也可以on

  // 利用Promise的then方法来等待操作结果
  findPromise.then(
    function (price) {
      console.log("promise fulfill");
      console.log(price); // findOne返回单个对象
    },
    function (err) {
      console.log("promise reject");
      return handleError(err);
    }
  );

  // 利用侦听complete/err事件来等待操作结果
  findPromise
    .on("complete", function (price) {
      console.log("event complete");
      console.log(price); // findOne返回单个对象
    })
    .on("err", function (err) {
      handleError(err);
    });

  // 用populate填充外键字段
  Price.findOne({ name: "大白菜" })
    .populate("_market")
    .exec(function (err, price) {
      if (err) return handleError(err);
      console.log("after populate >>>>>>>> \n", price);
    });

  // find查多个
  Price.find({ name: "大白菜" }) // equal
    .find({ name: /^大/ }) // like
    .where("avg")
    .gt(0.8)
    .lt(1.2) // less than, greater than
    .where("avg")
    .gte(0.8)
    .lte(1.2) // between
    .where("name")
    .in(["大白菜", "菠菜"]) // in
    .sort("avg") // order by asc
    .sort("-avg") // order by desc
    .limit(10) // top
    .select("name min max avg market") // select
    .exec(function (err, prices) {
      if (err) return handleError(err);
      console.log(prices); // find返回数组
      emitter.emit("startUpdate");
    });
}

update

更新没啥好说的,会写 conditions 条件基本就没问题了。还有一种更新方法,是先查找出 instance,直接修改 instance 的属性,然后调用 intance 的 save 方法。

// update示例代码
function update() {
  // Model.update(conditions, update, [options], [callback])
  Price.update(
    { name: "大白菜", avg: { $gt: 0.9 } }, // condition
    { avg: 1.1 }, // update to
    { multi: true }, // options
    function (err, numberAffected, raw) {
      // callback
      if (err) return handleError(err);
      console.log("The number of updated documents was %d", numberAffected);
      console.log("The raw response from Mongo was ", raw);
      emitter.emit("startRemove");
    }
  );
}

remove

删除没啥好说的,注意一点,如果要立即执行删除,最简单的方法是传入 callback,否则只会返回一个 query 对象,并不立即执行。其实增删改查也都一样,想立即执行,要么传入 callback,要么调用 exec 方法。

// remove示例代码
function remove() {
  // 删一个
  Price.findOneAndRemove({ name: "不存在" }, function (err) {
    // 删除不存在的不会报错
    if (err) return handleError(err);
  });

  // 删多个
  Price.remove({ name: "大白菜" }, function (err) {
    if (err) return handleError(err);
  });

  // 全删
  Market.remove(function (err) {
    if (err) return handleError(err);
  });
}

用事件串行化

增删改查都是异步返回的,为保证一个操作完成后再执行下一个,就要有一个机制来串行化。callback 里面再套 callback 是一种做法,但不够优雅。Promise 的 then 是一种方法, NodeJS 本身提供的 event 也是很好的选择。下面例子是使用 event 来串行化。

emitter.on("startQuery", function () {
  query();
});

emitter.on("startUpdate", function () {
  update();
});

emitter.on("startRemove", function () {
  remove();
});

create(); // 从创建开始

完整示例代码

END