STAY HUNGRY , STAY FOOLISH.

求知若饥,虚心若愚。

       浏览:

教你用Koa发送手机验证码实现注册登录

1.Koa是什么

Koa

Koa是下一代的Node.js的Web框架。既然说是下一代,那么肯定有上一代咯。
没错,上一代Node.js的Web框架叫Express。

Express

要问它俩有什么共同点,它俩的共同点是两个框架来自同一个团队
要问它俩最大的不同点,它俩最大的不同点就是轻与重

讲人话,Koa到底如何理解呢?简单理解,Koa就是一个轻量的服务器,与Java的Tomact类似。


2.Koa的特点

1.轻量

Koa的核心文件总共不超过40KB,Koa只提供封装好的http上下文、请求、响应,以及基于async/await的中间件容器。
KoaFile


2.灵活

轻的好处当然就是灵活,可以直接使用Koa的各类包,来满足你对业务不同的需求。NPM官网到目前为止,已经收录2584个关于Koa的npm包。
KoaPackage


3.先进

这个怎么解释呢?koa2和koa1有很大的区别,koa2利用ES7的async/await的来处理传统回调嵌套问题和代替koa1的generator。
可能听不懂什么意思,没关系,简单讲就是为解决异步
以前的ES5,接着ES6,最后ES7,举个简单例子。

1.使用ES5传统回调嵌套

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function ajaxs(callback){
$.get('a.html',function(dataa) {
console.log(dataa);
$.get('b.html',function(datab) {
console.log(datab);
$.get('c.html',function(datac) {
console.log(datac);
callback();
});
});
});
}

ajaxs(function(){
console.log('OK');
});

2.使用ES6的generator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function request(url) {
$.get(url, function(response){
console.log(response);
it.next(response);
});
}
function* ajaxs() {
yield request('a.html');
yield request('b.html');
yield request('c.html');
return console.log('OK');
}
let it = ajaxs();
it.next();

3.使用ES7的async/await

1
2
3
4
5
6
7
8
9
10
11
12
async function request(url) {
await $.get(url, function(response){
console.log(response);
});
}
async function ajaxs() {
await request('a.html');
await request('b.html');
await request('c.html');
console.log('OK');
}
ajaxs();

koa2可以让异步逻辑用同步写法实现,因为其支持asyncawait。该特性可以通过多层 async function 的同步写法代替传统的callback嵌套。
这样写的后端代码逻辑既清晰,又美观,是不是很先进。以后如果还有能解决异步的最佳方法,相信koa也会与时俱进,及时跟进,就目前而言,使用async/await是解决异步的最佳方法


3.Koa的中间件

中间件是什么?中间件就是指上面的2584个关于Koa的npm包,有点类似Java的jar包。
使用任意一个npm包可没有像Java那么简单,Java只需要导入jar包后build path即可,而JS需要引入且配置。

下面主要讲解常用的koa中间件:koa-router、koa-logger、koa-session、koa-bodyparser。
当然koa和mongoose整合也是必不可少的。
直接上完整的项目代码,首先看下整体的目录结构:
XJS

目录讲解:

1
2
3
4
5
6
7
app/models 模型层,定义各种表结构
app/controllers 业务层,实现各种功能
app/service 服务层,调用第三方的服务,如:发短信验证码

config 配置请求路径

app.js 服务器主入口

1.主入口app.js功能讲解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
'use strict'

/**
* mongoose Node连接MongoDB数据库
* bluebird 功能全面的Promise库
* koa web框架核心库
* koa-router 路由中间件
* koa-logger 打印日志中间件
* koa-session session中间件
* koa-bodyparser body解析器(如:POST请求传参)
*
* speakeasy 验证码生成器
* xss 防止xss攻击
* uuid 唯一识别码token
* sms 发送螺丝帽短信工具
*
**/

//引入node读取文件和路径库
const fs = require('fs')
const path = require('path')

//引入mongoose,配置MongoDB数据库
const mongoose = require('mongoose')
const url = 'mongodb://localhost/xjs'

