Model → Service → Controller 分层到底在分什么?

2026-09-12 123 浏览 0 评论 0 字

这篇文章不聊某一个具体项目,而是在讲后端 Web 工程里最常见的一种分层方式:Model 层、Service 层、Controller 层。我会先讲清楚它要解决什么问题、为什么很多团队越到后面越乱,然后把每层的边界、怎么写、以及常见的坑一点点剥开,最后给一套可以照着抄的落地清单。

一、这个分层到底在解决什么问题

几乎每个接手老项目的后端都有过类似的痛苦:打开一个接口的代码,发现里面从校验入参、拼 SQL、查数据库、算业务、再到拼返回 JSON 一气呵成,全写在一个方法里。功能是能跑,可一旦要改需求,你面临的不是 改一行 ,而是 在这坨里面翻半天 。

这种写法的本质问题只有一个 - 职责没有边界 。一段代码同时扮演多种角色,任何单一角色的调整都会牵一发动全身。

三层分层的核心思想就是: 把"接请求、管业务、碰数据"切成三个有明确边界的角色,谁也别越界。

HTTP 请求
   │
   ▼
Controller  -  接请求、回响应(不懂业务,也不碰 SQL)
   │  service.xxx()
   ▼
Service     -  业务逻辑(复杂查询、规则校验、数据加工)
   │  model.xxx
   ▼
Model       -  一张表一个模型,干原始的增删改查

只要边界守得住,收益其实是看得见的:

  1. 好改 :想换一种查询方式,只动 Service;想改返回字段,只动 Controller。
  2. 好测 :Service 不依赖 HTTP,可以脱离请求单独测。
  3. 好找 :出了 bug,扫码定位大概知道是 参数问题(Controller) 逻辑问题(Service) 数据问题(Model) 。
  4. 好复用 :同一个 Service 方法,既能被接口调,也能被定时任务调。

二、三层各自 该干 和 不该干

1. Model 层:一张表 = 一个模型

Model 层是和数据库一一对应的 图像 。它只做一件事: 把一张表抽象成代码里的一个模型,声明字段类型、主键、唯一性,并描述表和表之间的关联。

一个最小例子(这里我用一个通用的「用户」模型示范):

'use strict';

module.exports = app => {
  const { INTEGER, STRING, DATE } = app.Sequelize;

  const User = app.model.define(
    'user',
    {
      id: {
        type: INTEGER.UNSIGNED,
        primaryKey: true,
        autoIncrement: true,
        comment: '主键 ID',
      },
      username: {
        type: STRING(50),
        allowNull: false,
        unique: true,
        comment: '登录名',
      },
      nickname: {
        type: STRING(100),
        allowNull: false,
        comment: '昵称',
      },
      sort_order: {
        type: INTEGER,
        defaultValue: 0,
        comment: '展示排序',
      },
      status: {
        type: TINYINT,
        defaultValue: 1,
        comment: '1 正常 0 停用',
      },
      add_date: {
        type: DATE,
        allowNull: true,
        defaultValue: app.Sequelize.NOW,
        comment: '创建时间',
      },
    },
    {
      tableName: 't_user',            // 表名
      timestamps: true,
      createdAt: 'add_date',          // 创建时间字段映射
      updatedAt: 'update_date',       // 更新时间字段映射
      underscored: true,             // 字段自动转蛇形(snake_case)
    },
  );

  return User;
};

这里要重点讲的一个难点 : underscored: true 。一旦开了它,你在代码里写字段 username ,写进 SQL 的就是 username ;但如果你有个字段既想存 camelCase 又想库里是 snake_case,它也会帮你在读写时自动转换。好处是库里和前端各得其所,坏处是 - 如果你一段查询直接拼原生 SQL、一段又走 ORM,两边对不上字段名,就该报 字段不存在 了。 这是最容易踩的坑:混用 ORM 和原生 SQL 时,要先想清楚字段名到底以谁为准。

