全栈项目实践一:基于 Koa 进行二次开发——elpis-core

92 阅读5分钟

本文内容学习自 哲玄前端 《大前端全栈实践》课程

whiteboard_exported_image.png

什么是 elpis-core

elpis-core 是一个基于 Koa 的轻量级框架核心。它是整个项目的基础架构和启动引擎。

  1. 应用启动与初始化
    • 提供了 start 方法作为应用的入口点
    • 创建 Koa 实例并配置基本环境
    • 设置应用的基础路径和业务文件路径
    • 启动 HTTP 服务器并监听指定端口
  2. 模块化加载系统
    • 通过一系列 loader 加载不同类型的模块
    • configLoader : 加载应用配置
    • serviceLoader : 加载服务层组件
    • controllerLoader : 加载控制器
    • routerSchemaLoader : 加载路由校验
    • middlewareLoader : 加载中间件
    • extendLoader : 加载扩展功能
    • routerLoader : 加载路由定义
  3. 框架与业务代码分离
    • 实际业务逻辑位于 app 目录

它采用约定优于配置的方式,通过对目录的约定来规范项目结构,减少协作和沟通成本。

  • config/ 中存放不同环境下的配置文件
  • app/service/ 中存放所有服务层文件
  • app/controller/ 中存放所有控制器
  • app/router-schema/ 中存放所有路由接口的校验
  • app/middleware/ 中存放自定义中间件
  • app/extend/ 中存放所有扩展功能文件
  • app/router/ 中存放所有路由接口

开发者可以专注于在 app 目录中编写业务逻辑,而不必关心底层架构的实现细节。

elpis-core 的实现

const Koa = require('koa')
const path = require('path')

const env = require('./env')

const configLoader = require('./loader/config')
const serviceLoader = require('./loader/service')
const controllerLoader = require('./loader/controller')
const routerSchemaLoader = require('./loader/router-schema')
const middlewareLoader = require('./loader/middleware')
const extendLoader = require('./loader/extend')
const routerLoader = require('./loader/router')

module.exports = {
  /**
   * start elpis core
   * @param {object} options - options
      options = {
        name 项目名称,
      }
   */
  start: (options = {}) => {
    const app = new Koa()

    // 应用配置
    app.options = options
    console.log('projectName:', app.options.name)

    // 初始化环境配置
    app.$env = env(app)
    console.log(`-- [start] $env: ${app.$env.get()} --`)

    // 基础路径, 项目根目录
    app.baseDir = process.cwd()
    // 业务文件路径
    app.businessPath = path.resolve(app.baseDir, 'app')

    // 加载 Loader
    configLoader(app)
    console.log(`-- [start] load config done --`)

    serviceLoader(app)
    console.log(`-- [start] load service done --`)

    controllerLoader(app)
    console.log(`-- [start] load controller done --`)

    routerSchemaLoader(app)
    console.log(`-- [start] load router schema done --`)

    // 加载自定义的中间件
    middlewareLoader(app)
    console.log(`-- [start] load middleware done --`)

    extendLoader(app)
    console.log(`-- [start] load extend done --`)

    // 直接使用生态中的中间件
    try {
      // app/middleware.js
      require(path.resolve(app.businessPath, 'middleware.js'))(app)
      console.log(`-- [start] load global middleware done --`)
    } catch (e) {
      console.log(`[exception] An error occurred while parsing the file 'middleware.js' in ${app.businessPath}`)
    }

    routerLoader(app)
    console.log(`-- [start] load router done --`)

    try {
      const host = process.env.HOST || '0.0.0.0'
      const port = process.env.PORT || 8080

      app.listen(port, host)
      console.log(`server is running at http://${host}:${port}`)
    } catch (error) {
      console.log(error)
    }

    return app
  },
}

  • 此处采用 app.$env的原因:env是Koa的实例属性, 类型为string, koa.env会读取process.env.NODE_ENV的值

lodaer 的处理

需要注意 loader的加载顺序。

  • controller 依赖于 service 和 config
  • router 依赖于 router-schema, controller 和 middleware
  • router-schema需要在middleware前加载(API 检验需要)

还有以下注意事项

  • middleware 中执行返回的是异步函数,而 service 和 controller 中执行返回的是class类
  • koa实例中具有middleware属性。它是一个数组,专门用来存储通过 app.use()方法注册的所有中间件函数
    • 自己手动挂载 middleware 需要变成 app.middlewares
  • middlewareLoader只是加载了自定义的中间件,为了使用生态中的中间件,还需要加载 app/middleware.js文件

configLoader

配置区分 本地/测试/生产, 通过 env 环境读取不同文件配置 env.config

