elpis-core 的理解与总结

5 阅读5分钟

一、整体设计思路

这是一个 「迷你框架 + 业务目录」 结构:

  • 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() 里顺序是刻意排过的:

  1. new Koa(),挂上 app.optionsapp.baseDirapp.businessPathapp.env(读 _ENV)。
  2. middlewareLoader:扫描 app/middleware/**/*.js,生成 app.middlewares.*(中间件工厂,还没 app.use)。
  3. routerSchemaLoader:扫描 app/router-schema/**/*.js,合并成 app.routerSchema(按「路径 + 方法」索引的 JSON Schema)。
  4. configLoader:读 config/config.default.js + 当前环境对应的 config.*.js,得到 app.config(失败时区分 MODULE_NOT_FOUND 与其它错误)。
  5. serviceLoader:扫描 app/service/**new 实例挂到 app.service.*
  6. controllerLoader:扫描 app/controller/**new 实例挂到 app.controller.*
    顺序放在 service/config 之后,是因为你们的 BaseController / BaseService 构造里会读 app.configapp.service
  7. extendLoader:扫描 app/extend/**,把导出结果挂到 app[驼峰文件名](例如 logger.js → app.logger)。
  8. require(app/middleware.js)(app):真正把 Koa 全局中间件链 app.use 上去(静态、Nunjucks、bodyParser、错误处理、签名等)。
  9. 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._ENVpackage.json 里 dev/beta/prod 会设 _ENV)。
loader/middleware.jsglob('app/middleware/**/*.js'),路径段转驼峰,require(file)(app) 得到中间件函数,树形挂到 app.middlewares
loader/router-schema.js合并各文件的导出对象到 app.routerSchema
loader/config.js读项目根 config/Object.assign 出 app.configloadConfigModule 区分缺失模块与其它异常。
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.jsnew KoaRouter() → router.use(apiParamsVerify) → 执行各 app/router/*.js(app, router) → 注册 * 兜底重定向 → app.use(router.routes())

四、app/ 在干什么(目录 + 典型代码)

1. app/middleware.js(全局 HTTP 管道)

  • koa-staticapp/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/methodctx.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

中间件执行顺序

  1. koa-static

    • 如果命中 app/public 下的静态文件,直接返回。
  2. koa-nunjucks-2

    • ctx 注入 render 方法。
  3. koa-bodyparser

    • 解析请求体到 ctx.request.body
  4. errorHandler

    • 捕获下游异常并处理兜底逻辑。
  5. apiSignVerify

    • /api 请求做签名校验。
  6. router.routes()

    • 匹配路由与方法。
    • 为动态路径写入 ctx.params
    • 在路由处理器前执行 router.use(apiParamsVerify)
    • 执行匹配到的 controller 方法。
  7. router.allowedMethods()

    • 处理 OPTIONS405 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

为什么它重要

  1. 异常捕获为什么放外层
    errorHandler 在较外层包住下游,await next() 抛出的错误能被统一捕获并转成标准响应。

  2. ctx.params 为什么不是一开始就有
    ctx.paramskoa-router 匹配路由时才写入。
    所以读取 params 的校验器应放到 router.use(...)(路由层内部),而不是全局最外层。

  3. 短路返回(不再进入内层)
    例如 koa-static 命中静态文件后直接响应,请求不会再走到 controller。

  4. 前置 + 后置逻辑都能写在同一层
    一个中间件既能在 await next() 前做鉴权/日志,也能在后面做耗时统计、响应包装。

  5. 中断

  • 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

这就是「洋葱」的核心:进入时一层层向内,返回时一层层向外