koa2搭建(学习记录)

604 阅读7分钟

记录学习koa后台搭建的点点滴滴(基础目录根据狼叔的koa-generator生成,然后进行相应改造,瞎倒腾-_-),项目大概结构为: web=>router=>controller=>services=>model=>mongodb

github.com/NoManReady/…

1、目录结构

<!--log4js日记文件夹,可通过配置指定-->
dir:logs_development
<!--pm2日志文件夹,可通过配置指定-->
dir:pm2
<!--主代码文件放置点-->
dir:src
    <!--项目启动入口(www.js)-->
    dir:bin
    <!--项目配置文件夹-->
    dir:config
    <!--控制器,router直接调用-->
    dir:controller
    <!--中间件,logjs、jwt、catch等工具链的包装-->
    dir:middleware
    <!--mongoose模型定义(定义库表)-->
    dir:model
    <!--静态文件目录(javascript、css、images等)-->
    dir:public
    <!--koa-router路由文件夹-->
    dir:router
    <!--数据服务层,供controller调用(为了一些通用数据接口的复用)-->
    dir:services
    <!--一些工具函数的定义-->
    dir:utils
    <!--模板文件-->
    dir:views
    <!--入口文件-->
    file:app.js
<!--配置支持import/export语法-->
file:boot.js
<!--pm2配置文件-->
file:pm2.config.js

package.json启动命令

"scripts": {
    "start": "node ./boot.js",
    "dev": "./node_modules/.bin/nodemon ./boot.js",
    "pm2:start": "pm2 start pm2.config.js",
    "pm2:stop": "pm2 stop all",
    "show": "pm2 list",
    "test": "echo \"Error: no test specified\" && exit 1"
  }

2、配置项目

2.1、启动文件bin/www.js

介绍:做一些服务启动前的任务=》以http.createServer创建koa实例服务

import http from 'http'
import app from '../app'
import config from '../config/env_config'
import { initLogPath } from '../config/log_config'

// 服务启动前检查log4js相关文件是否创建,否:创建相关文件夹
initLogPath()

// 配置服务端口号
let port = normalizePort(config.port || process.env.PORT || '3000');

// 创建http服务
let server = http.createServer(app.callback());
// 监听及注册事件
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);

function normalizePort(val) {
  let port = parseInt(val, 10);

  if (isNaN(port)) {
    // named pipe
    return val;
  }

  if (port >= 0) {
    // port number
    return port;
  }

  return false;
}

/**
 * Event listener for HTTP server "error" event.
 */

function onError(error) {
  if (error.syscall !== 'listen') {
    throw error;
  }

  let bind = typeof port === 'string'
    ? 'Pipe ' + port
    : 'Port ' + port;

  // handle specific listen errors with friendly messages
  switch (error.code) {
    case 'EACCES':
      console.error(bind + ' requires elevated privileges');
      process.exit(1);
      break;
    case 'EADDRINUSE':
      console.error(bind + ' is already in use');
      process.exit(1);
      break;
    default:
      throw error;
  }
}

/**
 * Event listener for HTTP server "listening" event.
 */

function onListening() {
  let addr = server.address();
  let bind = typeof addr === 'string'
    ? 'pipe ' + addr
    : 'port ' + addr.port;
  app.debug('www', 'Listening on ' + bind);
}

2.2 主程序app.js

实例化koa、加载各种koa中间件,数据库连接等操作。

/**
 * App主入口
 */

import Koa from 'koa'
import views from 'koa-views'
import json from 'koa-json'
import onerror from 'koa-onerror'
import bodyparser from 'koa-bodyparser'
import koaStatic from 'koa-static'
// import logger from 'koa-logger'

import router_handle from './utils/router_handle'
import db_handle from './utils/db_handle'
import debug_handle from './utils/debug_handle'

import logMiddleware from './middleware/log4j'
import corsMiddleware from './middleware/cors'
import catchMiddleware from './middleware/catch'
import { jwtPre, jwtAuth } from './middleware/jwt'
// import session from './middleware/session'

import { isDev } from './config/env_config'

const app = new Koa()
// error handler
onerror(app)
// 注册debug功能
const debug = debug_handle(app, isDev)
// 注册路由信息
const router = router_handle(app)
// 注册数据库连接
db_handle()

// middlewares
app.use(bodyparser({
  enableTypes: ['json', 'form', 'text']
}))
app.use(json({ pretty: false, param: 'pretty' }))
// app.use(logger())
// 配置静态资源目录
app.use(koaStatic(__dirname + '/public'))
// 配置页面资源目录(使用pug语法)
app.use(views(__dirname + '/views', {
  extension: 'pug'
}))

