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

12 阅读13分钟

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

本文不是要造一个可以直接用于生产环境的框架,而是通过一个学习版框架 Rift,帮助只做过前端开发的同学理解:一个后端服务从接收请求到返回响应,中间到底经过了哪些层,以及为什么成熟框架通常会设计出 Router、Controller、Service、Middleware、Config、Loader 这些概念。

为什么前端也应该理解后端分层

很多前端同学第一次写 Node.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 的分层思想,目标是做一个轻量的约定式应用框架:

rift-core    负责框架启动、模块加载、运行时挂载
app          负责业务代码:路由、控制器、服务、中间件、页面
config       负责不同环境的配置

它想表达的核心思想是:

后端项目不是把所有逻辑塞进一个接口函数里,而是让一次请求按固定链路经过不同层,每一层只负责自己的事情。

一次请求进入后端后发生了什么

从浏览器发起一个请求后,DNS、TCP、TLS、IP 路由这些网络层面的事情会先把请求送到目标服务器。真正进入我们的 Node.js 服务后,请求大致会经历下面的流程:

浏览器 / fetch / axios
        |
        v
Node.js HTTP Server
        |
        v
Koa app
        |
        v
全局中间件 Middleware
        |
        v
路由匹配 Router
        |
        v
控制器 Controller
        |
        v
业务服务 Service
        |
        v
数据库 / 第三方接口 / 缓存
        |
        v
统一响应给前端

如果用前端的概念类比,可以粗略理解为:

Middleware  像请求进入页面前的全局拦截器
Router      像前端路由,负责 URL 和处理函数的映射
Controller  像页面容器,负责接收输入、调用业务、组织输出
Service     像 composable/store/action,承载可复用业务逻辑
Config      像环境变量和构建配置,不同环境读取不同配置
Loader      像自动 import,根据目录约定把模块装配起来

当然这个类比并不完全严谨,但对前端同学理解后端架构很有帮助。

为什么后端需要中间件

Koa 最重要的设计之一就是中间件。一个请求并不会直接进入业务代码,而是先经过一组按顺序执行的函数。

在 Rift 当前实现中,全局中间件统一在 app/middleware.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 中,一个接口通常会拆成三层:

router     声明 URL 和 Controller 方法的映射
controller 接收请求上下文,调用 service,组织响应
service    写业务逻辑、数据访问、外部接口调用

以项目列表接口为例。

Router:只负责路由映射

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:负责输入输出

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:负责业务逻辑

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 复用。
  • 单元测试时,可以更容易地针对业务逻辑测试。
  • 项目模块增多后,代码仍然能按职责定位。

统一响应:让前后端协作更稳定

很多接口项目最容易混乱的地方是响应格式:

// A 接口
{ code: 0, data: [] }

// B 接口
{ success: true, result: [] }

// C 接口
[]

对前端来说,这会让请求封装和错误处理变得很痛苦。

所以 Rift 提供了一个基础 Controller:

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 描述接口参数:

module.exports = {
  "/api/project/list/:projectId": {
    get: {
      params: {
        type: "object",
        properties: {
          projectId: { type: "string" },
        },
        required: ["projectId"],
      },
    },
  },
};

然后在 api-params-verify 中间件里使用 AJV 校验:

headers
body
query
params

这里有一个细节:参数校验中间件执行时,Koa Router 还没有真正匹配路由,所以 ctx.params 还没有值。

因此 Rift 自己做了一层动态路由匹配:

请求路径:/api/project/list/1
Schema: /api/project/list/:projectId

匹配成功后得到:
params = { projectId: "1" }

这能让参数校验发生在 Controller 之前,避免非法请求进入业务逻辑。

API 验签:接口也需要入口保护

Rift 中还有一个简单的 API 验签中间件:

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 开头的请求生效,并要求请求头里带上:

s_sign 签名
s_t    时间戳

这个实现很简单,不代表生产环境就应该这样设计签名算法。这里更想表达的是后端思维:

后端接口通常不是裸奔的,请求进入业务逻辑前,需要经过认证、鉴权、验签、限流、参数校验等入口保护。

这些逻辑如果散落在每个 Controller 里,项目会很难维护;放到中间件里,请求链路就会清晰很多。

Loader:框架怎么知道你写了哪些文件

到这里会出现一个问题:

我们在 app/controllerapp/serviceapp/middleware 里写了很多文件,Koa 实例怎么知道它们存在?

答案就是 Loader。

Rift 的核心不是某一个业务接口,而是 rift-core 里的启动编排:

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);
}

这段代码做的事情可以概括成:

创建 Koa 实例
  -> 挂载基础上下文
  -> 加载配置
  -> 加载扩展
  -> 加载中间件
  -> 加载 Service
  -> 加载 Controller
  -> 加载 Router Schema
  -> 注册全局中间件
  -> 注册路由
  -> 启动 HTTP 服务

这就是框架和普通 Koa demo 的区别。

普通 demo 里你需要手动 require 每一个文件;框架里开发者只要遵守目录约定,Loader 会自动扫描、实例化并挂载。

约定式目录:用规则减少选择成本

Rift 的应用目录大致是这样:

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

这种设计背后的思想是:

约定优于配置。

例如:

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 为例,它做了几件事:

1. 扫描 app/controller/**/*.js
2. 根据文件路径生成对象路径
3. 把 - 或 _ 转成驼峰
4. 执行模块导出的函数,把 app 传进去
5. new Controller()
6. 挂载到 app.controller

所以:

app/controller/project.js

会变成:

app.controller.project;

如果未来有更复杂的目录:

app/controller/admin/user-list.js

就可以映射成:

app.controller.admin.userList;

Service、Middleware 的加载思想也类似。

这其实就是一个简单版本的 IoC 思想:业务模块不需要到处手动创建依赖,而是由框架在启动阶段统一装配到运行时上下文里。

Extend:给 app 增加全局能力

后端项目里经常会有一些全局能力,比如:

  • 日志
  • 数据库连接
  • Redis 客户端
  • 请求工具
  • 配置中心客户端

Rift 用 app/extend 来承载这类能力。

当前项目里有一个 logger.js

app/extend/logger.js -> app.logger

这样在 Controller、Service、中间件里都可以通过 app.logger 记录日志。

这里要注意,Extend 适合放“全局基础能力”,不适合把业务逻辑都挂到 app 上。否则 app 会变成一个什么都有的全局对象,后期边界会越来越模糊。

Config 和 Env:不同环境应该读取不同配置

前端项目有 .env.development.env.production,后端也一样需要环境区分。

Rift 的 configLoader 支持这样的约定:

config/config.default.js
config/config.local.js
config/config.beta.js
config/config.production.js

加载后合并成:

app.config = {
  ...defaultConfig,
  ...envConfig,
};

也就是说:

  • 公共配置放在 config.default.js
  • 本地配置放在 config.local.js
  • 测试环境配置放在 config.beta.js
  • 生产环境配置放在 config.production.js

启动时通过 _ENV 决定当前环境:

_ENV=local node index.js
_ENV=beta node index.js
_ENV=production node index.js

这个设计的重点不是代码复杂度,而是隔离风险:

本地、测试、生产环境的数据库、日志、密钥、第三方接口地址不应该混在一起。

Router 兜底:页面请求和 API 请求要区别处理

Rift 的 routerLoader 在所有业务路由注册完成后,会追加一个兜底路由:

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,也托管了页面:

app/pages/       Vue 页面源码
app/view/        HTML 模板
app/public/      静态资源和构建产物

Webpack 会扫描:

app/pages/**/entry.*.js

然后生成多页面入口和模板:

app/public/dist/entry.page1.tpl
app/public/dist/entry.page2.tpl

页面路由:

router.get("/view/:page", viewController.render);

Controller 里渲染对应模板:

await ctx.render(`dist/entry.${ctx.params.page}`);

这部分能帮助前端同学理解一个传统 Web 服务的形态:

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 类型约束。
  • 启动参数校验和配置校验。
  • 进程管理、优雅退出、健康检查。

这也是学习后端架构时很重要的一点:

框架不是一次性写完的,而是在业务复杂度增加时,不断把重复模式沉淀为约定和基础能力。

总结

对只做过前端开发的同学来说,学习后端最容易卡住的不是语法,而是思维方式的切换。

前端更多关注:

组件如何拆
状态如何流动
页面如何渲染
交互如何响应

后端更多关注:

请求如何进入系统
通用逻辑如何前置
接口如何组织
业务逻辑放在哪
异常如何统一处理
配置如何隔离环境
模块如何被框架装配

Rift 这个项目用 Koa 做底座,通过 rift-core 实现了一套轻量的启动和 Loader 机制,让业务代码可以按照固定目录组织起来。

它真正想表达的不是“我写了一个 Koa 服务”,而是:

一个后端框架的本质,是定义请求处理链路、模块组织方式和运行时装配规则。

当你理解了 Middleware、Router、Controller、Service、Config、Loader 这些概念,再去看 Egg.js、NestJS、Express、Spring MVC 这类框架时,就不会只看到 API 用法,而能看到它们背后的架构取舍。