通过 env.config 覆盖 default.config 加载到 app.config 中

目录下对应的 config 配置

  • 默认配置:config/config.default.js
  • 本地配置:config/config.local.js
  • 测试配置:config/config.beta.js
  • 生产配置:config/config.prod.js

app.config = Object.assign({}, defaultConfig, envConfig)

serviceLoader

加载所有 service, 可以通过 'app.service.${目录}.${文件}' 访问

app/service
  |
  | -- custom-moduleA
            |
            | -- custom-serviceA.js
=> app.service.customModuleA.customServiceA

controllerLoader

加载所有 controller, 可以通过 'app.controller.${目录}.${文件}' 访问

app/controller
  |
  | -- custom-moduleA
            |
            | -- custom-controllerA.js
=> app.controller.customModuleA.customControllerA

routerSchemaLoader

通过 'json-schema & ajv ' 对 API 规则进行约束,配合自定义的 api-paramas-verify 中间件

app/router-schema/**.js
输出:
    app.routerSchema = {
      '${api1}': ${jsonSchema},
      '${api2}': ${jsonSchema},
      '${api3}': ${jsonSchema},
    }

middlewareLoader

加载所有 middleware, 可以通过 'app.middlewares.${目录}.${文件}' 访问

app/middleware
  |
  | -- custom-moduleA
            |
            | -- custom-middlewareA.js
=> app.middlewares.customModuleA.customMiddlewareA

extendLoader

加载所有 extend, 可以通过 'app.${文件}' 访问

app/extend
  |
  | -- custom-extend.js
=> app.customExtend

routerLoader

解析所有 app/router/ 下所有 js 文件,加载到 KoaRouter

至此,elpis-core的基础开发已经完成

elpis-core 的基础应用

通过添加中间件和扩展,丰富框架功能

模板页面渲染能力

Nunjucks 引入中间件 koa-nunjucks-2, ctx 会增加一个 render 方法

// app/middleware.js
const path = require('path')
const koaNunjucks = require('koa-nunjucks-2')

module.exports = app => {
  const publicPath = path.resolve(app.businessPath, 'public')
  // 模板渲染引擎
  app.use(
    koaNunjucks({
      ext: 'tpl',
      path: publicPath,
      nunjucksConfig: {
        noCache: true,
        trimBlocks: true,
      },
    })
  )

}

// app/router/view.js
module.exports = (app, router) => {
  const { view: ViewController } = app.controller

  // 用户输入 http://ip:port/view/page1 能渲染出对应的 enter.page1 页面
  router.get('/view/:page', ViewController.renderPage.bind(ViewController))
}



// app/controller/view.js
module.exports = app => {
  return class ViewController {
    /**
     * 渲染页面
     * @param {object} ctx 上下文
     */
    async renderPage(ctx) {
      await ctx.render(`output/entry.${ctx.params.page}`, {
        name: app.options?.name,
        env: app.$env.get(),
        options: JSON.stringify(app.options),
      })
    }
  }
}

用 tpl 而不用 html

  • 有动态数据,而且动态数据是由服务端注入进去的
  • html 往往是静态数据。直接返回 html 无法利用框架的数据绑定能力

post body 解析中间件

  • ctx.request 请求对象: Koa封装的请求对象
  • ctx.req 请求对象: Node封装的请求对象
  • ctx.response 响应对象: Koa封装的响应对象
  • ctx.res 响应对象: Node封装的响应对象

为了解析 ctx.request.body 引入中间件

// app/middleware.js
const bodyParser = require('koa-bodyparser')
module.exports = app => {
  // ctx.body 解析中间件
  app.use(
    bodyParser({
      enableTypes: ['json', 'form', 'text'],
      formLimit: '1000mb',
    })
  )
}

添加日志能力

添加扩展,使得调用 app.logger.info / app.logger.error 进行输出并添加日志

// app/extend/logger.js

const log4js = require('log4js')

/**
 * 日志工具
 * @param {object} app  Koa 实例
 * 调用 app.logger.info  /  app.logger.error
 */
module.exports = app => {
  let logger

  if (app.$env.isLocal()) {
    // 打印到控制台
    logger = console
  } else {
    // 日志输出并落地到磁盘文件
    log4js.configure({
      appenders: {
        console: { type: 'console' },
        // 日志文件切分
        dataFile: { type: 'dateFile', filename: `./logs/application_${app.$env.get()}.log`, pattern: '.yyyy-MM-dd' },
      },
      categories: {
        default: {
          appenders: ['console', 'dataFile'],
          level: 'trace',
        },
      },
    })
    logger = log4js.getLogger()
  }

  return logger
}

