本文会拆解 elpis-core 的设计哲学、架构实现与工程实践。如果你正在思考"如何设计一个 Node.js 框架",这篇文章会给你一个完整的答案。
一、为什么要做这个事情
作为程序员的我们如果只是单纯守着自己所谓的技术壁垒,去熟练的使用一门工具,或者一直做着重复性的增删改查工作,会导致我们的竞争力逐步减小,想要成为AI浪潮下的复合型人才,就需要逐步提升自身的竞争力,不再成为使用工具的奴隶,而是逐步扩充自身的全栈开发能力。本人作为前端开发从学习路径上会先选择拥抱Node.js 生态,但核心还是要构建自己的知识体系,Egg, Koa, Express 切记都是只是工具。本文围绕着如何设计一个可运行的服务端框架开展,也是我们掌握全栈开发能力的万里长征第一步。
在 Node.js 生态中,Express 和 Koa 提供了极简的 HTTP 抽象,但"极简"也意味着每个团队都要自己回答一系列问题:
- 项目目录怎么组织?
- 路由、控制器、服务层怎么拆分?
- 中间件加载顺序谁来保证?
- 多环境配置怎么管理?
Elpis-Core 的定位很明确:在 Koa 之上,用最小的代码量实现一套"约定优于配置"的自动化加载框架,让开发者只需要关注业务代码本身。
二、整体架构一览
ElpisCore.start(options)"] --> B["elpis-core/index.js 框架内核
new Koa() → 设置路径/环境 → 执行 Loader
→ 注册中间件 → 挂载路由 → app.listen()"] B --> C["Loaders"] B --> D["全局中间件
middleware.js"] B --> E["koa-router
路由注册"] style A fill:#e1f5fe,stroke:#0288d1 style B fill:#fff3e0,stroke:#f57c00 style C fill:#e8f5e9,stroke:#388e3c style D fill:#e8f5e9,stroke:#388e3c style E fill:#e8f5e9,stroke:#388e3c
整个框架的核心就是 Loader 机制 ------ 扫描约定目录下的文件,按照统一规则解析文件名、转换命名风格,然后挂载到 app 实例上。业务代码只需要放在正确的目录里,框架会自动完成发现和注册。
三、启动流程
项目的入口:
javascript
// index.js
const ElpisCore = require("./elpis-core");
ElpisCore.start({ name: "Eplis", homePage: "/" });
start() 内部的执行序列如下:
javascript
// 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. 配置
serviceLoader(app); // 2. 服务层
middlewareLoader(app); // 3. 中间件
controllerLoader(app); // 4. 控制器
routerSchemaLoader(app); // 5. 路由校验规则
extendLoader(app); // 6. 扩展
// 7. 注册全局中间件(业务层编排)
require(`${app.businessPath}/middleware.js`)(app);
// 8. 路由(最后加载,依赖前面所有组件)
routerLoader(app);
app.listen(port, host);
}
加载顺序的设计考量
这个顺序不是随意的,每一步都有依赖关系:
最基础"] --> B["② Service
业务逻辑层"] B --> C["③ Middleware
中间件函数"] C --> D["④ Controller
依赖 Service"] D --> E["⑤ RouterSchema
校验规则"] E --> F["⑥ Extend
扩展工具"] F --> G["⑦ 全局中间件
编排执行顺序"] G --> H["⑧ Router
依赖所有前置组件"] style A fill:#ffecb3,stroke:#ff8f00 style B fill:#c8e6c9,stroke:#2e7d32 style C fill:#bbdefb,stroke:#1565c0 style D fill:#c8e6c9,stroke:#2e7d32 style E fill:#d1c4e9,stroke:#4527a0 style F fill:#ffe0b2,stroke:#e65100 style G fill:#bbdefb,stroke:#1565c0 style H fill:#ffcdd2,stroke:#c62828
| 顺序 | 模块 | 原因 |
|---|---|---|
| 1 | Config | 最基础,所有模块都可能读取配置 |
| 2 | Service | 业务逻辑层,Controller 会调用它 |
| 3 | Middleware | 中间件函数加载到内存,供后续注册 |
| 4 | Controller | 请求处理器,依赖 Service |
| 5 | RouterSchema | 路由参数校验规则,供中间件使用 |
| 6 | Extend | 扩展工具,可能被任何模块使用 |
| 7 | 全局中间件 | 编排中间件执行顺序,依赖已加载的中间件实例 |
| 8 | Router | 最后注册,依赖 Controller + Middleware |
四、Loader 机制深度解析
Loader 是 Elpis-Core 的灵魂。7 个 Loader 共享同一套设计模式,但各有细节差异。
4.1 通用模式:扫描 → 解析 → 转换 → 挂载
递归查找 JS 文件"] --> B["路径解析
提取相对路径"] B --> C["命名转换
kebab-case → camelCase"] C --> D["嵌套挂载
按目录层级构建对象树"] D --> E["app.controller.*
app.service.*
app.middlewares.*"] style A fill:#e3f2fd,stroke:#1565c0 style B fill:#e8eaf6,stroke:#283593 style C fill:#f3e5f5,stroke:#6a1b9a style D fill:#fff3e0,stroke:#e65100 style E fill:#e8f5e9,stroke:#2e7d32
以 Controller Loader 为例,核心流程:
javascript
// elpis-core/loader/controller.js
module.exports = (app) => {
// 1. 扫描:用 glob 递归查找所有 JS 文件
const controllerPath = path.resolve(app.businessPath, `./controller`);
const fileList = glob.sync(path.resolve(controllerPath, `./**/*.js`));
const controller = {};
fileList.forEach((file) => {
// 2. 解析:提取相对路径
let name = file.substring(
file.lastIndexOf(`controller/`) + `controller/`.length,
file.lastIndexOf("."),
);
// 3. 转换:kebab-case → camelCase
name = name.replace(/[_-][a-z]/gi, (s) => s.substring(1).toUpperCase());
// 4. 挂载:按目录层级构建嵌套对象
let temp = controller;
const names = name.split(sep);
for (let i = 0; i < names.length; i++) {
if (i === names.length - 1) {
const Module = require(file)(app);
temp[names[i]] = new Module(); // 实例化
} else {
temp[names[i]] = temp[names[i]] || {};
temp = temp[names[i]];
}
}
});
app.controller = controller;
};
命名转换示例:
project.js"] -->|转换| B["app.controller.project"] C["app/controller/
custom-module/user.js"] -->|转换| D["app.controller.customModule.user"] E["app/controller/
my_api/data-list.js"] -->|转换| F["app.controller.myApi.dataList"] style A fill:#fff3e0,stroke:#e65100 style B fill:#e8f5e9,stroke:#2e7d32 style C fill:#fff3e0,stroke:#e65100 style D fill:#e8f5e9,stroke:#2e7d32 style E fill:#fff3e0,stroke:#e65100 style F fill:#e8f5e9,stroke:#2e7d32
正则 /[_-][a-z]/gi 同时处理了短横线和下划线两种风格,统一转为驼峰。
4.2 各 Loader 的差异对比
| Loader | 目录 | 挂载位置 | 是否实例化 | 特殊处理 |
|---|---|---|---|---|
| Config | config/ |
app.config |
否 | default + env 合并覆盖 |
| Service | app/service/ |
app.service.* |
是(new) |
支持多级目录嵌套 |
| Middleware | app/middleware/ |
app.middlewares.* |
否(函数) | 返回 Koa 中间件函数 |
| Controller | app/controller/ |
app.controller.* |
是(new) |
支持多级目录嵌套 |
| RouterSchema | app/router-schema/ |
app.routerSchema |
否 | 扁平合并,key 为 API 路径 |
| Extend | app/extend/ |
app.*(直接挂载) |
否 | 冲突检测,防止覆盖已有属性 |
| Router | app/router/ |
Koa 中间件栈 | --- | 兜底路由 302 重定向 |
关键区别在于:
- Controller 和 Service 会
new出实例,因为它们是有状态的类 - Middleware 返回的是函数,直接作为 Koa 中间件使用
- Extend 直接挂载到
app顶层,并做了冲突检测 - RouterSchema 采用扁平合并,以 API 路径为 key
4.3 跨平台兼容
所有 Loader 都使用 path.sep 处理路径分隔符:
javascript
const { sep } = path;
// Windows: sep = '\\'
// macOS/Linux: sep = '/'
这保证了框架在不同操作系统上的行为一致性。
五、环境管理
5.1 三环境模型
javascript
// elpis-core/env.js
module.exports = () => ({
isLocal() {
return process.env._ENV === "local";
},
isBeta() {
return process.env._ENV === "beta";
},
isProduction() {
return process.env._ENV === "production";
},
get() {
return process.env._ENV ?? "local";
},
});
通过 process.env._ENV 控制,默认回退到 local。启动脚本中注入:
json
{
"dev": "_ENV='local' nodemon ./index.js",
"beta": "_ENV='beta' nodemon ./index.js",
"prod": "_ENV='production' nodemon ./index.js"
}
5.2 配置合并策略
javascript
// elpis-core/loader/config.js
// 1. 加载 config.default.js 作为基础配置
// 2. 根据当前环境加载 config.{env}.js
// 3. Object.assign 合并,环境配置覆盖默认配置
app.config = Object.assign({}, defaultConfig, envConfig);
基础配置"] -->|Object.assign 合并| C["app.config"] B["config.{env}.js
环境配置"] -->|覆盖同名字段| C subgraph 环境配置文件 D["config.local.js"] E["config.beta.js"] F["config.prod.js"] end D -.->|_ENV=local| B E -.->|_ENV=beta| B F -.->|_ENV=production| B style A fill:#e8f5e9,stroke:#388e3c style B fill:#fff3e0,stroke:#f57c00 style C fill:#e1f5fe,stroke:#0288d1
这是一种简洁有效的配置管理方式:公共配置写一次,差异化配置按环境覆盖。
六、分层架构实战
Elpis 采用经典的 MVC 分层,请求的完整生命周期如下:
koa-static → nunjucks → bodyparser
→ errorHandler → apiSignVerify → apiParamsVerify"] B --> C["koa-router 路由匹配
/api/project/list → ProjectController
/view/:page → ViewController"] C --> D["Controller 层
解析参数 → 调用 Service → 组装响应"] D --> E["Service 层
封装业务逻辑 / 数据库操作 / 外部 API 调用"] E --> F(("HTTP Response")) style A fill:#ffcdd2,stroke:#c62828 style B fill:#e1f5fe,stroke:#0288d1 style C fill:#fff3e0,stroke:#f57c00 style D fill:#e8f5e9,stroke:#388e3c style E fill:#f3e5f5,stroke:#7b1fa2 style F fill:#ffcdd2,stroke:#c62828
6.1 Controller:请求的入口
Controller 负责接收请求、调用 Service、返回响应。项目中通过基类统一了响应格式:
javascript
// app/controller/base.js
module.exports = (app) =>
class BaseController {
constructor() {
this.app = app;
this.config = app.config;
}
success(ctx, data = {}, metadata = {}) {
ctx.status = 200;
ctx.body = { success: true, data, metadata };
}
fail(ctx, message, code) {
ctx.body = { success: false, message, code };
}
};
业务 Controller 继承基类:
javascript
// app/controller/project.js
module.exports = (app) => {
const BaseController = require("./base")(app);
return class ProjectController extends BaseController {
async getList(ctx) {
const { proj_key: projKey } = ctx.request.body;
const projectList = await app.service.project.getList();
this.success(ctx, projectList);
}
};
};
统一的 success/fail 方法确保所有 API 返回结构一致,前端无需猜测响应格式。
6.2 Service:业务逻辑的归宿
Service 层通过基类封装了公共能力(配置访问、HTTP 客户端):
javascript
// app/service/base.js
const superagent = require("superagent");
module.exports = (app) => {
return class BaseService {
constructor() {
this.app = app;
this.config = app.config;
this.curl = superagent; // HTTP 客户端,用于调用外部 API
}
};
};
业务 Service 继承基类,专注于逻辑实现:
javascript
// app/service/project.js
module.exports = (app) => {
const BaseService = require("./base")(app);
return class ProjectService extends BaseService {
async getList() {
// 实际项目中这里会查询数据库或调用外部接口
return [{ a: "1" }];
}
};
};
6.3 路由:连接 URL 与 Controller
javascript
// app/router/project.js
module.exports = (app, router) => {
const { project: ProjectController } = app.controller;
router.get(
"/api/project/list",
ProjectController.getList.bind(ProjectController),
);
};
注意 .bind(ProjectController) 的使用------因为 koa-router 调用处理函数时会改变 this 指向,bind 确保 Controller 方法内部的 this 始终指向正确的实例。
路由加载器还提供了兜底机制:
javascript
// elpis-core/loader/router.js
router.get("*", async (ctx) => {
ctx.status = 302;
ctx.redirect(app?.options?.homepage ?? "/");
});
未匹配的路径会被 302 重定向到首页,避免用户看到空白页或错误页。
七、中间件体系
7.1 全局中间件编排
app/middleware.js 是中间件的编排中心,决定了中间件的执行顺序:
javascript
// app/middleware.js
module.exports = (app) => {
// 静态资源服务
app.use(koaStatic(path.resolve(process.cwd(), "./app/public")));
// 模板渲染引擎(Nunjucks)
app.use(koaNunjucks({ ext: "tpl", path: "./app/public" }));
// 请求体解析
app.use(
bodyParser({ formLimit: "1000mb", enableTypes: ["json", "form", "text"] }),
);
// 异常兜底(最外层 try-catch)
app.use(app.middlewares.errorHandler);
// API 签名校验
app.use(app.middlewares.apiSignVerify);
// API 参数校验
app.use(app.middlewares.apiParamsVerify);
};
Koa 的中间件模型是洋葱模型,请求从外到内穿透,响应从内到外返回:
这里的顺序意味着:
- 请求先经过静态资源检查
- 然后是模板引擎注入
- body 解析
- 错误处理包裹后续所有逻辑
- 签名校验
- 参数校验
- 最后到达路由和 Controller
7.2 错误处理中间件
javascript
// app/middleware/error-handler.js
module.exports = (app) => {
return async (ctx, next) => {
try {
await next();
} catch (error) {
app.logger.error("[-- exception --]: ", error);
// 模板未找到 → 重定向首页
if (message?.indexOf("template not found") > -1) {
ctx.status = 302;
ctx.redirect(app.options?.homePage);
return;
}
// 其他异常 → 统一错误响应
ctx.status = 200;
ctx.body = {
success: false,
code: 50000,
message: "网络异常,请稍候重试",
};
}
};
};
这个中间件放在业务逻辑之前,利用 Koa 的洋葱模型,try-catch 可以捕获后续所有中间件和路由处理中抛出的异常。
7.3 API 签名校验
javascript
// app/middleware/api-sign-verify.js
module.exports = (app) => {
return async (ctx, next) => {
if (ctx.path.indexOf("/api") < 0) return await next(); // 非 API 请求跳过
const { s_sign, s_t } = ctx.request.headers;
const signature = md5(`${signKey}_${s_t}`);
// 校验签名 + 时间窗口(10 分钟)
if (
!s_sign ||
!s_t ||
signature !== s_sign.toLowerCase() ||
Date.now() - s_t > 600000
) {
ctx.body = {
success: false,
message: "signature not correct or api timeout!",
code: 445,
};
return;
}
await next();
};
};
签名算法:md5(signKey + "_" + timestamp),同时校验时间戳防止重放攻击(10 分钟窗口)。
7.4 参数校验中间件(JSON Schema + AJV)
javascript
// app/middleware/api-params-verify.js
module.exports = (app) => {
return async (ctx, next) => {
if (ctx.path.indexOf("/api") < 0) return await next();
const schema = app.routerSchema[ctx.path]?.[ctx.method.toLowerCase()];
if (!schema) return await next();
// 依次校验 headers → body → query → params
// 使用 AJV 编译 JSON Schema 并验证
// 校验失败返回 442 错误码
};
};
配合 app/router-schema/ 下的 Schema 定义:
javascript
// app/router-schema/project.js
module.exports = {
"/api/project/list": {
get: {
query: {
type: "object",
properties: {
proj_key: { type: "string" },
},
required: ["proj_key"],
},
},
},
};
这种声明式的参数校验方式,让接口约束一目了然,且与业务逻辑完全解耦。
八、扩展机制
8.1 Extend Loader
app/extend/ 目录下的模块会被直接挂载到 app 实例上:
javascript
// elpis-core/loader/extend.js
// 冲突检测:如果 app 上已存在同名属性,跳过并警告
for (const key in app) {
if (key === name) {
console.log(`[extend load error] name: ${name} is already in app`);
return;
}
}
app[name] = require(file)(app);
8.2 实际案例:日志扩展
javascript
// app/extend/logger.js
module.exports = (app) => {
if (app.env.isLocal()) {
return console; // 本地开发直接用 console
}
// 非本地环境:log4js 日志落盘
log4js.configure({
appenders: {
console: { type: "console" },
dateFile: {
type: "dateFile",
filename: "./logs/application.log",
pattern: ".yyyy-MM-dd", // 按天切分
},
},
categories: {
default: { appenders: ["console", "dateFile"], level: "trace" },
},
});
return log4js.getLogger();
};
加载后通过 app.logger 访问。本地环境用 console 减少噪音,线上环境用 log4js 实现日志落盘和按天切分。
九、项目目录全景
csharp
elpis/
├── index.js # 启动入口(2 行代码)
├── package.json
│
├── elpis-core/ # 框架内核
│ ├── index.js # 核心启动逻辑
│ ├── env.js # 环境管理
│ └── loader/ # 7 个自动加载器
│ ├── config.js
│ ├── service.js
│ ├── middleware.js
│ ├── controller.js
│ ├── router-schema.js
│ ├── extend.js
│ └── router.js
│
├── config/ # 多环境配置
│ ├── config.default.js
│ ├── config.local.js
│ ├── config.beta.js
│ └── config.prod.js
│
├── app/ # 业务代码
│ ├── middleware.js # 全局中间件编排
│ ├── controller/ # 控制器层
│ │ ├── base.js # 控制器基类
│ │ ├── project.js
│ │ └── view.js
│ ├── service/ # 服务层
│ │ ├── base.js # 服务基类
│ │ └── project.js
│ ├── middleware/ # 中间件
│ │ ├── error-handler.js
│ │ ├── api-sign-verify.js
│ │ └── api-params-verify.js
│ ├── router/ # 路由定义
│ │ ├── project.js
│ │ └── view.js
│ ├── router-schema/ # 路由参数校验规则
│ │ └── project.js
│ ├── extend/ # 扩展
│ │ └── logger.js
│ └── public/ # 静态资源
│ ├── static/
│ └── output/
│
└── logs/ # 日志输出
└── application.log
十、设计亮点与工程思考
10.1 约定优于配置的实践
整个框架没有一个 YAML/JSON 配置文件来声明"哪些文件是 Controller"。你只需要把文件放到 app/controller/ 目录下,框架就知道它是 Controller。这种约定带来的好处是:
- 新成员看到目录结构就能理解项目组织
- 不需要维护额外的注册/配置文件
- 减少了"配置漂移"的风险
10.2 工厂模式 + 依赖注入
所有业务模块都导出一个接收 app 的工厂函数:
javascript
module.exports = (app) => {
return class SomeController {
// 通过闭包访问 app
};
};
这种模式的优势:
- 每个模块都能访问完整的应用上下文
- 不需要全局变量或 import 循环
- 测试时可以轻松 mock
app对象
10.3 容错设计
框架在关键位置都加了 try-catch:
javascript
// 配置加载失败不阻塞启动
try {
defaultConfig = require(path.resolve(configPath, `./config.default.js`));
} catch (error) {
console.error("[exception] there is no config.default file");
}
// 全局中间件文件不存在也不崩溃
try {
require(`${app.businessPath}/middleware.js`)(app);
} catch (error) {
console.log("[exception] there is no global middleware file.");
}
这保证了框架的健壮性------即使某个非关键模块加载失败,应用仍然可以启动。
10.4 统一的命名转换
一个正则搞定文件名到代码标识符的映射:
javascript
name.replace(/[_-][a-z]/gi, (s) => s.substring(1).toUpperCase());
api-params-verify.js → apiParamsVerify,my_service.js → myService。文件系统用 kebab-case 保持可读性,代码中用 camelCase 符合 JavaScript 惯例。
十一、总结
Elpis-Core 实现了一个功能完备的框架内核:
- 7 个 Loader 覆盖了 Web 应用的所有核心组件
- 严格的加载顺序保证了依赖关系的正确性
- 约定式目录结构让项目组织零成本
- 工厂模式 + 闭包实现了优雅的依赖注入
- 多环境配置、参数校验、签名验证、错误兜底一应俱全
理解了 Elpis-Core,你就理解了大多数 Node.js 框架的底层思路。
本文基于 Elips-Core 框架源码分析撰写,适用于了解 Node.js Web 框架设计原理的开发者。