从洋葱模型到 Elpis Core:我对 Node.js 服务端开发的理解

最近我跟着课程完成了一个基于 Node.js 和 Koa 的服务端项目 Elpis Core。这是我第一次接触 Koa,也是第一次比较完整地了解一个服务端项目如何启动、如何组织模块,以及一个请求如何从进入服务器到最终返回响应。

刚开始阅读代码时,我把注意力放在 Router、Controller 和 Service 上,觉得只要理解路由怎么定义、接口怎么返回数据,就算理解了这个项目。继续学习后,我发现 Koa 中间件的执行机制才是串联整个项目的基础。尤其是洋葱模型,它直接影响异常处理、参数校验、签名校验以及路由参数的获取时机。

什么是 Koa 的洋葱模型

Koa 中间件通常采用下面的写法:

scss 复制代码
app.use(async (ctx, next) => {
  // 请求进入时执行

  await next();

  // 响应返回时执行
});

其中,ctx 是当前请求的上下文,里面包含请求和响应相关的信息;next 代表下一个中间件。调用 await next() 后,程序会进入后面的中间件,等后续逻辑全部执行完成,再回到当前中间件继续向下执行。

假设程序注册了三个中间件:

javascript 复制代码
app.use(async (ctx, next) => {
  console.log("中间件1开始");
  await next();
  console.log("中间件1结束");
});

app.use(async (ctx, next) => {
  console.log("中间件2开始");
  await next();
  console.log("中间件2结束");
});

app.use(async (ctx, next) => {
  console.log("中间件3开始");
  await next();
  console.log("中间件3结束");
});

它们的输出顺序是:

复制代码
中间件1开始
中间件2开始
中间件3开始
中间件3结束
中间件2结束
中间件1结束

这个过程可以表示为:

markdown 复制代码
请求进入
    ↓
中间件1前半部分
    ↓
中间件2前半部分
    ↓
中间件3前半部分
    ↓
具体业务处理
    ↑
中间件3后半部分
    ↑
中间件2后半部分
    ↑
中间件1后半部分
    ↑
返回响应

请求先按照中间件的注册顺序向内执行,业务处理完成后,再按照相反的顺序向外返回。整个过程像一层一层进入洋葱内部,然后再一层一层退出,所以被称为洋葱模型。

await next() 是理解洋葱模型的关键

我刚接触 Koa 时,对 next() 的理解比较简单,以为它只是"继续执行下一段代码"。后来通过实际执行顺序,我认识到 await next() 同时包含了"进入"和"等待"两个动作。

例如:

ini 复制代码
app.use(async (ctx, next) => {
  const startTime = Date.now();

  await next();

  const duration = Date.now() - startTime;
  console.log(`请求耗时:${duration}ms`);
});

请求进入中间件时,程序先记录开始时间。调用 await next() 后,请求进入后续中间件、路由和业务代码。等这些逻辑执行完成,程序回到当前中间件,再计算整个请求的耗时。

因此,next() 前后的代码适合处理不同的事情。

next() 之前通常用于:

  • 身份和签名校验;
  • 参数校验;
  • 记录请求开始时间;
  • 初始化请求上下文。

next() 之后通常用于:

  • 计算接口耗时;
  • 记录响应日志;
  • 设置响应头;
  • 对响应结果进行统一加工。

如果中间件不调用 next(),请求链会在当前中间件停止。例如,签名校验失败时可以直接返回错误:

ini 复制代码
if (!valid) {
  ctx.body = {
    success: false,
    message: "签名校验失败",
  };
  return;
}

await next();

由于失败分支没有执行 next(),后面的参数校验、Router 和 Controller 都不会执行。

异常处理中间件为什么要放在外层

洋葱模型的一个典型应用是全局异常处理。

Elpis Core 中的异常处理中间件采用了类似下面的结构:

vbnet 复制代码
module.exports = (app) => {
  return async (ctx, next) => {
    try {
      await next();
    } catch (error) {
      app.logger.error(error);

      ctx.body = {
        success: false,
        message: "网络异常,请稍后重试",
      };
    }
  };
};

异常处理中间件执行到 await next() 时,会进入后面的签名校验、参数校验、Router、Controller 和 Service。

假设 Service 中抛出了异常:

javascript 复制代码
Error Handler
    ↓
API Sign Verify
    ↓
API Params Verify
    ↓
Router
    ↓
Controller
    ↓
Service 抛出异常

异常会沿着调用链向外传播:

javascript 复制代码
Service 异常
    ↑
Controller
    ↑
Router
    ↑
API Params Verify
    ↑
API Sign Verify
    ↑
Error Handler 的 catch

最终,外层异常处理中间件可以捕获错误,记录日志并返回统一的异常信息。

这也让我理解了一个重要细节:异常处理中间件只能捕获注册在它后面的中间件所产生的异常。中间件的位置决定了它能够覆盖的范围。如果异常处理放在业务逻辑之后,它就无法包住 Controller 和 Service。

Elpis Core 中的完整洋葱模型

Elpis Core 在 app/middleware.js 中注册了静态资源、模板渲染、请求体解析、异常处理、签名校验和参数校验。之后,内核再注册 Router。

