从零开始开发全栈项目:一、基于 Koa 的全栈后端框架封装

151 阅读6分钟

基于 Koa 的服务端框架封装:打造可扩展的服务架构

在构建服务端框架时,我们通常需要解决以下几个核心问题:

  1. 路由的注册与管理:实现灵活的路由定义与加载。
  2. 代码分层:支持清晰的 ControllerService 分层结构,便于扩展和维护。
  3. 中间件支持:支持注册多种中间件,包括参数校验、登录校验、日志记录等。
  4. 配置管理:提供丰富的配置文件管理,支持环境变量和动态加载。

之后将一步步封装一个基于 Koa 的服务端框架,支持以上功能。

一、快速实现一个简单的 Koa 服务

在开始封装之前,我们先快速了解如何使用 Koa 构建一个基础服务:

const Koa = require('koa');
const Router = require('koa-router');

const app = new Koa()

const port = 8080
const host = '0.0.0.0'

app.listen(port, host)

二、框架设计思路

参考 Koa 官网 image.png 根据 Koa 的特性,我们设计了以下封装方法:

1. 功能抽象

将常见功能划分为以下几类:

  • Controller 和 Service:实现代码分层,解耦业务逻辑和接口处理。
  • 中间件:支持系统自带和用户自定义中间件,覆盖参数校验、日志记录等常见场景。
  • 配置管理:提供灵活的配置文件管理和环境变量支持。
  • 路由管理:集中化的路由定义和动态注册。
  • 扩展功能:支持用户添加自定义扩展模块,以满足个性化需求。

2. 模块加载器设计

针对上述功能,设计了以下加载器(Loader),实现模块化加载与初始化:

Loader 名称功能描述
routerLoader加载路由文件并注册到应用
middlewareLoader加载并注册中间件
controllerLoader加载 Controller,处理接口逻辑
serviceLoader加载 Service,处理业务逻辑
configLoader加载配置文件和环境变量
extendLoader加载用户自定义扩展模块
routerSchemaLoader加载路由的校验规则

三、实现可扩展的框架

下面是框架核心模块设计与实现的部分代码示例。

路由加载器设计与实现

1. 设计思路

我们约定,开发者将所有路由相关的代码统一存放在 app/router 文件夹下,路由加载器的主要功能包括:

  1. 定义 KoaRouter 对象,用于管理路由。
  2. 遍历指定文件夹下的所有自定义路由文件,并动态加载。
  3. 将所有子路由文件中的路由注册到 KoaRouter 上。
  4. 配置一个兜底方案,处理未匹配的路由。
  5. 将路由统一注册到 app 对象中。
  6. 导出一个加载函数 routerLoader

2. 子路由文件样式

开发者需要按照以下格式编写自定义路由文件,所有路由逻辑均通过 router 对象挂载:

// 示例:app/router/test.js

const testController = require('../controllers/testController');

module.exports = (app, router) => {
  router.get('/api/test', testController.test.bind(testController));
};


// routerLoader 定义
module.exports = (app) => {
  // app.use(**) 导入需要的内容
}

我们通过将 app 对象 传到 Loader 中。通过在 Loader 函数中调用 Loader.use 来向应用中注册路由

3. 具体的代码实现
const KoaRouter = require('koa-router')
const glob = require('glob')
const path = require('path')
const { sep } = path

module.exports = (app) => {
  // 1. 定义 KoaRouter 对象
  const router = new KoaRouter()

  // 2. 遍历制定文件夹下所有符合要求的文件。并加载
  const routerPath = path.resolve(`./app/router`)
  const fileList = glob.sync(path.resolve(routerPath, `.${sep}**${sep}**.js`))
  // 3. 将所有路由文件中的路由挂在在KoaRouter上
  fileList.forEach(file => {
    require(path.resolve(file))(app, router)
  })
  // 4. 配置兜底方案
  router.get('*', async (ctx, next) => {
    ctx.status = 302
    ctx.redirect(`${app?.options?.homePage ?? '/'}`)
  })
  // 5. 将路由注册到 app 中
  app.use(router.routes())
  app.use(router.allowedMethods())
}

中间件加载器设计与实现

1. 设计思路

Koa 使用“洋葱圈模型”处理中间件(middleware),即中间件按照加载顺序依次执行,先进入的中间件最后退出。为了满足这种严格的执行顺序,我们需要明确中间件的加载规则和顺序。