// logger(log4js中间件包装)
app.use(logMiddleware)
// cors(跨域cors中间件包装)
app.use(corsMiddleware)
// catch(异常处理中间件包装)
app.use(catchMiddleware)
// jwt(jwt认证中间件包装)
app.use(jwtPre)
app.use(jwtAuth)
// routes(注册路由)
app.use(router.routes(), router.allowedMethods())

// error-handling
app.on('error', (err, ctx) => {
  console.error('server error', err, ctx)
})

export default app

2.3、路由注册(urils/router_handle.js)

根据文件夹结构创建路由,默认index.js文件注册的为根路由,比如: router=>api=>index.js将创建/api的路由,router=>api=>users.js创建/api/users路由router=>index.js创建/的路由。

读取指定路由文件目录,解析文件夹及文件然后创建koa路由。

import path from 'path'
import Router from 'koa-router'
import { parsedir, resolvefiles } from './file_handle'
export default app => {
  const router = new Router()
  const files = parsedir(path.resolve(__dirname, '../router'))
  resolvefiles(files, (file, routePathsStr, entity) => {
    app.debug('router_handle', '路由注册路径:', `/${routePathsStr}`)
    app.debug('router_handle', '路由注册文件:', `${file.fileName + file.extName}`)
    // app.debug('router_handle','路由路径信息:', `${JSON.stringify(file, null, 2)}`)
    router.use(`/${routePathsStr}`, entity.routes(), entity.allowedMethods())
  })
  return router
}

utils/file_handle.js,根据需要自己配置需要导出文件的信息

// utils/file_handle.js
/**
 * 路径解析及加载内容(根据目录结构注册动态路由使用)
 */

import fs from 'fs'
import path from 'path'

/**
 * 解析目录
 */
export function parsedir(dirpath = __dirname, pPathStr = '') {
  const files = fs.readdirSync(dirpath)
  const dirFiles = []
  files.forEach(file => {
    // 获取绝对路径
    let absolutePath = path.join(dirpath, file)
    // 获取路径信息
    let stat = fs.lstatSync(absolutePath)
    // 是否为文件夹
    let isDirectory = stat.isDirectory()
    // 截取当前模块名称(替换父模块路径)
    let curModuleName = file.replace(`${pPathStr}/`, '')
    // 父模块路径信息
    let pPaths = pPathStr.split('/').filter(p => !!p)
    let extName = null
    // 路径为文件读取文件名称并设置当前模块名称为文件名(不包含后缀名)
    if (!isDirectory) {
      extName = path.extname(absolutePath)
      curModuleName = curModuleName.replace(extName, '')
    }
    // pPaths.push(curModuleName)
    // 是否为index文件
    let isIndex = curModuleName === 'index'
    // 定义路径对象
    let fileOption = {
      pPaths: pPaths,
      pPathStr: pPaths.join('/'),
      isDir: isDirectory,
      path: absolutePath,
      isIndex: isIndex,
      extName: extName,
      children: null,
      fileName: null,
      deep: 0
    }
    if (fileOption.isDir) {
      let deep = 0
      let nextPaths = [...pPaths.slice(0), curModuleName]
      let children = parsedir(absolutePath, nextPaths.join('/'))
      fileOption.children = children
      if (!children.length) {
        return
      }
      // 获取当前路径文件深度(有子路径则当前深度+1,深度遍历)
      for (let c of fileOption.children) {
        if (c.deep > deep) {
          deep = c.deep
        }
      }
      fileOption.deep = deep + 1
    } else {
      // 文件后缀名及文件名
      fileOption.extName = path.extname(absolutePath)
      fileOption.fileName = curModuleName
    }
    // index文件放置在尾部(确保路由最后注册)
    if (fileOption.isIndex) {
      dirFiles.push(fileOption)
    } else {
      dirFiles.unshift(fileOption)
    }
  })
  return dirFiles
}

/**
 * 解析字符串路径,执行回调
 */