//mongoose默认的promise库已过时
mongoose.Promise = require('bluebird')

//创建数据库连接
//方法1
//const db = mongoose.createConnection('localhost', 'xjs');
//方法2
mongoose.connect(url);
const db = mongoose.connection;


//监听数据库连接状态
db.on('error', ctx => console.log('连接异常:' + ctx))
db.on('connected', ctx => console.log('连接成功'))
db.on('disconnected', ctx => console.log('连接断开'))

//当前models路径
const models_path = path.join(__dirname, '/app/models')

//循环读取models
const walk = function(modelPath) {
fs
.readdirSync(modelPath)
.forEach(function(file) {
let filePath = path.join(modelPath, '/' + file)
let stat = fs.statSync(filePath)
console.log(filePath);
if (stat.isFile()) {
if (/(.*)\.(js|coffee)/.test(file)) {
require(filePath)
}
} else if (stat.isDirectory()) {
walk(filePath)
}
})
}

//执行
walk(models_path)

//引入Koa框架
const Koa = require('koa')
const logger = require('koa-logger')
const session = require('koa-session')
const bodyParser = require('koa-bodyparser')

//实例化koa
const app = new Koa()

//使用中间件
//引入koa-logger,支持日志打印
app.use(logger())

//引入koa-bodyparser,支持body为json、form、text格式
app.use(bodyParser())

//引入session,设置自定义的cookie名,默认是koa.sid
app.keys = ['xjs']
app.use(session(app))

//引入koa-router配置路由
const router = require('./config/routes')()
app.use(router.routes()).use(router.allowedMethods())


//监听端口3001
app.listen(3001)
console.log('Listening:3001')

1.引入nodejs核心功能模块fs、path

path:处理文件的路径
fs:提供本地文件的读写能力

2.配置MongoDB数据库

a.使用fs、path循环读取models文件夹下面的文件,导入多个表结构model
b.修改mongoose已过时的promise库,将其修改成bluebird
c.配置本地具体的数据库,开启连接
d.监听数据库连接状态

3.引入Koa中间件

a.koa-logger,实现控制台日志的打印
b.koa-bodyparser,实现Post请求传参
c.koa-session,需要用到session修改用户信息
d.koa-router,配置请求路由

4.启动Koa服务器,监听3001端口


2.路由routes.js功能讲解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
'use strict'
//引入koa-router中间件
const Router = require('koa-router')

//引入业务层
const User = require('../app/controllers/user')
const App = require('../app/controllers/app')

//导出一个匿名方法在配置koa时调用(app.js)
module.exports = function() {
//实例化一个router,并声明前缀
let router = new Router({
prefix: '/api'
});
//用户请求 中间件依次执行
router.post('/user/signup', App.hasBody, User.signup);
router.post('/user/verify', App.hasBody, User.verify);
router.post('/user/update', App.hasBody, App.hasToken, User.update);

//公用请求或工具请求,如:加密,校验accessToken
router.get('/app/jm', App.jm);

//TODO
return router;
}

1.引入koa-router,引入业务层,如用户、公用的方法。

2.配置各类请求及业务层对应的方法,如用户的获取验证码、验证登录、修改用户昵称等请求。

3.配置时可以使用中间件,举例说明:

1
router.post('/user/update', App.hasBody, App.hasToken, User.update);

该方法的意思是配置一个地址为:api/user/update的方法,该方法能够更新用户的昵称。
当用户确认修改昵称后,会依次执行公共业务层的hasBody、hasToken及用户业务层的update方法。
hasBody方法判断参数是否缺少、hasToken方法判断是否有accessToken用户的唯一标识,update方法用来更新用户昵称。
这是个递进的方法,一层一层拦截,如果没参数不会执行下面的方法,依次类推。


3.模型层user.js讲解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
'use strict'
//引入mongoose
const mongoose = require('mongoose')

