小程序node+Koa后端开发

1.开发环境配置

  • 框架/库
    • Node.js(10.15.3)
    • npm
    • Koa
    • nodemon pm2
  • 软件/工具
    • MySQL(XAMPP)
    • 微信开发者工具
    • VSCode
    • PostMan
    • Navict(数据库可视化工具)

2.起步

  • 安装koa
  1. yarn init -y
  2. yarn add koa
  • 启动node
  1. const Koa = require('koa')
  2. const app = new Koa()
  3. const port = 3000
  4. app.listen(port,()=> {
  5. console.log(`程序启动,请访问http://localhost:${port}`);
  6. })

3. koa

3.1 koa中间件

中间件的调用总会返回一个promise

调用next(),返回值是一个promise,在中间件中返回值会返回到promise中

  1. app.use(async (ctx,next)=> {
  2. console.log('hello word');
  3. let a = await next() // 这里接收promise返回值123
  4. console.log(a);
  5. })
  6. app.use(()=> {
  7. console.log('hello word 2');
  8. return '123'
  9. })

3.2 async/await

  1. 异步编程的一种解决方案(终极解决方案)
  2. async会将函数包装成一个peomise
  3. await会阻塞当前线程,等待执行完成,拿到结果才会继续执行,异步->同步
  1. app.use(async () => {
  2. // console.log('hello word 2');
  3. const axios = require('axios')
  4. const start = Date.now()
  5. // await 阻塞当前线程
  6. const res = await axios.get('http://www.7yue.pro')
  7. const end = Date.now()
  8. console.log(end - start);
  9. })

3.3 为什么一定要保证洋葱模型

参考https://blog.csdn.net/weixin_34187822/article/details/88875628

  1. 使用洋葱模型可以使中间件很好的进行数据传递
  2. koa使用中间件,全部使用async/await可以保证洋葱模型的执行顺序不变
  • 由于第二个中间件执行了异步操作,await阻塞了代码执行,所以先执行了2,等待await执行完成,再执行4
  • 如果调用了第三方中间件,我们并不知道其执行顺序,也就很难保证洋葱模型执行顺序
  1. app.use( (ctx, next) => {
  2. console.log('1');
  3. next()
  4. console.log('2');
  5. })
  6. app.use(async (ctx, next) => {
  7. console.log('3');
  8. const axios = require('axios')
  9. // await 阻塞当前线程
  10. const res = await axios.get('http://www.7yue.pro')
  11. next()
  12. console.log(4);
  13. })
  14. // 执行顺序 1 3 2 4
  • 所有中间件都使用async,在next()之前加await,可以保证异步编程变得像同步,也就可以保证洋葱模型的执行顺序
  • 在一个中间件通过ctx变量赋值,可以使其他中间件在调用next()后(要想获取到这个值,要保证其中间件的代码全部执行完成,所以要在next()后获取其值),接收到这个中间件传递的值,
  1. app.use(async (ctx, next) => {
  2. console.log('1');
  3. await next()
  4. console.log(ctx.r);
  5. console.log('2');
  6. })
  7. app.use(async (ctx, next) => {
  8. console.log('3');
  9. const axios = require('axios')
  10. // await 阻塞当前线程
  11. const res = await axios.get('http://www.7yue.pro')
  12. ctx.r = res
  13. await next()
  14. console.log(4);
  15. })

4. koa路由

  • koa通过ctx对象获取路由信息,及返回信息操作
    • ctx.path 获取路由路径
    • ctx.method 获取请求方法
    • ctx.body 可以直接返回json对象
  • 一个项目有很多路由,直接通过判断编写路由,项目会变得复杂不易维护,所以要引用第三方库,进行路由编写

4.1 koa-router

安装第三方包https://www.npmjs.com/package/koa-router

  1. yarn add koa-router
  • 基础使用
  1. var Koa = require('koa');
  2. var Router = require('koa-router');
  3. var app = new Koa();
  4. var router = new Router();
  5. router.get('/', (ctx, next) => {
  6. // ctx.router available
  7. ctx.body = {
  8. key: 'classic'
  9. }
  10. });
  11. app
  12. .use(router.routes())

4.2 主题与模型划分

  • web开发,好的代码的优点
    • 便于阅读
    • 利于维护
    • 提高编程效率
  • 数据请求,编写路由会有很多,可以按照分类进行划分,可以根据
    • 数据类型划分
  • 数据模型,有利于更好的设计数据库

4.3 api版本

  • 为什么api要有版本
    打个比方,当前路由返回的数据,
  1. {key:'classic'}

由于业务的变动,返回的数据会进行更改,

  1. {key: 'music'}

我们要考虑客户端的兼容性,一些用户会用老版本的数据,一些用户会使用新版本的数据,如果直接修改代码,可能导致老版本无法正常使用

所以很多情况下,服务器api需要兼容多个版本api,支持3个版本是比较好的,太多的话,开发维护的难度会逐渐增加

  • 如何支持api版本
    客户端请求路由,需要携带api版本号,携带方式有三种
  1. url路由 v1/classic/...
  2. 查询参数 classic/?version=v1...
  3. 放入header中
  • 开闭原则
    • 修改代码时存在风险的,尽量单独对原有代码进行扩展
    • 我们在编写代码时,对代码的修改是关闭的,对代码的扩展是开放的

4.4 实现路由自动注册require-directory

官网介绍https://www.npmjs.com/package/require-directory

  1. const Router = require('koa-router')
  2. const requireDirectory = require('require-directory')
  3. // 导入路径的所有模块
  4. const modules = requireDirectory(module, './api', {
  5. visit: whenLoadModule
  6. })
  7. // 每当导入一个模块就会执行这个函数
  8. function whenLoadModule(obj) {
  9. // 判断当前模块是否是路由模块
  10. if (obj instanceof Router) {
  11. app.use(obj.routes())
  12. }
  13. }

