基于Koa搭建一款简易版egg应用框架(1)-elpis

159 阅读8分钟

前提: 本篇文章是在学习了【抖音“哲玄前端”的《全栈实践课》】得出的一些个人总结,主要记录自己不明白或者深入去了解的点,具体实现可以前往抖音下单学习

第一章 - elpis-core内核开发

具体loader

elpis内核主要是编写各种loader并将其挂在到Koa实例上,其主要有

configLoader(配置项)

serviceLoader(服务层)

middlewareLoader(中间件)

routerSchemaLoader(路由检验)

controllerLoader(控制器)

extendLoader(其他拓展功能)

routerLoader(路由)

内核代码文件

elpis-core

| -- loader

| -- config.js (将config下的配置挂载到app.config)

| -- service.js (将service下的服务挂载到app.services)

| -- middleware.js (将各种中间件挂载到app.middlewares)

| -- router-schema.js (将config挂载到app.routerSchemas)

| -- controller.js (将控制器挂载到app.controllers)

| -- extend.js (将功能挂载到app)

| -- router.js (将定义的接口注册到koa-router)

| -- env.js (判断和和获取环境信息)

| -- index.js (创建koa实例并挂载各种loader在实例上)

env.js (判断和和获取环境信息)

module.exports = (app) => {
  return {
    // 判断当前环境是否为本地环境
    isLocal() {
      return process.env._ENV === 'local'
    },

    // 判断当前环境是否为测试环境
    isBeta() {
      return process.env._ENV === 'beta'
    },

    // 判断当前环境是否为生产环境
    isProduction() {
      return process.env._ENV === 'prod'
    },

    // 获取当前环境
    get() {
      return process.env._ENV ?? 'local'
    }
  }
}

index.js (创建koa实例并挂载各种loader在实例上)

这里得注意各个loader的挂载先后顺序,例如controllerLoader需要用到app.services,那就需要serviceLoader先于controllerLoad挂载

const Koa = require('koa');
const path = require('path');
const { sep } = path;
const env = require('./env')

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


module.exports = {
  /**
   * 
   *  @param {*} options 项目配置
      options = {
        name:'my-project', // 项目名称
        homepath: 'https://example.com', // 项目首页
      }
   */
  start(options = {}) {
    const app = new Koa(); // 创建实例

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

    // 设置基础路径
    app.baseDir = process.cwd()
    console.log('app.baseDir: ', app.baseDir);

    // 设置基础路径
    app.bussinessPath = path.resolve(app.baseDir, `.${sep}app`)
    console.log('app.bussinessPath: ', app.bussinessPath);

    // 初始化环境配置
    app.env = env()
    console.log(`-- [start] env: ${app.env.get()} --`);
    
    configLoader(app) // 加载config
    console.log(`-- config --`, app.config);
    console.log(`-- [start] loaded config --`);

    serviceLoader(app) // 加载service
    console.log(`-- services --`, app.services);
    console.log(`-- [start] loaded service --`);

    middlewareLoader(app) // 加载middleware
    console.log(`-- middlewares --`, app.middlewares);
    console.log(`-- [start] loaded middleware --`);

    routerSchemaLoader(app) // 加载routerSchema
    console.log(`-- routerSchemas --`, app.routerSchemas);
    console.log(`-- [start] loaded routerSchemas --`);

    controllerLoader(app) // 加载controller
    console.log(`-- controllers --`, app.controllers);
    console.log(`-- [start] loaded controller --`);

    extendLoader(app) // 加载extend
    console.log(`-- extend --`, app.extend);
    console.log(`-- [start] loaded extend --`);
    // 注册全局中间件
    try {
      require(`${app.bussinessPath + sep}middleware.js`)(app)
      console.log(`-- [start] loaded global middleware --`);
    } catch (error) {
      console.log('[Exception] there is no middleware file');

    }

    routerLoader(app) // 加载router
    console.log(`-- [start] loaded router --`);

    // 启动服务
    try {
      const port = process.env.PORT || 8080; // 获取端口号
      const host = process.env.IP || '0.0.0.0'; // 获取ip地址
      app.listen(port, host)
      console.log(`Server running on port: ${port}`)
    } catch (e) {
      console.error(e);
    }
  }
}

工具:

path: 其中提供的sep是用来在不同的操作系统上(Windows是\,Linux 和 macOS是/)都能正确处理文件路径。

