里程碑1:实现elpis-core以及基于elpis-core的基础设置搭建

本文以学习 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;
};

三段各自解决一个问题:

  1. 扫描glob.sync 递归找出层目录下所有 .js 文件,目录即声明,新增文件零注册。
  2. 命名 :一条正则 /[_-][a-z]/igapi-sign-verify.js 变成 apiSignVerify,文件系统命名和 JS 命名各自舒服。
  3. 挂载 :嵌套循环把目录层级映射为对象层级,app/controller/order/refund.jsapp.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,浏览器拿到的不是空壳页面。

相关推荐
FogLetter1 小时前
进程VS线程:你的电脑到底是怎么同时干那么多活的?
前端·面试
rememberme0011 小时前
华为云 CodeArts Pipeline前端 Vue 项目自动打包发布配置指南
前端·vue.js·华为云
默_笙1 小时前
🍔 我用 React 写了一个 TodoList,终于搞懂了"状态该放谁家"
前端·javascript
FogLetter1 小时前
我真的写了个“诈尸式”缓存组件:手撕React KeepAlive
前端·react.js·面试
A24207349301 小时前
Vue.js 初学者注意事项与项目开发实践指南
前端·javascript·vue.js
用户059540174462 小时前
「记仇」记忆存储测试踩坑实录:用 Pytest+Docker 把 BUG 扼杀在凌晨 3 点前
前端·css
dllmayday2 小时前
Bootstrap三开关样式
前端·javascript·bootstrap
杨先生哦2 小时前
【2026热端攻防系列 11/12】前端AI风控攻防实战:验证码缺陷、人机验证绕过、智能爬虫对抗与企业智能风控加固方案
前端·人工智能·笔记·爬虫·安全
CodeSheep2 小时前
hvv员工家属爆料:老公22年入职的,普通员工,老是被误以为高薪多奖金,实际上还没配股,暂时钱没存多少,我倒是很心疼他工作辛苦。
前端·后端·程序员