export function resolvefiles(files, resolve, exp = /\w+\.js$/) {
  if (!files || !resolve) return
  if (typeof resolve !== 'function') return
  if (typeof files === 'function') {
    files = files()
  }
  if (typeof files === 'object') {
    files = Object.values(files)
  }
  if (!files instanceof Array) {
    files = [...files]
  }
  // 每个层级根据路径深度排序-降序(deep高的优先处理)
  files = files.sort((a, b) => b.deep - a.deep)
  for (let f of files) {
    if (f.isDir) {
      // 文件夹路径递归处理
      resolvefiles(f.children, resolve, exp)
    } else if (exp.test(f.path)) {
      // 文件路径注册
      let routePaths = f.pPaths.slice()
      if (!f.isIndex) {
        routePaths.push(f.fileName)
      }
      let file_entity = require(f.path).default
      if (file_entity) {
        resolve(f, routePaths.join('/').replace(/\/_/g, '/:'), file_entity)
      }
    } else {
      console.log(`Url:${f.path} is illegal.`)
    }
  }
}

2.4、权限认证模块

使用JWT认证方式,依赖koa-jwt,jsonwebtoken模块,文件目录:middleware/jwt.js。

import jwt from 'koa-jwt'
// import CryptoJS from 'crypto-js'
import util from 'util'
import { sign, verify } from 'jsonwebtoken'
import { createToken } from '../utils/token_handle'
// 认证token函数
const verifyPromise = util.promisify(verify)
// JWT加密私钥
export const JWT_SECRET_KEY = 'JWT_SECRET_KEY'
// AES加密私钥
// http://www.esitecms.com/archives/cryptojs-aes
// export const AES_SECRET_KEY = CryptoJS.enc.Utf8.parse('AES_SECRET_KEY')
// export const AES_SECRET_KEY = 'AES_SECRET_KEY'
// // AES加密配置
// export const AES_CONFIG = {
//   mode: CryptoJS.mode.ECB,
//   padding: CryptoJS.pad.Pkcs7
// }
// 每次请求是否刷新token
export const RENEW_TOKEN = true
// token过期时间
export const EXPIRE_TIME = 60 * 60 * 1000
// cookie key值
export const COOKIE_KEY = 'JWT:SMB_WEB_TOKEN'
// token key值
export const TOKEN_KEY = 'JWT:TOKEN_KEY'
// cookie配置参数
export const COOKIR_CONFIG = { path: '/' }
// 无需认证路径
export const UNLESS_PATH = [/login|register/, '/']

/**
 * 配置jwt认证头中间件
 * @param {koa上下文} ctx 
 * @param {next} next 
 */
export const jwtPre = async (ctx, next) => {
  let header = ctx.header
  let request = ctx.request
  let debug = ctx.app.debug
  try {
    const token = ctx.cookies.get(COOKIE_KEY, COOKIR_CONFIG) || request.query.token || request.body.token
    if (token) {
      try {
        // let jwtToken = CryptoJS.AES.decrypt(token, AES_SECRET_KEY, AES_CONFIG).toString(CryptoJS.enc.Utf8)
        let paylod = await verifyPromise(token, JWT_SECRET_KEY)
        let currentTime = Date.now() / 1000
        let shouldFreshToken = currentTime - paylod.iat > EXPIRE_TIME / 2
        if (RENEW_TOKEN && shouldFreshToken) {
          createToken(ctx, { name: paylod.name, id: paylod.id })
        }
        ctx.authUser = paylod
      } catch (err) {
        debug('jwtMiddleware', `Token verify fail.`)
      }
    }
    await next()
    debug('jwtMiddleware', ctx._matchedRoute)
  } catch (err) {
    let { status } = err
    if (status === 401) {
      if (header['Req-Type'] === 'restful') {
        ctx.body = {
          code: 401,
          message: 'JWT Authorization failure.'
        }
      } else {
        ctx.redirect('/')
      }
    } else {
      throw err
    }
  }
}

/**
 * 获取jwt签名token
 * @param {签名数据} payload 
 * @param {签名过期时间} expires 
 */
export const jwtSign = (payload, expires = EXPIRE_TIME) => {
  return sign(payload, JWT_SECRET_KEY, { expiresIn: expires })
}

/**
 * 获取jwt认证中间件
 */
export const jwtAuth = jwt({ secret: JWT_SECRET_KEY, cookie: COOKIE_KEY, tokenKey: TOKEN_KEY }).unless({ path: UNLESS_PATH })

注意:中间件应用顺序:app.use(jwtPre);app.use(jwtAuth)

createToken负责创建jwttoken及写入cookie,并返回创建的token

import { jwtSign, COOKIE_KEY, EXPIRE_TIME, COOKIR_CONFIG } from '../middleware/jwt'
export function createToken(ctx, data) {
  let token = jwtSign(data)
  ctx.cookies.set(COOKIE_KEY, token, Object.assign({
    maxAge: EXPIRE_TIME,
    httpOnly: true,
    overwrite: true
  }, COOKIR_CONFIG))
  return token
}

