主要是学习了慕课网从0到1打造超好用Web框架 一步到位 掌握KOA2服务端开发的一个总结和复盘。
原生的koa2还是不太好用,需要增加插件和中间件才能变得好用,合适我们。所以主要的总结包括几点:
1.整体框架的搭建
2.路由系统的改造
3.异常处理
4.基于LinValidator实现的校验器
5.Sequelize的基本使用规则
6.JWT令牌与Auth权限控制中间件
7.小程序中携带令牌
1.整体框架的搭建
1)首先就是要安装koa框架,安装完之后新建一个app.js的文件
const Koa = require('koa')
const app = new Koa()
app.listen(3000, () => {
console.log('listening on port 3000')
})
这样就算是能启动一个项目了。
2)由于原生koa获取参数比较困难,所以还需要装koa-bodyparser。
const bodyparser = require('koa-bodyparser')
app.use(bodyparser())
3)假如需要使用到外部访问服务器里面的静态资源,还可以使用koa-static
koa-static指定了目录之后,在访问路径后面加上文件夹里面的路径和文件就可以访问了。
例如:服务器地址为http://localhost:3000 而资源是static/image/001.jpg 那么就可以通过http://localhost:3000/image/001.jpg 来获取资源
const static = require('koa-static')
const path = require('path')
app.use(static(path.join(__dirname, '/static')))
这样在app.js里面就基本完成了,初始化代码一般不写在这里,假如将所有的代码都写在这里,就会变得十分混乱,不好管理,所以需要创建多个文件,进行归类。
4)在根目录下创建core文件夹,并且在里面创建一个init.js的文件作为初始文件。
我们可以通过在app.js中将koa的实例化对象app传入该类里面,然后在里面写核心代码,大部分的代码都是通过app.use,中间件的形式来实现的。
init文件里面主要做的就是注册路由,将通用的方法和配置放在global里面,global为nodejs里面的全局对象。通过将配置和通用方法放在global里面,就可以随时调用这些配置和方法了。而一般要放的就是配置和抛出异常的函数。
init文件:
class InitManager {
static InitCore(app) {
InitManager.app = app
InitManager.loadConfig()
}
static loadConfig(path='') {
const configPath = path || process.cwd() + '/config/config.js'
const config = require(configPath)
global.config = config
}
}
module.exports = InitManager
目前主要是将根目录的config文件夹中的config传入到全局变量里面。
至此,整体框架的搭建就算是基本完成了。
2.路由系统的改造
1)对于koa的路由,最重要的就是安装koa-router
安装完之后可以新建一个app/api的文件夹,在里面将要放上路由文件。
路由文件需要根据模块来进行归类,一个模块新建一个文件。
文件夹里面的v1代表的是第一个版本,因为在迭代当中,直接更改原先文件的话可能会造成以前的冲突,而且不好更改,所以可以进行复制,然后在里面更改,及时版本报错也可以及时切换。
2)路由的使用
首先引入koa-router
然后创建一个Router的实例,里面可以传入参数,最主要的就是传入前缀,可以在写路径的时候写少一点。
const Router = require('koa-router')
const router = new Router({
prefix : '/v1/book'
})
router.get('/hot-list', async (ctx, next) => {
ctx.body = ctx.request.body
})
module.exports = router
这样,一个最基本的路由就算是完成了。
然后只需要在app中利用中间件的形式引入即可
app.use(r.routes())
3)自动引入路由文件
每写入一个路由文件,就需要引入一次,这样子的手动方法比较麻烦,所以我们需要使用require-directory来进行自动加载,获取到文件中的模块之后,只需要使用app.use加载就行了。
因为是按照中间件的方法引入的,所以我们可以写在InitManager中,程序刚开启的时候就导入路由模块。
const requireDirectory = require('require-directory') // 自动加载模块
const Router = require('koa-router')
class InitManager{
static InitLoadRouters() {
const requireRouterPath = `${process.cwd()}/app/api` // process.cwd 获取程序根目录
requireDirectory(module, requireRouterPath, {
visit: r => {
if (r instanceof Router) {
InitManager.app.use(r.routes())
}
}
})
}
}
requireDirectory接受的第三个参数为选项,而visit相当于导入后的回调函数,所以我们可以在导入之后判断是否属于Router的类,是的话就进行中间件加载路由。
自此路由的改造就算是完成了,能进行自动加载,只需要在app/api的文件夹中写入的文件都能进行加载。
3异常处理与抛出异常
处理异常非常有必要,决定了在什么时候展现什么内容给用户看,而且开发环境和生产环境的抛出异常也略有区别。开发环境假如是系统错误,则需要抛出来。而假如是生产环境当中,则需要只告诉用户有错误,并且记录下错误就行了,并不需要具体告诉客户是什么原因。
这里我们可以利用koa的中间件原理,在一开始的时候就用try catch来进行包裹整个流程,然后在catch中进行异常的处理即可。
1)创建异常中间件
首先我们需要新建一个middleware的文件夹,用来专门存放中间件的,然后再创建一个exception.js,由于中间件都是一个函数,里面接受一个ctx为上下文,next为执行下一步,所以我们只需在里面写入函数,然后导出,最后再用app.use该函数即可。
const catchError = async (ctx, next) => {
try {
await next()
} catch (e) {
// 进行异常的处理
}
}
module.exports = catchError
大致的框架就是这样,我们只需要在catch里面编写异常处理即可。
在里面我们需要判断是自定义错误,还是系统错误,判断的已经也很简单,只需要查看抛出的e是否继承自自定义的函数即可。这一步等下再写。先判断是系统错误的时候,假如是系统错误,又分为两类,一类是生产环境,第二类是开发环境。
假如是开发环境,我们只需要再一次抛出错误即可。
而假如是生产环境,我们只需要告诉用户出错了,并记录到日志里即可。
所以首先,我们需要在config的文件里面定义我们所属的环境。
module.exports = {
env: 'dev'// prod
}
然后再在异常中进行判断,抛出错误。
const catchError = async (ctx, next) => {
try {
await next()
} catch (e) {
if (e属于自定义错误) {
/
} else {
// 原生未知错误
if (global.config.env === 'dev') {
throw e
}
ctx.body = {
msg: e.message,
error_code: 999,
request: `${ctx.method} ${ctx.path}`
}
ctx.status = 500
}
}
}
2)自定义错误
很多时候,我们需要自定义错误,不能所有都依靠直接抛出错误,这样不好对错误进行归类。所以要进行自定义错误的编写。而且有些时候是用户操作不当,某些字段没填或者填错了而提交的,也需要抛出自定义错误来告诉他哪里发生错误了。
其实原理就是定义一个类,让他继承Error这个类,那么我们就可以抛出错误了。又因为我们需要里面的关键词和状态码还有关键信息,所以只需要在里面定义一些属性,又因为在try catch中捕捉到的e是自定义错误类的一个实例,那么这个类就会有我们需要的信息,并将这些信息返回给用户即可。
class HttpException extends Error {
constructor(msg = '未定义错误', errorCode = 10000, code = 400) {
super()
this.msg = msg
this.errorCode = 10000
this.code = 400
}
}
这个就是自定义错误的基类了。往后的错误类只需要继承这个类即可。然后在try catch中的e,就能获取到e.msg等信息了。
然后我们再来补全一下之前的异常处理代码。
const catchError = async (ctx, next) => {
try {
await next()
} catch (e) {
if (e instanceof HttpException) { // 判断成功的都为自定义错误
ctx.body = {
msg: e.msg,
error_code: e.errorCode,
request: `${ctx.method} ${ctx.path}`
}
ctx.status = e.code
} else {
// 原生未知错误
if (global.config.env === 'dev') {
throw e
}
ctx.body = {
msg: e.message,
error_code: 999,
request: `${ctx.method} ${ctx.path}`
}
ctx.status = 500
}
}
}
这样就能抛出我们想要的信息了。
3)全局使用异常
因为所有的异常类都在一个文件里面,但我们又想在每个文件都能使用,这时候就需要使用之前说过的global了。然后在一开始导入异常的文件,并将其作为global的属性存在。即可在全局抛出异常了。在init里面运行该方法即可。
static loadHttpException() {
const httpException = require('./http-exception')
global.errors = httpException
}
4.基于LinValidator实现的校验器
1)前置准备
LinVlalidator是由七月老师所写的校验器,其本身也是基于validator来进行编写的,所以在使用之前需要先安装validator。因为LinValidator里面使用了loadsh,所以也需要安装lodash。然后在源码中下载LinValidator-v2.js,放入和异常同级的目录下。因为里面当验证不成功的时候还需要抛出异常。
2)编写验证器
当完成上面的步骤之后,就可以使用lin-validator了。首先,我们需要创建一个文件,用来存放各个验证器的。每个验证器都是继承自lin-validator。
然后我们可以在constructor里面定义需要验证的字段。
const { LinValidator, Rule } = require('../../core/lin-validator-v2')
class PositiveIntegerValidator extends LinValidator {
constructor() {
super()
this.id = [new Rule('isInt', '需要是正整数', { min: 1 })]
}
}
就好比这个验证器,验证的是id,是否为正整数。因为this.id是一个数组,所以可以有多项验证规则。
当数组中存在new Rule('isOptional')的时候,则表示该字段为非必填字段。
this.secret = [
new Rule('isOptional'),
new Rule('isLength', '至少6个字符', {
min: 6,
max: 128
})
]
还可以添加自定义规则。自定义规则为以validate开头,然后后面写方法名称,写在类的方法里面,写了之后验证器就会执行里面的方法。
class RegisterValidator extends LinValidator {
constructor(){
....
}
// 自定义规则需要以validate开头,参数为所有值
validatePassword(vals) {
const pwd1 = vals.body.password1
const pwd2 = vals.body.password2
if (pwd1 !== pwd2) {
throw new Error('两个密码不相等')
}
}
}
new Rule()里面的选项具体可以参考validator使用
这样,一个验证器就算完成了。接下来就是在路由里面的使用了。
3)路由中的使用
使用则比较简单了,只需要在router中添加,并且传入ctx即可。
const v = await new PositiveIntegerValidator().validate(ctx)
注:这里加await是因为lin-validator-v2需要使用同步的方法才行,因为在自定义方法中,会出现查询数据库等获取信息,判断是否存在的情况,属于异步操作,所以需要加上await才能保证查询完成。
lin-validator还支持传入别名的方法,比如虽然在验证器里面写的是this.id = xxx ,但假如要book_id也使用这个方法。就需要使用别名了。使用方法就是在validate里面传入第二个参数。第二个参数为接受一个对象,需要更改哪个字段,字段就作为key,更改后的名字作为value即可。
const v = await new PositiveIntegerValidator().validate(ctx,{id:'book_id'})
当判断完成之后,我们就可以通过v来进行获取参数了。如下所示。
const v = await new PositiveIntegerValidator().validate(ctx)
v.get('path.id') // 获取:id
v.get('body.id') // 通过post传入的id
v.get('query.id') // 通过在网址的后面添加的?id=xxx
5.Sequelize的基本使用规则
1)安装和配置
首先需要npm install sequelize
然后就是配置了
首先我们在config文件夹下建立一个database.js的文件用于存放数据库的相关代码,由于在线上和生产环境上是不一样的账号密码和数据库地址,所以我们可以根据之前在global中配置的config的env来决定是线上环境还是生产环境
let data_conf = {}
if (global.config.env === 'dev') {
data_conf = {
dbName: 'island',
user: 'root',
password: '',
host: 'localhost',
port: 3306
}
} else if (global.config.env === 'prod') {
data_conf = {
dbName: 'island',
user: 'root',
password: '',
host: 'localhost',
port: 3306
}
}
module.exports = {
data_conf
}
这样配置文件就算是写完了。
然后就是需要在core文件夹中,创建一个db.js
主要是用于通过sequelize连接数据库和对数据库进行一些初始化的配置的。
const { Sequelize} = require('sequelize')
const {
dbName,
host,
port,
user,
password
} = require('../config/database').data_conf
const sequelize = new Sequelize(dbName, user, password, {
dialect: 'mysql',
host,
port,
// logging: true, //执行mysql语句时打印在控制台 默认打印到控制台
timezone: '+08:00', // 以北京时间为准
define: {
// 自定义配置
timestamps: true, // 创建表时增加create update字段
paranoid: true, // 创建delete 软删除
createdAt: 'created_at',
updatedAt: 'updated_at',
deletedAt: 'deleted_at',
underscored: true, // 字段名驼峰转成下划线
freezeTableName: true, // 默认false修改表名为复数,true不修改表名,与数据库表名同步
scopes: {
bh: {
attributes: {
exclude: ['updatedAt', 'deletedAt', 'createdAt']
}
}
}
}
})
sequelize.sync({
force: false // 强制删除数据库再重建
}) // 不加这句不导入数据库
module.exports = {
sequelize
}
其中里面的scopes类似于一种预定义的代码,在执行语句中,只要写上上面定义好的bh,就可以执行attribute这里的将时间屏蔽进行输出了。
2)创建表
定义了数据库我们就可以进行表的创建了。在根目录新建一个model的文件夹,里面存放的是一张张的表,一张表代表一个文件。然后再在里面进行表的创建。
以user表为例
const bcyrpt = require('bcrypt')
const { Sequelize, Model } = require('sequelize')
const { sequelize } = require('../../core/db')
class User extends Model {
static async verifyEmailPassword(email, plainPassword) {
........
}
static async getUserByOpenId(openid) {
.......
}
static async registerByOpenId(openid) {
.......
}
}
// 初始化数据库表
User.init(
{
nickname: Sequelize.STRING,
email: {
type: Sequelize.STRING(128),
unique: true
},
password: {
type: Sequelize.STRING,
set(val) {
// model内置的方法
const salt = bcyrpt.genSaltSync(10) // 生成盐
const psw = bcyrpt.hashSync(val, salt) // 生成密码串
this.setDataValue('password', psw)
}
},
openid: {
type: Sequelize.STRING(64),
unique: true
}
},
{ sequelize, tableName: 'user' }
)
module.exports = {
User
}
我们需要新建一个类来继承sequelize里面的Model,然后再用静态方法init来定义表里面的信息,而默认id不用定义,生成表的时候会自动附带上,而字段的类型则需要使用Sequelize.STRING来进行定义。还有各种字段可以到官网上进行查看。
在这里需要说的是,定义字段的时候可以接受一个set的属性,那么在插入密码的时候,会走这段代码,不用写在新建用户的代码里面,这样就显得比较简洁了。设置的时候需要使用this.setDataValue()才行。
3)查询数据库
创建表了之后,导出的这个继承自Model的对象就具备了查询该表数据库的功能,主要用到的有
(1)findOne
(2)findAll
(3)count 计算数量
(4)create
基本上就这几个,里面接受的第一个参数为查询条件
const { Sequelize, Model, Op } = require('sequelize')
await Favor.findAll({
where: {
uid,
type: {
[Op.not]: 400
}
}
})
这里的Op为条件符号,需要引入sequelize里面的op才行。有Op.not,Op.In (表示一个范围)
假如要使用事务,则需要这样写。
sequelize.transaction(async t => {
await Favor.create(
{
art_id,
uid,
type
},
{ transaction: t }
)
const art = await Art.getData(art_id, type)
await art.increment('fav_nums', { by: 1, transaction: t })
})
在事务里面进行操作,并在每次操作中带上事务t
查询到的数据还可以使用increment,decrement等对某一字段进行增加或者减少的操作。
自此,有关node使用数据库的方面就算基本说完了。
6.JWT令牌与Auth权限控制中间件
由于是要对外开放接口,但不是所有人都能访问的,这时候就需要颁发令牌,然后让用户携带领令牌才能进行访问。而权限这块在课程里面只是做了个数字类型的,令牌中的数字小于需要访问的接口的数字,则不给访问。
JWT令牌比较简单,是一个无状态的令牌,不需要将信息存储于服务器内。主要就是使用jwt内置的方法,加上secretkey和时间即可。在解密的时候使用secretkey来破译获取存储的信息即可。
1)颁发令牌
const jwt = require('jsonwebtoken')
const generateToken = function(uid, scope) {
const secretKey = global.config.security.secretKey
const expiresIn = global.config.security.expiresIn
const token = jwt.sign(
{
uid,
scope
},
secretKey,
{
expiresIn
}
)
return token
}
函数中,将secretKey和到期时间预先放入config配置文件中。然后使用jwt的sign方法即可得到token,拿到token之后返回给用户,用户将其存放如storage里面即可。
2)解密令牌
只需使用verify将token和secretKey存入里面即可,假如过期会抛出异常,假如正确则会返回存入token里面的信息。
try {
decode = jwt.verify(userToken.name, global.config.security.secretKey)
} catch (e) {
if (e.name === 'TokenExpiredError') {
errMsg = 'token已过期'
}
throw new global.errors.Forbidden(errMsg)
}
3)使用中间件验证token
每个路由当中都可以传入多个中间件,存入之后,就可以依次执行里面的中间件。所以利用这一原理,我们可以在需要验证token的api中加上验证token的中间件,然后下一个参数才是路由里面的具体方法。
同时,由于每个路由可能访问的权限也是不一样的,那么就可以通过存入参数,或者是通过一个实例,在新建实例的时候就将权限的大小存入里面,作为实例的属性而存在,就可以解决权限的问题了。
所以首先在middleware里面新建一个验证的类
const basicAuth = require('basic-auth')
const jwt = require('jsonwebtoken')
class Auth {
constructor(level = 1) {
this.level = level
Auth.USER = 8
Auth.ADMIN = 16
Auth.SUPER_ADMIN = 32
}
get m() {
return async (ctx, next) => {
const userToken = basicAuth(ctx.req)
let errMsg = 'token不合法'
if (!userToken || !userToken.name) {
throw new global.errors.Forbidden(errMsg)
}
let decode
try {
decode = jwt.verify(userToken.name, global.config.security.secretKey)
} catch (e) {
if (e.name === 'TokenExpiredError') {
errMsg = 'token已过期'
}
throw new global.errors.Forbidden(errMsg)
}
if (decode.scope < this.level) {
errMsg = '权限不足'
throw new global.errors.Forbidden(errMsg)
}
ctx.auth = {
uid: decode.uid,
scope: decode.scope
}
await next()
}
}
static verifyToken (token) {
try{
jwt.verify(token, global.config.security.secretKey)
return true
} catch(e) {
return false
}
}
}
module.exports = { Auth }
basicAuth是用于获取传入的token的。token不是在body里面,而是通过在Authorization里面传入服务器当中的。而basicAuth包括了name和password,而token存放在name里面,所以在name里获取就行了。
需要权限控制的时候,在新建一个Auth()的实例里面传入level即可,默认为1。
然后只需要在需要用到权限的地方新建一个Auth实例,并且传入它的m的方法即可。
router.get('/favors', new Auth().m, async (ctx, next) => {
// 具体代码
})
7.小程序中携带令牌
上面已经说过如何获取令牌了,现在要说的就是发送令牌,由于上面利用了BasicAuth,在传输token的时候需要在header中设置authorization来进行传输。
主要步骤为:
首先安装base64加密包。js-base64
然后对token进行base64加密,然后放入header中的authorization即可。
import { Base64 } from 'js-base64'
Page({
onGetBookComment() {
wx.request({
url: 'http://localhost:3000/v1/book/1/short_comment',
method: 'GET',
header: {
Authorization: this._encode()
},
success: (res) => {
console.log(res.data)
}
})
},
// 定义的一个加密的方法
_encode() {
const token = wx.getStorageSync('token')
const base64Token = Base64.encode(token + ':')
return `Basic ${base64Token}`
}
})
这样即可传输token到服务器上,需要注意的是,因为Basic Auth有name和password的说法,而我们是在name中获取的token,所以需要在加密的时候用:来进行区分。将token放在前面即可。
至此,nodejs搭建的服务器就基本理清楚了,课程中并没有关于文件的操作,对于nodejs的api用得也不是很多,主要的还是得根据业务需求来进行学习和掌握才行。