Model → Service → Controller 分层到底在分什么?
这篇文章不聊某一个具体项目,而是在讲后端 Web 工程里最常见的一种分层方式:Model 层、Service 层、Controller 层。我会先讲清楚它要解决什么问题、为什么很多团队越到后面越乱,然后把每层的边界、怎么写、以及常见的坑一点点剥开,最后给一套可以照着抄的落地清单。
一、这个分层到底在解决什么问题
几乎每个接手老项目的后端都有过类似的痛苦:打开一个接口的代码,发现里面从校验入参、拼 SQL、查数据库、算业务、再到拼返回 JSON 一气呵成,全写在一个方法里。功能是能跑,可一旦要改需求,你面临的不是 改一行 ,而是 在这坨里面翻半天 。
这种写法的本质问题只有一个 - 职责没有边界 。一段代码同时扮演多种角色,任何单一角色的调整都会牵一发动全身。
三层分层的核心思想就是: 把"接请求、管业务、碰数据"切成三个有明确边界的角色,谁也别越界。
HTTP 请求
│
▼
Controller - 接请求、回响应(不懂业务,也不碰 SQL)
│ service.xxx()
▼
Service - 业务逻辑(复杂查询、规则校验、数据加工)
│ model.xxx
▼
Model - 一张表一个模型,干原始的增删改查只要边界守得住,收益其实是看得见的:
- 好改 :想换一种查询方式,只动 Service;想改返回字段,只动 Controller。
- 好测 :Service 不依赖 HTTP,可以脱离请求单独测。
- 好找 :出了 bug,扫码定位大概知道是 参数问题(Controller) 逻辑问题(Service) 数据问题(Model) 。
- 好复用 :同一个 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;
}
};几个容易忽略的细节:
- 参数默认值 :
pageNum不传给默认 1、pageSize默认 10。这是第一道防线,防止异常参数把查询带崩。很多新手写查询不设默认,一旦前端漏传就 INTEGER 类型报错。 - 先查后改 :更新时先
findByPk确认记录存在,再update。这样能给上层一个 到底有没有改成功 的信号(返回 1 还是 0),是判断接口返回 成功/失败 的好手段。 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 能否落地的差别,做一个,别两套混着用。
三、自己搭一遍的最小闭环
理论说了那么多,不如动手。我们来自己实现一个最精简闭环,只做「列表 + 单条 + 更新」三件事:
- 建了一个 Model (上面
User就是这个,直接可用)。 - 建了一个 Service (上面
UserService就是这个)。 - 建了一个 Controller (上面
UserController就是这个)。 - 挂路由 ,注意具体路由要放在参数化路由之前(下面这条容易踩)。
// 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 。别忘了这个细节,否则你会以为接口写错了。
五、分层没有做对时的 味道
如果你总觉得哪里不对劲,对照下面几条自省:
- Controller 里开始出现
if判断业务、查数据库了 → 业务逻辑漏锅,该收进 Service。 - Service 里直接
ctx.body返回,或者 throw Response 了 → 返回归 Controller,Service 别轻易碰 HTTP 关心。 - Model 里写了一大段查询工具 → Model 只做表映射,别把差异化查询全塞进去。
- 一个方法管拔很长 → 大概率是越界了,把内聚拆成小方法。
- 混用 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 两个动作,就收敛出全项目一致的 命中/错误结构,以及全局异常中间件到底干了什么。




