关于 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 解析器包含了注册路由中间件的功能)。
最终我们得到以下的转换结构:
在入口文件引入 core.js 并调用,则应用拥有框架提供的能力
const Core = require('./core');
Core.start({})
现在我们需要实现这个 resolver,使能力可以注入到整个框架的运行过程中。
能力注入实现
以 middleware 为例,我希望所有用户自定义的 middleware:name.js文件都在 /app/middlewares 目录下,在初始化的时候通过调用 middlewareLoader 的方法,把所有 middleware:name.js文件的运行结果都挂载到 app 对象下的效果。实现思路如下:
- 提取所有 /app/middlewares/xx/xx.js 的文件。
- 处理文件名称。
- 获取文件的运行结果,根据注入方式判断是否需要二次处理。
- 注入到 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 内持续开发的效果。
例如增加:静态文件处理、参数校验、提供添加插件能力等等。此外,如何提高代码可维护性、拓展性、兼容性、运行性能也是值得完善的点。