5. nodemon自动重启server

  • 全局安装nodemon
  1. npm i -g nodemon
  • 启动服务
  1. nodemon app.js
  • 编写scripts脚本
  1. "scripts": {
  2. "start": "nodemon app.js"
  3. },

执行yarn start启动服务

5.1vscode配置nodemon调试

  • 既想通过断点调试,又想通过nodemon重启服务,配置.vscode
  • 配置vscode,点击侧边栏爬虫按钮—>点击下拉小箭头—>添加配置
  1. // .vscode/launch.json
  2. {
  3. "version": "0.2.0",
  4. "configurations": [
  5. {
  6. "type": "node",
  7. "request": "launch",
  8. "name": "nodemon",
  9. "runtimeExecutable": "nodemon",
  10. "program": "${workspaceFolder}/app.js",
  11. "restart": true,
  12. "console": "integratedTerminal",
  13. "internalConsoleOptions": "neverOpen"
  14. },
  15. {
  16. "type": "node",
  17. "request": "launch",
  18. "name": "启动程序",
  19. "program": "${workspaceFolder}\\app.js"
  20. },
  21. {
  22. "type": "node",
  23. "request": "launch",
  24. "name": "当前文件",
  25. "program": "${file}" // 当前文件
  26. }
  27. ]
  28. }

在小爬虫选择调试方式时,选择nodemon即可兼容调试和自动重启

6. 初始化管理器

分离app.js文件代码
— core 公共方法/类

  1. // core/init.js
  2. const requireDirectory = require('require-directory')
  3. const Router = require('koa-router')
  4. class InitManager {
  5. static initCore(app) {
  6. // 入口方法
  7. // InitManager.initLoadRouters(app)
  8. InitManager.app = app
  9. InitManager.initLoadRouters()
  10. }
  11. // 初始化路由
  12. static initLoadRouters() {
  13. // 导入路径的所有模块
  14. const apiDir = `${process.cwd()}/app/api`;
  15. requireDirectory(module, apiDir, { visit: whenLoadModule })
  16. // 每当导入一个模块就会执行这个函数
  17. function whenLoadModule(obj) {
  18. // 判断自动加载的模块是否为路由类型
  19. if (obj instanceof Router) {
  20. InitManager.app.use(obj.routes())
  21. }
  22. }
  23. }
  24. }
  25. module.exports = InitManager
  1. // app.js
  2. const Koa = require('koa')
  3. // 引入初始化管理器
  4. const InitManager = require('./core/init')
  5. const app = new Koa()
  6. InitManager.initCore(app)
  7. const port = 3000 //端口号
  8. app.listen(port, () => {
  9. console.log(`程序启动,请访问http://localhost:${port}`);
  10. })

7. 获取参数与linValidator校验器

向服务器传递参数方式

  1. url路径传参(params)
  2. ?后面进行传参(query)
  3. header进行传参
  4. body进行传参

7.1通过koa-bodyparser中间件获取body参数

官网https://www.npmjs.com/package/koa-bodyparser

  1. // app.js
  2. const parser = require('koa-bodyparser')
  3. app.use(parser())
  1. // -app/api/classic.js
  2. const Router = require('koa-router')
  3. const router = new Router()
  4. router.post('/v1/classic/latest/:id', (ctx, next) => {
  5. const path = ctx.params
  6. const query = ctx.query
  7. const headers = ctx.header
  8. const body = ctx.request.body
  9. ctx.body = {
  10. key: 'classic'
  11. }
  12. })
  13. module.exports = router

7.2校验参数

  1. 校验出不合法参数,返回给客户端
  2. 某些参数的必填项
  3. 参数要符合规范(比如:手机号,email…)
    校验参数有重要的两点:
  4. 防止非法参数
  5. 要给客户端明确的提示

8. 异常理论与异常处理

8.1 异常理论

  1. 为什么要进行异常处理
    告诉用户,或自己排查错误时,需要判断异常,查找错误
  2. 函数执行时会发生的情况
  • 无异常,正确返回结果
  • 发生了异常
  1. 根据函数设计《代码大全2》,判断异常方式
  • return false/null (此方式会导致函数调用时丢失异常)
  • throw new Error (此方式由于函数调用太多,所有函数都进行处理会使代码变复杂)
  • 全局异常处理,创建一种机制,监听任何异常

8.2 异步异常处理

  • 将函数变成promise
  • 如果某一个函数返回的是promise
  • 使用async/await简化函数调用链条
  • 如果函数调用链中返回promise,调用链中其他函数都使用async/await调用函数
  1. function f1() {
  2. f2()
  3. }
  4. async function f2() {
  5. try {
  6. await f3()
  7. } catch (error) {
  8. console.log('error');
  9. }
  10. }
  11. function f3() {
  12. return new Promise((resolve, reject) => {
  13. // 将异步函数包装成promise
  14. setTimeout(() => {
  15. reject('err')
  16. })
  17. })
  18. /* return await setTimeout(() => {
  19. throw new Error('err')
  20. }); */
  21. }
  22. f1()

8.3 编写全局异常处理中间件

面向切面编程

  1. // middlewares/exception.js
  2. const catchError = async (ctx, next) => {
  3. try {
  4. await next()
  5. } catch (error) {
  6. ctx.body = '服务器有点问题,请等待...'
  7. }
  8. }
  9. module.exports = catchError