增强系统健壮性

// app/middleware.js

module.exports = app => {
  // 异常捕获中间件
  app.use(app.middlewares.errorHandler)

  // API 签名校验中间件
  app.use(app.middlewares.apiSignVerify)

  // API 参数校验中间件
  app.use(app.middlewares.apiParamsVerify)
}

错误处理

运行时异常错误处理,兜底所有异常

// app/middleware/error-handler.js
/**
 * @param {object} app  Koa 实例
 */
module.exports = app => {
  return async (ctx, next) => {
    try {
      await next()
    } catch (err) {
      // 异常处理
      const { status, message, detail } = err

      app.logger.info(JSON.stringify(err))
      app.logger.error('[-- exception --]:', err)
      app.logger.error('[-- exception --]:', status, message, detail)

      if (message && message.indexOf('template not found') > -1) {
        ctx.status = 302 // 临时重定向
        ctx.redirect(`${app.options?.homePage}`)
        return
      }

      ctx.status = 200
      ctx.body = {
        success: false,
        code: 50000,
        message: '网络异常 请稍后重试',
      }
    }
  }
}

API校验
API 签名合法性校验
// app/middleware/api-sign-verify.js

const md5 = require('md5')

/**
 * @param {object} app  Koa 实例
 */
module.exports = app => {
  return async (ctx, next) => {
    // 只校验 '/api' 开头的请求路径
    if (!ctx.request.path.startsWith('/api')) {
      return await next()
    }

    // 签名校验
    const { path, method, headers } = ctx.request
    const { s_sign, s_t } = headers
    const signKey = 'CJGXcdChs2EYFoU78LFpU5ElBbL0n7AcJ8rbgEBB'
    const signature = md5(`${signKey}_${s_t}`)
    app.logger.info(`[${method} ${path}] signature: ${signature}`)
    if (!s_sign || !s_t || s_sign != signature || Date.now() - s_t > 1000 * 60 * 5) {
      ctx.status = 200
      ctx.body = {
        success: false,
        code: 445,
        message: 'Signature not correct or api timeout!!!',
      }
      return
    }

    await next()
  }
}

API 参数合法性校验
router-schema & ajv

controller 中不用再关心某个值是否存在的逻辑代码,更专注于业务代码

ajv.compile 预编译所有的 router-schema,提升性能

  • 将 Schema 编译成验证函数并缓存起来,避免在每次请求中重复编译
  • routerSchemaLoader需要在middlewareLoader前加载
// app/middleware/api-params-verify.js

const Ajv = require('ajv')
const ajv = new Ajv()

/**
 * @param {object} app  Koa 实例
 */
module.exports = app => {
  // 预编译所有 router-schema
  const compiledValidators = {}
  for (const path in app.routerSchema) {
    for (const method in app.routerSchema[path]) {
      compiledValidators[`${path}_${method.toLowerCase()}`] = {}
      for (const verifyKey in app.routerSchema[path][method]) {
        compiledValidators[`${path}_${method.toLowerCase()}`][verifyKey] = ajv.compile(
          app.routerSchema[path][method][verifyKey]
        )
      }
    }
  }

  return async (ctx, next) => {
    // 只校验 '/api' 开头的请求路径
    if (!ctx.request.path.startsWith('/api')) {
      return await next()
    }

    // 获取需要校验的参数
    const { path, method, headers, body, query } = ctx.request
    app.logger.info(`[${method} ${path}] body: ${JSON.stringify(body)}`)
    app.logger.info(`[${method} ${path}] query: ${JSON.stringify(query)}`)
    app.logger.info(`[${method} ${path}] headers: ${JSON.stringify(headers)}`)

    const validators = compiledValidators[`${path}_${method.toLowerCase()}`]
    if (!validators) {
      return await next()
    }

    const verifyObj = { headers, body, query }
    for (const verifyKey in verifyObj) {
      const validator = validators[verifyKey]
      if (!validator) continue

      const valid = validator(verifyObj[verifyKey])
      if (!valid) {
        ctx.status = 200
        ctx.body = {
          success: false,
          code: 442,
          message: `request validate error: ${ajv.errorsText(validator.errors)}`,
        }
        return
      }
    }
    await next()
  }
}

params 有没有校验的必要性?

  • params 是 url 的组成部分, 所以它只能是 string 类型
  • 如果 url 格式不正确, 无法匹配到对应路由处理程序, 会走进兜底路由, 不需要手动校验
  • 由于 params 类型单一, 并且 koa-router 进行了 url 匹配, 所以对于 params 的前置校验并没有必要性, 对于不匹配的路由使用兜底路由或者 404 处理即可。