基于以上,我们为中间件加载器设计了以下功能:

  1. 遍历指定文件夹下的所有自定义中间件文件,并动态加载。
  2. 按照一定的规则修改文件名,生成统一的中间件名称。
  3. 定义一个中间件集合,存储所有加载的中间件。
  4. 将中间件按照相对路径存储到中间件集合中,方便后续管理。
  5. 将中间件集合存储在 app 对象中。
  6. 提供一个注册函数,用于按顺序将中间件注册到 Koa 应用。

以下参考内容来自 pauli.cn/koa-docs-1x… 94530532-8FF1-456c-9006-15EF76EC7A86.png

2. loader 代码实现
module.exports = (app) => {
    // 1. 遍历指定文件夹下的所有自定义中间件文件,并动态加载。
    const middlewarePath = path.resolve(app.businessPath, `.${sep}middleware`)
    const fileList = glob.sync(path.resolve(middlewarePath, `.${sep}**${sep}**.js`))

    const middlewares = {}
    fileList.forEach(file => {
        // 提取文件名称
        let name = path.resolve(file)

        // 截取路径 app/middleware/custom-module/custom-middleware.js => custom-module/custom-middleware
        name = name.substring(name.lastIndexOf(`middleware${sep}`) + `middleware${sep}`.length, name.lastIndexOf('.'))

        // 2. 按照一定的规则修改文件名,生成统一的中间件名称。
        name = name.replace(/[_-][a-z]/ig, (s) => s.substring(1).toUpperCase())

        let tempMiddleware = middlewares // 临时对象,用于最终存储加载的文件
        const names = name.split(sep)
        // 3. 定义一个中间件集合,存储所有加载的中间件。
        // 4. 将中间件按照相对路径存储到中间件集合中,方便后续管理。
        for (let pathIndex = 0, len = names.length; pathIndex < len; ++pathIndex) {
            if (pathIndex === len - 1) {
                // 此时为文件 动态加载这个文件,并把app传给他注册中间件
                tempMiddleware[names[pathIndex]] = require(path.resolve(file))(app)
            } else {
                // 此时为目录 需要提前将父级目录创建
                if (!tempMiddleware[names[pathIndex]]) {
                    tempMiddleware[names[pathIndex]] = {}
                }
                tempMiddleware = tempMiddleware[names[pathIndex]]
            }
        }
    })

    // 5. 将中间件集合存储在 `app` 对象中。
    app.middlewares = middlewares
}
3. 中间件注册函数

除自定义的中间件意外,可以把必须的中间件全部放到这里来定义

// 省略依赖的引用

module.exports = (app) => {
  // 配置静态根目录
  const koaStatic = require('koa-static')
  app.use(koaStatic(path.resolve(process.cwd(), './app/public')))

  // 模板渲染
  app.use(koaNunjucks({
    ext: 'tpl',
    path: path.resolve('./app/public'),
    nunjucksConfig: {
      noCache: true,
      trimBlocks: true
    }
  }))

  // 自定义中间件1
  app.use(app.middlewares.customMiddleware1)

  // 自定义中间件2
  app.use(app.middlewares.customMiddleware2)

  // 自定义中间件3
  app.use(app.middlewares.customMiddleware3)
}

补充其他的Loader

1. controllerLoader 与 serviceLoader

在 Koa 应用中,controllerservice 通常为类,我们需要将它们以类的实例形式存储在 app 对象中。类似于中间件加载器,controllerLoaderserviceLoader 也需要遍历指定文件夹获取对应的文件并加载,但其实现方式会有所不同,因为它们涉及到类的实例化。

目标:

  1. controllerLoaderserviceLoader 需要遍历指定文件夹并加载类文件。
  2. 将加载的类实例化后存储到 app 对象中,方便后续调用。
  3. 这两个加载器的实现可以基于类似的逻辑,但需要注意的是,controllerLoader 主要处理与 HTTP 请求相关的控制器,而 serviceLoader 则处理业务逻辑相关的服务。
