5.2 四层架构
我们把后端分成四层:
┌─────────────────────────┐
│ Router 路由层 │ URL → Controller 方法
├─────────────────────────┤
│ Controller 控制层 │ 解析请求 → 调 Service → 返回响应
├─────────────────────────┤
│ Service 服务层 │ 业务逻辑、参数校验、多模型协调
├─────────────────────────┤
│ Model 模型层 │ 数据 CRUD(操作数据库/内存)
└─────────────────────────┘
每层的职责
| 层级 | 职责 | 关心什么 | 不关心什么 |
|---|---|---|---|
| Router | URL 与 HTTP 方法映射 | 路由怎么组织、怎么挂载 | 业务逻辑 |
| Controller | 请求与响应 | 怎么拿参数、怎么返回格式 | 数据存在哪、业务规则是什么 |
| Service | 业务逻辑 | 业务规则、参数校验、事务 | HTTP 协议、URL 长什么样 |
| Model | 数据操作 | 怎么存、怎么查、怎么改 | 业务是什么、给谁用 |
调用方向
Controller → Service → Model
只能上层调用下层,不能反过来。
Service 不能 import Controller,Model 不能 import Service。否则就乱了。
5.3 为什么 Model 层不能直接暴露给 Controller
很多人会问:Service 层好像只是转调一下 Model,有必要吗?
简单的 CRUD 看起来确实像多余的。但业务一复杂,Service 层的价值就出来了:
场景1:创建项目时还要记录操作日志
// 没有 Service 层,Controller 里要写:
async function createProject(req, res) {
await projectModel.create(req.body);
await auditLogModel.create({ action: 'create_project', ... });
// 还可能发通知、加权限...
}
有了 Service 层:
// Controller 只关心调用和返回
async function create(req, res) {
const id = await projectService.create(req.body);
sendOk(res, { id });
}
// Service 管业务逻辑
async function create(data) {
const project = await projectModel.create(data);
await auditLogModel.record('create_project', project.id);
return project;
}
场景2:不同接口共用同一段业务逻辑
比如"创建项目"的校验逻辑,在 API 创建和批量导入时都要用。有 Service 层就直接复用。
场景3:换数据库不影响业务逻辑
如果哪天从 MySQL 换成 MongoDB,只需要改 Model 层,Service 和 Controller 不用动。
5.4 按模块组织 vs 按类型组织
按类型组织(不推荐)
src/
├── controllers/
│ ├── project-controller.ts
│ └── user-controller.ts
├── services/
│ ├── project-service.ts
│ └── user-service.ts
└── models/
├── project-model.ts
└── user-model.ts
看起来整齐,但改一个项目相关的功能要跳 3 个文件夹。模块多了之后找代码很痛苦。
按模块组织(推荐)
src/
├── project/
│ ├── project-router.ts
│ ├── project-controller.ts
│ ├── project-service.ts
│ └── project-model.ts
└── user/
├── user-router.ts
├── user-controller.ts
├── user-service.ts
└── user-model.ts
一个模块的所有代码在一个文件夹里,新增和删除都很方便。
我们教程里采用的是按业务模块 + 按层的混合方式:
src/
├── routers/project/
├── controllers/project/
├── services/project/
└── models/project/
每个层下面按模块分子文件夹。这样既保持了分层清晰,又能按模块找到对应代码。
5.5 依赖注入
什么是依赖注入
简单说:一个类需要什么依赖,从外面传进来,不要自己 new。
反面例子:
class ProjectService {
private projectModel = new ProjectModel(); // ❌ 自己创建依赖
list() {
return this.projectModel.list();
}
}
正面例子:
class ProjectService {
constructor(private readonly projects: ProjectModel) {} // ✅ 从外面传进来
list() {
return this.projects.list();
}
}
为什么要依赖注入
- 好测试:测试时可以传 mock 对象
- 解耦:Service 不关心 Model 怎么创建的
- 灵活:同一个 Model 实例可以被多个 Service 共享
在哪里组装依赖
在 bootstrap.ts 里统一创建所有实例:
// src/bootstrap.ts
import { ProjectModel } from './models/project/project-model.js';
import { ProjectService } from './services/project/project-service.js';
import { ProjectController } from './controllers/project/project-controller.js';
export function bootstrap() {
// 1. 创建 Model
const projectModel = new ProjectModel();
// 2. 创建 Service,注入 Model
const projectService = new ProjectService(projectModel);
// 3. 创建 Controller,注入 Service
const projectController = new ProjectController(projectService);
return {
projectModel,
projectService,
projectController,
};
}
然后路由里用传进来的 controller:
export function createProjectRouter(controller: ProjectController) {
const router = Router();
router.get('/list', controller.list);
router.post('/', controller.create);
// ...
return router;
}
不需要用 NestJS 那种复杂的 DI 容器,手动组装就够用了。
5.6 怎么判断代码放哪层
一个简单的判断方法:
如果把这个项目的 Web 框架从 Express 换成 Koa,这部分代码需不需要改?
- 要改 → Controller 层 / Router 层
- 不用改 → Service 层 / Model 层
再问一个问题:
如果把数据库从 MySQL 换成 MongoDB,这部分代码需不需要改?
- 要改 → Model 层
- 不用改 → Service 层 / Controller 层
这样想一下,代码放哪层就清楚了。
5.7 小结
- 分层的目的是解耦,让每层只关心自己的事
- 四层:Router → Controller → Service → Model,只能上层调下层
- Service 层在简单 CRUD 里像多余的,但业务复杂了就体现价值
- 按模块组织目录,比按类型组织更好维护
- 依赖注入:依赖从外面传进来,不要自己 new,方便测试和解耦