configLoader(配置项)

const path = require('path')
const { sep } = path

/**
 * config loader
 * @param {object} app Koa实例
 * 
 * 配置区分 本地/测试/生产,通过 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
 */
module.exports = (app) => {
  // 获取config目录
  const configPath = path.resolve(app.baseDir, `.${sep}config`)
  // 获取默认config
  let defaultConfig = {}
  try {
    defaultConfig = require(path.resolve(configPath, '.${sep}config.default.js'))
  } catch (error) {
    console.error('[Exception] there is no config.default file')
  }
  // 获取env config
  let envConfig = {}
  try {
    envConfig = require(path.resolve(configPath, `.${sep}config.${app.env.get()}.js`))
  } catch (error) {
    console.error(`[Exception] there is no config.${app.env.get()} file`)
  }
  // 合并配置
  app.config = Object.assign({}, defaultConfig, envConfig)
}

serviceLoader(服务层)、controllerLoader(控制器)、middlewareLoader(中间件)

serviceLoader、middlewareLoader、controllerLoader的实现思路是一样的,就不重复展示了

const path = require('path')
const { sep } = path
const glob = require('glob')

/**
 * service loader
 * @param {object} app Koa实例
 * 
 * 加载所有的cservice, 可通过 `app.services.${目录}.${文件}` 来访问
 * 
  例子:
  app/service
    |- auth
      |- auth.js
  ==> app.services.auth.auth
 * 
 */
module.exports = (app) => {
  const servicePath = path.resolve(app.bussinessPath, `.${sep}service`)
  const serviceFiles = glob.sync(path.resolve(servicePath, `.${sep}**${sep}**.js`))

  // 遍历所有文件目录并加载到app.services下
  const services = {}
  serviceFiles.forEach((serviceFile) => {
    // 提取文件名称
    let serviceName = path.resolve(serviceFile)
    serviceName = serviceName.substring(servicePath.length + 1, serviceName.lastIndexOf('.'))
    // 驼峰化
    serviceName = serviceName.replace(/[_-][a-z]/ig, (match) => match.substring(1).toUpperCase())
    let tempservice = services
    const names = serviceName.split(sep)
    for (let i = 0; i < names.length; i++) {
      const name = names[i]
      if (i === names.length - 1) {
        const ServiceMoudle = require(path.resolve(serviceFile))(app)
        tempservice[name] = new ServiceMoudle()
      } else {
        if (!tempservice[name]) {
          tempservice[name] = {}
        }
        tempservice = tempservice[name]
      }
    }
  })
  app.services = services
}

routerSchemaLoader(路由检验)

将各路由的检验规则挂载到app.routerSchemas上,可用于检验路由或者参数是否符合规范

const path = require('path')
const { sep } = path
const glob = require('glob')

/**
 * router-schema loader
 * @param {object} app Koa实例
 * 
 * 通过'json-schema' & 'ajv' 对API规则进行约束,配合 api-params-verify 中间件使用 实现路由参数校验。
 * 
  例子:
  app/router-schema/**.js
  ==> app.routerSchemas = {
    '${api1}' : ${jsonSchema},
    '${api2}' : ${jsonSchema},
    '${api3}' : ${jsonSchema},
    '${api4}' : ${jsonSchema},
  }
 * 
 */
module.exports = (app) => {
  // 读取router-schema目录下所有js文件,并加载到app.routerSchema中
  const routerSchemaPath = path.resolve(app.bussinessPath, `.${sep}router-schema`)
  const routerSchemaFiles = glob.sync(path.resolve(routerSchemaPath, `.${sep}**${sep}**.js`))
  let routerSchemas = {}
  routerSchemaFiles.forEach(file => {
    console.log(file, 'routerSchemas file');

    routerSchemas = {
      ...routerSchemas,
      ...require(path.resolve(file))
    }
  })
  app.routerSchemas = routerSchemas
}

extendLoader(其他拓展功能)

将应用需要的功能挂载到app方便调用

const path = require('path')
const { sep } = path
const glob = require('glob')

/**
 * extend loader
 * @param {object} app Koa实例
 * 
 * 加载所有的extend, 可通过 `app.${文件}` 来访问
 * 
  例子:
  app/extend
    |- auth.js
  ==> app.auth
 * 
 */
