基于koa二次封装的服务端框架设计

355 阅读6分钟

关于 Koa

最近在学习 node.js,koa 是一款 nodejs 的轻量级框架,其简洁、轻便、灵活的特点,使开发者非常容易就能上手开发一个服务。另外,开发者能够根据实际需求功能,选择所需中间件,不断完善自身的应用。

一个能处理 post 方法请求体的 koa 服务实现如下:

const Koa = require('koa');
const router = require('koa-router');
const bodyParser = require('koa-bodyparser');
const app = new Koa();
const router = new router();

app.use(bodyParser({}));

app.post('/', (ctx, next) => {
    // 逻辑处理
    ......
    console.log(ctx.body);
})

app.use(router.routes());
app.use(router.allowedMethods());
app.listen(3001);

在这里,通过添加 'koa-router'、'koa-bodyparser' 中间件,使服务拥有路由处理,请求体解析的能力。在 app.post() 内的回调函数中,根据实际的业务处理添加对应的逻辑。

应用规模

假设使用 koa 进行开发,前期的确能快速搭建出一个应用,但随着应用规模的增加,我们可能需要添加越来越多的中间件增加应用的能力;同时业务逻辑也会变得越来越复杂,代码也会变得越来越臃肿。

参与过项目的成员不断增加,每个开发者的代码规范、编码风格不一样,容易造成代码结构混乱,影响到开发的质量和效率。

此时,你可能会制定一套开发规范,让成员依照这套规范去执行,能一定程度上解决问题。当然,也可以约定一套统一的开发规则,基于此规则对 koa 进行二次封装。这样,每个成员在开发的时候必定需要遵守约定的规则。

设计思路

要实现一个二次封装的框架,个人认为可以从以下三点出发思考:

  • 定义能力模块。 通过实际开发场景分析,得出目前框架需要支持哪些功能,再对功能进行归类,得出不同的能力模块。每一个能力模块都是针对解决一类问题设计。
  • 定义注入方式。 如何编写对应功能文件,如何导出,框架在运行时如何引入文件的产出。
  • 能力注入实现。 根据前两点,实现对应的解析器。

下面根据提到的三点,详细分析,然后对 koa 进行二次封装。

定义能力模块

首先,从业务逻辑处理角度,我们可以参考常见的 "router -> controller -> service" 流程,得出三个模块。

  • router:解决不同接口映射的路由匹配能力。
  • controller:路由对应的业务处理。
  • service:业务对应的服务处理。

其次,在整个中间件的请求响应过程中,在外层能够添加自定义的中间件或者引用第三方的中间件,增加应用处理能力。因此,得出一个 middleware 模块用于统一中间件的处理。

最后,添加区分环境的能力,能根据不同的运行环境使用不同的配置。同时添加一些脱离中间件过程的全局性质的能力(如:日志记录、多语言......),再次添加两个模块。

  • config:各个环境下的配置。
  • extend:脱离中间件过程独立的扩展能力。

定义注入方式

知道框架有什么能力,那么该如何编写?在哪里写?写完后要怎么注入?

首先代码的编写,常见的做法按目录分类,每一类下面可以根据业务继续划分子目录。当然,如果是脱离中间件过程的可以不用继续划分子目录,因为与业务无关。综合得出以下目录格式:

// project
app---------------
  |-- controller
        |-- custom
              |-- xxx.js
  |-- extend
        |-- logger.js
  |-- middlewares
  |-- router
  |-- service
  |-- config
        |-- config.dev.js
        |-- config.prod.js
  |-- middleware.js

这里的 middleware.js 用于整合自定义的 middleware 以及第三方的中间件。

然后是文件的编写方式,可以是导出一个函数、一个类、一个对象等等,取决于约定的方式。

最后是注入,通过一个解析器,把所有的文件分类解析,然后挂载到 app 对象中。例如以下代码:

// core.js
const Koa = require('koa');
const path = require('path');

module.exports = {
  start(options = {}) {
    const app = new Koa();
    app.options = options; 
    app.baseDir = process.cwd();
    app.businessPath = path.resolve(app.baseDir, `./app`);
  
    middlewareLoader(app);  // 加载 middleware
    controllerLoader(app); // 加载controller
    serviceLoader(app);  // 加载service
    configLoader(app);   // 加载config
    extendLoader(app); // 加载extend

    try {  // 注册全局中间件(用户引用外部的中间件)默认写在 app/middleware.js
      require(`${app.businessPath}$/middleware.js`)(app);
    } catch (error) {
      console.log('[exception] there is no global middleware file.');
    }

    routerLoader(app); // 加载router
  }
}