//定义表结构
const UserSchema = new mongoose.Schema({
number: { //手机号
unique: true,
type: String
},
code: String, //验证码
accessToken: String, //用户唯一标识
nickname: String, //昵称
avatar: String, //头像
verified: { //是否验证过
type: Boolean,
default: false
},
meta: {
createAt: { //创建时间
type: Date,
default: Date.now()
},
updateAt: { //更新时间
type: Date,
default: Date.now()
}
}
})

//定义添加数据的拦截器
UserSchema.pre('save', function(next) {
if (!this.isNew) { //老数据
this.meta.updateAt = Date.now()
}
next()
})

//导出用户Model
module.exports = mongoose.model('User', UserSchema)

1.定义用户表结构,如手机号String类型且唯一,验证码String,是否验证过Boolean类型且默认是没验证过,创建和更新时间Date类似且默认是当前服务器时间。

2.定义拦截器,功能是如果是老数据,记录下更新时间。

3.导出用户Model,记录用户Model在mongoose里面,方便在业务层里面调用,调用如下:

1
2
const mongoose = require('mongoose')
const User = mongoose.model('User')

4.业务层user.js讲解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
/**
* xss 防止xss攻击
* uuid 唯一识别码token
* sms 发送螺丝帽短信工具
**/

const xss = require('xss')
const mongoose = require('mongoose')
const User = mongoose.model('User')
const uuid = require('uuid')
const sms = require('../service/sms')

//获取验证码
exports.signup = async ctx => {
let number = ctx.request.body.number; //post
//let number = ctx.query.number;//get
let user = await User.findOne({
number: number
}).exec();

let code = sms.getCode();
console.log(code);

if (!user) { //新用户
let accessToken = uuid.v4();
user = new User({
number: xss(number),
code: code,
accessToken: accessToken
})
console.log('新用户');
} else {
user.code = code;
console.log('老用户');
}
try {
await user.save();
//await sms.send(number, code);
ctx.body = {
success: true,
msg: '验证码已发送'
};
} catch (e) {
ctx.body = {
success: false,
msg: '验证码发送失败'
};
}
}

//验证登录
exports.verify = async ctx => {
let code = ctx.request.body.code;
let number = ctx.request.body.number;

if (!code || !number) {
ctx.body = {
success: false,
msg: '验证没通过'
}
return ctx;
}

let user = await User.findOne({
number: number,
code: code
}).exec();
if (user) {
user.verified = true;
user = await user.save();
ctx.body = {
success: true,
msg: '验证通过',
data: user
}
} else {
ctx.body = {
success: false,
msg: '验证没通过'
}
}
}

//修改用户昵称
exports.update = async ctx => {
let body = ctx.request.body;
let user = ctx.session.user;
user.nickname = xss(body['nickname'].trim());
user = await user.save();
ctx.body = {
success: true,
data: {
nickname: user.nickname,
_id: user._id
}
}
}

1.引入其他第三方工具库,xss防止xss攻击的,uuid获取唯一识别码保存accessToken的,sms自定义发送短信的。

2.使用最新的ES7asyncawait,再加上ES6的语法,代码简单清爽,一目了然。

3.GET请求和POST请求在获取前端的参数上有所不同:

GET请求:

1
let number = ctx.query.number;//从query里面获取

POST请求:

1
let number = ctx.request.body.number;//从request的body里面获取

4.可以通过上下文的session直接获取到当前用户的信息

1
let user = ctx.session.user;

5.可直接使用模型层User进行CRUD操作:

1
2
let user = User.findOne(...);//查询一条用户数据
user.save();//添加或更新用户数据

5.业务层app.js讲解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
//引入mongoose
const mongoose = require('mongoose')

//引入用户Model
const User = mongoose.model('User')

//导出加密方法 暂未写
exports.jm = ctx => {
console.log(ctx);
console.log(ctx.method);
ctx.body = '加密';
}

//导出是否缺少参数方法
exports.hasBody = async(ctx, next) => {
let body = ctx.request.body || {}
if (Object.keys(body).length === 0) {
ctx.body = {
success: false,
msg: '参数缺失'
}
return ctx
}
await next();
}