8.4 处理异常信息编写

  1. 输出的错误error,要简化清晰明了的把信息传给前端
  2. 返回给前端的信息有以下:
  • message
  • error_code 详细,开发者自己定义的 10001 20003
  • request_url 当前请求的url
  1. 处理异常错误分为两种
  • 已知型错误
    • 参数校验错误
    • 明确处理错误
    • try catch
  • 未知型错误
    • 程序潜在的错误,无意识的,根本就不知道他出错了
    • 连接数据库时,账号密码输错了
  1. // middlewares/exception.js
  2. const catchError = async (ctx, next) => {
  3. try {
  4. await next()
  5. } catch (error) {
  6. if(error.errorCode) {
  7. // 已知异常
  8. ctx.body = {
  9. msg: error.message,
  10. error_code: error.errorCode,
  11. request_url: error.requestUrl
  12. },
  13. ctx.status = error.status
  14. }else {
  15. // 未知异常
  16. ctx.body = {
  17. msg: 'we made a mistake, unknown error',
  18. error_code: 999,
  19. request: `${ctx.method} ${ctx.path}`
  20. }
  21. ctx.status = 500
  22. }
  23. /**
  24. * error 堆栈调用信息
  25. * error 简化清晰明了的信息,给前端
  26. * Http Status Code 2xx,4xx,5xx
  27. **返回的信息
  28. * message
  29. * error_code 详细,开发者自己定义的 10001 20003
  30. * request_url 当前请求的url
  31. **错误类型
  32. * *已知型错误
  33. * 参数校验错误
  34. * 明确处理错误
  35. * try catch
  36. * *未知型错误
  37. * 程序潜在的错误,无意识的,根本就不知道他出错了
  38. * 连接数据库时,账号密码输错了
  39. */
  40. }
  41. }
  42. module.exports = catchError
  1. // 使用
  2. if (true) {
  3. const error = new Error('为什么错误')
  4. error.errorCode = 10001
  5. error.status = 400
  6. error.requestUrl = `${ctx.method} ${ctx.path}`
  7. throw error
  8. }

8.5 定义HttpException基类

处理异常信息编写,返回给前台的信息,每次都是返回固定的几个参数,我们可以封装一个继承原生js的Error的处理http异常的类,来简化我们的代码

  1. // core/http-exception.js
  2. class HttpException extends Error {
  3. constructor(msg = "服务器异常", errorCode = 10000, status = 400) {
  4. super()
  5. this.errorCode = errorCode
  6. this.status = status
  7. this.msg = msg
  8. }
  9. }
  10. module.exports = {
  11. HttpException
  12. }

调用

  1. // middlewares/exception.js
  2. if(error instanceof HttpException) {
  3. ctx.body = {
  4. msg: error.message,
  5. error_code: error.errorCode,
  6. request: `${ctx.method} ${ctx.path}`
  7. },
  8. ctx.status = error.status
  9. }

8.6 扩展异常基类与global全局变量

继承基类,扩展特定的异常类

  1. class ParameterException extends HttpException {
  2. constructor(msg, errorCode) {
  3. super()
  4. this.code = 400
  5. this.msg = msg || "参数错误"
  6. this.errorCode = errorCode || 10000
  7. }
  8. }
  9. class NotFound extends HttpException {
  10. constructor(msg, errorCode) {
  11. super()
  12. this.code = 404
  13. this.msg = msg || "资源未找到"
  14. this.errorCode = errorCode || 10000
  15. }
  16. }

每次调都要先引用对应的类,我们可以使用全局变量,在应用初始化时就加载异常类,供全局调用

  1. //* 初始化核心方法
  2. static initCore(app) {
  3. // 入口方法
  4. InitManager.app = app
  5. InitManager.initLoadRouters()
  6. InitManager.loadHttpException()
  7. }
  8. //* global加载异常处理方法
  9. static loadHttpException() {
  10. const errors = require('./http-exception')
  11. global.errs = errors
  12. }

使用

  1. const error = new global.errs.ParameterException()

8.7 lin-validator校验器的使用

lin-validator.js依赖的文件http-exception.js,util.js

  1. 定义编写校验文件
  1. //validator.js
  2. const { LinValidator, Rule } = require('../../core/lin-validator')
  3. /**
  4. * 校验正整数
  5. * @class PositiveIntergerValidator
  6. * @extends {LinValidator}
  7. */
  8. class PositiveIntergerValidator extends LinValidator {
  9. constructor() {
  10. super()
  11. // 校验名称, 返回结果, 可选参数
  12. this.id = [new Rule('isInt', '需要是正整数', { min: 1 })]
  13. }
  14. }
  15. module.exports = {
  16. PositiveIntergerValidator
  17. }

Rule校验器中的参数和validator.js模块参数相同,参考官网:https://github.com/validatorjs/validator.js

  1. 使用校验器校验参数
  1. // api/v1/classic.js
  2. const Router = require('koa-router')
  3. const router = new Router()
  4. const { PositiveIntergerValidator } = require('../../validators/validator')
  5. router.post('/v1/:id/classic/latest', (ctx, next) => {
  6. const params = ctx.params
  7. const query = ctx.query
  8. const headers = ctx.header
  9. const body = ctx.request.body
  10. // 校验ctx中的参数
  11. const v = new PositiveIntergerValidator().validate(ctx)
  12. })
  13. module.exports = router
  1. 参数的获取
  1. const v = await new PositiveIntergerValidator().validate(ctx)
  2. // 使用验证器获取参数
  3. //获取值并进行类型转换 get方法使用的是loadsh的get方法,如果想获取原数据,第二个参数设置为false
  4. const id = v.get('path.id', false)
  5. console.log(id);

8.8 全局配置文件设置