关于加载顺序,思路是,只要与中间件流程相关的解析器就放到前面加载,它们之间的顺序可以不讲究,因为可以通过调用 app 对象获取相应的能力。而不在中间件流程中的解析器则需要确定顺序,此处我认为 extend 需要依赖 config 的,所以加载顺序为"config -> extend"。最后需要注册所有的中间件和路由,所以加载了 middleware.js,以及加载 router 模块(router 解析器包含了注册路由中间件的功能)。

最终我们得到以下的转换结构: image.png

在入口文件引入 core.js 并调用,则应用拥有框架提供的能力

const Core = require('./core');

Core.start({})

现在我们需要实现这个 resolver,使能力可以注入到整个框架的运行过程中。

能力注入实现

以 middleware 为例,我希望所有用户自定义的 middleware:name.js文件都在 /app/middlewares 目录下,在初始化的时候通过调用 middlewareLoader 的方法,把所有 middleware:name.js文件的运行结果都挂载到 app 对象下的效果。实现思路如下:

  1. 提取所有 /app/middlewares/xx/xx.js 的文件。
  2. 处理文件名称。
  3. 获取文件的运行结果,根据注入方式判断是否需要二次处理。
  4. 注入到 app 对象中。
// middlewareLoader.js

const glob = require('glob');
const path = require('path');

/**
 * middleware loader
 * @param {*} app Koa实例
 * 
 * 加载所有middle,可通过 ‘app.middlewares.${目录}.${文件}访问’
 * 
 *  例子:
 *  app/middlewares
 *    |
 *    | -- custom-module
 *            |
 *            | -- custom-middleware.js
 * 
 *  => app.middlewares.customModule.customMiddleware
 * 
 */
module.exports = (app) => {
  // 读取 app/middlewares/**/**.js 下的所有文件
  const middlewarePath = path.resolve(app.businessPath, `./middlewares`);
  const fileList = glob.sync(path.resolve(middlewarePath, `./**/**.js`));

  // 遍历所有文件目录,把内容加载到 app.middlewares下 
  const middlewares = {}
  fileList.forEach(file => {
    // 提取文件名称
    let name = path.resolve(file);

    // 截取路径名称 app/middlewares/custom-module/custom-middleware.js => custom-module/custom-middleware
    name = name.substring(name.lastIndexOf(`middlewares/`) + `middlewares/`.length, name.lastIndexOf('.'));

    // 把 ‘-’ 改成驼峰命名,custom-module/custom-middleware.js => customModule.customMiddleware
    name = name.replace(/[_-][a-z]/ig, (s) => s.substring(1).toUpperCase());

    // 挂载 middleware 到内存对象 app 中
    let tempMiddleware = middlewares;
    const names = name.split(sep);
    for(let i = 0, len = names.length; i < len; ++i) {
      if(i === len - 1) {
        tempMiddleware[names[i]] = require(path.resolve(file))(app);
      } else {
        if(!tempMiddleware[names[i]]) {
          tempMiddleware[names[i]] = {};
        }
        tempMiddleware =  tempMiddleware[names[i]];
      }
    }
  });
  
  app.middlewares = middlewares;
};

至此,一个能把自定义中间件注入到 app 对象的 loader 就实现了,app 对象在整个洋葱模型的执行流程中,都拥有了调用所有自定义中间件的能力。同样的思路,可以实现 controllerLoader、serviceLoader、configLoader 等等。

所有 loader 实现完成后,一个基于 koa 二次封装,具备一定处理能力,并且具备一定的开发约定能力的服务端框架便初步形成了。

思考

虽然此时二次封装的框架与市面上的企业级应用框架还相差得远,但是根据上述的设计思路,可以按需添加能力,完善框架,达到可以提供给一个 team 内持续开发的效果。

例如增加:静态文件处理、参数校验、提供添加插件能力等等。此外,如何提高代码可维护性、拓展性、兼容性、运行性能也是值得完善的点。