//导出是否有accessToken方法
exports.hasToken = async(ctx, next) => {
var accessToken = ctx.query.accessToken
if (!accessToken) {
accessToken = ctx.request.body.accessToken
}
if (!accessToken) {
ctx.body = {
success: false,
msg: '没accessToken'
}
return ctx
}
var user = await User.findOne({
accessToken: accessToken
}).exec();

if (!user) {
ctx.body = {
success: false,
msg: '用户没登录'
}
return ctx
}
ctx.session = ctx.session || {}
ctx.session.user = user;
await next();
}

1.注意在hasToken方法里,如果该用户的accessToken存在,则获取到该用户,且放进session里面供业务层user.js使用。

1
ctx.session.user = user;//设置该用户信息进入session

2.hasToken方法可以在需要校验用户登录后才能正常调用的接口,在配置路径的时候很方便。

如:更新用户昵称、上传用户头像、支付等。

1
2
3
router.post('/user/update', App.hasBody, App.hasToken, User.update);//更新用户昵称
router.post('/user/avator', App.hasBody, App.hasToken, User.update);//上传头像
....

5.第三方服务sms.js讲解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
'use strict'

/**
* speakeasy 验证码生成器
**/

const https = require('https')
const querystring = require('querystring')
const Promise = require('bluebird')
const speakeasy = require('speakeasy')

exports.getCode = function() {
var code = speakeasy.totp({
secret: 'xjsapp',
digits: 6
})

return code
}

exports.send = function(number, code) {
return new Promise(function(resolve, reject) {
if (!number) {
return reject(new Error('手机号不能为空!'))
}

var postData = {
mobile: number,
message: '您的验证码是' + code + '。请在页面中提交验证码完成验证。【享健身】'
}

var content = querystring.stringify(postData)
var options = {
host: 'sms-api.luosimao.com',
path: '/v1/send.json',
method: 'POST',
auth: 'api:key-xxxxxxxxxxxxxxxxxxxxxxxxxx',
agent: false,
rejectUnauthorized: false,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': content.length
}
}

var str = ''
var req = https.request(options, function(res) {
if (res.statusCode === 404) {
reject(new Error('短信服务器没有响应'))
return
}

res.setEncoding('utf8')
res.on('data', function(chunk) {
str += chunk
})
res.on('end', function() {
var data

try {
data = JSON.parse(str)
} catch (e) {
reject(e)
}

if (data.error === 0) {
resolve(data)
} else {
var errorMap = {
'-10': '验证信息失败 检查apikey是否和各种中心内的一致,调用传入是否正确',
'-11': '用户接口被禁用滥发违规内容,验证码被刷等,请联系客服解除',
'-20': '短信余额不足 进入个人中心购买充值',
'-30': '短信内容为空 检查调用传入参数:message',
'-31': '短信内容存在敏感词 接口会同时返回 hit 属性提供敏感词说明,请修改短信内容,更换词语',
'-32': '短信内容缺少签名信息 短信内容末尾增加签名信息eg.【公司名称】',
'-33': '短信过长,超过300字(含签名) 调整短信内容或拆分为多条进行发送',
'-40': '错误的手机号 检查手机号是否正确',
'-41': '号码在黑名单中 号码因频繁发送或其他原因暂停发送,请联系客服确认',
'-42': '验证码类短信发送频率过快 前台增加60秒获取限制',
'-50': '请求发送IP不在白名单内 查看触发短信IP白名单的设'
}

reject(new Error(errorMap[data.error]))
}
})
})

req.write(content)
req.end()
})
}

1.引入第三方库speakeasy,该库能随机生成6位数的验证码。

2.这个js封装对螺丝帽服务的短信接口和生成随机的二维码,供业务层user.js使用。

1
2
3
let code = sms.getCode();//获取6位数验证码
...
sms.send(number, code);//发送手机验证码

总结一下:

Koa功能还是很强大的,和MongoDB一起使用完全能应对中小型业务。

骚年们,继续加油吧!