设置开发环境抛出错误,供开发查看,生产环境不需要抛出错误

  1. 编写配置文件
  1. // config/config.js
  2. module.exports = {
  3. env: "dev",
  4. }
  1. 在项目初始化时,将配置文件赋值给global对象,供全局使用
  1. // core/init.js
  2. static initCore(app) {
  3. // 入口方法
  4. InitManager.loadConfig()
  5. }
  6. //* 加载配置文件
  7. static loadConfig(path = '') {
  8. const configPath = path || process.cwd() + '/config/config.js'
  9. const config = require(configPath)
  10. global.config = config
  11. }
  1. 捕获错误时,区分开开发环境和生产环境
  1. if (global.config.env === 'dev') {
  2. throw error
  3. }

9. 操作mySql数据

9.1 连接数据库

  1. 安装依赖
  1. yarn add mysql2 sequelize
  1. 配置连接数据库相关参数
  1. // config/config.js
  2. database: {
  3. dbName: "isLand", // 数据库名
  4. host: "localhost",
  5. port: 3306,
  6. user: "root",
  7. password: ""
  8. },
  1. 使用sequelize创建连接数据库模块
  1. // core/db.js
  2. const Sequelize = require('sequelize')
  3. const {
  4. dbName,
  5. host,
  6. port,
  7. user,
  8. password
  9. } = require('../config/config.js').database
  10. const sequelize = new Sequelize(dbName, user, password, {
  11. dialect: 'mysql',
  12. host,
  13. port,
  14. logging: true, //显示数据库操作
  15. timezone: '+08:00', //时区,不设置会与北京相差8小时
  16. define: {
  17. // create_time update_time delete_time
  18. timestamps: true, //创建删除更新时间
  19. paranoid: true,
  20. createdAt: 'created_at',
  21. updatedAt: 'updated_at',
  22. deletedAt: 'deleted_at',
  23. underscored: true,
  24. freezeTableName: true
  25. }
  26. })
  27. //同步更新数据库
  28. sequelize.sync({
  29. force: true
  30. })
  31. module.exports = {
  32. sequelize
  33. }

sequelize可以自定义一些连接数据库的配置,详情见官网1http://www.nodeclass.com/api/sequelize.html;
官网2https://sequelize.org/master/

  1. 使用sequelize创建数据库模型User
  1. // app/models/user.js
  2. const { sequelize } = require('../../core/db') //sequelize实例
  3. const { Sequelize, Model } = require('sequelize')
  4. class User extends Model {
  5. }
  6. User.init({
  7. /**
  8. * 主键: 不能重复 不能为空
  9. * 注册: User id 设计 id编号系统 60001 60002
  10. * 自动增长id编号
  11. * id编号自己设计最好是数字,字符串,
  12. * 不要使用随机字符串,例如:GUID
  13. *
  14. * 暴露了用户编号
  15. * 即使别人知道用户编号,也无法做坏事
  16. * 接口保护 权限 访问接口 Token
  17. */
  18. id: {
  19. type: Sequelize.INTEGER,
  20. primaryKey: true, // 设置主键
  21. autoIncrement: true, // 自动增长
  22. },
  23. nickname: Sequelize.STRING,
  24. email: Sequelize.STRING,
  25. password: Sequelize.STRING,
  26. openid: {
  27. type: Sequelize.STRING(64), //限制最大范围
  28. unique: true, //指定唯一
  29. },
  30. /**
  31. * 用户 --小程序 openid 不变 且唯一
  32. * A,B
  33. *
  34. * 你 小程序/公众号 unionID 是唯一的
  35. */
  36. }, {
  37. sequelize,
  38. tableName: 'user' // 数据迁移
  39. })
  40. // 数据迁移 SQL更新 风险
  1. 引入user模型,创建数据库表
  1. // app.js
  2. require('./app/models/user')

10. 用户注册流程与sequelize新增数据

1.用户注册逻辑

  1. 通过路由接收路由参数
  2. 通过LinValidator校验路由参数
  3. 校验成功将数据保存到数据库