// controller
module.exports = (app) => {
  const controllerPath = path.resolve(app.businessPath, `.${sep}controller`)
  const fileList = glob.sync(path.resolve(controllerPath, `.${sep}**${sep}**.js`))

  const controller = {}
  fileList.forEach(file => {
    let name = path.resolve(file)

    name = name.substring(name.lastIndexOf(`controller${sep}`) + `controller${sep}`.length, name.lastIndexOf('.'))

    // 把 '-' 统一为小驼峰, 例如 custom-controller => customController
    name = name.replace(/[_-][a-z]/ig, (s) => s.substring(1).toUpperCase())

    let tempController = controller
    const names = name.split(sep)
    // 按照文件相对路径分段
    for (let pathIndex = 0, len = names.length; pathIndex < len; ++pathIndex) {
      if (pathIndex === len - 1) {
        // 此时为文件 动态加载这个文件,并把app传给他注册
        const ControllerMoudle = require(path.resolve(file))(app)
        tempController[names[pathIndex]] = new ControllerMoudle()
        const item = new ControllerMoudle()
      } else {
        if (!tempController[names[pathIndex]]) {
          tempController[names[pathIndex]] = {}
        }
        tempController = tempController[names[pathIndex]]
      }
    }
  })

  // 遍历所有文件目录,把export的中间件加载到 app.controller 下
  app.controller = controller
}
// service
module.exports = (app) => {
    const servicePath = path.resolve(app.businessPath, `.${sep}service`)
    const fileList = glob.sync(path.resolve(servicePath, `.${sep}**${sep}**.js`))

    const service = {}
    fileList.forEach(file => {
        let name = path.resolve(file)

        name = name.substring(name.lastIndexOf(`service${sep}`) + `service${sep}`.length, name.lastIndexOf('.'))

        // 把 '-' 统一为小驼峰, 例如 custom-service => customService
        name = name.replace(/[_-][a-z]/ig, (s) => s.substring(1).toUpperCase())

        let tempService = service
        const names = name.split(sep)
        for (let pathIndex = 0, len = names.length; pathIndex < len; ++pathIndex) {
            if (pathIndex === len - 1) {
                const ServiceMoudle = require(path.resolve(file))(app)
                tempService[names[pathIndex]] = new ServiceMoudle()
            } else {
                if (!tempService[names[pathIndex]]) {
                    tempService[names[pathIndex]] = {}
                }
                tempService = tempService[names[pathIndex]]
            }
        }
    })

    app.service = service
}
2. configLoader

在开发过程中,我们通常会根据不同的环境(如本地环境、测试环境和生产环境)进行配置管理。这种配置不仅包括不同环境下的数据库连接、API 地址、端口号等信息,还可能涉及到一些默认配置。为了方便管理和使用,我们需要设计一个配置管理系统,支持环境区分和默认配置。

另外,配置文件一般存放在另外一个文件夹中。

目标:

  1. 根据不同的环境加载相应的配置文件。
  2. 提供默认配置,避免在每个环境中都需要重复配置。
  3. 方便动态扩展支持不同环境。
