5 Node 中使用数据

我们在使用 node 处理业务逻辑的时候难免要和数据打交道,这时候数据库就派上用场了。在 node 中我们最常用的数据库有两种,redis 和 mongodb。本章也正是围绕这两个数据库展开讲解。

5.1 redis

redis 提供 key-value 类型的存储结构,是一种内存数据库,因此数据查询速度特别快,而且它还可以通过配置来实现将数据定期备份到磁盘上的功能,一定程度上解决进程掉线后数据恢复的问题。

node 中推荐使用 ioredis 这个驱动来对 redis 进行操作。redis 这个驱动虽然使用人数更多,但是从 redis 3.x 开始增加了 cluster 模式,但是这个驱动并不支持这种模式,所以不推荐使用。

var Redis = require('ioredis');
/**
 * 如果不传参数默认连接127.0.0.1:6379端口
 * */
var redis = new Redis(/*{"port" : 6379,"host" : "127.0.0.1",password: 'auth'}*/);//没有密码不需要传password参数
/*
var clusterRedis = [
    {
        "host":"127.0.0.1",            
        "port":6379
    },
    {
        "host":"127.0.0.1",            
        "port":6380
    }
];
var redis =  new Redis.Cluster(clusterRedis,{redisOptions:{password: 'auth'});//集群连接方式
*/

redis.set('foo', 'bar', function(err,reply) {
  console.log(err, reply);//正常情况打印 null 'OK'
});
redis.get('foo', function (err, result) {
  console.log(err,result);//正常情况打印 null 'bar'
});

代码 5.1.1 redis命令基本演示

redis 中大多数的命令格式都是这样的 command key param1 prama2 ... 对应 ioredis 中的函数就是 redis.command(key, param1, param2, ...) 比如说 代码5.1.1 中的栗子,我们在 redis-cli 中执行 set foo bar 命令就对应我们的 redis.set('foo', 'bar') 这行代码。注意到我们这里在接收处理结果的时候都是使用 callback 的方式,ioredis 内部也支持 promise 方式来接收处理结构,你只需要将回调函数去掉,改成 then 函数:

redis.set('foo','bar').then(function(reply) {

});

代码 5.1.2 使用 promise 方式接收返回数据

有时候我们在使用 redis 的时候,在一个处理逻辑中要连续发送多条 redis 命令,这时候你可以考虑用 ioredis 中提供的 pipeline 或者 multi 函数。

使用 pipeline 时 ioredis 内部将一系列指令缓存到内存,最后通过 exec 函数执行后打包发送到 redis 服务器,而且它支持链式的调用方式:

redis.pipeline().set('foo', 'bar').get('foo').exec(function (err, results) {
});

代码 5.1.3 pipeline 链式调用

甚至可以在调用每个命令的时候都加一个回调函数,这里在get位置加一个回调函数:

redis.pipeline().set('foo', 'bar').get('foo',function(err,result) {
    console.log('get foo',err,result);
}).exec(function (err, results) {
    console.log('with single callback',err, results);
});

代码 5.1.4 pipeline 链式函数中加回调

当然这里还有一种更加简洁的调用方式,就是都把参数放到数组里:

redis.pipeline([
    ['set','foo','bar'],
    ['get','foo']
]).exec(function(err,results) {
    console.log('array params',err,results);
});

代码 5.1.5 pipeline 数组参数调用方式

multi 函数跟 pipeline 函数的区别是,multi 提供了事务的功能,提交到 redis 服务器的命令的会被依次执行,pipeline 则是批量执行一批提交一批指令,但是在 redis 内部都是独立执行的,没有先后顺序,只是最终服务器将所有处理结果一起返回给了调用者。不过要想完全保证事务的原子性,我们还需要使用 watch 函数,防止我们在事务中操作一个事务的过程中,当前操作的某一个键值又被其他连接的客户端给修改了:

redis.watch('foo');
redis.multi().set('foo', 'bar').get('foo').exec(function (err, results) {
    redis.unwatch();
    console.log('chain',err, results);
});

代码 5.1.6 multi 事务操作代码

最后一件需要重点指明的事情是,如果你当前使用了 cluster 方式连接 redis,那么最好不要使用 pipeline 和 multi 因为,ioredis 在调用这两个函数的时候,仅仅会往一个节点发送指令,但是你又不能保证你这里面操作的所有键值都在一个节点上,所以说调用这两个函数的时候很有可能会失败。

5.2 mongodb

mongodb官方提供了 Node.js 的 mongodb 驱动,不过鉴于其提供驱动的功能太过于简单,所以又涌现了许多基于官方驱动上开发的第三方驱动。下面要讲一个使用广泛的第三方驱动, mongoose