2. 邮箱注册

  1. 编写路由
  1. // app/api/v1/user.js
  2. const Router = require('koa-router')
  3. const { RegisterValidator } = require('../../validators/validator')
  4. const {success} = require('../../lib/helper')
  5. const { User } = require('../../models/user')
  6. const router = new Router({
  7. prefix: "/v1/user" //自动配置url前缀
  8. })
  9. /**
  10. * 注册
  11. */
  12. // router.post('/register', new RegisterValidator() async (ctx) => {
  13. /**
  14. * 使用中间件的形式做校验,全局只有1个validator
  15. *
  16. */
  17. router.post('/register', async (ctx) => {
  18. /**
  19. * 编写路由思维路径
  20. * 1. 接收参数 LinValidator
  21. * email password1 password2 nickname
  22. * 2. 将参数保存数据库
  23. * v.get
  24. * sql Model
  25. */
  26. // 使用实例化方式,调用10次会实例化10次
  27. const v = await new RegisterValidator().validate(ctx)
  28. const user = {
  29. email: v.get('body.email'),
  30. password: v.get('body.password1'),
  31. nickname: v.get('body.nickname')
  32. }
  33. await User.create(user)
  34. // 使用抛出错误的方法,抛出一个成功
  35. // throw new global.errs.Success()
  36. // 封装成一个函数引入
  37. success('注册成功')
  38. })
  39. module.exports = router
  1. 编写注册校验Validator
  1. // app\validators\validator.js
  2. class RegisterValidator extends LinValidator {
  3. constructor() {
  4. super()
  5. this.email = [
  6. new Rule('isEmail', '不符合Email规范')
  7. ]
  8. this.password1 = [
  9. // 用户密码指定范围,密码强度
  10. new Rule('isLength', '密码至少6个字符,最多32个字符', {
  11. min: 6,
  12. max: 32
  13. }),
  14. new Rule('matches', '密码必须包含数字、大写英文字母、小写英文字母', '^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]')
  15. ]
  16. this.password2 = this.password1
  17. this.nickname = [
  18. new Rule('isLength', '昵称至少6个字符,最多32个字符', {
  19. min: 2,
  20. max: 32
  21. })
  22. ]
  23. }
  24. // 自定义校验方法,前缀必须是validate
  25. validatePassword(vals) {
  26. const psw1 = vals.body.password1
  27. const psw2 = vals.body.password2
  28. if (psw1 !== psw2) {
  29. throw new Error('两个密码必须相同')
  30. }
  31. }
  32. async validateEmail(vals) {
  33. const email = vals.body.email
  34. const user = await User.findOne({
  35. where: {
  36. email: email
  37. }
  38. })
  39. if (user) {
  40. throw new Error('Email已经存在')
  41. }
  42. }
  43. }
  1. 将数据保存到数据库
  1. // app\api\v1\user.js
  2. const user = {
  3. email: v.get('body.email'),
  4. password: v.get('body.password1'),
  5. nickname: v.get('body.nickname')
  6. }
  7. await User.create(user)
  • 保存数据时,保存的密码是经过加密的,使用bcrypt插件加密
  1. // app\models\user.js
  2. password: {
  3. type: Sequelize.STRING,
  4. /**
  5. * note: model的属性操作
  6. * 设计模式 观察者模式
  7. * es6: Reflect Vue3.0
  8. */
  9. set(val) {
  10. //note: 密码加密 盐
  11. const salt = bcrypt.genSaltSync(10)
  12. /**
  13. * 10的意思: 指的是生成盐的成本,越大,花费成本越高,密码安全性越高,一般取默认值
  14. * 明文,相同密码加密之后也要不同,防止彩虹攻击
  15. */
  16. const psw = bcrypt.hashSync(val, salt)
  17. this.setDataValue('password', psw)
  18. }
  19. },
  1. 保存数据成功,返回成功消息
  • 可以使用抛出错误的方法,抛出一个成功
    throw new global.errs.Success()
  • 封装成一个函数引入,更容易理解
  1. // app\lib\helper.js
  2. function success(msg,errorCode) {
  3. throw new global.errs.Success(msg,errorCode)
  4. }
  5. module.exports={
  6. success
  7. }
  1. // app\api\v1\user.js
  2. const {success} = require('../../lib/helper')
  3. success('注册成功')'

11. 用户登录操作流程

用户登录逻辑

  1. 接收登录参数(账号,密码,登录类型)
  2. 校验登录参数
  3. 根据不同的登录类型,执行不同的登录方法
  4. 核对数据库用户身份是否正确
  5. 登录成功返回成功信息

1. 编写用户登录路由

  1. // app\api\v1\token.js
  2. const Router = require('koa-router')
  3. const { TokenValidator } = require('../../validators/validator')
  4. const { LoginType } = require('../../lib/enums')
  5. const { User } = require('../../models/user')
  6. /**
  7. * 登录
  8. * session 考虑状态 无状态
  9. * email password
  10. * 2. 令牌获取 颁布令牌
  11. * token 无意义的随机字符串
  12. * jwt 可以携带数据
  13. */
  14. const router = new Router({
  15. prefix: "/v1/token" //自动配置url前缀
  16. })
  17. router.post('/', async (ctx) => {
  18. const v = await new TokenValidator().validate(ctx)
  19. // 根据type类型,执行不同的登录方法
  20. switch (v.get('body.type')) {
  21. case LoginType.USER_EMAIL:
  22. await emailLogin(v.get('body.account'), v.get('body.secret'))
  23. break;
  24. case LoginType.USER_MINI_PROGRAM:
  25. break;
  26. default:
  27. throw new global.errs.ParameterException('没有相应的处理函数')
  28. break;
  29. }
  30. })
  31. /**
  32. * email登录
  33. *
  34. * @param {*} account 账户
  35. * @param {*} secret 密码
  36. */
  37. async function emailLogin(account, secret) {
  38. const user = await User.verifyEmailPassword(account, secret)
  39. }
  40. module.exports = router

2. 校验用户登录路由参数

  1. // app\validators\validator.js
  2. class TokenValidator extends LinValidator {
  3. constructor() {
  4. super()
  5. // 账号
  6. this.account = [
  7. new Rule('isLength', '不符合账号规则', { min: 4, max: 32 })
  8. ]
  9. // 密码
  10. this.secret = [
  11. /**
  12. * 是必须要传入的吗
  13. * web 账号+密码
  14. * 登录 多元化 小程序登录不需要校验密码
  15. * 微信打开小程序 已经验证了合法用户了
  16. * web account + secret
  17. * 小程序 account
  18. * 手机号登录
  19. * 1. 可以为空,可以不传
  20. * 2. 空 不为空
  21. */
  22. new Rule('isOptional'),
  23. new Rule('isLength', '至少6个字符', { min: 6, max: 128 })
  24. ]
  25. // 验证登录方式 type JS 枚举
  26. }
  27. validateLoginType(vals) {
  28. if (!vals.body.type) {
  29. throw new Error('type是必传参数')
  30. }
  31. if (!LoginType.isThisType(vals.body.type)) {
  32. throw new Error('type参数不合法')
  33. }
  34. }
  35. }

isOptional校验规则,可以该参数为可传,可传

3. 判断登录类型type,执行登录方法

  1. // app\api\v1\token.js
  2. /**
  3. * email登录
  4. *
  5. * @param {*} account 账户
  6. * @param {*} secret 密码
  7. */
  8. async function emailLogin(account, secret) {
  9. const user = await User.verifyEmailPassword(account, secret)
  10. }

