从前端视角理解后端分层:基于 Koa 自研一个约定式 Node.js 服务框架
本文不是要造一个可以直接用于生产环境的框架,而是通过一个学习版框架 Rift,帮助只做过前端开发的同学理解:一个后端服务从接收请求到返回响应,中间到底经过了哪些层,以及为什么成熟框架通常会设计出 Router、Controller、Service、Middleware、Config、Loader 这些概念。
为什么前端也应该理解后端分层
很多前端同学第一次写 Node.js 服务时,代码通常会长这样:
js
router.get("/api/project/list", async (ctx) => {
const data = await queryDatabase();
ctx.body = {
code: 0,
data,
success: true,
};
});
这个写法在 demo 阶段没有问题,但业务一复杂,就会遇到几个问题:
- 路由里同时写参数校验、鉴权、业务逻辑、数据库查询和响应格式,代码会越来越厚。
- 不同接口的成功、失败响应格式不统一,前端处理成本变高。
- 日志、异常处理、验签、跨域这些通用能力容易散落在各个接口里。
- 新增业务模块时,不知道文件应该放在哪里,也不知道各层之间应该怎么协作。
所以后端框架的核心价值,不只是"启动一个 HTTP 服务",而是提供一套组织复杂度的方式。
Rift 这个项目基于 Koa,参考 Egg.js 的分层思想,目标是做一个轻量的约定式应用框架:
text
rift-core 负责框架启动、模块加载、运行时挂载
app 负责业务代码:路由、控制器、服务、中间件、页面
config 负责不同环境的配置
它想表达的核心思想是:
后端项目不是把所有逻辑塞进一个接口函数里,而是让一次请求按固定链路经过不同层,每一层只负责自己的事情。
一次请求进入后端后发生了什么
从浏览器发起一个请求后,DNS、TCP、TLS、IP 路由这些网络层面的事情会先把请求送到目标服务器。真正进入我们的 Node.js 服务后,请求大致会经历下面的流程:
text
浏览器 / fetch / axios
|
v
Node.js HTTP Server
|
v
Koa app
|
v
全局中间件 Middleware
|
v
路由匹配 Router
|
v
控制器 Controller
|
v
业务服务 Service
|
v
数据库 / 第三方接口 / 缓存
|
v
统一响应给前端
如果用前端的概念类比,可以粗略理解为:
text
Middleware 像请求进入页面前的全局拦截器
Router 像前端路由,负责 URL 和处理函数的映射
Controller 像页面容器,负责接收输入、调用业务、组织输出
Service 像 composable/store/action,承载可复用业务逻辑
Config 像环境变量和构建配置,不同环境读取不同配置
Loader 像自动 import,根据目录约定把模块装配起来
当然这个类比并不完全严谨,但对前端同学理解后端架构很有帮助。
为什么后端需要中间件
Koa 最重要的设计之一就是中间件。一个请求并不会直接进入业务代码,而是先经过一组按顺序执行的函数。
在 Rift 当前实现中,全局中间件统一在 app/middleware.js 中注册:
js
module.exports = (app) => {
app.use(require("koa-static")(path.resolve(app.baseDir, "./app/public")));
app.use(
require("koa-nunjucks-2")({
ext: "tpl",
path: path.resolve(app.baseDir, "./app/public"),
functionName: "render",
writeResponse: true,
}),
);
app.use(require("koa-bodyparser")());
app.use(app.middlewares.errHandler);
app.use(app.middlewares.apiSignVerify);
app.use(app.middlewares.apiParamsVerify);
};
它体现了一个很重要的架构思想:通用逻辑不要写在业务接口里,而应该前置成请求链路的一部分。
比如:
- 静态资源中间件负责返回
js、css、图片等资源。 - 模板中间件负责让
ctx.render()可以渲染页面。 bodyparser负责把请求体解析成ctx.request.body。errHandler负责兜住异常,避免直接把后端堆栈暴露给前端。apiSignVerify负责 API 验签。apiParamsVerify负责 API 参数校验。
这样 Controller 里就不用关心"请求体怎么解析""参数是否合法""异常怎么返回"这些通用问题,只需要关心当前接口本身。
为什么要拆 Router、Controller、Service
在 Rift 中,一个接口通常会拆成三层:
text
router 声明 URL 和 Controller 方法的映射
controller 接收请求上下文,调用 service,组织响应
service 写业务逻辑、数据访问、外部接口调用
以项目列表接口为例。
Router:只负责路由映射
js
module.exports = (app, router) => {
const { project: projectController } = app.controller;
router.get(
"/api/project/list/:projectId",
projectController.getProjectList.bind(projectController),
);
router.get(
"/api/project/list",
projectController.getProjectList.bind(projectController),
);
};
Router 层不应该写复杂业务逻辑,它只回答一个问题:
哪个 URL,交给哪个 Controller 方法处理?
Controller:负责输入输出
js
module.exports = (app) => {
const BaseController = require("./base")(app);
return class ProjectController extends BaseController {
async getProjectList(ctx) {
const { project: projectService } = app.service;
const res = await projectService.getList();
app.logger.info("获取项目列表", res);
this.success(ctx, res);
}
};
};
Controller 更像是请求的"门面层"。它知道 ctx,知道怎么拿参数,也知道怎么给前端返回响应。
但是它不应该承载太多业务细节。否则 Controller 会变成一个巨大的函数,后期很难测试和复用。
Service:负责业务逻辑
js
module.exports = (app) => {
const BaseService = require("./base")(app);
return class ProjectService extends BaseService {
async getList() {
return [
{ id: 1, name: "project1" },
{ id: 2, name: "project2" },
];
}
};
};
Service 关注业务本身。以后如果这个接口需要查数据库、查缓存、调用第三方接口,都应该优先放在 Service,而不是写在 Router 或 Controller 里。
这种拆分带来的好处是:
- Controller 变薄,接口输入输出更清晰。
- Service 可以被多个 Controller 复用。
- 单元测试时,可以更容易地针对业务逻辑测试。
- 项目模块增多后,代码仍然能按职责定位。
统一响应:让前后端协作更稳定
很多接口项目最容易混乱的地方是响应格式:
js
// A 接口
{ code: 0, data: [] }
// B 接口
{ success: true, result: [] }
// C 接口
[]
对前端来说,这会让请求封装和错误处理变得很痛苦。
所以 Rift 提供了一个基础 Controller:
js
module.exports = (app) => {
return class BaseController {
success(ctx, data, metaData) {
ctx.status = 200;
ctx.body = {
code: 0,
data,
metaData,
success: true,
};
}
fail(ctx, message, code) {
ctx.status = 200;
ctx.body = {
code,
message,
success: false,
};
}
};
};
这个设计看起来很简单,但它背后的思想是:
响应格式是前后端契约,不应该由每个接口自由发挥。
当项目变大后,"统一"本身就是一种架构能力。
参数校验:不要相信任何外部输入
前端经常会做表单校验,但后端不能因为前端校验过就直接相信请求参数。
原因很简单:请求不一定来自你的页面,也可能来自 Postman、脚本、爬虫,甚至恶意请求。
所以 Rift 中增加了 router-schema 层,用 JSON Schema 描述接口参数:
js
module.exports = {
"/api/project/list/:projectId": {
get: {
params: {
type: "object",
properties: {
projectId: { type: "string" },
},
required: ["projectId"],
},
},
},
};
然后在 api-params-verify 中间件里使用 AJV 校验:
text
headers
body
query
params
这里有一个细节:参数校验中间件执行时,Koa Router 还没有真正匹配路由,所以 ctx.params 还没有值。
因此 Rift 自己做了一层动态路由匹配:
text
请求路径:/api/project/list/1
Schema: /api/project/list/:projectId
匹配成功后得到:
params = { projectId: "1" }
这能让参数校验发生在 Controller 之前,避免非法请求进入业务逻辑。
API 验签:接口也需要入口保护
Rift 中还有一个简单的 API 验签中间件:
js
const signature = md5(`${signKey}_${st}`);
if (!sSign || !st || signature !== sSign || Date.now() - st > API_EXPIRE) {
ctx.status = 200;
ctx.body = {
code: 445,
success: false,
message: "签名错误",
};
return;
}
它只对 /api 开头的请求生效,并要求请求头里带上:
text
s_sign 签名
s_t 时间戳
这个实现很简单,不代表生产环境就应该这样设计签名算法。这里更想表达的是后端思维:
后端接口通常不是裸奔的,请求进入业务逻辑前,需要经过认证、鉴权、验签、限流、参数校验等入口保护。
这些逻辑如果散落在每个 Controller 里,项目会很难维护;放到中间件里,请求链路就会清晰很多。
Loader:框架怎么知道你写了哪些文件
到这里会出现一个问题:
我们在 app/controller、app/service、app/middleware 里写了很多文件,Koa 实例怎么知道它们存在?
答案就是 Loader。
Rift 的核心不是某一个业务接口,而是 rift-core 里的启动编排:
js
start(options = {}) {
const app = new Koa();
app.options = options;
app.baseDir = process.cwd();
app.businessPath = path.resolve(app.baseDir, "./app");
app.env = env(app);
configLoader(app);
extendLoader(app);
middlewareLoader(app);
serviceLoader(app);
controllerLoader(app);
routerSchema(app);
require(path.resolve(app.businessPath, "./middleware.js"))(app);
routerLoader(app);
app.listen(port, host);
}
这段代码做的事情可以概括成:
text
创建 Koa 实例
-> 挂载基础上下文
-> 加载配置
-> 加载扩展
-> 加载中间件
-> 加载 Service
-> 加载 Controller
-> 加载 Router Schema
-> 注册全局中间件
-> 注册路由
-> 启动 HTTP 服务
这就是框架和普通 Koa demo 的区别。
普通 demo 里你需要手动 require 每一个文件;框架里开发者只要遵守目录约定,Loader 会自动扫描、实例化并挂载。
约定式目录:用规则减少选择成本
Rift 的应用目录大致是这样:
text
app/
controller/
base.js
project.js
view.js
service/
base.js
project.js
router/
project.js
view.js
router-schema/
project.js
middleware/
err-handler.js
api-sign-verify.js
api-params-verify.js
extend/
logger.js
middleware.js
pages/
public/
view/
rift-core/
index.js
env.js
loader/
config.js
extend.js
middleware.js
service.js
controller.js
router-schema.js
router.js
这种设计背后的思想是:
约定优于配置。
例如:
text
app/controller/project.js -> app.controller.project
app/service/project.js -> app.service.project
app/middleware/api-sign-verify.js -> app.middlewares.apiSignVerify
app/extend/logger.js -> app.logger
文件放在哪里,运行时就挂载到哪里。开发者不用每次新增模块都思考"我要在哪里 import、在哪里注册"。
这和前端工程里的自动路由、自动注册组件、自动导入 hooks 是同一种思路。
Controller 和 Service 是怎么被自动挂载的
以 controllerLoader 为例,它做了几件事:
text
1. 扫描 app/controller/**/*.js
2. 根据文件路径生成对象路径
3. 把 - 或 _ 转成驼峰
4. 执行模块导出的函数,把 app 传进去
5. new Controller()
6. 挂载到 app.controller
所以:
text
app/controller/project.js
会变成:
js
app.controller.project;
如果未来有更复杂的目录:
text
app/controller/admin/user-list.js
就可以映射成:
js
app.controller.admin.userList;
Service、Middleware 的加载思想也类似。
这其实就是一个简单版本的 IoC 思想:业务模块不需要到处手动创建依赖,而是由框架在启动阶段统一装配到运行时上下文里。
Extend:给 app 增加全局能力
后端项目里经常会有一些全局能力,比如:
- 日志
- 数据库连接
- Redis 客户端
- 请求工具
- 配置中心客户端
Rift 用 app/extend 来承载这类能力。
当前项目里有一个 logger.js:
text
app/extend/logger.js -> app.logger
这样在 Controller、Service、中间件里都可以通过 app.logger 记录日志。
这里要注意,Extend 适合放"全局基础能力",不适合把业务逻辑都挂到 app 上。否则 app 会变成一个什么都有的全局对象,后期边界会越来越模糊。
Config 和 Env:不同环境应该读取不同配置
前端项目有 .env.development、.env.production,后端也一样需要环境区分。
Rift 的 configLoader 支持这样的约定:
text
config/config.default.js
config/config.local.js
config/config.beta.js
config/config.production.js
加载后合并成:
js
app.config = {
...defaultConfig,
...envConfig,
};
也就是说:
- 公共配置放在
config.default.js - 本地配置放在
config.local.js - 测试环境配置放在
config.beta.js - 生产环境配置放在
config.production.js
启动时通过 _ENV 决定当前环境:
bash
_ENV=local node index.js
_ENV=beta node index.js
_ENV=production node index.js
这个设计的重点不是代码复杂度,而是隔离风险:
本地、测试、生产环境的数据库、日志、密钥、第三方接口地址不应该混在一起。
Router 兜底:页面请求和 API 请求要区别处理
Rift 的 routerLoader 在所有业务路由注册完成后,会追加一个兜底路由:
js
router.get("*", async (ctx) => {
const { path } = ctx;
if (!/^\/api/g.test(path)) {
ctx.status = 302;
ctx.redirect(app.options?.homePage);
return;
}
ctx.status = 404;
ctx.body = {
code: 404,
message: "接口不存在",
success: false,
};
});
这体现了一个细节:页面请求和 API 请求的兜底策略通常不一样。
- 页面路径不存在,可以重定向到首页。
- API 路径不存在,应该返回结构化的 404 JSON。
否则前端请求一个不存在的接口时,如果后端返回了一段 HTML,前端请求库就很难统一处理错误。
前后端一体化:页面也可以由 Node 服务托管
这个项目除了 API,也托管了页面:
text
app/pages/ Vue 页面源码
app/view/ HTML 模板
app/public/ 静态资源和构建产物
Webpack 会扫描:
text
app/pages/**/entry.*.js
然后生成多页面入口和模板:
text
app/public/dist/entry.page1.tpl
app/public/dist/entry.page2.tpl
页面路由:
js
router.get("/view/:page", viewController.render);
Controller 里渲染对应模板:
js
await ctx.render(`dist/entry.${ctx.params.page}`);
这部分能帮助前端同学理解一个传统 Web 服务的形态:
text
Node 服务既可以提供 API,也可以返回 HTML,还可以托管静态资源。
现代前端经常把页面部署在 CDN,把 API 部署在后端服务。但从框架学习角度,把这两部分放在一个项目里,更容易看清楚完整请求链路。
这个学习版框架还缺什么
既然是学习版,也要清楚它和生产级框架的差距。
当前 Rift 已经具备:
- Koa 应用启动
- 约定式 Loader
- Middleware / Router / Controller / Service 分层
- 统一响应
- 全局异常处理
- API 验签
- AJV 参数校验
- 多环境配置加载
- app 扩展能力
- Vue 多页面构建和模板渲染
但如果要走向生产环境,还需要继续补:
- 更完善的日志链路,比如 requestId、链路追踪、访问日志。
- 更安全的鉴权体系,比如 JWT、Session、RBAC 权限模型。
- 数据库层封装,比如 DAO、Model、事务处理、连接池管理。
- 更合理的错误码体系和异常分类。
- 静态资源缓存策略,比如 hash 文件强缓存、模板 no-cache。
- 单元测试和集成测试。
- TypeScript 类型约束。
- 启动参数校验和配置校验。
- 进程管理、优雅退出、健康检查。
这也是学习后端架构时很重要的一点:
框架不是一次性写完的,而是在业务复杂度增加时,不断把重复模式沉淀为约定和基础能力。
总结
对只做过前端开发的同学来说,学习后端最容易卡住的不是语法,而是思维方式的切换。
前端更多关注:
text
组件如何拆
状态如何流动
页面如何渲染
交互如何响应
后端更多关注:
text
请求如何进入系统
通用逻辑如何前置
接口如何组织
业务逻辑放在哪
异常如何统一处理
配置如何隔离环境
模块如何被框架装配
Rift 这个项目用 Koa 做底座,通过 rift-core 实现了一套轻量的启动和 Loader 机制,让业务代码可以按照固定目录组织起来。
它真正想表达的不是"我写了一个 Koa 服务",而是:
一个后端框架的本质,是定义请求处理链路、模块组织方式和运行时装配规则。
当你理解了 Middleware、Router、Controller、Service、Config、Loader 这些概念,再去看 Egg.js、NestJS、Express、Spring MVC 这类框架时,就不会只看到 API 用法,而能看到它们背后的架构取舍。