2. Service 层:业务逻辑的家

Service 是重头。它接收 Controller 传进来的参数,负责真正 动脑子 的地方:条件组装查询、过滤、默认值、事务、关联。

'use strict';

const Service = require('egg').Service;
const { Op } = require('sequelize');

module.exports = class UserService extends Service {
  // 分页列表
  async list(params = {}) {
    const { pageNum = 1, pageSize = 10, keyword, status } = params;
    const where = {};
    if (keyword) where.username = { [Op.like]: `%${keyword}%` };
    if (status) where.status = status;

    const offset = (pageNum - 1) * pageSize;
    const { rows, count } = await this.app.model.User.findAndCountAll({
      where,
      offset: Number(offset),
      limit: Number(pageSize),
      order: [['sort_order', 'ASC']],
    });
    return { rows, total: count };   // 约定:列表返回行 + 总数
  }

  async getById(id) {
    return await this.app.model.User.findByPk(id);
  }

  async update(data) {
    const record = await this.app.model.User.findByPk(data.id);
    if (!record) return 0;           // 查无此人
    await record.update(data);       // 先查后改
    return 1;
  }
};

几个容易忽略的细节:

  1. 参数默认值 : pageNum 不传给默认 1、 pageSize 默认 10。这是第一道防线,防止异常参数把查询带崩。很多新手写查询不设默认,一旦前端漏传就 INTEGER 类型报错。
  2. 先查后改 :更新时先 findByPk 确认记录存在,再 update 。这样能给上层一个 到底有没有改成功 的信号(返回 1 还是 0),是判断接口返回 成功/失败 的好手段。
  3. return { rows, total } :这是一个团队约定 - 列表接口统一返回 行数据 + 总数 ,所有列表页的分页都靠它。约定比散落在各处各搞一套强得多。

3. Controller 层:接口的翻译官

Controller 是离 HTTP 最近的一层, 它不应该有业务逻辑,只干两件事:把 ctx 里的入参透传进 Service,再把 Service 返回的东西包成统一的 JSON 响应。

'use strict';

const Controller = require('egg').Controller;

module.exports = class UserController extends Controller {
  async list() {
    const { ctx } = this;
    const res = await ctx.service.user.list(ctx.query);
    ctx.body = {
      code: 200,
      message: '',
      rows: res.rows,
      success: true,
      total: res.total,
    };
  }

  async single() {
    const { ctx } = this;
    const data = await ctx.service.user.getById(ctx.params.id);
    if (!data) {
      ctx.returnError('记录不存在');
      return;
    }
    ctx.body = { code: 200, message: '', data, success: true };
  }

  async update() {
    const { ctx } = this;
    const body = ctx.request.body;
    if (!body.id) {
      ctx.returnError('缺少参数 id');
      return;
    }
    const result = await ctx.service.user.update(body);
    ctx.body = {
      code: 200,
      message: result ? '更新成功' : '更新失败',
      data: result,
      success: !!result,
    };
  }
};

这里有个很典型的 架构选择 值得单独说: Controller 里怎么写 失败 ? 主流有两种:

方案做法优点缺点
A. 直接赋值失败时给 ctx.body 里塞 success: false直观、好理解每个接口都要手工判断,极易漏
B. 抛一个业务异常调用 ctx.returnError(...) ,由全局异常中间件统一兜底一处兜底全盘生效,响应格式永远统一需要你写一个全局异常处理中间件(下一篇会展开)

两种都有人用。项目如果已经有一个 全局异常兜底 的机制(很多用 Egg/Express 的框架都会封装),推荐直接用 B - 把失败也交给异常中间件统一处理,Controller 就显得非常 干净 ,只剩成功路径。 但前提是,你能保证那个异常中间件一定存在,并且会把业务异常转成统一的 JSON。这是 A 和 B 能否落地的差别,做一个,别两套混着用。

三、自己搭一遍的最小闭环