4. 核对用户邮箱密码

  1. // app\models\user.js
  2. class User extends Model {
  3. /**
  4. * 核对用户邮箱密码
  5. *
  6. * @static
  7. * @param {*} email
  8. * @param {*} plainPassword
  9. * @memberof User
  10. */
  11. static async verifyEmailPassword(email,plainPassword) {
  12. const user = await User.findOne({
  13. where: {
  14. email
  15. }
  16. })
  17. if(!user) throw new global.errs.AuthFailed('用户不存在')
  18. // 密码验证
  19. const correct = bcrypt.compareSync(plainPassword,user.password)
  20. if(!correct) throw new global.errs.AuthFailed('密码不正确')
  21. return user
  22. }
  23. }

5. 生成jwt令牌

  1. 使用第三方生成token的库
  1. yarn add jsonwebtoken
  1. 封装生成token的函数
  1. // 配置jwt秘钥
  2. // config/config.js
  3. security: { // jwt秘钥
  4. secretKey: "qwert", // 令牌key,一般要设置很复杂
  5. expiresIn: 60 * 60 * 24 * 30 // 过期时间
  6. },
  1. // 封装生成token的函数
  2. // core/util.js
  3. const jwt = require("jsonwebtoken")
  4. const config = require("../config/config")
  5. /**
  6. * 生成jwt Token
  7. *
  8. * @param {*} uid 用户id
  9. * @param {*} scope 用户权限
  10. * @returns
  11. */
  12. const generateToken = function(uid, scope) {
  13. const secretKey = config.security.secretKey
  14. const expiresIn = config.security.expiresIn
  15. const token = jwt.sign({ uid, scope }, secretKey, { expiresIn })
  16. return token
  17. }
  18. module.exports = {
  19. generateToken
  20. }
  1. 登录成功获取token
  1. // app/v1/token.js
  2. const { User } = require('../../models/user')
  3. const { generateToken } = require('../../../core/util')
  4. /**
  5. *
  6. * email登录
  7. * 普通用户
  8. *
  9. * @param {*} account 账户
  10. * @param {*} secret 密码
  11. */
  12. async function emailLogin(account, secret) {
  13. const user = await User.verifyEmailPassword(account, secret)
  14. return token = generateToken(user.id, Auth.USER)
  15. }

6. 验证token令牌合法性

token从前端传递过来,后台获取token的方式:

  • 通过路由body header获取
  • HTTP 规定 身份验证机制 HttpBasicAuth

检测前端传过来的token是否合法,通过以下几方面进行判断

  1. 通过basic-auth插件解析token,获取token中的用户信息
  • 如果没有有解析的值,或者解析的值没有用户信息则抛出错误:token不合法
  1. 使用jsonwebtoken中的verify方法,验证解析token中的用户信息和后台服务配置的秘钥(secretKey)是否一致,如果信息错误,抛出错误
  • 如果error.name == ‘TokenExpiredError’,则说明token过期
  • 其他情况说明token不合法
  1. 验证token权限(后面会讲)
  • 如果token的权限值小于api的权限值,则权限不足
  1. token合法,返回合法的信息(这里是uid和scope)
  1. ctx.auth = {
  2. uid: decode.uid,
  3. scope: decode.scope
  4. }
  1. 执行下一个中间件
  1. await next()
  • 示例代码
  1. // core/http-exception.js
  2. //定义错误类型
  3. class Forbbiden extends HttpException {
  4. constructor(msg, errorCode) {
  5. super()
  6. this.msg = msg || '禁止访问'
  7. this.errorCode = errorCode || 10006
  8. this.code = 403
  9. }
  10. }
  1. // middlewares/auth.js
  2. /**
  3. * 权限控制检测
  4. * 中间件
  5. */
  6. const basicAuth = require('basic-auth')
  7. const jwt = require('jsonwebtoken')
  8. class Auth {
  9. constructor(level) {
  10. // 定义权限
  11. this.level = level || 1
  12. //note: 定义权限类常量
  13. Auth.USER = 8; // 用户
  14. Auth.ADMIN = 16 // admin
  15. Auth.SUPER_ADMIN = 32 // 超级admin
  16. }
  17. get m() {
  18. return async (ctx, next) => {
  19. /**
  20. * token 检测
  21. * 1. token获取 body header
  22. * HTTP 规定 身份验证机制 HttpBasicAuth
  23. * 2. 判断token合法性
  24. */
  25. // console.log(ctx);
  26. const userToken = basicAuth(ctx.req)
  27. let errMsg = 'token不合法'
  28. if (!userToken || !userToken.name) {
  29. throw new global.errs.Forbbiden(errMsg)
  30. }
  31. try {
  32. // 校验令牌,用户传过来的token, 全局配置文件中的令牌key
  33. //note: decode是jwt令牌返回的信息,里面有自定义的变量,例如uid
  34. var decode = jwt.verify(userToken.name,
  35. global.config.security.secretKey)
  36. } catch (error) {
  37. /**
  38. * 明确提示用户到底哪种情况不合法
  39. * token不合法
  40. * token过期
  41. */
  42. if (error.name == 'TokenExpiredError') {
  43. //! 过期
  44. errMsg = 'token已过期'
  45. }
  46. //! 不合法
  47. throw new global.errs.Forbbiden(errMsg)
  48. }
  49. if (decode.scope < this.level) {
  50. errMsg = '权限不足'
  51. throw new global.errs.Forbbiden(errMsg)
  52. }
  53. // uid,scope
  54. ctx.auth = {
  55. uid: decode.uid,
  56. scope: decode.scope
  57. }
  58. await next()
  59. }
  60. }
  61. }
  62. module.exports = {
  63. Auth
  64. }