module.exports = (app) => {
  const extendPath = path.resolve(app.bussinessPath, `.${sep}extend`)
  const extendFiles = glob.sync(path.resolve(extendPath, `.${sep}**${sep}**.js`))

  // 遍历所有文件目录并加载到app.extends下
  const extend = {}
  extendFiles.forEach((extendFile) => {
    // 提取文件名称
    let extendName = path.resolve(extendFile)
    extendName = extendName.substring(extendPath.length + 1, extendName.lastIndexOf('.'))
    // 驼峰化
    extendName = extendName.replace(/[_-][a-z]/ig, (match) => match.substring(1).toUpperCase())
    
    // 过滤 app 已经存在的key
    for (const key in app) {
      if (key === extendName) {
        console.log(`[extend load error] name:${extendName} is already exists in app`);
        return
      }
    }
    app[extendName] = require(path.resolve(extendFile))(app)
  })
  app.extend = extend
}

routerLoader(路由)

遍历router文件夹下的文件来注册文件中定义好的路由到koa-router

const KoaRouter = require('koa-router');
const path = require('path')
const { sep } = path
const glob = require('glob')

/**
 * router loader
 * @param {object} app Koa实例
 */
module.exports = (app) => {
  // 找到文件路径
  const routerPath = path.resolve(app.bussinessPath, `.${sep}router`)

  // 实例化路由
  const router = new KoaRouter()

  // 注册所有路由
  const routerFiles = glob.sync(path.resolve(routerPath, `.${sep}**${sep}**.js`))

  routerFiles.forEach(file => {
    require(path.resolve(file))(app, router)
  })

  // 路由兜底(保证系统的健壮性)
  router.get('*', async (ctx, next) => {
    ctx.status = 302
    ctx.redirect(`${app?.options?.homePage ?? '/'}`)
  })

  // 注册路由
  app.use(router.routes())
  app.use(router.allowedMethods())
}

第二章 - 内核应用

对应的目录规范

| -- config

| -- config.default.js (存放默认配置)

| -- config.beta.js (存放测试环境配置)

| -- config.prod.js (存放生产环境配置)

| -- app

| -- service (存放各种服务文件)

| -- middleware (存放各种中间件文件,如本章节的签名合法性校验、参数检验、错误处理)

| -- router-schema (存放各种路由校验文件)

| -- controller (存放各种控制器文件)

| -- extend (存放各种拓展功能的文件,如本章节的日志功能)

| -- router (存放各种模块的路由文件)

| -- middleware.js (存放全局的middleware)

config相关应用代码 (配置项)

可以进行相关配置,通过app.config调用

module.exports = {
  name: '何木杉皮',
}

service相关应用代码 (服务层)

可以编写相关服务代码(如调用数据库获取数据并返回),通过app.services调用对应的服务

| -- service

| -- base.js (编写服务基类)

| -- xxx.js (基于基类的各服务类,如本章节的project.js)

const superagent = require('superagent')
module.exports = (app) => class BaseService {
  /**
   * service基类
   * 统一管理 service 公共方法
   */
  constructor() {
    this.app = app
    this.config = app.config
    this.curl = superagent
  }
}
module.exports = (app) => {
  const BaseService = require('./base.js')(app)
  return class ProjectService extends BaseService {
    async getList() {
      // return await app.db.Project.find();
      return [{ name: 'project1', desc: 'project1 description' }, { name: 'project2', desc: 'project2 description' }]
    }
  }
}

工具:

superagent: 轻量的,渐进式的 ajax api,类似于前端的axios,nodejs 第三方模块,专注于处理服务端/客户端的http请求

middleware相关应用代码 (中间件)

可以编写中间件代码(如签名合法性校验、参数校验、错误处理),在app/middleware.js中通过app.use(app.middlewares.xxx)调用

| -- middleware

| -- api-params-verify.js (编写参数校验中间件)

| -- api-sign-verify.js (编写签名合法性校验中间件)

| -- error-handler.js (编写错误处理中间件)

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

/**
 * api 参数校验
 */