理论说了那么多,不如动手。我们来自己实现一个最精简闭环,只做「列表 + 单条 + 更新」三件事:

  1. 建了一个 Model (上面 User 就是这个,直接可用)。
  2. 建了一个 Service (上面 UserService 就是这个)。
  3. 建了一个 Controller (上面 UserController 就是这个)。
  4. 挂路由 ,注意具体路由要放在参数化路由之前(下面这条容易踩)。
// app/router.js(只看新增片段)
apiV1Router.get('/user/list', controller.user.list);
apiV1Router.get('/user/:id', controller.user.single);   // 参数化路由放后面!
apiV1Router.put('/user', controller.user.update);

路由顺序这个坑值得单独点一下 :如果把 /user/:id 写在 /user/list 前面,那么请求 /user/list 时, list 会被 :id 当成一个 ID 捕获,永远匹配不到真正的列表接口。这是 GET 接口最容易出现的线上事故之一。解决就是一条铁律: 具体路由在前,参数化( :id )路由在后 。

四、开工验证

假设项目起动方式是 npm run dev ,监听 http://127.0.0.1:7030 ,且接口前缀统一 /api/v1 :

cd 你的服务端目录
npm run dev

然后分别验证:

# 1) 用更新接口做一次 无 id 即新增 
curl -X PUT http://127.0.0.1:7030/api/v1/user \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <你的 token>" \
  -d '{"username":"tester","nickname":"示例"}'

# 2) 拉列表
curl 'http://127.0.0.1:7030/api/v1/user/list?pageNum=1&pageSize=10' \
  -H "Authorization: Bearer <你的 token>"

# 3) 查单条
curl http://127.0.0.1:7030/api/v1/user/1 -H "Authorization: Bearer <你的 token>"

列表接口正常时,响应大约是这样(数据不同但结构一致):

{
  "code": 200,
  "message": "",
  "rows": [],
  "success": true,
  "total": 0
}

注意:全文示例都默认带了 Authorization: Bearer <token> ,因为这只是一个带鉴权中间件的常规做法 - 未登录的路径会被拦截返回 401 。别忘了这个细节,否则你会以为接口写错了。

五、分层没有做对时的 味道

如果你总觉得哪里不对劲,对照下面几条自省:

  1. Controller 里开始出现 if 判断业务、查数据库了 → 业务逻辑漏锅,该收进 Service。
  2. Service 里直接 ctx.body 返回,或者 throw Response 了 → 返回归 Controller,Service 别轻易碰 HTTP 关心。
  3. Model 里写了一大段查询工具 → Model 只做表映射,别把差异化查询全塞进去。
  4. 一个方法管拔很长 → 大概率是越界了,把内聚拆成小方法。
  5. 混用 ORM / 原生 SQL 时老报字段错误 → 大概率是字段命名(snake_case / camelCase)没对齐,见前文 Model 那段的坑。

六、落地检查清单

一个正经新模块,照下面的清单走,基本不跑偏:

  • [ ] Model:字段、 tableName 、时间戳字段映射、 underscored 开关都说清楚,每个字段写 comment 。
  • [ ] Service:实现 list / getById / update (必要时加 insert / delete ),业务 / 校验 / 事务都在这一层。
  • [ ] Controller:只读参数 → 调 Service → 包响应 → 用统一异常或 success:false 走 失败 路径,二选一,别混。
  • [ ] 路由:具体路由在前、参数化路由在后。
  • [ ] 列表接口把参数写好默认值;写操作 先查后改 。
  • [ ] 启动后 curl 验证,确认响应 JSON 结构统一。

到这里,三层分层你就不会流于表面了。下一篇文章我会把 统一响应格式 独立出来,讲透 Controller 怎么只用 returnSucc / returnError 两个动作,就收敛出全项目一致的 命中/错误结构,以及全局异常中间件到底干了什么。


发布评论

发布评论前请先 登录。
0 评论
点赞
收藏

评论列表 0

暂无评论