7. api权限问题

  • API 权限 非公开api需要token才能访问
  • token 过期/不合法 就不能访问api
  1. 编写权限Map
  1. // app/lib/enums.js
  2. // 权限类型
  3. const AuthType = {
  4. LATEST: 7, //课程列表
  5. }
  1. 编写验证jwt令牌权限的中间件
  1. // middlewares/auth.js
  2. /**
  3. * 权限控制检测
  4. * 中间件
  5. */
  6. const basicAuth = require('basic-auth')
  7. const jwt = require('jsonwebtoken')
  8. class Auth {
  9. constructor(level) {
  10. // 定义权限
  11. this.level = level || 1
  12. //note: 定义权限类常量
  13. Auth.USER = 8; // 用户
  14. Auth.ADMIN = 16 // admin
  15. Auth.SUPER_ADMIN = 32 // 超级admin
  16. }
  17. get m() {
  18. return async (ctx, next) => {
  19. /**
  20. * token 检测
  21. * 1. token获取 body header
  22. * HTTP 规定 身份验证机制 HttpBasicAuth
  23. * 2. 判断token合法性
  24. */
  25. // console.log(ctx);
  26. const userToken = basicAuth(ctx.req)
  27. let errMsg = 'token不合法'
  28. if (!userToken || !userToken.name) {
  29. throw new global.errs.Forbbiden(errMsg)
  30. }
  31. try {
  32. // 校验令牌,用户传过来的token, 全局配置文件中的令牌key
  33. //note: decode是jwt令牌返回的信息,里面有自定义的变量,例如uid
  34. var decode = jwt.verify(userToken.name,
  35. global.config.security.secretKey)
  36. } catch (error) {
  37. /**
  38. * 明确提示用户到底哪种情况不合法
  39. * token不合法
  40. * token过期
  41. */
  42. if (error.name == 'TokenExpiredError') {
  43. //! 过期
  44. errMsg = 'token已过期'
  45. }
  46. //! 不合法
  47. throw new global.errs.Forbbiden(errMsg)
  48. }
  49. if (decode.scope < this.level) {
  50. errMsg = '权限不足'
  51. throw new global.errs.Forbbiden(errMsg)
  52. }
  53. // uid,scope
  54. ctx.auth = {
  55. uid: decode.uid,
  56. scope: decode.scope
  57. }
  58. await next()
  59. }
  60. }
  61. }
  62. module.exports = {
  63. Auth
  64. }
  1. 修改classic.js路由,做权限分级
  1. const { Auth } = require('../../../middlewares/auth')
  2. const { AuthType } = require('../../lib/enums')
  3. router.get('/latest', new Auth(AuthType.LATEST).m, async (ctx, next) => {
  4. /**
  5. * 1.权限是一个很难很复杂的问题
  6. * 目前的auth中间件只是实现了一种限制
  7. * 2.权限分级 scope
  8. * 普通用户 管理员
  9. * 8 16
  10. * 如果普通用户携带的权限数字是8,如果把/latest api的权限数字设置为9,
  11. * 普通用户权限8是小于api权限9的,所以用户无法访问此api
  12. * 但是管理员用户的权限数字是16,大于9,所以管理员可以访问此api
  13. */
  14. ctx.body = ctx.auth.uid
  15. })
  • 权限分级
    权限是一个很难很复杂的问题 | 普通用户 | 管理员 | | —- | —- | | 8 | 16 |
  • 如果普通用户携带的权限数字是8,如果把/latest api的权限数字设置为9,
  • 普通用户权限8是小于api权限9的,所以用户无法访问此api
  • 但是管理员用户的权限数字是16,大于9,所以管理员可以访问此api

8. 编写小程序通过openid登录系统的后台服务

小程序登录原理

  1. 小程序生成code发送给服务端
  2. 服务端拿着code请求微信服务端
  3. 请求成功微信服务端返回openid(唯一标识);鉴定用户是否合法
  • 小程序端没有显式的注册
  1. 请求微信服务
  • 微信服务传参形式
  1. https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/login/auth.code2Session.html

请求微信服务器的请求参数:

  1. code //动态生成
  2. appid
  3. appsecret
  • 通过微信服务url传递参数请求服务器
  1. 接收微信服务返回的openid
  • 为用户建立档案 将数据写入user表,同时生成一个uid编号
  • 不建议使用openid作为uid的编号,
    • (1)openid比较长,作为主键查询效率比较低
    • (2)openid实际上是比较机密的数据,如果在小程序和服务端进行传递容易泄露
  1. 考虑token失效的情况
  • 如果token失效,再次登录传入code,就会再次走codeToToken的流程
  • 我们会再次拿到openid,我们需要查询数据库是否有此openid,
    • (1)如果有同样的openid则不再保存数据库
    • (2)如果没有存在则创建新的user档案