简化后的完整请求链是:

javascript 复制代码
客户端请求
    ↓
静态资源中间件
    ↓
模板渲染中间件
    ↓
Body Parser
    ↓
Error Handler
    ↓
API Sign Verify
    ↓
API Params Verify
    ↓
Router
    ↓
Controller
    ↓
Service
    ↑
Controller 生成响应
    ↑
Router 返回
    ↑
API Params Verify 返回
    ↑
API Sign Verify 返回
    ↑
Error Handler 返回
    ↑
客户端收到响应

这里的 Controller 和 Service 本身不是 Koa 全局中间件,但它们位于 Router 的处理流程中,因此可以把它们看作洋葱模型最内层的业务部分。

在这个模型中,各层有不同的职责:

  • Body Parser 解析请求体;
  • Error Handler 捕获内层异常;
  • API Sign Verify 判断请求是否合法;
  • API Params Verify 校验请求参数;
  • Router 匹配请求地址;
  • Controller 获取参数并组织响应;
  • Service 处理具体业务。

通过这种方式,Controller 不需要同时处理异常、签名和参数格式,业务代码会更集中。

ctx.params 问题理解中间件顺序

学习过程中,我遇到了一个很具体的问题:为什么在参数校验中间件中无法获得 ctx.params

当时的代码类似:

csharp 复制代码
const { body, query, headers } = ctx.request;
const { params, path, method } = ctx;

bodyqueryheaders 可以读取,但是 params 可能是 undefined

分析后我发现,ctx.params 不是 Koa 创建请求时就有的数据。它由 koa-router 在匹配动态路由时生成。

例如定义路由:

csharp 复制代码
router.get("/api/project/:id", controller);

当客户端请求:

bash 复制代码
/api/project/123

Router 匹配成功后才会生成:

ini 复制代码
ctx.params = {
  id: "123",
};

但是 Elpis Core 当前的全局执行顺序是:

复制代码
API Params Verify
→ Router
→ Controller

参数校验执行时,请求还没有进入 Router,因此动态路由还没有完成匹配,ctx.params 自然也没有生成。

从洋葱模型来看,当程序执行到参数校验中间件的前半部分时,Router 还位于它的内层:

markdown 复制代码
API Params Verify 前半部分
    ↓
Router

如果先执行:

python 复制代码
await next();

等 Router 和 Controller 执行完成后,程序回到参数校验中间件的后半部分,此时可能可以读取 ctx.params。但是 Controller 已经执行完毕,再校验参数就失去了提前拦截请求的作用。

更合适的方式是把动态参数校验放到具体路由中:

bash 复制代码
router.get(
  "/api/project/:id",
  app.middlewares.apiParamsVerify,
  projectController.getDetail.bind(projectController),
);

执行顺序就会变成:

bash 复制代码
Router 匹配 `/api/project/:id`
→ 生成 ctx.params
→ 执行参数校验
→ 执行 Controller

这个问题让我对洋葱模型有了更具体的认识。中间件的注册顺序决定了它什么时候执行,也决定了它在当前时刻能够取得哪些数据。

现在我判断一个中间件应该放在哪里,会先考虑下面几个问题:

  1. 这个中间件需要哪些数据?
  2. 这些数据由哪个模块生成?
  3. 它需要在业务执行前拦截请求,还是在业务完成后处理响应?
  4. 它需要捕获哪些内层代码产生的异常?

Elpis Core 的整体结构

Elpis Core 可以理解为对 Koa 的一层项目化封装。Koa 提供了 HTTP 服务和中间件机制,Elpis Core 在此基础上增加了目录约定、模块加载和业务分层。

项目大致分成两部分:

ruby 复制代码
elpis-core
├── 创建 Koa 实例
├── 识别运行环境
├── 加载项目模块
├── 注册中间件
├── 注册路由
└── 启动 HTTP 服务

app
├── router
├── controller
├── service
├── middleware
├── router-schema
└── extend

elpis-core 负责应用的启动和组装,app 目录负责具体业务。

启动入口调用:

php 复制代码
ElpisCore.start({
  name: "elpis",
  homePage: "/view/page1",
});

start() 创建 Koa 实例后,会调用不同的 Loader 加载模块,最后注册 Router 并监听端口。

一个请求进入系统以后,大致经过:

复制代码
Middleware
→ Router
→ Controller
→ Service
→ Controller 组织结果
→ 返回客户端

Elpis Core 的第一个核心思想:约定目录并自动加载

Elpis Core 会扫描指定目录,将模块自动挂载到 app 对象上。

例如:

bash 复制代码
app/controller/project.js

加载后可以通过下面的方式访问:

复制代码
app.controller.project

app/service/project.js 会被加载为:

复制代码
app.service.project

app/middleware/api-params-verify.js 会被加载为:

复制代码
app.middlewares.apiParamsVerify

Loader 还会把文件名中的横线和下划线转换为驼峰命名,所以业务模块只要按照约定的目录和命名方式创建,就可以被内核自动发现。

这样做减少了手动 require 和逐个注册模块的代码。业务开发者可以通过目录快速判断每个文件的职责。