module.exports = (app) => {
  const $schema = 'http://json-schema.org/draft-07/schema#'
  return async (ctx, next) => {
    // 只对apiq请求进行签名验证
    if (!ctx.path.startsWith('/api/')) {
      return await next();
    }

    // 获取请求参数
    const { query, body, headers } = ctx.request;
    const { path, method } = ctx;
    const params = ctx.params

    app.logger.info(`method: ${method}, path: ${path}, body: ${JSON.stringify(body)}`)
    app.logger.info(`method: ${method}, path: ${path}, query: ${JSON.stringify(query)}`)
    app.logger.info(`method: ${method}, path: ${path}, params: ${JSON.stringify(params)}`)
    app.logger.info(`method: ${method}, path: ${path}, headers: ${JSON.stringify(headers)}`)

    let schema = null;
    const routerSchemaKeys = Object.keys(app.routerSchema);
    for (const key of routerSchemaKeys) {
      const keys = [];
      const regex = pathToRegexp(key, keys);
      const match = regex.exec(path);
      if (match) {
        schema = app.routerSchema[key][method.toLowerCase()];
        break;
      }
    }

    let valid = true
    let validate

    // 检验headers
    if (valid && headers && schema.headers) {
      schema.headers.$schema = $schema
      validate = ajv.compile(schema.headers)
      valid = validate(headers)
    }

    // 检验body
    if (valid && body && schema.body) {
      schema.body.$schema = $schema
      validate = ajv.compile(schema.body)
      valid = validate(body)
    }

    // 检验query
    if (valid && query && schema.query) {
      schema.query.$schema = $schema
      validate = ajv.compile(schema.query)
      valid = validate(query)
    }

    // 检验params
    if (valid && params && schema.params) {
      schema.params.$schema = $schema
      validate = ajv.compile(schema.params)
      valid = validate(params)
    }
    
    if (!valid) {
      ctx.status = 200
      ctx.body = {
        code: 442,
        success: false,
        message: `request validation failed: ${ajv.errorsText(validate.errors)}`
      }
      return
    }
    await next()
  }
}
const md5 = require('md5');
/**
 * api 签名合法性校验
 */
module.exports = (app) => {
  return async (ctx, next) => {
    // 只对apiq请求进行签名验证
    if (!ctx.path.startsWith('/api/')) {
      return await next();
    }
    const { path, method } = ctx
    const { headers } = ctx.request
    const { s_sign: sSign, s_t: st } = headers

    const signKey = 'xxxxxxxxxx'
    const signature = md5(`${signKey}_${st}`)
    app.logger.info(`method: ${method}, path: ${path}, signature: ${signature}`)

    if (!st || !sSign || signature !== sSign.toLowerCase() || Date.now() - st > 600000) {
      ctx.status = 200
      ctx.body = {
        code: 445,
        success: false,
        message: 'signature not correct or timeout'
      }
      return
    }
    await next()
  }
}
/**
 * 运行时异常错误处理,兜底所有异常
 * @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.includes('template not found')) {
        // 页面重定向
        ctx.status = 302 //临时重定向  301是永久重定向
        ctx.redirect(`${app.options?.homePage}`)
        return
      }
      const resBody = {
        success: false,
        code: 50000,
        message: '网络异常,请稍后重置'
      }
      ctx.status = 200
      ctx.body = resBody
    }
  }
}

工具:

md5: 一款加密工具

用法:

const md5 = require('md5')

const secret = md5('要加密的字符串')

ajv: 一个用于验证 JSON 数据的库,它支持 JSON Schema 规范,这里主要是用来检验参数是否有问题

routerSchema相关应用代码 (路由检验)

可以编写相规范检验代码(如检验接口参数是否符合规范),通过app.routerSchema[xxx]获取对应的规则

| -- router-schame

| -- xxx.js (路由检验规则,如本章节的project.js)

module.exports = {
  '/api/project/list/:id': {
    post: {
      body: {
        type: 'object',
        properties: {
          page: {
            type: 'integer'
          },
          page_size: {
            type: 'integer'
          }
        },
        required: ['page', 'page_size']
      },
      params: {
        type: 'object',
        properties: {
          id: {
            type: 'string'
          },
        },
        required: ['id']
      }
    }
  }
}

controller相关应用代码 (控制器)

可以编写中转代码(如处理服务层返回的数据并转发给路由层,根据路由渲染页面等),通过app.controllers.xxx.xxxController调用对应的控制器

| -- controlle

| -- base.js (编写控制器基类,编写一些公共方法,如api成功或失败的处理)

| -- xxx.js (基于基类的各控制器,如本章节的view.js、project.js)

module.exports = (app) => class BaseController {
  /**
   * controller基类
   * 统一管理 controller 公共方法
   */
  constructor() {
    this.app = app
    this.config = app.config
    this.services = app.services
  }
  /**
   * api 成功返回
   * @param {object} ctx 上下文对象
   * @param {object} data 返回数据
   * @param {string} message 提示信息
   * @param {number} metaData 附加数据
   */
  success(ctx, data, message = '操作成功', metaData = null) {
    ctx.status = 200
    ctx.body = {
      success: true,
      data,
      message,
      metaData
    }
  }
  /**
   * api 失败返回
   * @param {object} ctx 上下文对象
   * @param {string} message 提示信息
   * @param {number} code 错误码
   */
  fail(ctx, message = '操作失败', code) {
    ctx.status = 200
    ctx.body = {
      success: false,
      message,
      code
    }
  }

}
module.exports = (app) => {
  const BaseController = require('./base')(app)
  return class ProjectController extends BaseController {
    /**
     * 获取数据列表
     * @param {object} ctx 上下文对象
     */
    async getList(ctx) {
      const { project: ProjectService } = this.services
      const res = await ProjectService.getList()
      this.success(ctx, res, '获取成功', null)
    }
  }
}
module.exports = (app) => {
  return class ViewController {
    /**
     * 渲染页面
     * @param {object} ctx 上下文对象
     */
    async renderPage(ctx) {
      await ctx.render(`output/entry.${ctx.params.page}`, {
        title: app.options.name,
        env: app.env.get(),
        user: app.config.name,
      });
    }
  }
}

