一、整体设计思路
这是一个 「迷你框架 + 业务目录」 结构:
elpis-core:不直接写业务,只做 Koa 应用组装 和 约定优于配置的扫描加载(glob + 路径转驼峰 + 挂到app上)。目标是:业务只往固定目录丢文件,启动时自动注册。app/:唯一业务根(app.businessPath指向这里)。里面的router / controller / service / middleware / router-schema / extend等,都是给elpis-core的 loader 消费的。- 根目录
config/:环境相关配置(与app/平级),由elpis-core/loader/config.js读取,合并进app.config。 - 根目录
index.js:调用ElpisCore.start({ name, homePage/homePath }),给app.options塞全局选项(例如首页、项目名)。
一句话:elpis-core = 启动器 + 插件式装载器;app = 你的业务实现。
二、启动流水线(elpis-core/index.js 在干什么)
start() 里顺序是刻意排过的:
new Koa(),挂上app.options、app.baseDir、app.businessPath、app.env(读_ENV)。middlewareLoader:扫描app/middleware/**/*.js,生成app.middlewares.*(中间件工厂,还没app.use)。routerSchemaLoader:扫描app/router-schema/**/*.js,合并成app.routerSchema(按「路径 + 方法」索引的 JSON Schema)。configLoader:读config/config.default.js+ 当前环境对应的config.*.js,得到app.config(失败时区分MODULE_NOT_FOUND与其它错误)。serviceLoader:扫描app/service/**,new实例挂到app.service.*。controllerLoader:扫描app/controller/**,new实例挂到app.controller.*。
顺序放在 service/config 之后,是因为你们的BaseController/BaseService构造里会读app.config、app.service。extendLoader:扫描app/extend/**,把导出结果挂到app[驼峰文件名](例如logger.js→app.logger)。require(app/middleware.js)(app):真正把 Koa 全局中间件链app.use上去(静态、Nunjucks、bodyParser、错误处理、签名等)。routerLoader:创建koa-router,先router.use(app.middlewares.apiParamsVerify),再加载app/router/**/*.js注册路由,最后app.use(router.routes())等。
设计要点:
- 业务中间件先只加载成
app.middlewares,由app/middleware.js决定何时app.use,避免 core 写死顺序。 - 需要
ctx.params的校验必须挂在 router 内部,因为ctx.params是 koa-router 匹配后才写的,不是 Koa 自带的。
三、elpis-core 各 loader
| 文件 | 作用 |
|---|---|
index.js | 组装 app、按依赖顺序调用各 loader、listen。 |
env.js | 导出函数,返回 { isLocal/isBeta/isProduction/get },看 process.env._ENV(package.json 里 dev/beta/prod 会设 _ENV)。 |
loader/middleware.js | glob('app/middleware/**/*.js'),路径段转驼峰,require(file)(app) 得到中间件函数,树形挂到 app.middlewares。 |
loader/router-schema.js | 合并各文件的导出对象到 app.routerSchema。 |
loader/config.js | 读项目根 config/,Object.assign 出 app.config;loadConfigModule 区分缺失模块与其它异常。 |
loader/service.js | 与 middleware 类似的路径规则,new ServiceClass() 挂 app.service。 |
loader/controller.js | 同上,new ControllerClass() 挂 app.controller。 |
loader/extend.js | 按文件名避免与 app 已有 key 冲突,app[name] = require(file)(app)。 |
loader/router.js | new KoaRouter() → router.use(apiParamsVerify) → 执行各 app/router/*.js(app, router) → 注册 * 兜底重定向 → app.use(router.routes())。 |
四、app/ 在干什么(目录 + 典型代码)
1. app/middleware.js(全局 HTTP 管道)
koa-static:app/public静态资源(含 webpack 产物、图片等)。koa-nunjucks-2:给ctx.render,模板根目录也是app/public,扩展名.tpl。koa-bodyparser:解析 JSON/form 等到ctx.request.body。app.middlewares.errorHandler:统一异常;例如模板找不到时可 302 回首页。app.middlewares.apiSignVerify:API 签名校验。- 不在此注册
apiParamsVerify:注释写明改到router.use,保证能读到ctx.params。
2. app/middleware/*.js(可被 loader 收集的中间件)
例如 api-params-verify.js:
- 非
/api直接next。 - 从
ctx.request(body/query/headers)、ctx.path/method、ctx.params取数。 - 用
app.routerSchema[ctx.path][method]找 schema,Ajv 校验;失败返回统一 JSON(code: 442等)。
3. app/router-schema/*.js
导出「路径 → 方法 → { query/body/headers/params } 的 schema 片段」,给上面校验用。
4. app/router/*.js
约定形态:(app, router) => { ... },里面写 router.get/post/...,并从 app.controller 取实例 bind 上去。
这是 URL 与 Controller 方法的绑定层。
5. app/controller/*.js
约定:module.exports = app => class ...,loader 里 new。
负责 读请求、调 service、写 ctx.body 或 ctx.render。
view 控制器里 ctx.render('output/entry.xxx', data) 就是走 Nunjucks 的服务端模板渲染。
6. app/service/*.js
约定同样是 app => class,实例挂 app.service,给 controller 调,不写 HTTP。
7. app/pages/(前端)
- 多入口
entry.*.js→boot(...)挂 Vue、Pinia、路由等。 - Webpack(
app/webpack)把页面打成app/public/dist/...,供 tpl 里<script src=...>引用;build:dev/build:prod走不同配置。
8. app/public/
- 构建输出、
.tpl模板、静态资源;既是 Nunjucks 的根,也是 静态文件 URL 的根。
9. app/extend/(可选)
给 app 挂 logger 等横切能力;注意实际是 app.xxx 而不是 app.extend.xxx(以 loader 代码为准)。
五、请求生命周期
本文档描述 Elpis 当前代码中的请求执行时序(以 elpis-core + app/middleware.js + koa-router 为准)。
时序图
sequenceDiagram
autonumber
participant C as 客户端
participant K as Koa 应用
participant S as koa-static
participant N as koa-nunjucks-2
participant B as bodyParser
participant E as errorHandler
participant V as apiSignVerify
participant R as router.routes()
participant P as apiParamsVerify (router.use)
participant H as 路由处理器(Controller)
participant A as router.allowedMethods()
C->>K: HTTP 请求
K->>S: middleware #1
alt 命中 app/public 下静态文件
S-->>C: 200 文件响应
else 未命中
S->>N: next()
N->>B: next()
B->>E: next()
E->>V: next()
V->>R: next()
Note over R: koa-router 按 path + method 匹配路由<br/>并为动态路由写入 ctx.params
R->>P: router.use(apiParamsVerify)
alt /api 且 schema 校验失败
P-->>C: 200 { success:false, code:442, ... }
else 校验通过
P->>H: next()
alt 页面路由(例如 /view/:page)
H->>N: ctx.render(...)
N-->>C: HTML 响应
else API 路由(例如 /api/...)
H-->>C: JSON 响应
end
end
R->>A: 继续
A-->>C: 按需处理 OPTIONS/405
end
中间件执行顺序
-
koa-static- 如果命中
app/public下的静态文件,直接返回。
- 如果命中
-
koa-nunjucks-2- 给
ctx注入render方法。
- 给
-
koa-bodyparser- 解析请求体到
ctx.request.body。
- 解析请求体到
-
errorHandler- 捕获下游异常并处理兜底逻辑。
-
apiSignVerify- 对
/api请求做签名校验。
- 对
-
router.routes()- 匹配路由与方法。
- 为动态路径写入
ctx.params。 - 在路由处理器前执行
router.use(apiParamsVerify)。 - 执行匹配到的 controller 方法。
-
router.allowedMethods()- 处理
OPTIONS、405 Method Not Allowed等。
- 处理
说明
ctx.params不是 Koa 原生字段,而是koa-router在路由匹配阶段写入的。apiParamsVerify挂在router.use(...)中,才能稳定读取ctx.params。ctx.render来自koa-nunjucks-2,在app/middleware.js里完成配置。
洋葱模型(结合本项目)
Koa 的中间件是典型「洋葱模型」:先进后出。
每一层中间件大致都长这样:
async (ctx, next) => {
// 进入该层(前置逻辑)
await next();
// 下游返回后(后置逻辑)
}
可以把一次请求想象成「穿过洋葱到中心,再原路返回」:
进入方向(外 -> 内):
koa-static
-> koa-nunjucks-2
-> bodyParser
-> errorHandler
-> apiSignVerify
-> router.routes()
-> apiParamsVerify (router.use)
-> controller/service
返回方向(内 -> 外):
controller/service
-> apiParamsVerify
-> router.routes()
-> apiSignVerify
-> errorHandler
-> bodyParser
-> koa-nunjucks-2
-> koa-static
为什么它重要
-
异常捕获为什么放外层
errorHandler在较外层包住下游,await next()抛出的错误能被统一捕获并转成标准响应。 -
ctx.params为什么不是一开始就有
ctx.params在koa-router匹配路由时才写入。
所以读取params的校验器应放到router.use(...)(路由层内部),而不是全局最外层。 -
短路返回(不再进入内层)
例如koa-static命中静态文件后直接响应,请求不会再走到 controller。 -
前置 + 后置逻辑都能写在同一层
一个中间件既能在await next()前做鉴权/日志,也能在后面做耗时统计、响应包装。 -
中断
api-sign-verify.js这段里,命中失败后return,表示不再调用next(),请求链在这一层被短路,后面的路由/controller 不会执行。 但有个细节要注意:- 这里“不会往后走”是指不会走更内层(比如
router.routes()、controller)。 - 如果外层还有中间件包着它,并且外层是
await next()结构,那么外层在await next()后面的代码仍会执行(比如errorHandler的后置逻辑)。
一个简化伪代码(帮助理解执行顺序)
app.use(async (ctx, next) => { // A: errorHandler
console.log('A in');
try {
await next();
} catch (e) {}
console.log('A out');
});
app.use(async (ctx, next) => { // B: apiSignVerify
console.log('B in');
await next();
console.log('B out');
});
app.use(async (ctx) => { // C: controller
console.log('C handle');
ctx.body = 'ok';
});
日志顺序会是:
A in
B in
C handle
B out
A out
这就是「洋葱」的核心:进入时一层层向内,返回时一层层向外。