module.exports = (app) => {
    const configPath = path.resolve(app.baseDir, `.${sep}config`)

    let defaultConfig = {}
    try {
        defaultConfig = require(path.resolve(configPath, `.${sep}config.default`))
    } catch (error) {
        console.error('[exception] this is no config.default file')
    }

    // 获取 env.config
    let envConfig = {}
    try {
        if (app.env.isLocal()) { // 本地
            envConfig = require(path.resolve(configPath, `.${sep}config.local`))
        } else if (app.env.isBeta()) { // 测试
            envConfig = require(path.resolve(configPath, `.${sep}config.beta`))
        } else if (app.env.isProduction()) { // 生产
            envConfig = require(path.resolve(configPath, `.${sep}config.prod`))
        }
    } catch (error) {
        console.error('[exception] this is no env.config file')
    }

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

在应用的架构中,除了核心的路由、中间件、控制器和服务等功能,往往还需要一些额外的功能模块,比如日志、缓存、权限校验等。这些功能不属于应用的核心功能,但又是应用正常运行不可或缺的一部分。

为了方便扩展和管理这些附加功能,我们设计了一个扩展模块。这个模块的作用是集中存放所有额外的功能,开发者可以通过向该模块添加新的功能,使应用更加灵活。

module.exports = (app) => {
    const extendPath = path.resolve(app.businessPath, `.${sep}extend`)
    const fileList = glob.sync(path.resolve(extendPath, `.${sep}**${sep}**.js`))

    const extend = {}
    fileList.forEach(file => {
        let name = path.resolve(file)

        name = name.substring(name.lastIndexOf(`extend${sep}`) + `extend${sep}`.length, name.lastIndexOf('.'))

        name = name.replace(/[_-][a-z]/ig, (s) => s.substring(1).toUpperCase())

        for (const key in app) {
            if (key === name) {
                console.log(`[extend load error] name:app.${name} is already exist`)
                return
            }
        }

        app[name] = require(path.resolve(file))(app)
    })

    app.extend = extend
}

三、测试

这样,我们设计并实现了一个基于 Koa 的基础后端框架:

6b12a164a96fc983760f4471c47dda4c.png

之后我们简单做一下测试

首先完善一下我们的工程目录

db219fa402400f62c1fa4f54e753ae20.png

app/ // 应用相关核心代码

├── controller/ // 控制器层,处理业务逻辑,响应路由请求

├── extend/ // 扩展模块(如日志、缓存等)

├── middleware/ // 中间件功能,支持自定义中间件

├── router/ // 路由文件夹,用于定义 API 路由规则

├── router-schema/ // 路由校验规则文件夹,定义接口的参数验证规则

├── service/ // 服务层,用于封装核心业务逻辑

middleware.js // 中间件加载器

config/ // 配置文件夹

├── config.beta.js // Beta 环境配置

├── config.default.js // 默认配置

├── config.local.js // 本地开发配置

├── config.prod.js // 生产环境配置

elpis-core/ // 核心库代码(可选,外部或内部工具方法封装)

// controler/test.js
module.exports = (app) => {
  return class TestController {
    constructor() {
      this.app = app
      this.config = app.config
      this.service = app.service
    }
  
    success(ctx, data = {}, metadata = {}) {
      ctx.status = 200
      ctx.body = {
        success: false,
        data,
        metadata
      }
    }
  
    async getList(ctx) {
      const { project: projectService } = app.service
      const projectList = await projectService.getList()

      this.success(ctx, projectList)
    }

  }
}
// service/test.js
module.exports = (app) => {
  return class TestService {
    constructor() {
      this.app = app
      this.config = app.config
      this.curl = superagent
    }
    async getList() {
      return [{
        id: 1,
        name: 'project1',
        desc: 'project1 desc'
      }, {
        id: 2,
        name: 'project2',
        desc: 'project2 desc'
      }]
    }
  }
}

// router/test.js
module.exports = (app, router) => {
  const { project: testController } = app.controller
  router.get('/api/test/list',testController.getList.bind(testController))
}

// middleware/error.js
// 写了一个错误处理类。可以 catch 常见错误并作统一处理
module.exports = (app) => {
  return async (ctx, next) => {
    try {
      await next();
    } catch (err) {
      const { status, message, detail } = err

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

      if (message && message.indexOf('template not found') > -1) {
        // 找不到模板
        // OperationalError: template not found: output/entry.page3.tpl
        // 页面重定向
        ctx.status = 302
        ctx.redirect(`${app.options?.homePage}`)
        return
      }

      const resBody = {
        code: 50000,
        message: '网络异常 请稍后再试',
        success: false
      }

      ctx.status = 200
      ctx.body = resBody
    }
  }
}

// extend/logger.js
const log4js = require('log4js')
module.exports = (app) => {
  let logger

  if (app.env.isLocal()) {
    logger = console
  } else {
    log4js.configure({
      appenders: {
        console: { type: 'console' },
        dataFile: {
          type: 'dateFile',
          filename: './logs/application.log',
          pattern: '.yyyy-MM-dd',
          daysToKeep: 30,
          keepFileExt: true
        }
      },

      categories: {
        default: {
          appenders: ['console', 'dataFile'],
          level: 'trace'
        }
      }
    })

    logger = log4js.getLogger()
  }

  return logger
}

大佬们,烟已点好,求多多指教!
如果在使用 elpis-core 的过程中发现不足,欢迎各位大佬提出优化建议。我会虚心接受并快速迭代。希望大家能一起完善这个框架,让它更好地服务于开发者!

感谢哲哥的详细指导 live.douyin.com/60047125967…

注:抖音 “哲玄前端”,《大前端全栈实践课》