一、从 Koa 应用到可复用的 Web 引擎
当一个 Node.js 项目只有几个接口时,在入口文件里注册路由、解析请求、调用业务函数就足够了。随着接口和页面增加,每个项目都会重复处理目录组织、配置加载、实例创建、日志接入和错误响应。elpis-core 将这些重复工作收敛为一套约定:业务代码放在指定目录,引擎按依赖顺序加载,再把能力装配到同一个 Koa 应用上。
从源码看,elpis-core 是建立在 Koa 之上的应用组织与启动层。Node.js 提供运行环境,Koa 提供上下文和中间件执行机制,koa-router 提供路由匹配,elpis-core 负责把它们连接成可编写业务的工程骨架。洋葱模型由 Koa 及其 koa-compose 依赖实现。
这里的"轻量化"主要体现在抽象层少、装配路径清晰:使用 CommonJS、工厂函数、类和目录扫描完成核心组织,没有引入装饰器或复杂的依赖注入容器。仓库同时包含前端及构建依赖,因此"轻量"不等于整个项目零依赖,也不能直接推导出更高吞吐量;当前未进行性能基准测试。
1.1 框架理念
- 约定优于重复配置:文件位置决定模块归属,文件名决定访问名称。
- 核心装配与业务实现分离 :
elpis-core/管启动和加载,app/管路由、业务、中间件与扩展。 - 职责分层:Router 描述 HTTP 入口,Controller 协调请求与响应,Service 承载业务和数据访问。
- 横切能力中间件化:签名、校验和异常处理集中在请求链上,减少控制器中的重复代码。
- 显式生命周期:先准备依赖,再创建消费者,最后注册路由并监听端口。
依赖传递方式可以理解为"工厂参数注入 + 应用级能力注册表"。业务模块显式接收 app,再通过 app.service、app.config 等访问能力;当前没有自动分析构造参数、循环依赖消解或请求级作用域。
二、整体结构与目录契约
text
项目根目录
├── index.js 应用入口
├── elpis-core/
│ ├── index.js 创建应用、编排启动、监听端口
│ ├── env.js 环境判断
│ └── loader/
│ ├── config.js 配置合并
│ ├── extend.js 应用扩展
│ ├── service.js 服务实例装配
│ ├── router-schema.js 接口规则聚合
│ ├── middleware.js 中间件工厂加载
│ ├── controller.js 控制器实例装配
│ └── router.js 路由注册
├── config/ 默认及环境配置
└── app/
├── middleware.js 全局中间件的注册顺序
├── middleware/ 中间件定义
├── controller/ 控制器及基类
├── service/ 服务及基类
├── router/ 路由声明
├── router-schema/ JSON Schema
├── extend/ logger 等应用扩展
└── public/ 静态资源及模板
下表按实际赋值语句整理。源码注释里出现过 controllers、services、routerSchemas 等复数名称,实际接口以表中名称为准。
| 目录或文件 | 导出约定 | 挂载结果 |
|---|---|---|
| config/config.*.js | 配置对象 | app.config |
| app/service/*.js | app => Service 类 | app.service.*,启动时实例化 |
| app/controller/*.js | app => Controller 类 | app.controller.*,启动时实例化 |
| app/middleware/*.js | app => async (ctx, next) => ... | app.middlewares.* |
| app/router-schema/*.js | 按路径和方法组织的对象 | app.routerSchema |
| app/extend/logger.js | app => 扩展对象 | app.logger,直接挂在 app 上 |
| app/router/*.js | (app, router) => 注册路由 | 同一个 Router 实例 |
| app/middleware.js | app => 调用 app.use | Koa 中间件栈 |
Service、Controller 和 Middleware 支持按子目录形成嵌套对象,并将连字符或下划线后的字母转为大写。例如,按现有规则新增 app/service/admin/project-list.js,会映射为 app.service.admin.projectList。Extend 的实现不同:直接执行 app[name] = factory(app),不会生成统一的 app.extend 容器,也没有相同的嵌套对象装配过程。
三、启动过程:先装配依赖,再接收请求
入口非常简洁,以下为现有实现:
javascript
const ElpisCore = require('./elpis-core');
ElpisCore.start({
name: 'elpis-core',
homePage: '/'
});
start() 创建 Koa 实例,将 options 放到 app.options,使用 process.cwd() 作为 app.baseDir,再定位业务目录 app.businessPath。因此启动命令应在项目根目录执行,不能假定它始终以框架文件所在目录为基准。
text
ElpisCore.start(options)
│
▼
创建 Koa → 设置 options / baseDir / businessPath / env
│
▼
config → extend → service → router-schema → middleware
│
▼
controller
│
▼
执行 app/middleware.js
│
▼
router.routes()
│
▼
router.allowedMethods()
│
▼
listen(PORT || 8080, IP || 0.0.0.0)
这是一张启动装配图,其中 Loader 通常只在启动时执行;请求不会每次重新扫描目录。
顺序本身就是依赖声明:扩展可以读取配置,Service 可以读取配置和扩展,Controller 创建时 app.service 已准备好,路由注册时 app.controller 已存在。Middleware Loader 的工作是执行工厂并保存中间件函数,真正的执行顺序由后续 app.use() 决定。不要把"加载中间件"与"注册中间件"混为一谈。
配置通过 Object.assign({}, defaultConfig, envConfig) 合并,属于浅合并。假如默认配置和环境配置都定义了 database 对象,后者会整体覆盖前者的 database,不会自动逐字段深合并。
3.1 Loader 的关键机制
Service 和 Controller 的装配可以提炼为以下过程,属于等价简化说明:
javascript
// file 是 glob.sync() 找到的模块绝对路径
const createClass = require(file);
const ModuleClass = createClass(app);
const instance = new ModuleClass(app);
// 实际 Loader 还会处理子目录和驼峰命名
registry[moduleName] = instance;
工厂函数把应用能力带进模块作用域,类实例把可复用的方法组织起来。所有 Service 文件加载完后,Loader 才整体赋值 app.service,因此不能依赖扫描顺序在 Service 构造阶段读取另一个刚加载的 Service。服务间协作更适合在请求期访问已经完成装配的 this.app.service。
Controller 和 Service 在启动时创建一次,在同一个应用实例的多个请求之间复用。请求数据应放在局部变量或 ctx.state 中,避免写入共享实例的 this.currentUser、this.requestParams 等字段,否则异步请求交错时可能相互覆盖。
四、洋葱模型:进入、等待与回溯
Koa 中间件的基本签名是 async (ctx, next)。ctx 表示当前请求上下文,next() 表示执行后续中间件链。await next() 之前的逻辑在进入时执行,之后的逻辑在下游完成后执行,形成从外到内、再从内到外的包裹结构。这里的洋葱模型指请求执行机制,与领域建模中的"洋葱架构"是不同概念。
4.1 洋葱模型图
正常执行:A-before → B-before → 业务处理 → B-after → A-after。
Service 是 Controller 调用的业务方法,不是 Koa 自动增加的一层中间件。图中的业务区域表示调用关系,而中间件的包裹来自 await next()。
以下是可在当前项目根目录运行的独立演示,使用 Koa 已安装的 koa-compose,不会启动端口:
javascript
const compose = require('koa-compose');
async function demo() {
const trace = [];
const middleware = [
async (ctx, next) => {
trace.push('A-in');
await next();
trace.push('A-out');
},
async (ctx, next) => {
trace.push('B-in');
await next();
trace.push('B-out');
},
async ctx => {
trace.push('handler');
ctx.body = { code: 0 };
}
];
await compose(middleware)({});
console.log(trace.join(' -> '));
}
demo().catch(console.error);
// A-in -> B-in -> handler -> B-out -> A-out
本地 Koa 的 application.js 在 callback() 中调用 compose(this.middleware)。koa-compose 通过 dispatch(i) 将"下一层"绑定为 dispatch(i + 1),返回 Promise,并拒绝同一链路重复调用 next()。elpis-core 沿用这一机制。
4.2 当前分支的真实请求链
text
请求
↓
koa-static 静态资源命中时可以直接响应
↓ 未命中
koa-nunjucks-2 提供 ctx.render
↓
koa-bodyparser 解析 JSON / form / text
↓
errorHander try { await next() } catch ...
↓
apiSignVerify 签名不通过:返回业务码 445
↓
apiParamsVerify 参数不通过:返回业务码 442
↓
router.routes() 匹配路径,解析 ctx.params
↓
Controller → Service → ctx.body
↓
沿已进入的中间件逐层回溯 → Koa 输出响应
router.allowedMethods() 注册在 router.routes() 后面。只有前面的路由链继续调用 next() 时才会进入,因此不能画成每个成功业务请求必经的一层。
三个行为决定了中间件能否正确组合:
- 短路 :校验失败后设置
ctx.body并return,不调用next(),Controller 就不会执行;已经进入的外层中间件仍可继续回溯。 - 等待 :只写
next()而不等待或返回 Promise,会破坏完成时序,也会让外层try/catch无法可靠捕获下游异步错误。 - 异常传播 :下游抛错后,普通的 after 语句会被跳过;必须执行的清理或计时逻辑应放在
finally。
现有 errorHander 位于 bodyparser 后面,所以它可以捕获签名、参数校验及业务链路中的异常,却无法捕获 bodyparser 在进入它之前抛出的解析异常。若希望统一处理这类错误,应将错误中间件注册到更外层。这也是洋葱模型对实际工程设计的直接影响。
五、沿着一个接口读懂 Router、Controller 与 Service
当前已实现 GET /api/project/list,要求 query 中存在字符串 proj_key,同时经过全局签名校验。以下展示真实调用契约,省略与讲解无关的注释或数据字段。
5.1 Router:只声明入口
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) 保留实例的 this。把类方法直接作为回调传入时,方法内部的 this.service、this.success() 不能假定仍然指向原实例。
5.2 Controller:协调协议与业务
javascript
// app/controller/project.js:现有行为的简化
module.exports = app => {
const BaseController = require('./base')(app);
return class ProjectController extends BaseController {
async getList(ctx) {
const list = await this.service.project.getList();
this.success(ctx, list);
}
};
};
BaseController 在构造时保存 app、app.config 和 app.service;success() 将响应统一写为 { code: 0, msg, data, metadata }。实际 ProjectController 还读取并打印 proj_key,但没有将它传入 Service,所以当前接口并不按这个参数筛选项目。
5.3 Service:承接业务和数据来源
javascript
// app/service/project.js:结构简化,数据字段有省略
module.exports = app => {
const BaseService = require('./base')(app);
return class ProjectService extends BaseService {
async getList() {
return [
{ id: 1, name: '项目1' },
{ id: 2, name: '项目2' }
];
}
};
};
现有服务返回两条固定示例数据,包含描述及创建、更新时间;没有连接数据库。BaseService 提供 this.app、this.config 和指向 superagent 的 this.curl。这种分层允许将来在 Service 内替换为数据库查询或上游 HTTP 调用,而保留 Router 的地址和 Controller 的响应约定。
5.4 接口规则独立声明
javascript
// app/router-schema/project.js:现有实现
module.exports = {
'/api/project/list': {
get: {
query: {
type: 'object',
properties: {
proj_key: { type: 'string' }
},
required: ['proj_key']
}
}
}
};
Router Schema Loader 合并各文件导出的对象,中间件按 app.routerSchema[ctx.path][method.toLowerCase()] 获取规则,再用 Ajv 校验 headers、body、query。缺少规则时直接放行;规则顶层路径重复时,后加载的定义会覆盖前者,而不会自动合并该路径下的各 HTTP 方法。
当前 proj_key 只要求存在且为字符串,没有 minLength,因此空字符串也能通过。Query 中数字一般以字符串形式进入;如果未来声明数值规则,需要明确类型转换策略,不能把未启用的自动转换视作已有能力。
动态路由需要特别留意:在全局校验中间件执行时,koa-router 还未解析 ctx.params。BaseController 虽然提供 validateParams(ctx),但仍用具体的 ctx.path 查规则。例如请求 /api/project/123 无法自然命中以 /api/project/:id 为键的规则。要支持通用动态路由校验,应在匹配路由后绑定对应规则,再校验 ctx.params;仅把函数移到 Controller 中调用,还没有解决模板路径匹配问题。
六、扩展一个能力:请求耗时中间件
以下是遵循当前契约的建议扩展,仓库尚未包含这个文件:
javascript
// 新增 app/middleware/request-time.js
module.exports = app => {
return async (ctx, next) => {
const start = process.hrtime.bigint();
try {
await next();
} finally {
const elapsed = Number(process.hrtime.bigint() - start) / 1e6;
app.logger.info({
method: ctx.method,
path: ctx.path,
status: ctx.status,
durationMs: Number(elapsed.toFixed(2))
});
}
};
};
Loader 会把它挂载为 app.middlewares.requestTime。随后需要在 app/middleware.js 中显式注册;仅创建文件不会让它参与请求处理。若希望计时包含静态请求,并在统一异常处理完成后记录状态,可以把开头的注册顺序调整为:
javascript
// 建议顺序片段:需要同时调整现有 app/middleware.js
app.use(app.middlewares.requestTime);
app.use(app.middlewares.errorHander);
// 然后注册 static、nunjucks、bodyparser、签名、参数校验
// 路由仍由引擎最后统一注册
这个例子体现了 Loader 和洋葱模型的分工:Loader 解决"如何发现并创建能力",app.use 解决"能力在什么位置执行",await next() 解决"何时执行回溯逻辑"。
七、页面渲染与应用扩展
当前除 API 外,还支持 GET /view/:page。ViewController 使用如下调用渲染模板:
javascript
// app/controller/view.js:现有实现的核心
await ctx.render(`output/entry.${ctx.params.page}`, {
name: app.options?.name,
env: app.env.get(),
options: JSON.stringify(app.options)
});
Nunjucks 的模板根目录是 app/public,扩展名为 tpl。因此 /view/page1 对应 app/public/output/entry.page1.tpl;当前受跟踪文件中有 page1、page2 模板。引擎本身不负责前端构建,页面是否存在还取决于模板文件是否已经生成。当前注册的静态目录覆盖整个 app/public,包含模板所在目录,模板源文件也可能被直接作为静态资源访问;工程化时应将模板与公开资源分目录。
app/extend/logger.js 则提供日志能力:当 app.env.isLocal() 为真时使用 console,其余分支通过 log4js 配置控制台及按日期划分的文件日志。由于 Extend 先于业务模块加载,中间件可以直接使用 app.logger。
Router Loader 最后注册了 GET 通配路由,将未命中的 GET 请求重定向到 homePage。这是页面兜底策略,但也会影响未知 GET API。如果首页本身没有可响应的资源或路由,默认重定向到 / 可能形成循环;更合理的后续设计是区分 API 的 JSON 404 与页面入口。