一、阶段概述
第一阶段从零搭建了一个基于 Koa 2 的企业级 Node.js 框架内核 elpis-core,在第一阶段的学习中我深刻理解了框架每一层的分层设计理念,下面将总结每一层的职责、设计意图和实现细节。
二、整体架构总览
2.1 项目目录结构
csharp
elpis/
├── index.js # 应用入口:调 elpis-core 启动
├── config/ # 多环境配置
│ ├── config.default.js # 基础配置(所有环境共享)
│ ├── config.local.js # 本地开发覆盖
│ ├── config.beta.js # 测试环境覆盖
│ └── config.prod.js # 生产环境覆盖
├── elpis-core/ # 框架内核(与应用业务完全解耦)
│ ├── index.js # 启动入口:编排加载顺序
│ ├── env.js # 环境识别
│ └── loader/ # 7 个自动加载器
│ ├── middleware.js
│ ├── router-schema.js
│ ├── controller.js
│ ├── service.js
│ ├── config.js
│ ├── extend.js
│ └── router.js
└── app/ # 业务代码(按约定放入对应目录即可)
├── middleware.js # 全局中间件注册(顺序编排)
├── controller/ # 控制器层
│ ├── base.js # BaseController(公共能力下沉)
│ ├── project.js # ProjectController
│ └── view.js # ViewController
├── service/ # 服务层
│ ├── base.js # BaseService(注入 app/config/curl)
│ └── project.js # ProjectService
├── middleware/ # 中间件层(横切关注点)
│ ├── error-handler.js # 全局错误兜底
│ ├── api-sign-verify.js # API 签名校验
│ └── api-params-verify.js # API 参数 JSON Schema 校验
├── router/ # 路由层(URL → Controller 映射)
│ ├── project.js # /api/project/list
│ └── view.js # /view/:page
├── router-schema/ # API 参数规则(与路由解耦)
│ └── project.js
├── extend/ # 应用扩展(挂载到 app 上)
│ └── logger.js # log4js 日志
└── public/ # 静态资源 & 模板
├── output/ # Nunjucks 模板 (.tpl)
└── static/ # CSS / 图片
2.2 分层架构图
bash
┌──────────────────────┐
│ HTTP Request │
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ 1. koa-static │ 静态文件?
│ 2. koa-nunjucks-2 │ 模板渲染准备
│ 3. koa-bodyparser │ 解析 body
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ 4. error-handler │ ←── 最外层 try/catch
│ ┌─────────────────┤ 兜底所有异常
│ │ await next() │
│ └─────────────────┤
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ 5. api-sign-verify │ ←── 只拦截 /api/*
│ MD5 签名 + 时间戳 │ 防篡改 + 防重放
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ 6. api-params-verify │ ←── 只拦截 /api/*
│ AJV JSON Schema │ headers/body/query/params
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ Router 路由层 │
│ /api/project/list │──→ ProjectController.getList
│ /view/:page │──→ ViewController.renderPage
│ * │──→ 302 重定向到首页
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ Controller 控制层 │
│ 获取参数 │
│ 调用 Service │
│ 组装响应 (success/fail)│
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ Service 服务层 │
│ 业务逻辑 │
│ 数据查询 / 外部 API │
└──────────────────────┘
三、逐层深度解析
3.1 启动入口:elpis-core/index.js ------ 框架的"总调度"
这是整个框架的生命周期管理器 。ElpisCore.start() 被调用后,按严格顺序执行 7 个加载步骤:
scss
// 1. 初始化 app 实例
const app = new koa();
app.options = options; // 应用配置
app.baseDir = process.cwd(); // 项目根目录
app.businessPath = './app'; // 业务代码目录
// 2. 环境识别
app.env = env();
// 3. 按序加载各层
middlewareLoader(app); // ① 中间件(先加载,供后续注册使用)
routerSchemaLoader(app); // ② 参数校验规则
controllerLoader(app); // ③ 控制器
serviceLoader(app); // ④ 服务
configLoader(app); // ⑤ 配置(最后加载,因为前面各层可能依赖 config)
extendLoader(app); // ⑥ 扩展(挂载 logger 等到 app)
// 4. 注册全局中间件链
require('./app/middleware.js')(app);
// 5. 注册路由
routerLoader(app);
// 6. 启动 HTTP 服务
app.listen(port, host);
设计要点:
- 加载顺序即依赖关系:Middleware 和 RouterSchema 必须在 Controller/Service 之前加载,因为 Controller 可能不依赖它们,但后续的路由注册和中间件链需要所有组件就位。
app对象是唯一的依赖注入容器 :所有层加载的结果都挂载到app上(app.controller、app.service、app.config等),各层之间通过app互相访问。- 业务与框架分离 :
elpis-core/是纯框架代码,app/是纯业务代码。框架只定义规则,业务只按约定写。
3.2 环境识别层:elpis-core/env.js
职责:单一的环境判断,对外暴露 4 个方法:
scss
app.env = {
isLocal(), // _ENV === 'local'
isBeta(), // _ENV === 'beta'
isProduction(), // _ENV === 'production'
get() // 返回当前 _ENV,默认 'local'
}
设计意图 :将环境判断封装成语义化 API,而不是让业务代码到处写 process.env._ENV === 'xxx'。配合 cross-env 在 npm scripts 中注入环境变量:
json
"dev": "cross-env _ENV=local node ./index.js"
"beta": "cross-env _ENV=beta nodemon ./index.js"
"prod": "cross-env _ENV=production nodemon ./index.js"
被谁使用:Config Loader 根据环境加载不同配置文件;Logger 根据环境决定是 console 输出还是文件落盘。
3.3 自动加载器(Loader)------ 框架的核心机制
7 个 Loader 实现原理高度一致,核心是三步:
perl
glob 扫描目录 → 驼峰转换命名 → require + 工厂函数 → 挂载到 app
3.3.1 通用加载模式
以 controller loader 为例:
ini
// ① glob 扫描:递归发现 app/controller/**/*.js
const fileList = glob.sync('app/controller/**/**.js');
fileList.forEach(file => {
// ② 路径 → 驼峰命名
// app/controller/custom-module/custom-controller.js
// → customModule.customController
// ③ require 工厂函数,new 实例,挂载到 app.controller
const ControllerClass = require(file)(app); // 注入 app
app.controller.customModule.customController = new ControllerClass();
});
这带来的好处:
- 零配置路由/注入 :开发者只需要在
app/controller/下新建文件,框架自动发现并挂载,无需手动require和注册。 - 命名即命名空间 :目录结构直接映射为
app.controller.xxx.yyy,层次清晰。 - 工厂函数注入 app :每个模块通过
(app) => Class接收 app 实例,实现依赖注入。
3.3.2 各 Loader 对比
| Loader | 扫描目录 | 挂载到 | 加载方式 | 用途 |
|---|---|---|---|---|
| middleware | app/middleware/ |
app.middlewares |
require(file)(app) 返回函数 |
供全局中间件链调用 |
| router-schema | app/router-schema/ |
app.routerSchema |
require(file) 返回对象合并 |
供 api-params-verify 查找 |
| controller | app/controller/ |
app.controller |
new (require(file)(app)) |
供 Router 映射 |
| service | app/service/ |
app.service |
new (require(file)(app)) |
供 Controller 调用 |
| config | config/ |
app.config |
按环境合并 default + env | 全局配置 |
| extend | app/extend/ |
app[name] |
直接挂载到 app 顶层 | 扩展 app 能力(如 logger) |
| router | app/router/ |
KoaRouter 实例 | require(file)(app, router) |
注册路由规则 |
关键细节 :extend loader 在挂载前会检测命名冲突------如果 app 上已有同名属性,打印警告并跳过,防止覆盖框架核心属性。
3.4 全局中间件链:app/middleware.js
职责 :定义请求的处理管道------这是整个应用最关键的架构决策之一。
javascript
module.exports = (app) => {
// ① koa-static ------ 静态文件(CSS/图片/JS),命中则直接返回
// ② koa-nunjucks-2 ------ 模板引擎准备(ctx.render 可用)
// ③ koa-bodyparser ------ 解析请求体为 ctx.request.body
// ④ error-handler ------ 全局异常兜底(最外层 try/catch)
// ⑤ api-sign-verify ------ API 签名校验(仅拦截 /api/*)
// ⑥ api-params-verify ------ API 参数校验(仅拦截 /api/*)
};
注册顺序即执行顺序,这个顺序背后有明确的逻辑:
- 静态文件最先 ------如果请求的是
logo.png,直接返回,不浪费后续中间件的 CPU。 - 模板和 BodyParser 其次 ------为后续中间件准备好
ctx.render和ctx.request.body。 - 错误处理第四------包裹了后续所有中间件和路由,任何未捕获异常都在这里兜底。
- 签名和参数校验最后------只有 API 请求才触发,页面请求直接跳过。
3.4.1 error-handler:全局异常兜底
csharp
try {
await next(); // 包裹后续所有中间件 + 路由 + 控制器
} catch (err) {
// 模板未找到 → 302 重定向到首页(用户体验)
// 其他异常 → 返回 { success: false, code: 50000 }(统一错误格式)
}
设计意图:
- 区分"页面异常"和"API 异常"------页面异常重定向,API 异常返回 JSON。
- 对外隐藏错误细节------生产环境不暴露 stack trace,统一返回"网络异常,请稍后再试"。
3.4.2 api-sign-verify:API 签名校验
csharp
// 只拦截 /api/* 路径
if (!ctx.path.includes('/api')) return await next();
// 1. 取请求头中的签名 s_sign 和时间戳 s_t
// 2. 服务端用相同算法计算: md5(signKey + '_' + 时间戳)
// 3. 比对签名是否一致
// 4. 检查时间戳是否在 10 分钟内(防重放攻击)
设计意图:防止 API 被未授权调用或请求被篡改。这是一种轻量级的 API 安全方案,不依赖 Session/Token,适合微服务间调用或简单的前后端分离场景。
3.4.3 api-params-verify:AJV JSON Schema 参数校验
arduino
// 1. 从 app.routerSchema 查找当前 path + method 的 JSON Schema
// 2. 依次校验 headers → body → query → params
// 3. 任一校验失败,返回 code: 442 + 详细错误信息
设计意图 :将验证规则与业务代码解耦 。Controller 里不再写 if (!proj_key) { ... },而是在 router-schema/project.js 中声明式定义:
css
'/api/project/list': {
get: {
query: {
type: 'object',
properties: { proj_key: { type: 'string' } },
required: ['proj_key']
}
}
}
这样当 API 增多时,所有校验规则集中管理,Controller 只关心业务逻辑。
3.5 路由层:app/router/
职责 :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));
};
// app/router/view.js
module.exports = (app, router) => {
const { view: viewController } = app.controller;
router.get('/view/:page', viewController.renderPage.bind(viewController));
};
设计约束:
- Router 只负责"哪个 URL 调哪个 Controller 的哪个方法",不写任何业务逻辑。
- Router loader 会自动注册一个
GET *兜底路由 → 302 重定向到首页(防止 404 白屏)。 .bind(controller)是必须的,因为 KoaRouter 调用 handler 时this会丢失。
3.6 控制器层:app/controller/
职责 :作为请求的"指挥者" ,协调参数获取、服务调用、响应组装。
BaseController:公共能力下沉
javascript
class BaseController {
constructor() {
this.app = app; // 可访问所有层
this.config = app.config; // 可读取配置
}
success(ctx, data, metadata) { // 统一成功响应格式
ctx.body = { sucess: true, data, metadata };
}
fail(ctx, message, code) { // 统一失败响应格式
ctx.body = { sucess: false, message, code };
}
}
设计意图 :success() 和 fail() 保证了所有 API 返回结构一致 。如果以后要加 traceId、timestamp 等字段,只需改 BaseController 两个方法即可。
ProjectController:具体业务控制器
scala
class ProjectController extends BaseController {
async getList(ctx) {
const { proj_key } = ctx.request.query; // ① 拿参数
const res = await app.service.project.getList(); // ② 调服务
this.success(ctx, res); // ③ 组装响应
}
}
控制器的"三件事"模式 :拿参数 → 调 Service → 返回结果。Controller 不写具体业务逻辑,它是一个编排者而非执行者。
3.7 服务层:app/service/
职责 :业务逻辑的承载者。这是唯一可以写数据查询、外部 API 调用、复杂计算的地方。
BaseService:基础设施注入
kotlin
class BaseService {
constructor() {
this.app = app;
this.config = app.config;
this.curl = superagent; // 预置 HTTP 客户端
}
}
ProjectService:具体业务服务
scala
class ProjectService extends BaseService {
async getList() {
// 当前返回硬编码数据
// 后续会替换为: this.curl.get('https://api.xxx') 或 knex('projects').select()
return [{ name: 'project1', desc: 'project1 desc' }, ...];
}
}
设计意图:
- Controller 不写业务逻辑------复杂逻辑抽到 Service,Controller 只做调度。
- Service 不操作 ctx------Service 是纯数据层,不依赖 HTTP 上下文,这样 Service 可以被 Controller、定时任务、甚至单元测试直接调用。
- Service 之间可互相调用 ------
app.service.xxx可以访问其他 Service。
3.8 配置层:config/
职责 :环境差异的集中管理。
arduino
config.default.js → 基础配置(所有环境共享)
+
config.local.js → 本地开发覆盖(如 name: 'tcb-local')
config.beta.js → 测试环境覆盖
config.prod.js → 生产环境覆盖
↓
app.config = Object.assign({}, default, env)
设计意图 :开发环境的数据库地址、测试环境的 API 域名、生产环境的日志级别------这些差异不应该散落在代码的 if/else 里,而是通过配置文件 + 环境变量集中管理。
3.9 扩展层:app/extend/
职责 :给 app 实例附加能力,使其突破 Koa 原生功能。
javascript
// app/extend/logger.js
module.exports = (app) => {
if (app.env.isLocal()) {
return console; // 本地:控制台打印
} else {
return log4js.getLogger(); // 非本地:log4js 落盘
}
};
// 挂载后:app.logger.info(...)、app.logger.error(...)
设计意图 :extend/ 是一个开放扩展点。未来可以挂载:
app.redis------ Redis 客户端app.mq------ 消息队列生产者app.cache------ 本地缓存实例
Koa 原生只有 app.use()、app.listen() 等基础方法,通过 extend 机制给 app 注入业务所需的一切能力。
四、核心架构设计原则总结
| 设计原则 | 在项目中的体现 |
|---|---|
| 约定优于配置 | 文件放在 app/controller/ 就自动成为控制器,无需手动注册 |
| 单一职责 | Router 只做映射、Controller 只做编排、Service 只做业务、Middleware 只做横切 |
| 依赖注入 | 所有模块通过工厂函数 (app) => 接收依赖,不自己 require |
| 开闭原则 | extend 机制开放扩展,loader 机制对修改封闭 |
| 关注点分离 | 安全校验(middleware)、参数规则(router-schema)、业务逻辑(controller/service)各自独立 |
| 环境抽象 | 通过 env + config 将环境差异集中管理,业务代码不感知环境 |
五、最后
第一阶段最大的收获不是"写了一个能跑的项目",而是亲手实现了一个微型框架内核 。理解了企业级 Node.js 应用的本质不是堆砌中间件,而是通过加载器自动装配 + 分层解耦 + 依赖注入,让每一层各司其职,后续还需要把这些进一步的运用,加强自己的理解。