我把这种方式理解为约定式开发。内核规定项目结构,业务代码遵守结构,Loader 负责把两者连接起来。

Elpis Core 的第二个核心思想:按职责进行业务分层

Elpis Core 将接口处理拆分成 Router、Controller 和 Service。

Router 负责将请求地址映射到处理方法:

bash 复制代码
router.get(
  "/api/project/list",
  projectController.getList.bind(projectController),
);

Controller 负责读取请求参数、调用 Service 并组织响应:

javascript 复制代码
async getList(ctx) {
  const { proj_key: projKey } = ctx.request.query;
  const projectList = await app.service.project.getList();

  this.success(ctx, projectList);
}

Service 负责具体业务:

csharp 复制代码
async getList() {
  return [
    {
      name: "project1",
      desc: "project1 desc",
    },
  ];
}

它们之间的关系可以概括为:

复制代码
Router:请求由哪个方法处理
Controller:怎样接收参数和返回结果
Service:业务怎样执行

随着业务增加,Service 还可以负责访问数据库、调用其他服务或者组合多个数据来源。Controller 则继续处理 HTTP 层面的事情。

项目中的 BaseControllerBaseService 用来收拢公共能力。例如,BaseController 提供了统一的成功和失败响应:

kotlin 复制代码
this.success(ctx, data);
this.fail(ctx, message, code);

这样可以减少每个接口中的重复代码。

Elpis Core 的第三个核心思想:用中间件处理公共逻辑

签名校验、参数校验、异常处理等逻辑会被多个接口使用。如果把它们写进每个 Controller,会出现大量重复代码。

Elpis Core 将这些逻辑放入 Middleware:

csharp 复制代码
error-handler.js
api-sign-verify.js
api-params-verify.js

请求进入具体业务之前,先经过这些中间件。校验失败时,中间件可以直接终止请求;校验通过后,再执行 Router 和 Controller。

这说明中间件适合处理与具体业务关系不大的公共流程,而 Controller 和 Service主要关注当前接口要完成的事情。

Elpis Core 的第四个核心思想:把参数规则从业务代码中分离

项目使用 AJV 和 JSON Schema 描述接口参数。

例如:

css 复制代码
query: {
  type: "object",
  properties: {
    proj_key: {
      type: "string",
    },
  },
  required: ["proj_key"],
}

这段 Schema 表示 query 中必须包含字符串类型的 proj_key

通过参数校验模块,可以分别检查:

csharp 复制代码
headers
body
query
params

Controller 在参数校验通过后再处理业务,因此不需要在每个接口中重复编写大量的空值判断和类型判断。

这种设计也让我分清了不同请求参数的来源:

ini 复制代码
ctx.request.query;
ctx.request.body;
ctx.request.headers;
ctx.params;

其中,querybodyparams 虽然都属于请求参数,但它们由不同模块在不同阶段产生。ctx.params 问题正好说明了这一点。

我在项目中的学习收获

Elpis Core 是我跟着课程完成的项目,不是我独立设计的服务端框架。实现过程中,我逐渐从"这段代码怎么写"转向"这段代码在什么时候执行"。

我现在能够从两个层面理解这个项目。

第一个层面是单次请求的执行过程:

复制代码
请求进入
→ 中间件逐层执行
→ Router 匹配
→ Controller 接收请求
→ Service 处理业务
→ 响应逐层返回

第二个层面是服务启动时的组装过程:

复制代码
创建 Koa
→ 加载环境和配置
→ 加载业务模块
→ 注册中间件
→ 注册路由
→ 监听端口

洋葱模型帮助我理解一次请求如何运行,Loader 和分层设计则帮助我理解一个服务端项目如何组织。

通过 ctx.params 获取不到的问题,我还认识到,中间件顺序会影响数据产生的时机、请求能否被提前拦截,以及异常能否被外层捕获。以后阅读类似框架时,我会先找启动入口,再查看模块加载顺序和中间件注册顺序,最后沿着一个具体请求追踪完整调用链。

相关推荐
懂软件的胡子个哥1 小时前
微信 API 消息回调怎么设计,才能避免丢消息和重复处理
运维·微信·架构·wechatapi·个人微信号二次开发
co松柏4 小时前
一文吃透 Pi:10w stars 的极简 Agent harness
后端·架构
小聪7084 小时前
elpis-core 抽离 npm 包过程的难点和卡点
前端·架构
Bolt4 小时前
Agent: 将 harness 工程升级到认知工程
人工智能·架构·agent
晚安日记wanna4 小时前
Redis 持久化RDB 和 AOF 到底该怎么选
redis·面试·架构
墨天梦4 小时前
07-KVCache与缓存友好架构
缓存·架构
linan1014 小时前
android调用C++通用方式
linux·架构·智能硬件
白远山5 小时前
自助健身小程序源码:架构拆解、核心链路与本地部署实战
java·架构·uni-app·需求分析
国科安芯5 小时前
商业航天星载数据管理单元的存储容错与接口集成方案研究
嵌入式硬件·架构·ecc·商业航天·抗辐射