3、请求流程

1、启动服务:yarn dev 2、访问服务,这里是127.0.0.1::10000 3、请求根据app.js中use的中间件顺序依次执行:bodyparser=>json=>koaStatic=>logMiddleware=>corsMiddleware=>catchMiddleware=>jwtPre=>jwtAuth=>routes,然后以洋葱式的模式依次处理,这里配置了访问路径符合[/login|register/, '/']格式的不进行jwt认证,当你访问/时,根据创建的跟路由进行处理,没有认证信息跳转至登录页:

router.get('index', '/', async (ctx, next) => {
  if (ctx.authUser) {
    await ctx.render('index', {
      title: 'Hello Koa 2!'
    })
  } else {
    await ctx.render('login', {
      title: 'Welcome to use smb system.',
      type: 'EAP202'
    })
  }
})

4、无认证时,前端发起登录请求post:/api/login

import Router from 'koa-router'
import userCtrl from '../../controller/user'

const router = new Router()

router.post('/login', userCtrl.login)
router.get('/test', userCtrl.test)
router.get('/register', userCtrl.register)
export default router

userCtrl.login处理器接收并处理 controller/user.js=>

userCtrl根据用户名及密码调用userServices服务校验用户,通过则创建token:createToken,否则返回 --登录失败(账号或密码错误)信息

/**
 * user业务逻辑层
 */
import * as userServices from '../services/user'
import { createToken } from '../utils/token_handle'
import { resOk, resErr } from '../utils/res_handle'

class UserController {
  static async login(ctx) {
    let {
      username,
      password
    } = ctx.request.body
    let result = await userServices.login({ name: username, password })
    if (result) {
      let token = createToken(ctx, { name: result.name, id: result._id })
      resOk(ctx, { token })
    } else {
      resErr(ctx, '登录失败(账号或密码错误)')
    }
  }
  static async test(ctx) {
    ctx.body = { name: 'test' }
  }
  static async register(ctx) {
    let user = {
      name: 'czg1',
      nickName: 'sb',
      password: '46f9cbb4666fd9b109436288a339d72d',
      profession: ['IT', 'YA'],
      age: 10,
      workYear: 3,
      desc: 'SB of ZDJ',
      sex: 'female'
    }
    let result = await userServices.register(user)
    if (result) {
      resOk(ctx, result, '注册成功')
    } else {
      resErr(ctx, '用户名已被注册')
    }
  }
}
export default UserController

services/user.js

userServices调用userModel进行数据库查询操作

import UserModel from '../model/User'

export const login = async ({ name, password }) => {
  let user = await UserModel.findOne({ name, password }, { name: 1, _id: 1 })
  return user
}

export const register = async (u) => {
  let isExist = await UserModel.findOne({ name: u.name }, { _id: 1 })
  if (isExist) {
    return null
  }
  let userModel = new UserModel(u)
  let user = await userModel.save()
  console.log(user)
  return user
}

model/User.js

基于mongoose定义库表结构及实例方法、静态方法。

/**
 * User model领域层(对接数据库mongoose)
 */

import {
  Schema,
  model
} from 'mongoose'

const COLLECTION_NAME = 'User'

const UserSchema = new Schema({
  name: String,
  createAt: {
    type: Date,
    default: Date.now
  },
  nickName: String,
  password: String,
  profession: [String],
  age: Number,
  workYear: Number,
  desc: String,
  sex: {
    type: String,
    enum: ['male', 'female'],
    default: 'male'
  },
  token: String
}, {
    versionKey: false,
    collation: COLLECTION_NAME
  })

/**
 * 定义实例方法
 * let user =new User({sex:'male'})
 * user.getUserBySex((err,users)={
 *    console.log(users)
 * })
 */
UserSchema.methods.getUserBySex = function (cb) {
  return this.model(COLLECTION_NAME).find({
    sex: this.sex
  }, cb)
}
/**
 * 定义静态方法
 * User.getUserBySex('male',(err,users)=>{
 *    console.log(users)
 * })
 */
UserSchema.static.getUserBySex = function (sex, cb) {
  return this.find({
    sex
  }, cb)
}
const User = model(COLLECTION_NAME, UserSchema)

export default User

4、总结

大体的项目结构就是这样了,做个记录防止遗忘,以后再看看,以晕死==

github.com/NoManReady/…