elpis-core是一个基于 koa 的 node 服务端框架核心,它做的事情只有三件:加载、组装、启动 。它本身不携任何业务代码,而是依赖性一套目录约定,把项目跟目录 app/ 根目录下的 controller、middleware、router、service 这些文件自动加载到内存,挂载到app对象上,最终按照固定顺序组成一条 koa 的中间件链路并启动服务。
简单来说就是让我们写后端能像写前端那样有清醒的目录结构,不用像裸写koa那样在 index.js 里面一行一行的require 和 app.use。我们平时用 Next.js 写页面,路由是根据pages自动生成的,不需要手动注册, elpis-core 干的就是同一件事情,只不过是自动生成了 controller、service等配置和路由。
这里就不拆解框架的每一行代码了,大家有需要的话可以去看官方文档和源码,现在开始步入正题吧....
本篇文章记录一下我作为前端开发第一次接触后端框架时,对 elpis-core 核心理解的整个理解过程,我会从几个我实际卡出的困惑带大家由浅到深的去了解他的核心机制,其中包括 约定式加载 、工厂签名 、洋葱圈 这三个话题,以及我花时间最久的两个点 请求参数为什么拿不到 、加载顺序为什么不能乱写,最后是我个人在学习中遇到的一些坑点和难点。
了解 elpis-core 的常用概念和原理
我先把几个基础的东西讲清楚,不然后面会晕。
elpis-core 由三部分组成 koa、elpis-core 本体、业务目录 app/**。
Koa 是一个很轻量级的 Node Web 框架,它只提供一个基础的能力:ctx(请求时的上下文)、app(应用实例),以及中间件机制,可以把它理解为一个 "运行时"。
elpis-core 在 koa 之上加了一层约定和自动加载,把 koa 变成了一个"有结构"的框架,它不认识任何业务,只负责把app/下的目录找出来然后组装、排好顺序加载出来。
app 才是我们真正写业务代码的地方。
启动入口
整个项目启动就一行
js
const ElpisCore = require("./elpis-core")
ElpisCore.start({
name: "名称",
homePage: "/",
})
7 个 loader
elpis-core 的核心就7个loader,每个loader负责一个目录,然后把扫出来的文件挂载到app上的某个属性:
| loader | 扫描目录 | 挂载到 |
|---|---|---|
| configLoader | config/ |
app.config |
| extendLoader | app/extend/ |
app.logger 等 |
| routerSchemaLoader | app/router-schema/ |
app.routerSchema |
| controllerLoader | app/controller/ |
app.controller |
| serviceLoader | app/service/ |
app.service |
| middlewareLoader | app/middleware/ |
app.middlewares |
| routerLoader | app/router/ |
注册到 Koa 路由上 |
这样子实现有个好处就是新增一个controller无需再去改动框架,直接在 app/controller/ 目录下新增一个文件就好了
一个统一的签名
所有业务文件导出函数都是一个接受app参数的函数:
js
module.export = (app) => {
// xxxx
}
正是因为统一,所以loader里面才能一行代码把任何文件给拉起来:
js
tempMiddleware[names[i]] = require(path.resolve(file))(app)
// controller返回的是是类,所以需要 new一下
const ControllerModule = require(path.resolve(file))(app)
const controller[names[i]] = new ControllerModule()
app 是什么
app 就是一个容器,框架把加载出来的东西都挂载在上面:
arduino
app.config 配置
app.controller 控制器
app.service 服务层
app.middlewares 中间件
app.routerSchema 参数校验规则
app.options 启动时传进来的配置
app.logger 日志工具(由 extend 挂上来)
业务代码之间不相互 import ,而都是从这个容器中拿,前端类比一下,有点像是 store:大家不依赖彼此,只是从这个里面拿东西,后来我才知道这个模式叫做 依赖注入。
洋葱圈
koa的中间件是一个"洋葱圈"模型,在 app/middleware.js 里按顺序 app.use 进去:
js
app.use(静态资源);
app.use(模板引擎);
app.use(body 解析);
app.use(异常捕获);
app.use(签名校验);
app.use(参数校验);
// ... 最后
app.use(router.routes());
请求进来的时候,会沿着注册顺序一路往最里面走,然后一路往外出来:
js
app.use(async (ctx, next) => {
console.log("进来")
await next()
console.log("出去")
})
前端类比的话就像是 axios 拦截器。但是"洋葱圈"模型有个不一样的地方:就是 await next() 前后的代码都会执行!
困惑一:ctx.params 为什么拿不到(我花时间最久的一个)
这是我这段时间卡得最久的一件事,也是我觉得最值得写下来的。
起因
我在写一个 API 参数校验中间件 app/middleware/api-params-verify.js。思路很朴素:请求进来,把 headers、body、query、params 都拿出来,用 JSON Schema 校验一遍,不通过就返回错误码。
于是我在中间件里写了这么一行:
js
const { params, path, method } = ctx;
结果 params 永远是 undefined。query、body、headers 都是正常的,只有它不行。
第一次误判
我第一反应是可能是这个中间件注册的位置不对,换成路由级的 router.use 试试。结果变了,但没变好------params 从 undefined 变成了 {}。
从没有变成空对象,说明路由那边确实动过这个值,但动的时候我的中间件已经执行过了。于是我就去看源码。
去翻 koa-router 的源码
在 koa-router 的 dispatch 函数里:
js
const dispatch = function (ctx, next) {
const path = ctx.path;
const matched = router.match(path, ctx.method);
// ① 没匹配到任何路由,直接跳过,什么都不做
if (!matched.route) return next();
const matchedLayers = matched.pathAndMethod;
// ② 匹配上了,才会把参数写进 ctx
const layerChain = matchedLayers.reduce(function (memo, layer) {
memo.push(function (ctx, next) {
ctx.captures = layer.captures(path, ctx.captures);
ctx.params = layer.params(path, ctx.captures, ctx.params); // ← 就是这里
return next();
});
return memo.concat(layer.stack);
}, []);
// ③ 然后才依次执行「这一层的中间件 + 业务 handler」
return compose(layerChain)(ctx, next);
};
- ctx.params 本身不是 koa 自身携带的属性,是
koa-router加上去的。 - params 是在路由匹配成功之后写入进去的,而且写在这一层的中间件和 handle 之前。
- 我的全局中间件注册在
router。routes()前面,那时候路由还没有匹配的,所以拿到的是undefinde。
这里顺带也解释了为什么 router.use 拿到的是 {} 而不是undefinde, 因为 Layer.prototype.params 的实现是 existingParams || {}, 它总会给你一个对象,只是没有参数的路由给的是空的。
解决方案:其实有很多种解法,但是我选择的是在全局中间件里自己用 koa-router 的 Layer 解析一遍,这种最简单了,另外一个是把校验函数放在每个路由 layer.stack 的顶部。
困惑二:加载顺序为什么不能随便写
我在 elpis-core 中打印了 loader 加载结果,方便我调试,一直有个疑问,就是我明明已经配置好了middleware、controller,但是打印出来的东西就是下面这样子的。
css
-----[start] middleware done-- [] ← 打出来是个空数组?
-----[start] controller done-- { base: BaseController { config: undefined } }
-----[start] config done-- { name: '大前端', age: 4 } ← 配置最后才加载
三个反常的地方:
middleware done-- []:中间件明明加载了,为什么是空的?controller里的config是undefined;- 配置居然排在 controller 后面加载。
第一个问题的答案很搞笑:日志里打印的是 app.middleware,而 Koa 自己内部就有一个 app.middleware(是它自己的中间件数组)。框架自己挂在 app 上的是 app.middlewares,多了一个 s,两个完全不是一个东西。 我当时打印错了属性,还以为是加载失败了。
第二个问题就不搞笑了,它是真 bug。因为 app/controller/base.js 是在构造函数里取配置的:
js
constructor() {
this.app = app;
this.config = app.config; // 构造时 app.config 还不存在
}
而 configLoader 排在 controller 后面才执行。构造函数只跑一次,跑的时候 app.config 还是空的,所以 this.config 就永远是 undefined。这种 bug 不报错、不崩溃,只是某个配置默默不生效,你用了半天才发现读取配置一直在出错,查起来非常痛苦。
顺序应该怎么排
把七个 loader 的依赖关系理一遍,其实很清晰:
markdown
1. config ← controller / service 的构造函数要读它,必须最先
2. 业务资源 ← router-schema / controller / service / extend
3. middleware ← 中间件工厂里可能会用到配置、日志、schema
4. 全局中间件 + 路由 ← app.use,且必须在 router.routes() 之前
5. 启动服务
改完之后再看日志:
css
-----[start] config done-- { name: '大前端', age: 4 } ← 最先
-----[start] controller done-- { base: BaseController { config: { name: '大前端', age: 4 } } }
config 从 undefined 变成了真实配置,问题解决。
完结
有问题及时私信和留言,我会逐一解决。我带大家用"先有困惑、再去验证"的方式学习框架,这样上手会更快、记忆也更牢,只有你自己真正把学习当成需求来弄,才会事半功倍。祝大家都能如愿所偿!