5.2.1 mongoose

前面讲了 mongskin ,算是 mongodb 知识点的开胃菜, mongoskin 中的函数绝大部分和 mongodb 命令行是类似的。下面要讲的 mongoose 却稍有不同,因为其有一个 ODM (Object Data Model) 的概念,类似于 hibernate 开发中用到的 ORM (Object Relational Mapping) 的概念,它提供了一种将 mongodb 中字段映射为 JavaScript 对象属性的能力。如果我们用 mongoose 来实现一系列的增删改查操作,就必须先定义一个 Schema,不过下面要先讲怎样在 mongoose 中建立连接,否则接下来的例子就没法运行了:

var mongoose = require('mongoose');

mongoose.connect('mongodb://localhost/live', {/*user:'username',pass:'password'*/}); // connect to database

代码 5.2.1.1 mongoose 建立连接代码

在 mongoose 中使用 connect 函数可以初始化 mongodb 连接,第一个参数代表 mongodb 的连接字符串,第二个参数存放连接控制参数,比如说用户名、密码之类的。其实第一个字符串中有更多连接参数控制,可以参考 mongodb 的 官方文档,其中就包括用户名和密码信息(格式为 mongodb://username:password@host:port/database?options...),但是如果你的密码中有特殊字符的话(比如说@),就比较难办了,所以将用户名和密码放到第二个参数中比较保险。

接下来就将mongoose中非常之重要的 Schema,首先直接构造一个我们在 5.2.1 小节中使用过的 article 的schema 声明:

var mongoose = require('mongoose');
require('./conn');//代码5.2.1.1对应的代码

var Schema = mongoose.Schema;

var articleSchema = new Schema({
  name:  String,
  content:   String,
  comments: [{ body: String, date: Date }],
  create_at: { type: Date, default: Date.now }
});
var Article = mongoose.model('article', articleSchema);

代码 5.2.1.2 声明 Schema

通过以上代码可以总结出 shema 干的事情就是把数据库的各个字段的数据类型定义出来,最后我们还通过 model 函数获得了一个 mongoose 中的 Model 类,mongoose 的增删改查都通过这个类来进行。注意第一个参数代表表名。

new Article({
    name:'chapter5',
    content:'Express.js 基础',
    comments : [
        {body:'写的不多',date:new Date('2016-10-11')},
        {body:'我顶',date:new Date('2017-01-01')}
    ],
    create_at:new Date('2016/07/03')
}).save(function(err,item) {
    console.log(err,item);
});

代码 5.2.1.3 mongoose 插入操作

为啥说 model 函数得到的是类呢,通过 代码 5.2.1.3 就可以看出,我们通过 new 生成一个对象实例,然后调用其 save 函数将其插入数据库。如果我们将 create_at 属性去掉,那么其值就会自动取当前时间。不过等你执行完上述代码后,查看数据库,咦,surprise,数据库里竟然多了一个名字叫 articles 的表,不是说 model 的第一个参数是执行关联的表明吗,明明在 代码 5.2.1.2 中指定的表明是 article 啊?是的,不要惊讶,mongoose 默认就是这么设计的,如果你想绑定到一个自定义的一个表明上,可以在实例化 Schema 的时候,传入一个可选参数:

var articleSchema = new Schema({/*此处省略字段定义*/},{collection:'article'});

这样将 articleShema 插入model 后得到的 Article 就绑定表 article 上了。

说了插入单条,再说一下批量插入,这时候使用 insertMany 函数即可:

Article.insertMany([
    {name:'chapter1',content:'Node.js 简介1',create_at:new Date('2016/07/01')},
    {name:'chapter1',content:'Node.js 简介2',create_at:new Date('2016/07/01')},
    {name:'chapter1',content:'Node.js 简介3',create_at:new Date('2016/07/01')},
    {name:'chapter2',content:'Node.js 基础4',create_at:new Date('2016/07/02')},
    {name:'chapter2',content:'Node.js 基础5',create_at:new Date('2016/07/02')}
],function(err,ret) {
    console.log('插入数组',err,ret);
});

代码 5.2.1.4 mongoose 批量插入操作

mongoose 的修改操作和官方 API 差不多:

Article.update({name:'chapter2'},{
    $set:{content:'Node.js 入门'}
},function(err,ret) {
    console.log('更新单条数据',err,ret);
});
Article.update({name:'chapter2'},{
    $set:{content:'Node.js 入门'}
},{multi:true},function(err,ret) {
    console.log('更新多条数据',err,ret);
});

代码 5.2.1.5 mongoose 修改操作

不过它的删除稍微有些不同,就是删除的时候仅仅只能指定一个查询参数,如果你想仅仅删除一条的话,那就需要先查询出来,然后再删除。

Article.findOne({name:'chapter1'}).remove().exec(function(err,ret) {
    console.log('删除数据',err,ret);
});
Article.remove({name:'chapter1'},function(err,ret) {
    console.log('删除数据',err,ret);
});

代码 5.2.1.6 mongoose 删除操作

上面总结了一下 mongoose 的一些基本用法,不过前面的描述还不足以体现 mongoose 的强大,下面讲到的一些高级用法,绝对能让你感到惊艳。

首先 mongoose 提供了中间件( middleware )的功能,我们可以在执行数据命令前和执行后添加钩子函数,先上代码:

var mongoose = require('mongoose');
require('./conn');//代码6.2.2.1对应的代码

var Schema = mongoose.Schema;

var articleSchema = new Schema({
  name:  String,
  content:   String,
  comments: [{ body: String, date: Date }],
  create_at: { type: Date, default: Date.now }
});

articleSchema.pre('save',function(next) {
    this.content = this.name  + '\n' + this.content;
    next();
});

articleSchema.post('save', function(doc) {
    console.log('%s has been saved', doc._id);
});

var Article = mongoose.model('article', articleSchema);

new Article({
    name:'chapter5',
    content:'Node 中使用数据库',
    comments : [
        {body:'写的不多',date:new Date('2016-10-11')},
        {body:'我顶',date:new Date('2017-01-01')}
    ],
    create_at:'2017-02-11'
}).save(function(err,item) {
    console.log(err,item);
});

代码 5.2.1.7 save 的中间件函数演示

我们创建了一个 article 的 schema 定义,同时定义了两个中间件。通过 pre('save') 操作,我们在文章的第一行拼接了文章的标题,然后注意一定要调用 next 函数,否则当前数据库操作就不会得到执行。通过 post('save') 操作用来在数据库操作完成之后执行一些级联操作,这里我们简单的打印了一下日志。这两个中间件函数会先于 save 函数的回调函数前执行。

在调用 save 函数时,mongoose 中还提供了一个 validate 中间件,他会在 pre('save') 之前被触发,用来校验传入 save 函数的各个属性是不是合法:

articleSchema.pre('validate',function(next) {
    if (/<script>/.test(this.content)) {
        return next(new Error('文章内容非法'));
    }
    next();
});
new Article({
    name:'chapter5',
    content:'Node 中使用数据库<script>alert(document.cookie)</script>',
}).save(function(err,item) {
    console.log(err,item);
});

代码 5.2.1.8 save 的 validate 中间件函数演示

上面的代码执行后,会抛出异常,因为我们的 article content 字段中包含 script 标签。令人欣喜的是,mongoose 还提供将 validate 中间件直接加到 schema 定义上的功能:

var mongoose = require('mongoose');
require('./conn');//代码6.2.2.1对应的代码

var Schema = mongoose.Schema;

var articleSchema = new Schema({
    name:  {
        type:String,
        required: [true,'必须提供文章标题'],
        maxlength : [50,'文章标题不能多于50个字符']
    },
    isbn : {
        type:String,
        unique:true,
        sparse: true
    },
    content:  {
        type:String,
        validate:{
            validator : function() {
                return !(/<script>/.test(this.content));
            },
            message : '文章内容非法'
        }
    },
    starts : {
        type:Number,
        min:0,
        max:[5,'最多只能给5颗星'],
        default:0
    },
    level : {
        type:String,
        enum:['专家推荐','潜力无限','家有作家初长成','我只是个小学生']
    },
    category : {
        type:String,
        enum:{
            values:['诗歌','散文','杂文','议论文','小说'],
            message:'当前标签不支持'
        }
    },
    cover_url : {
        type:String,
        match:[/^http(s?):\/\//,'封面图格式非法']
    },
    comments: [{ body: String, date: Date }],
    create_at: { type: Date, default: Date.now }
});

articleSchema.pre('save',function(next) {
    this.content = this.name  + '\n' + this.content;
    next();
});

articleSchema.post('save', function(doc) {
    console.log('%s has been saved', doc._id);
});

var Article = mongoose.model('article', articleSchema);

new Article({
    name:'chapter5',
    content:'Node 中使用数据库<script>alert(document.cookie)</script>',
}).save(function(err,item) {
    if (err && err.name === 'ValidationError') {
        for (var field in err.errors) {
            var error = err.errors[field];
            console.error(error.message,error.path,error.value);
        }
    }
});

代码 5.2.1.9 在 schema 中使用校验器

mongoose 内建了好多校验器(validator),对于所有类型字段来说都可以使用 required 校验器,对于 Number 类型字段来说,可以使用 minmax 校验器,对于 String 类型字段来说,可以使用 enum match maxlength minlength 校验器。

所有校验器都可以设置在校验失败后的错误提示信息,如果相对某一个字段设置 required 约束,那么可以写成 required:true ,还可以进一步指定校验失败后的提示信息,也就是写成这样 required:[true,'这个字段必须指定'] 。但是对于 enum 来说,由于本身定义的时候就是一个数组结构(参见上面代码中 level 字段的定义),所以 mongoose 内部在定义其 message 属性时使用这样一个 Object 结构:{values:[/*枚举字段定义*/],message:'出错提示信息'}

还记得在代码 5.2.1.8中我们自定义的那个 content 字段的校验中间件不?这个中间件可以直接写到 schema 定义中,在代码 5.2.1.9中的 content 字段中的 validate 属性,就能替换掉之前我们写过的校验中间件。

最终你在调用 save 函数之前,这层层的字段定义约束都会被执行,如果校验出错,那么 save 回调函数返回的第一个参数中的 name 属性的值将是 ValidationError,然后其 errors 属性中保存着字段的详细信息的一个 key-value数据结构,键名是出错的字段名,值是一个包含错误详情的对象,这个对象中 message 属性就是我们在 schema 中设置的出错信息, path 是出错的字段名,value 是引起出错的具体的设置的值。

最终需要注意,unique 这个约束并不是一个 ValidationError (实际上其 name 属性值为 MongoError),所以你 save 失败后得到的error 对象中没有errors 属性。unique 和 sparse 仅仅是 schema 调用 mongodb 的驱动创建了数据库索引而已。代码 5.2.1.9 中关于 isbn 的约束,也可以通过 schema 中的 index 函数来实现:

articleSchema.index('isbn',{unique:true,sparse:true});

代码 5.2.1.10

前面讲了许多 mongoose 的插入、修改之类的操作,一直没有提到查询操作,下面就来讲一下查询。

在讲查询之前,需要先将我们在代码5.2.1.9中定义的 articleSchema 进行一下扩充,增加下面这个字段:

_author : {type:Schema.Types.ObjectId,ref:'user'},

代码 5.2.1.11

至于其中的 _ref 属性是怎么回事,我们先卖个关子,一会儿再说。

mongoose 在查询方面,有好多细节做了优化,比如说在筛选返回字段的时候可以直接通过字符串来指定:

Article.findOne({name:nameRand},'name -_id',function(err,item) {
  if (err) {
    return console.error('findOne',err);
  }
  console.log('findOne',item && item.name === nameRand);
});

代码 5.2.1.12 mongoose 查询使用字符串筛选字段

mongoose 的查询中的各个控制参数都可以链式的调用各个函数来解决,比如说上例中用到的字段筛选可以使用 select 函数来替代,即改成 Article.findOne({name:nameRand}).select('name -_id').exec(function(err,item) {}); 当中可以添加无数个链式函数来控制查询行为,比如说 limit skip lean 等等,最后以 exec 函数结尾添加回调函数。mongoose 查询默认返回的是 MongooseDocuments 类型对象,使用lean 函数后可以将其转成普通 javascript 对象:

Article.find({name:/^name/}).select('_author').lean().exec(function(err,items) {
  if (err) {
    return console.error('find',err);
  }
  console.log('find',items);
});

代码 5.2.1.13 mongoose 查询返回纯 javascript 对象

转纯 javascript 对象的使用场景一般比较少见,当我们拿查询的结果作为参数来调用一些第三方库(比如说 protobufjs )时,不调用lean的情况下会出错。

最后还要暴一下 mongoose 中的大杀器,就是联合查询,其实 mongdb 本身是没有联合查询功能的,这个功能是在 mongoose 层面延伸的功能:

Article
  .findById(articleId)
  .select('name _author')
  .populate('_author','nickname -_id')
  .exec(function(err,item) {
  if (err) {
    return console.error('findById',err);
  }
  console.log('populate',item);
});

代码 5.2.1.14 mongoose 联合查询功能

还记得我们在代码 5.2.1.10中卖的关子不,我们看到其中有一个 _ref 属性,它的作用就是告诉 mongoose _author 字段的值对应 users 表中的主键字段,如果在查询的时候使用 populate 函数,则 mongoose 将在底层做两次查询(查询 articles 表 和 users 表),然后把查询结果合并。最终得到的结构演示如下:

{ name: 'name0.6169953700982793',
  _author: { nickname: 'nick0.09724390163323227' },
  _id: 5916e9178be9f133b4798002 }

5.3 代码

本章代码参见这里:https://github.com/yunnysunny/nodebook-sample/tree/master/chapter5

最后更新于