5.3.5 mongoose操作数据库
Node.js中连接使用mongoose库来操作MongoDB数据库。这里查看mongoose中文版文档。
安装mongoose(前提是已经安装好了mongoDB数据库)。
npm install --save mongoose
Express入口文件index.js中通过代码连接数据库。
注意,不需要等待数据库连接完成,就可以继续后续操作,这些操作在连接完成之前会被缓存起来,一旦连接上就会调用。
var mongoose = require('mongoose');
mongoose.connect('mongodb://localhost/test');
var db = mongoose.connection;
db.on('error', console.error.bind(console, 'connection error:'));
db.once('open', function() {
// we're connected!
});
基本使用
mongoose中创建集合的方式基于Schema,通过它来声明集合每一个文档的数据结构(包括属性和行为)。
var kittySchema = mongoose.Schema({
name: String
});
// kittySchema也可以创建方法,方法给文档实例使用
kittySchema.methods.speak = function () {
var greeting = this.name
? "Meow name is " + this.name
: "I don't have a name";
console.log(greeting);
}
接下来,利用Schema生成用来构造document的model,model表示一个文档的构造体。
var Kitten = mongoose.model('Kitten', kittySchema);
model方法的第一个参数最好使用负数的名字,如果使用单数的名字,它会自动转成复数名称。
通过Kitten来创建一个文档实例
var felyne = new Kitten({ name: 'Felyne' });
console.log(felyne.name);
var fluffy = new Kitten({ name: 'fluffy' });
fluffy.speak(); // "Meow name is fluffy"
保存这两个文档到数据库
felyne.save(function (err, felyne) {
if (err) return console.error(err);
});
fluffy.save(function (err, fluffy) {
if (err) return console.error(err);
});
查找我们保存的数据
Kitten.find(function (err, kittens) {
if (err) return console.error(err);
console.log(kittens);
})
Schema详解
Schema相当于文档字段的定义,或者可以理解成表格数据的表头信息。
以下是 mongoose 的所有合法 SchemaTypes:
String:字符串。
Number:数字,包括整数、小数、十进制、十六进制等。
Date:标准的ISO格式日期。
Buffer:用来存储字节。
Boolean:布尔。
Mixed:可以理解成任意类型,定义时传入Object对象也会被当做Mixed类型处理。
ObjectId:标记用的唯一ID,他不是简单的数字或字符串,由24位Hash字符串组成。
Array:数组。
Decimal128:使用128位二进制表示的十进制超大值整数类型,可用于科学计数法。
Schema:属性也支持使用另一个Schema。
创建Schema字段的方式有两种:
var schema = new Schema({
name: String,
age: { type: Number, min: 18, max:65, default: 20},
any:{}
})
字段配置支持的选项
required: 布尔值或函数 如果值为真,为此属性添加 required 验证器
default: 任何值或函数 设置此路径默认值。如果是函数,函数返回值为默认值
select: 布尔值 指定 query 的默认 projections
validate: 函数 adds a validator function for this property
其他特定字段的配置项请参考这里。
Schema也支持自定义Type。
Model详解
Model是Schema编译而来,表示的是从数据库保存和读取的 documents。
var schema = new mongoose.Schema({ name: String, age: Number });
var Tank = mongoose.model('Child', schema);
尤其注意
第一个参数是跟 model 对应的集合( collection )名字的 单数 形式。 Mongoose 会自动找到名称是 model 名字 *复数* 形式的 collection 。 对于上例,Child 这个 model 就对应数据库中 Children 这个 collection。.model() 这个函数是对 schema 做了拷贝(生成了 model)。 你要确保在调用 .model() 之前把所有需要的东西都加进 schema 里了!
以下两种方式都可以保存单条数据,但推荐第二种:
var Tank = mongoose.model('Tank', yourSchema);
var small = new Tank({ size: 'small' });
small.save(function (err) {
if (err) return handleError(err);
// saved!
})
// or
Tank.create({ size: 'small' }, function (err, small) {
if (err) return handleError(err);
// saved!
})
如果 model 是通过调用 mongoose.model() 生成的,它将使用 mongoose 的默认连接。如果自行创建了连接,就需要使用 connection 的 model() 函数代替 mongoose 的 model() 函数。
var connection = mongoose.createConnection('mongodb://localhost:27017/test');
var Tank = connection.model('Tank', yourSchema);
Document
通过Model操作得到的结构都是Document对象(或数组)。Document对象的大多数类型属性都可以直接操作,但是Date类型和Mixed类型则不行,操作之后,需要调用markModified()方法。因此操作属性的底层使用的是set,所以不支持对值直接进行操作。
Tank.findById(id, function (err, tank) {
if (err) return handleError(err);
tank.size = 'large';
// or
tank.set({ size: 'large' });
// 因为Date内部不支持直接调用方法修改自身。
tank.bornDate.setMonth(3);
tank.makModified('bornDate');
tank.save(function (err, updatedTank) {
if (err) return handleError(err);
res.send(updatedTank);
});
});
查询器
查询是mongoose最主要的功能,支持单个和多个查询,支持ID和任何条件查询,同时还支持聚合查询和排序等。
Model 的方法中包含查询条件参数的( find findById count update )都可以按以下两种方式执行:
传入 callback 参数,操作会被立即执行,查询结果被传给回调函数( callback )。
如果查询时发生错误,error 参数即是错误文档, result 参数会是 null。
如果查询成功,error 参数是 null,result 即是查询的结果。
不传 callback 参数,会返回一个Query实例。
Query 实例本质上是一个封装过的Promise对象。
它支持链式调用。
查询结果的格式取决于做什么操作:
findOne() 是单个文档(有可能是 null )
find() 是文档列表
count() 是文档数量
update() 是被修改的文档数量。
Models API 文档中有详细描述被传给回调函数的值。
var Person = mongoose.model('Person', yourSchema);
Person.
find({
occupation: /host/,
'name.last': 'Ghost',
age: { $gt: 17, $lt: 66 },
likes: { $in: ['vaporizing', 'talking'] }
}).
limit(10).
sort({ occupation: -1 }).
select({ name: 1, occupation: 1 }).
exec(callback);
填充(populate)
mongoose里的populate类似SQL中的join操作,也就是可以进行级联查询。
创建了两个 Model。Person model 的 stories 字段设为 ObjectId数组。 ref 选项告诉 Mongoose 在填充的时候使用哪个 model,本例中为 Story model。 所有储存在此的 _id 都必须是 Story model 中 document 的 _id。
var mongoose = require('mongoose');
var Schema = mongoose.Schema;
var personSchema = Schema({
_id: Schema.Types.ObjectId,
name: String,
age: Number,
stories: [{ type: Schema.Types.ObjectId, ref: 'Story' }]
});
var storySchema = Schema({
author: { type: Schema.Types.ObjectId, ref: 'Person' },
title: String,
fans: [{ type: Schema.Types.ObjectId, ref: 'Person' }]
});
var Story = mongoose.model('Story', storySchema);
var Person = mongoose.model('Person', personSchema);
var author = new Person({
_id: new mongoose.Types.ObjectId(),
name: 'Ian Fleming',
age: 50
});
author.save(function (err) {
if (err) return handleError(err);
var story1 = new Story({
title: 'Casino Royale',
author: author._id // 把Person的Id赋给story1
});
story1.save(function (err) {
if (err) return handleError(err);
// thats it!
});
});
查询Story的方式也很简单,需要在find后面链式调用populate。
被填充的字段已经不是原来的 _id,而是被指定的 document 代替,这个 document 由另一条 query 从数据库返回。
Story.
findOne({ title: 'Casino Royale' }).
populate('author').
exec(function (err, story) {
if (err) return handleError(err);
console.log('The author is %s', story.author.name);
// prints "The author is Ian Fleming"
});
如果我们只需要填充的 document 其中一部分字段怎么办? 第二参数传入 field name syntax 就能实现。
Story.
findOne({ title: /casino royale/i }).
populate('author', 'name'). // only return the Persons name
exec(callback);
填充多个document可以使用多个populate。
验证
验证可以保证在数据被存储在mongoDB之前对数据进行校验。
如果你要使用验证,请注意一下几点:
验证定义于 SchemaType
验证是一个中间件。它默认作为 pre('save')` 钩子注册在 schema 上
你可以使用 doc.validate(callback) 或 doc.validateSync() 手动验证
验证器不对未定义的值进行验证,唯一例外是 required 验证器,所有 SchemaTypes 都有内建 required 验证器。required 验证器使用 checkRequired() 函数 判定这个值是否满足 required 验证器
验证是异步递归的。当你调用 Model#save,子文档验证也会执行,出错的话 Model#save 回调会接收错误
验证是可定制的
内建验证器处理required:
Numbers 有 min 和 max 验证器.
Strings 有 enum、 match、 maxlength 和 minlength 验证器
内建验证器不够用可以使用validate自建验证器,内建验证器支持返回Promise
var schema = new Schema({
name: {
type: String,
required: true
},
phone: {
type: String,
validate: {
validator: function(v) {
return /\d{3}-\d{3}-\d{4}/.test(v);
},
message: '{VALUE} is not a valid phone number!'
},
required: [true, 'User phone number required']
},
name: {
type: String,
// You can also make a validator async by returning a promise. If you
// return a promise, do **not** specify the `isAsync` option.
validate: function(v) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
resolve(false);
}, 5);
});
}
}
});
var Cat = db.model('Cat', schema);
// This cat has no name :(
var cat = new Cat();
cat.save(function(error) {
assert.equal(error.errors['name'].message,
'Path `name` is required.');
error = cat.validateSync();
assert.equal(error.errors['name'].message,
'Path `name` is required.');
});
中间件
mongoose也提供了和Express类似的中间件机制(或者叫钩子机制)。
根据调用发生的位置,分为pre和post两种钩子,pre是在mongoose指定方法(如save)之前运行,而post是在之后。
var schema = new Schema(..);
schema.pre('save', function(next) {
// do stuff
next();
});
schema.pre('save', async function() {
await doStuff();
await doMoreStuff();
});
// 这个save pre钩子会和前面连个实现并行机制运行
schema.pre('save', true, function(next, done) {
// calling next kicks off the next middleware in parallel
next();
setTimeout(done, 100);
});
save() 函数触发 validate() 钩子,mongoose validate() 其实就是 pre('save') 钩子, 这意味着所有 pre('validate') 和 post('validate') 都会在 pre('save') 钩子之前调用。
Mongoose 4.x 有四种中间件: document 中间件,model 中间件,aggregate 中间件,和 query 中间件。
Document 中间件支持以下 document 操作, this 指向当前 document:
init
validate
save
remove
Query 中间件,this 指向当前 query。 Query 中间件支持以下 Model 和 Query 操作:
count
find
findOne
findOneAndRemove
findOneAndUpdate
update
Query 中间件 不同于 document 中间件:document 中间件中, this 指向被更新 document,query 中间件中, this 指向 query 对象而不是被更新 document。
例如,如果你要在每次 update之前更新 updatedAt 时间戳, 你可以使用 pre 钩子。
schema.pre('update', function() {
this.update({},{ $set: { updatedAt: new Date() } });
});
Model 中间件,this 指向当前 model。 Model 中间件支持以下 Model 操作:
insertMany
Aggregate(聚合) 中间件作用于 MyModel.aggregate(), 它会在你对 aggregate 对象调用 exec() 时执行。 对于 aggregate 中间件,this 指向当前aggregation 对象。
aggregate
mongoose中最重要的API是Model API,需要多加参考。
六. 案例:实现一个后台管理系统
6.1 创建一个git项目,发布到github
在码云上面注册一个账号。
创建一个项目songs-manager
接下来,在你电脑的本地找到一个可以存放工程的目录,比如我们就叫它:~/workspace,把命令行环境切换到这个目录中。
cd ~/workspace
通过git clone 把项目克隆到本地,克隆完毕修改下README.md。整个项目的架构规范就按照这个来即可。
# Songs-Manager
#### 介绍
管理曲库和歌手库的Nodejs练习项目
#### 软件架构
├── README.md - 项目文档
├── app.js - 初始化应用
├── bin - 命令脚本
├── controllers - 定义路由处理的实现逻辑
├── helpers - 可以被项目各部分所调用的功能函数和代码
├── middlewares - Express 中间件,将要处理在进入路由之前的请求
├── models - 表示数据,实现业务逻辑和处理存储
├── package.json - 项目配置及其依赖的包
├── public - 包含所有的静态文件,像图片、样式和脚本
├── routes - 定义路由,这里的路由仅用于转发
├── tests - 测试在其他文件夹的的代码
└── views - 提供模板文件,模板文件将会在你路由中进行渲染和使用
在目录下配置git邮件信息(码云需要绑定了邮箱才可以)
$ git config user.email
express songs-manager创建项目,允许在原项目创建
$ express songs-manager --view=ejs
提示:目标路径已存在,是否继续?输入:y
把工作目录切换到项目目录:
cd ./songs-manager
后续操作就是在整个环境中安装依赖包和开发了
项目中安装nodemon
npm install --save nodemon
或者
yarn add -D nodemon
修改package.json
"license": "Apache Licence 2.0",
"scripts": {
"start": "node ./bin/www",
"debug": "set DEBUG=myapp:* && nodemon --inspect ./bin/www"
}
express生成的项目,默认么有安装依赖,需要用命令安装依赖
npm install
或者
yarn install
启动项目:
npm run start
或者
yarn run start
注意,如果启动不成功很有可能是端口3000已经被占用,需要关闭进程:
windows:
netstat -ano | findstr 端口号
参数为LISTENING 时,就需要手动关闭这个进程,最后一个参数是这个进程的进程号。
taskkill -PID 进程号 -F
mac:
$ lsof -i:端口号
$ kill 进程号
6.2 实现注册登录
接下来,我们利用Express实现一个具备基本功能的曲库管理网站,我们需要使用一个叫Hui的框架。
Hui是一个基于Bootstrap的轻量级前端框架,它的设计风格很美观,而且简单免费,兼容性好。同时提供了一个H-ui.admin,这个是基于Hui的组件库构建的一整套后台管理页面,可以快速开发轻量级网站后台模版。
首先,我们利用这个框架实现一个基本的登录注册功能。
6.2.1 登录页面改造
先进入官网体验一下体验框架,然后下载框架。注意,我们下载H-ui.Admin.page v3.1,它是基于普通页面,而非iframe的。
下载完成后,把它放到songs-manager项目中,留待使用。
把H-ui.Admin.page v3.1的公共的static和lib目录放到public目录中,以便访问HTML页面时可以直接访问到这些静态资源文件。
选中其中的login.html放到views目录下面,并把文件名改为login.ejs,这样该文件就能转成一个ejs文件可以拼接数据了,并把所有script和link标签引用的资源文件路径,指向可访问的根路径下的对应目录。
如static/h-ui/css/H-ui.min.css改成/static/h-ui/css/H-ui.min.css
(可选)删除无用代码,例如:百度统计的代码:
var _hmt = _hmt || [];
(function() {
var hm = document.createElement("script");
hm.src = "https://hm.baidu.com/hm.js?080836300300be57b7f34f4b3e97d911";
var s = document.getElementsByTagName("script")[0];
s.parentNode.insertBefore(hm, s);
})();
增加routes/index.js的路由,渲染login页面:
/* 登录页面 */
router.get('/login', function (req, res, next) {
res.render('login', { title: '登录管理系统' });
});
查看效果。
6.2.2 后端登录逻辑实现
1. 解决HTTP无状态
上文讲解网络协议的时候,最后我们提到HTTP是无状态协议,这样就导致我们无法光通过HTTP请求就能判断客户端的身份来源(你只知道IP地址,但你不知道对方操作者是人还是机器)。
2. session和cookie
cookie解决了服务器存储和获取客户端身份的问题,它的基本原理是:
当用户第一次浏览某个使用Cookie的网站时,该网站的服务器为该用户生成一个唯一的识别码(Cookie id),同时创建一个Cookie对象;
Cookie是什么?
Cookie是网站为了辨别用户身份、进行session跟踪而储存在用户本地终端上的数据。
Cookie有时候是由服务端生成的,发送给客户端(通常是浏览器)的,当然客户端也可以自己生成Cookie。
Cookie总是保存在客户端中,按在客户端中的存储位置,可分为内存Cookie和硬盘Cookie:
服务器如何通知客户端保存Cookie?
服务器返回给客户端的响应报文可以将Cookie值插入到一个 Set-Cookie HTTP请求报头中。
默认情况下它是一个会话级别的cookie,存储在浏览器的内存中,用户退出浏览器之后被删除。
如何区分会话内存Cookie和持久硬盘Cookie?
如果网站希望浏览器将该Cookie存储在磁盘上,则需要设置最大时效(maxAge),并给出一个以秒为单位的时间(将最大时效设为0则是命令浏览器删除该Cookie);
如果存储时没有设置失效则是内存Cookie。
浏览器收到该响应报文之后,根据报文头里的Set-Cookie生成相应的Cookie,保存在客户端。该Cookie里面记录着用户当前的信息。
当用户再次访问该网站时,浏览器首先检查所有存储的Cookie,如果找到了该网站的Cookie,则把该Cookie附在请求资源的HTTP请求头上发送给服务器。
服务器接收到用户的HTTP请求报文之后,从报文头获取到该用户的Cookie,从里面找到所需要的东西。
Cookie的主要作用就是在客户端存储用户访问网站的一些信息,以便下次访问时可以携带这些数据进行验证。常用于:
记住密码,下次自动登录。
电商网站保存购物车数据。
记录用户浏览的足迹,定制化广告推荐。
Cookie有哪些缺点?
同域的HTTP请求默认会携带Cookie,而且HTTP请求中的Cookie是明文传递的,所以安全性成问题,不过用HTTPS协议可以解决这个问题。
Cookie的大小限制在4KB左右。对于复杂的存储需求来说是不够用的。
如果想解决上面的两个问题,就应该考虑SessionStorage或者LocalStorage了,这两个都是本地存储API,不仅解决了容量问题,而且存储的数据不会自动被HTTP请求携带。
session解决了HTTP无状态的问题,它利用了Cookie的存储和请求携带的特点,可以判断多次用户连接是否是来源一个用户。
Session是一种服务器端的机制,Session 对象用来存储特定用户会话所需的信息。因此Session是由服务端生成,保存在服务器的内存、缓存、硬盘或数据库中。
它的基本原理是:
用户首次访问服务器,服务器就为该客户端创建一个Session并生成一个与此Session相关的Session id。这个Session id是唯一的、不重复的、不容易找到规律的字符串。
服务器生成Session id之后,这个Session id在本次响应中通过HTTP的响应报文返回到客户端保存,而保存这个Session id的正是Cookie。
浏览器在下次请求时,自动的按照规则把这个Session id发送给服务器。
服务器通过Session id找到存储在服务器上的Session在信息。
Session和Cookie看似很相似,但他们完全不是一个东西,Cookie是服务器用来让客户端存储和携带的数据容器,而Session是服务器判断客户端身份的一种验证方式。
3. 保存会话身份
session是服务器的机制,因此可以用在Express中。
接下来我们就利用session机制来实现一个用户是否登录的过程。
安装相关中间件
npm install --save express-session
express-session:用来方便的创建和操作session,在服务端默认会使用MemoryStore存储 Session,这样在进程重启时会导致Session丢失,且不能多进程环境中传递。在生产环境中,应该使用外部存储,以确保 Session 的持久性。
脚手架生成的项目,cookie-parser已经默认安装好了
改造app.js文件,加入如下代码:
//require others...
var session = require('express-session')
//
app.use(session({
secret:'fddfsjfkl',// 加密session信息的私钥
cookie:{maxAge:60*1000},// Cookie的过期时间
resave:true,// 强制将 session 保存回session存储区,即使在请求期间session从不被修改。
saveUninitialized:true,// 强制将未初始的session保存到存储中。Session在新创建而未修改时,是未初始化状态。
}))
在routes目录中创建一个api.js文件,该文件对应的是/api/v1的路由:
api.js
var express = require('express');
var router = express.Router();
/* 登录页面 */
router.post('/login', function (req, res, next) {
const body = req.body
console.log(body)
if (
body.username === '小明' &&
body.password === '123456' &&
body.email && body.email.toLowerCase() === '[email protected]' &&
body.verifyCode && body.verifyCode.toLowerCase() === '1234'
) {
req.session.isLogin = true
if (body.online === 'online') {
// 以后处理
}
res.send({
username: '小明',
age: 34,
school: '清华大学'
});
} else {
res.status(401).send('登录失败')
}
});
/* 退出登录页面 */
router.post('/logout', function (req, res, next) {
// 销毁当前用户的session
if (req.session && req.session.isLogin) {
req.session.destroy();
res.send('登出成功');
} else {
res.send('当前没有用户登录');
}
});
module.exports = router;
在app.js中,引入api.js:
var apiRouter = require('./routes/api')
// 加在app.use('/users', usersRouter);语句后面
app.use('/api/v1', apiRouter);
通过PostMan测试下接口。
测试登录
测试退出登录
接下来改造前端登录页面login.ejs,使之点击登录按钮可以自动请求接口并处理返回数据。
修改body的主体内容:
主要是添加表单元素的name属性,请求地址,绑定监听器,调整样式。
// 在这里编写主要的事件逻辑
//获取验证码,倒计时不能再点击
function countDown (elemTarget, times) {
// self表示当前正在被点击的元素,通过给它上面追加属性避免定义全局变量
if (elemTarget.count !== undefined && elemTarget.count <= times) {
return
}
elemTarget.count = 0
// 使用setTimeout而避免使用setInterval,因为setInterval不能保证时间是在每隔1s
elemTarget.clearId = setTimeout(function () {
elemTarget.value = '等待' + (++elemTarget.count) + '秒再次发送'
if (elemTarget.count > times) {
clearInterval(elemTarget.clearId)
elemTarget.value = '再次发送验证码'
elemTarget.count = undefined
} else {
// 自己调用自己
elemTarget.clearId = setTimeout(arguments.callee, 1000)
}
}, 1000)
postEmailVerifyCode()
}
function postEmailVerifyCode () {
console.log('发送验证码')
}
// 引入jquery.form之后,jquery.form扩展了jquery的方法,增加了ajaxForm,
// ajaxForm会对form表单进行封装,但不会触发form的提交
// 当点击button提交时,会把form的提交转换成ajax的提交
$('#login-form').ajaxForm({
success: function (data) {
window.location.href = '/'
},
error: function (data) {
const {status, responseText} = data
alert('错误码' + status + responseText)
}
});
这里我们使用一个jquery.form.js的库
它可以将一个表单的submit转换成ajax提交,而无须我们做任何多余处理。中文文档在这里,点击查看。
修改下login.ejs中的样式,防止容器内部的表单元素超出容器。
.loginBox{ position:absolute; width:617px; height:370px; background:transparent url("../images/admin-loginform-bg.png") center no-repeat fixed / cover; left:50%; top:50%; margin-left:-309px; margin-top:-244px; padding-top:38px}
验证页面看看,填写验证信息,是否能够登录成功,目前我们用的都是假数据:
{username: "小明", password: "123456", email: "[email protected]", verifyCode: "1234"}