最近我跟着课程完成了一个基于 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;
body、query 和 headers 可以读取,但是 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
这个问题让我对洋葱模型有了更具体的认识。中间件的注册顺序决定了它什么时候执行,也决定了它在当前时刻能够取得哪些数据。
现在我判断一个中间件应该放在哪里,会先考虑下面几个问题:
- 这个中间件需要哪些数据?
- 这些数据由哪个模块生成?
- 它需要在业务执行前拦截请求,还是在业务完成后处理响应?
- 它需要捕获哪些内层代码产生的异常?
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 层面的事情。
项目中的 BaseController 和 BaseService 用来收拢公共能力。例如,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;
其中,query、body 和 params 虽然都属于请求参数,但它们由不同模块在不同阶段产生。ctx.params 问题正好说明了这一点。
我在项目中的学习收获
Elpis Core 是我跟着课程完成的项目,不是我独立设计的服务端框架。实现过程中,我逐渐从"这段代码怎么写"转向"这段代码在什么时候执行"。
我现在能够从两个层面理解这个项目。
第一个层面是单次请求的执行过程:
请求进入
→ 中间件逐层执行
→ Router 匹配
→ Controller 接收请求
→ Service 处理业务
→ 响应逐层返回
第二个层面是服务启动时的组装过程:
创建 Koa
→ 加载环境和配置
→ 加载业务模块
→ 注册中间件
→ 注册路由
→ 监听端口
洋葱模型帮助我理解一次请求如何运行,Loader 和分层设计则帮助我理解一个服务端项目如何组织。
通过 ctx.params 获取不到的问题,我还认识到,中间件顺序会影响数据产生的时机、请求能否被提前拦截,以及异常能否被外层捕获。以后阅读类似框架时,我会先找启动入口,再查看模块加载顺序和中间件注册顺序,最后沿着一个具体请求追踪完整调用链。