本文以学习 elpis-core 为样本,完整拆解一个约定式 Node.js 框架内核的设计与实现:它如何用 7 个 Loader 把整个业务目录自动装配到 Koa 实例上,启动顺序背后的依赖图长什么样,以及那些值得细品的设计
一、elpis-core简介
- 约定优于配置 :目录结构本身就是模块声明,
app/controller/user.js放进去就自动变成app.controller.user,不需要任何注册代码。 - 框架与业务分离 :框架代码(
elpis-core/)和业务代码(app/)物理隔离,业务开发者永远不需要new Koa()。 - 统一的生命周期:启动时按固定顺序装配所有模块,请求时按固定中间件链路流转。
elpis-core 的整体架构只有三层:
arduino
┌────────────────────────────────────────────────────┐
│ 入口 index.js │
│ ElpisCore.start({ name, homePage }) │
└────────────────────────┬───────────────────────────┘
▼
┌────────────────────────────────────────────────────┐
│ elpis-core(框架内核) │
│ env.js + index.js(启动编排) + loader/ × 7 │
│ config / extend / service / middleware / │
│ router-schema / controller / router │
└────────────────────────┬───────────────────────────┘
│ 扫描 & 挂载
▼
┌────────────────────────────────────────────────────┐
│ app/(业务约定目录) │
│ controller/ service/ middleware/ router/ │
│ router-schema/ extend/ middleware.js public/ │
└────────────────────────┬───────────────────────────┘
▼
┌────────────────────────────────────────────────────┐
│ config/(多环境配置) │
│ config.default.js / .local / .beta / .prod │
└────────────────────────────────────────────────────┘
内核负责"怎么加载",业务只关心"写什么"。
二、启动编排:start() 里的一条依赖链
elpis-core 对外只暴露一个 API------start(options)。整个方法不到 90 行,完成了从创建 Koa 实例到监听端口的全部工作。它的核心逻辑可以概括为一句话:给 app 挂基础属性,然后按精心安排的顺序执行 7 个 Loader,最后注册全局中间件和路由。
scss
// elpis-core/index.js 核心流程(有删减)
start(options = {}) {
const app = new Koa();
app.options = options; // 启动参数
app.baseDir = process.cwd(); // 项目根目录
app.businessPath = path.resolve(app.baseDir, `./app`); // 业务目录
app.env = env(); // 环境工具
configLoader(app); // 1. 配置
extendLoader(app); // 2. 扩展(logger 等)
serviceLoader(app); // 3. service 层
middleWareLoader(app); // 4. 中间件工厂
routerSchemaLoader(app); // 5. 参数校验 schema
controllerLoader(app); // 6. controller 层
require(`${app.businessPath}/middleware.js`)(app); // 7. 全局中间件链
routerLoader(app); // 8. 路由
app.listen(port, host); // 9. 启动
}
记录:加载顺序不是排列组合,是依赖图
| 顺序 | 步骤 | 产物 | 它依赖谁 | 谁依赖它 |
|---|---|---|---|---|
| 1 | config | app.config |
app.env |
controller 构造时读 this.config = app.config |
| 2 | extend | app.logger 等 |
无 | 各中间件运行时打日志 |
| 3 | service | app.service.*(已实例化) |
config | controller 运行时调用 |
| 4 | middleware | app.middlewares.*(工厂结果) |
extend | 全局 middleware.js 要 app.use() 它们 |
| 5 | router-schema | app.routerSchema |
无 | 参数校验中间件按 path 取 schema |
| 6 | controller | app.controller.*(已实例化) |
config、service | 路由文件引用 |
| 7 | 全局 middleware.js | 中间件链 | app.middlewares |
必须早于路由挂载(洋葱模型) |
| 8 | router | 路由表 + 兜底 | app.controller |
最后,收口 |
三、Loader 机制:约定优于配置的落地
7 个 Loader 里的大多数共享同一套"三段式"实现模式,这是整个内核最核心的设计。
3.1 三段式:扫描 → 命名 → 挂载
以 controller loader 为例(elpis-core/loader/controller.js):
ini
module.exports = (app) => {
// 第一步:glob 递归扫描 app/controller/**/*.js
const controllerPath = path.resolve(app.businessPath, `./controller`);
const fileList = glob.sync(path.resolve(controllerPath, `./**/**.js`));
const controller = {};
fileList.forEach(file => {
// 第二步:文件路径 → 挂载名
// app/controller/custom-module/custom-controller.js
// → custom-module/custom-controller(截取相对路径)
// → customModule.customController(分隔符转驼峰)
let name = path.resolve(file);
name = name.substring(name.lastIndexOf(`controller${sep}`) + `controller${sep}`.length, name.lastIndexOf(`.`));
name = name.replace(/[_-][a-z]/ig, (s) => s.substring(1).toUpperCase());
// 第三步:按目录层级嵌套挂载
let tempController = controller;
const names = name.split(sep);
for (let i = 0, len = names.length; i < len; i++) {
if (i === len - 1) { // 最后一段是文件:加载并实例化
const Controller = require(path.resolve(file))(app);
tempController[names[i]] = new Controller();
} else { // 中间段是目录:建嵌套对象
if (!tempController[names[i]]) tempController[names[i]] = {};
tempController = tempController[names[i]];
}
}
});
app.controller = controller;
};
三段各自解决一个问题:
- 扫描 :
glob.sync递归找出层目录下所有.js文件,目录即声明,新增文件零注册。 - 命名 :一条正则
/[_-][a-z]/ig把api-sign-verify.js变成apiSignVerify,文件系统命名和 JS 命名各自舒服。 - 挂载 :嵌套循环把目录层级映射为对象层级,
app/controller/order/refund.js→app.controller.order.refund。子目录天然成为模块分组。
3.2 七个 Loader 的个性差异
共性之下,每个 Loader 根据职责有不同变体:
| Loader | 扫描范围 | 挂载点 | 特殊行为 |
|---|---|---|---|
| config | 根目录 config/(非 glob) |
app.config |
default + env 两份配置 Object.assign 合并 |
| extend | app/extend/*.js(只一层) |
直接挂 app |
与 app 已有属性重名则跳过 |
| service | app/service/**/*.js |
app.service |
new Service() 实例化 |
| middleware | app/middleware/**/*.js |
app.middlewares(复数) |
只存工厂结果,不 app.use() |
| router-schema | app/router-schema/**/*.js |
app.routerSchema |
所有文件导出对象浅合并成一份 |
| controller | app/controller/**/*.js |
app.controller |
new Controller() 实例化 |
| router | app/router/**/*.js |
KoaRouter 实例 | 注册路由 + router.all('*') 兜底 |
两个值得单独说的设计:
config 的多环境合并 。读取 config/config.default.js 作为基座,再按 app.env.isLocal() / isBeta() / isProduction() 选一份环境配置覆盖上去:
less
// elpis-core/loader/config.js
app.config = Object.assign({}, defaultConfig, envConfig)
环境判断来自 process.env._ENV,由 npm scripts 注入("dev": "set _ENV='local' && nodemon ./index.js")。env.js 还提供了一个兜底细节:get() 在 _ENV 未设置时返回 'local',保证裸 node index.js 启动时默认按本地环境走,不会拿到空配置。
middleware 的"只加载不注册" 。middleware loader 把工厂函数的执行结果挂到 app.middlewares,但刻意不碰 app.use()。为什么?因为中间件的执行顺序直接决定系统行为,这个决策权必须留给业务方。这就是下一节要讲的"两阶段中间件"。
四、一次请求的完整生命周期
把所有部件串起来,看一个 API 请求 GET /api/project/list 的完整旅程:
scss
浏览器
│ GET /api/project/list?s_t=...&s_sign=...&proj_key=...
▼
koa-static 静态资源目录没命中,透传
▼
koa-nunjucks-2 给 ctx 注入 render(),透传
▼
koa-bodyparser 解析请求体(GET 无 body,透传)
▼
error-handler try { await next() } 包住后面所有环节
▼
api-sign-verify 校验 s_sign + s_t(md5 签名,60 秒时间窗)
▼
api-params-verity 按 path+method 从 app.routerSchema 取 schema,Ajv 校验
▼
koa-router 匹配 /api/project/list
▼
ProjectController.getList()
│ └─ this.app.service.project.getList() ← 调用 service 层
│ └─ this.success(ctx, data) ← BaseController 统一响应结构
▼
响应 { code: 0, data: [...] }
而页面请求 GET /view/page1 走另一条岔路:ViewController 调用 ctx.render('output/entry.page1', { name, env, options }),由 nunjucks 读取 app/public/output/entry.page1.tpl 在服务端拼出完整 HTML------这是 SSR,浏览器拿到的不是空壳页面。