extend相关应用代码 (其他拓展功能)

可以编写拓展应用功能的代码(如日志输出),通过app.xxxAaa使用相关功能

| -- extend

| -- logger.js (编写控制器基类,编写一些公共方法,如api成功或失败的处理)

const log4js = require('log4js');
/**
 * 日志工具
 * 外部调用 app.logger.log / app.logger.error / app.logger.info
 */
module.exports = (app) => {
  let logger
  if (app.env.isLocal()) {
    logger = console
  } else {
    log4js.configure({
      appenders: {
        console: { type: 'console' },
        // 日志文件切分
        dateFile: {
          type: 'dateFile',
          filename: './logs/application.log',
          pattern: '.yyyy-MM-dd',
        },
      },
      categories: {
        default: { appenders: ['console', 'dateFile'], level: 'trace' }
      }
    })
    logger = log4js.getLogger()
  }
  return logger
}

工具:

log4js: log4js是一个Node.js 日志记录库,它允许你在 Node.js 应用程序中轻松地实现灵活、高效的日志记录

了解更多: zhuanlan.zhihu.com/p/22110802

router相关应用代码 (路由)

可以编写项目的路由代码(如文件路由、接口路由),通过 '地址+定义的接口' 访问,执行对应controller的回调

| -- router

| -- view.js (页面路由)

| -- xxx.js (业务接口,如本章节的project.js)

module.exports = (app, router) => {
  const { project: projectController } = app.controllers;
  router.post('/api/project/list', projectController.getList.bind(projectController));
}
module.exports = (app, router) => {
  const { view: viewController } = app.controllers;
  router.get('/view/:page', viewController.renderPage.bind(viewController));
}

middleware.js (存放全局的middleware)

给app实例挂载上app.middlewares的各种中间件,同时挂载服务需要的一些全局中间件

const path = require('path')

module.exports = (app) => {

  // 配置静态文件根目录
  app.use(require('koa-static')(path.resolve(process.cwd(), './app/public')))

  // 配置模板引擎
  const koaNunjucks = require('koa-nunjucks-2')
  app.use(koaNunjucks({
    ext: 'html',
    path: path.resolve(process.cwd(), './app/public'),
    nunjucksConfig: {
      noCache: true,
      trimBlocks: true
    }
  }))

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

  // 引入异常捕获中间件
  app.use(app.middlewares.errorHandler)

  // API 签名合法性校验
  app.use(app.middlewares.apiSignVerify)

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

工具:

koa-nunjucks-2: 轻量级的 Koa 中间件,用于集成 Nunjucks 模板引擎,提供了一个render方法,可以直接在 Koa 的上下文(ctx)中使用,用于渲染模板

koa-bodyparser: 一个用于解析 HTTP 请求主体(body)的中间件,适用于 Koa.js 框架,支持解析 JSON、表单 (form)和文本(text)类型的请求体

以上工具的具体用法可自行查找