从前端视角理解后端分层:基于 Koa 自研一个约定式 Node.js 服务框架

从前端视角理解后端分层:基于 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);
};

它体现了一个很重要的架构思想:通用逻辑不要写在业务接口里,而应该前置成请求链路的一部分

比如:

  • 静态资源中间件负责返回 jscss、图片等资源。
  • 模板中间件负责让 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/controllerapp/serviceapp/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 用法,而能看到它们背后的架构取舍。

相关推荐
腾讯云云开发3 小时前
CloudBase把一套完整的 Vibe Coding 平台开源了
后端·全栈·vibecoding
lulu12165440784 小时前
Claude Code SpringBoot技能体系架构设计与演进
java·人工智能·spring boot·后端·ai编程
心之语歌4 小时前
Vert.x 高性能物联网 MQTT 网关构建指南
后端
DolphinDB4 小时前
基于 DolphinDB 搭建微服务的 SpringBoot 项目
后端·算法
属于自己的天空5 小时前
装好 Claude Code 后的第一件事:5 个可以直接抄的真实场景
后端
程序员老邢5 小时前
《技术底稿 42》查新功能通用化改造:从单一期刊到多源命中,缓存与表结构一次重构
java·后端·缓存·重构·技术底稿
独守一隅6 小时前
别再 MyBatis-Plus saveBatch 了!5600万条数据的真正批量插入方案
后端
Jutick6 小时前
Qwen 已返回 `tool_calls`,为什么你的行情回答仍可能不可信?
后端·架构
IT策士6 小时前
Django 从 0 到 1 打造完整电商平台:使用 Celery 异步发送邮件/短信
后端·python·django