基于node实现一个轻量化web引擎:elpis-core

一、从 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.serviceapp.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/                 静态资源及模板

下表按实际赋值语句整理。源码注释里出现过 controllersservicesrouterSchemas 等复数名称,实际接口以表中名称为准。

目录或文件 导出约定 挂载结果
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.currentUserthis.requestParams 等字段,否则异步请求交错时可能相互覆盖。

四、洋葱模型:进入、等待与回溯

Koa 中间件的基本签名是 async (ctx, next)ctx 表示当前请求上下文,next() 表示执行后续中间件链。await next() 之前的逻辑在进入时执行,之后的逻辑在下游完成后执行,形成从外到内、再从内到外的包裹结构。这里的洋葱模型指请求执行机制,与领域建模中的"洋葱架构"是不同概念。

4.1 洋葱模型图

graph TD Req["请求进入"] --> A1 subgraph outer["A:外层中间件"] A1["A-before"] subgraph inner["B:内层中间件"] B1["B-before"] C["Router / Controller"] S["Service"] B2["B-after"] B1 -->|"await next()"| C C -->|"调用业务方法"| S S -->|"结果返回"| B2 end A2["A-after"] A1 -->|"await next()"| B1 B2 --> A2 end A2 --> Res["响应返回"]

正常执行: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.jscallback() 中调用 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() 时才会进入,因此不能画成每个成功业务请求必经的一层。

三个行为决定了中间件能否正确组合:

  1. 短路 :校验失败后设置 ctx.bodyreturn,不调用 next(),Controller 就不会执行;已经进入的外层中间件仍可继续回溯。
  2. 等待 :只写 next() 而不等待或返回 Promise,会破坏完成时序,也会让外层 try/catch 无法可靠捕获下游异步错误。
  3. 异常传播 :下游抛错后,普通的 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.servicethis.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 在构造时保存 appapp.configapp.servicesuccess() 将响应统一写为 { 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.appthis.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 与页面入口。

相关推荐
子非鱼a1 小时前
【WEB】[SWPU2019]Web1
java·服务器·前端
涛涛ing1 小时前
9 月第一周,前端圈又炸了四次
前端
葡萄城技术团队1 小时前
从自然语言到表格操作:SpreadJS 表格智能体如何执行一个任务
前端
Neighbor_OldY2 小时前
【实战复盘】文件上传漏洞检测与应急处置:校验绕过、图片马与WebShell的排查修复指南
运维·前端·web安全
金花顺2 小时前
android_media_AudioTrack_setup
前端
晓天衡宇•评测社区2 小时前
Seedance 2.5 登顶图生视频榜单,Wan 3.0 在游戏与教育场景进入前列
前端·javascript·网络
码海无涯回头无岸2 小时前
Ai agent - LLM是一个函数
前端
Profile排查笔记2 小时前
指纹浏览器推荐:用一套验收清单筛选 Profile、代理与自动化能力
前端·人工智能·后端·自动化
卡皮巴拉c992 小时前
基于pnpm搭建monorepo项目
前端·javascript