snipaste20191108_174900.png

  • 判断登录类型,执行微信相关业务逻辑
  1. // api/v1/token.js
  2. const { WXManager } = require('../../services/wx')
  3. case LoginType.USER_MINI_PROGRAM:
  4. //小程序
  5. token = await WXManager.codeToToken(v.get('body.account'))
  6. break;
  • 请求微信服务相关配置
  1. // config/config.js
  2. wx: {
  3. appid: "wx77124add68e6adcb",
  4. appsecret: "0621bffd050cfcbb0139c014652e0453",
  5. loginUrl: "https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code"
  6. }
  • user模型,查询和添加openid操作
  1. // models/user.js
  2. /**
  3. * 获取openid
  4. *
  5. * @static
  6. * @param {*} openid
  7. * @returns
  8. * @memberof User
  9. */
  10. static async getUserByOpenid(openid) {
  11. const user = await User.findOne({
  12. where: {
  13. openid
  14. }
  15. })
  16. return user
  17. }
  18. /**
  19. * 添加openid
  20. *
  21. * @static
  22. * @param {*} openid
  23. * @returns
  24. * @memberof User
  25. */
  26. static async registerByOpenid(openid) {
  27. return await User.create({
  28. openid
  29. })
  30. }
  • 微信相关业务逻辑
  1. // app/services/wx.js
  2. /**
  3. * 微信相关业务逻辑
  4. */
  5. const util = require('util')
  6. const axios = require('axios')
  7. const { User } = require('../models/user')
  8. const { generateToken } = require('../../core/util')
  9. const { Auth } = require('../../middlewares/auth')
  10. class WXManager {
  11. static async codeToToken(code) {
  12. /**
  13. * 小程序登录逻辑
  14. * 1. 小程序生成code发送给服务端
  15. * 2. 服务端拿着code请求微信服务端
  16. * 3. 请求成功微信服务端返回openid(唯一标识);鉴定用户是否合法
  17. * 小程序端没有显示的注册
  18. * 4. 请求微信服务
  19. * 微信服务传参形式
  20. * https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/login/auth.code2Session.html
  21. * code 动态生成
  22. * appid appsecret
  23. * 通过微信服务url传递参数请求服务器
  24. */
  25. // 格式化url
  26. const url = util.format(
  27. global.config.wx.loginUrl,
  28. global.config.wx.appid,
  29. global.config.wx.appsecret,
  30. code
  31. )
  32. // console.log(url);
  33. const result = await axios.get(url)
  34. // console.log(result.data);
  35. if (result.status !== 200) {
  36. throw new global.errs.AuthFailed('openid获取失败')
  37. }
  38. const errcode = result.data.errcode
  39. const errmsg = result.data.errmsg
  40. if (errcode) {
  41. throw new global.errs.AuthFailed(
  42. 'openid获取失败' + errmsg,
  43. errcode
  44. )
  45. }
  46. /**
  47. * 5. 接收微信服务返回的openid
  48. * 为用户建立档案 将数据写入user表,同时生成一个uid编号
  49. * 不建议使用openid作为uid的编号,
  50. * (1)openid比较长,作为主键查询效率比较低
  51. * (2)openid实际上是比较机密的数据,如果在小程序和服务端进行传递容易泄露
  52. * 6. 考虑token失效的情况
  53. * 如果token失效,再次登录传入code,就会再次走codeToToken的流程
  54. * 我们会再次拿到openid,我们需要查询数据库是否有此openid,
  55. * (1)如果有同样的openid则不再保存数据库
  56. * (2)如果没有存在则创建新的user档案
  57. */
  58. let user = await User.getUserByOpenid(result.data.openid)
  59. if (!user) {
  60. user = await User.registerByOpenid(result.data.openid)
  61. }
  62. return generateToken(user.id, Auth.USER)
  63. }
  64. }
  65. module.exports = {
  66. WXManager
  67. }

9. 在小程序登录,验证接口

  1. // 获取code
  2. wx.login({
  3. success: res => {
  4. if(res.code) {
  5. wx.request({
  6. url: 'http://localhost:9000/v1/token',
  7. method: 'POST',
  8. data: {
  9. account: res.code,
  10. type: 100
  11. },
  12. success:res=> {
  13. console.log(res)
  14. const code = res.statusCode.toString()
  15. // 字符串以2开头的
  16. if(code.startsWith('2')) {
  17. wx.setStorageSync('token',res.data.token)
  18. console.log(wx.getStorageSync('token'))
  19. }
  20. }
  21. })
  22. }
  23. }
  24. })

12. 验证token令牌是否有效

验证token令牌是否有效逻辑

  1. 服务端接收前台传递的token参数
  2. 校验token参数是否为空
  3. 验证token有效性
  4. 返回验证结果给前台

服务端

  • 路由请求接口
  1. // api/v1/token.js
  2. // 验证令牌是否有效
  3. router.post('/verify', async (ctx) => {
  4. // token
  5. const v = await new NotEmptyValidator().validate(ctx)
  6. const result = Auth.verifyToken(v.get('body.token'))
  7. ctx.body = {
  8. result
  9. }
  10. })
  • 校验token是否为空
  1. /**
  2. * 校验token是否为空
  3. *
  4. * @class NotEmptyValidator
  5. * @extends {LinValidator}
  6. */
  7. class NotEmptyValidator extends LinValidator{
  8. constructor() {
  9. super()
  10. this.token = [
  11. new Rule('isLength','不允许为空',{min:1})
  12. ]
  13. }
  14. }
  • 验证令牌是否有效中间件
  1. // middlewares/auth.js
  2. /**
  3. * 验证令牌是否有效
  4. *
  5. * @static
  6. * @param {*} token
  7. * @memberof Auth
  8. */
  9. static verifyToken(token) {
  10. try {
  11. jwt.verify(token,
  12. global.config.security.secretKey)
  13. return true
  14. } catch (error) {
  15. return false
  16. }
  17. }

小程序端

  1. onVerifyToken() {
  2. wx.request({
  3. url: 'http://localhost:9000/v1/token/verify',
  4. method: 'POST',
  5. data: {
  6. token: wx.getStorageSync('token')+1123,
  7. },
  8. success: res => {
  9. console.log(res)
  10. const code = res.statusCode.toString()
  11. // 字符串以2开头的
  12. if (code.startsWith('2')) {
  13. console.log(res.data)
  14. }
  15. }
  16. })
  17. }

13. 业务逻辑写在哪

  1. 在API接口编写(简单的)
  2. Model(对于web分层架构来说都写在Model里)
    MVC模式 业务逻辑写在Model里
  • 业务分层
    • 简单的业务,写在Model里
    • 复杂的业务,在Model上面在加一层Service
      例如:
  1. Thinkphp Model Service Logic
  2. java Model DTO