用户在待办页面输入"理解依赖注入",然后点击保存。
前端发出请求:
ts
axios.post('/api/todos', { title: '理解依赖注入' })
从前端视角看,事情已经做完了一半。从后端视角看,真正的旅程才刚刚开始:谁接收请求?谁判断标题是否合法?谁生成数据?谁负责写进数据库?失败又该由谁解释?
成熟的 Spring Boot 项目不会把这些事塞进一个巨大方法,而是让请求依次经过不同角色。
MySQL Repository Service Controller Vue / React MySQL Repository Service Controller Vue / React #mermaid-svg-wcMXVBy285JO1JEf{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-wcMXVBy285JO1JEf .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-wcMXVBy285JO1JEf .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-wcMXVBy285JO1JEf .error-icon{fill:#552222;}#mermaid-svg-wcMXVBy285JO1JEf .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-wcMXVBy285JO1JEf .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-wcMXVBy285JO1JEf .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-wcMXVBy285JO1JEf .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-wcMXVBy285JO1JEf .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-wcMXVBy285JO1JEf .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-wcMXVBy285JO1JEf .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-wcMXVBy285JO1JEf .marker{fill:#333333;stroke:#333333;}#mermaid-svg-wcMXVBy285JO1JEf .marker.cross{stroke:#333333;}#mermaid-svg-wcMXVBy285JO1JEf svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-wcMXVBy285JO1JEf p{margin:0;}#mermaid-svg-wcMXVBy285JO1JEf .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-wcMXVBy285JO1JEf text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-wcMXVBy285JO1JEf .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-wcMXVBy285JO1JEf .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-wcMXVBy285JO1JEf .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-wcMXVBy285JO1JEf .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-wcMXVBy285JO1JEf #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-wcMXVBy285JO1JEf .sequenceNumber{fill:white;}#mermaid-svg-wcMXVBy285JO1JEf #sequencenumber{fill:#333;}#mermaid-svg-wcMXVBy285JO1JEf #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-wcMXVBy285JO1JEf .messageText{fill:#333;stroke:none;}#mermaid-svg-wcMXVBy285JO1JEf .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-wcMXVBy285JO1JEf .labelText,#mermaid-svg-wcMXVBy285JO1JEf .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-wcMXVBy285JO1JEf .loopText,#mermaid-svg-wcMXVBy285JO1JEf .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-wcMXVBy285JO1JEf .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-wcMXVBy285JO1JEf .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-wcMXVBy285JO1JEf .noteText,#mermaid-svg-wcMXVBy285JO1JEf .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-wcMXVBy285JO1JEf .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-wcMXVBy285JO1JEf .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-wcMXVBy285JO1JEf .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-wcMXVBy285JO1JEf .actorPopupMenu{position:absolute;}#mermaid-svg-wcMXVBy285JO1JEf .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-wcMXVBy285JO1JEf .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-wcMXVBy285JO1JEf .actor-man circle,#mermaid-svg-wcMXVBy285JO1JEf line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-wcMXVBy285JO1JEf :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} POST /api/todos创建待办保存数据INSERT新记录待办对象响应数据201 + JSON
Controller:站在 HTTP 世界与业务世界之间
Controller 最接近前端。它理解 URL、请求方法、请求头、查询参数、JSON 和状态码。
java
@PostMapping
ResponseEntity<TodoResponse> create(
@RequestBody CreateTodoRequest request
) {
return ResponseEntity.status(201).body(todoService.create(request));
}
这段代码做的是"翻译",不是业务决策:
@PostMapping把 POST 请求翻译成方法调用;@RequestBody把 JSON 翻译成 Java 对象;ResponseEntity把 Java 返回值翻译成 HTTP 响应。
Controller 不应该知道 SQL,也不应该判断"已完成待办是否允许再次完成"。它像前后端边界上的协议适配器:外面说 HTTP,里面说业务动作。
如果一个 Controller 有几百行,通常意味着边界已经模糊。它既在接待请求,又在计算规则,还在操作数据库,任何修改都可能牵动整条链路。
Service:系统真正表达业务的地方
Service 负责回答:"这件事应该怎样发生?"
创建待办可能包含这些规则:标题去掉首尾空格、默认状态为未完成、记录创建者、限制同名任务、触发积分或通知。它们都不是 HTTP 规则,也不是数据库规则,而是产品规则。
java
@Service
class TodoService {
TodoResponse create(CreateTodoRequest request) {
var todo = new Todo(request.title().trim(), false);
return TodoResponse.from(todoRepository.save(todo));
}
}
前端也有类似分工:组件响应点击,业务状态由 composable、store 或领域函数处理,请求模块负责通信。项目小时混在一起似乎更快,项目大后边界会决定维护成本。
Service 的另一个价值是摆脱"入口依赖"。今天创建待办来自 HTTP,明天可能来自定时任务、消息队列或后台管理。只要业务逻辑放在 Service,这些入口都能复用同一套规则。
Repository:Service 不需要知道数据藏在哪里
Repository 表达的是数据能力:查询、保存、删除。它把业务世界与存储细节隔开。
java
interface TodoRepository {
Optional<Todo> findById(Long id);
Todo save(Todo todo);
void deleteById(Long id);
}
注意,这个接口没有出现 MySQL、SQL 或表名。Service 只知道"我能保存一个 Todo",并不知道底层是 MySQL、内存,还是远程服务。
这种隔离不是为了炫技,而是为了控制变化:
- 数据库字段变化时,HTTP 接口不必跟着变化;
- 测试 Service 时,可以换成内存实现;
- 数据访问方案变化时,业务规则不必重写。
在使用 MyBatis-Plus 的项目里,Mapper 常常承担 Repository 的技术实现。简单项目可以直接由 Service 调用 Mapper;更强调领域边界的项目会再包一层 Repository。层数不是越多越专业,关键是变化能否被限制在合理范围内。
分层不是"每个请求都必须走满三层"
有些团队把分层变成机械规定:Controller 调 Service,Service 调 Repository,每层只有一行转发。这样的代码只增加跳转成本。
分层真正解决的是职责和变化方向:
- HTTP 变化留在 Controller;
- 业务变化留在 Service;
- 存储变化留在 Repository 或 Mapper。
如果一个只读接口非常简单,Service 看起来可能很薄;这并不错误。相反,如果业务复杂,即使没有数据库,Service 仍然值得存在。
判断一层是否有价值,不是数代码行,而是看它是否保护了一个清晰边界。
DTO:接口数据不是数据库对象的自拍照
前端提交创建请求时,只需要标题:
json
{ "title": "理解 DTO" }
数据库里的 Todo 却可能有十几个字段:id、userId、createdAt、updatedAt、version、内部删除标记等。如果把数据库对象直接暴露给前端,内部结构就变成了公开合同。
DTO,Data Transfer Object,就是专门跨边界传输的数据对象:
java
record CreateTodoRequest(String title) {}
record TodoResponse(Long id, String title, boolean completed) {}
它很像前端为 API 单独声明的 TypeScript 类型,而不是直接拿数据库生成类型到处使用。
DTO 带来三个重要边界:
安全边界。 密码摘要、内部状态等字段不会因为自动序列化而泄露。
稳定边界。 数据库改名或拆表,不等于前端必须同步升级。
表达边界。 前端需要的是适合展示的数据,例如格式化状态、聚合数量,而不一定是数据库原始行。
依赖注入:对象不再亲自寻找合作伙伴
Service 需要 Repository,Controller 需要 Service。最直接的写法是到处 new:
java
var repository = new MysqlTodoRepository();
var service = new TodoService(repository);
var controller = new TodoController(service);
项目一大,这些对象还需要数据库连接、配置、日志和其他 Service,组装会迅速变得混乱。
Spring 的做法是:对象只声明自己需要什么,框架负责提供。
java
TodoController(TodoService todoService) {
this.todoService = todoService;
}
这就是依赖注入。Controller 没有决定使用哪个 TodoService,它只是通过构造函数表达依赖。Spring 启动时创建 Service,再把它传进来。
前端开发者可以把它联想到组件通过 props 或 context 获得能力:组件使用依赖,但不负责在内部硬编码依赖的创建过程。两者不是完全相同,但"使用者与创建方式分离"的思路一致。
Spring 容器:一个有规则的对象管理中心
被 Spring 管理的对象称为 Bean。@RestController、@Service、@Repository 等注解会让对应类成为候选 Bean。
应用启动时,Spring 大致会做这些事:
- 扫描项目中的组件;
- 分析每个构造函数需要什么;
- 按依赖顺序创建对象;
- 把对象连接起来;
- 在整个应用生命周期中管理它们。
这套机制叫 IoC,控制反转。以前由业务代码主动创建和管理对象,现在由框架控制创建过程,业务对象只表达需求。
"反转"的不是业务逻辑,而是对象管理权。
构造函数注入通常比字段注入更推荐,因为依赖在创建时就必须完整,测试时也能直接传入替代对象。一个类如果构造函数有十几个参数,也是在提醒你:它可能承担了太多职责。
一个 URL 中藏着三类参数
前后端联调时,最常见的混乱来自"参数到底放哪里"。可以按照含义区分:
路径参数定位资源:
http
GET /api/todos/42
42 是资源身份,Controller 用 @PathVariable 读取。
查询参数描述筛选方式:
http
GET /api/todos?completed=false&page=0
它们不会创造新资源,只是在说明怎样查看列表,Controller 用 @RequestParam 读取。
请求体承载较完整的数据:
http
POST /api/todos
Content-Type: application/json
{ "title": "理解请求体" }
Controller 用 @RequestBody 读取。
这不是 Spring 独有规则,而是 HTTP API 的共同语言。地址设计得稳定,前端请求封装就会自然很多。
REST 的重点不是背诵,而是减少猜测
待办 API 可以长这样:
| 请求 | 意图 | 常见结果 |
|---|---|---|
GET /api/todos |
查看列表 | 200 |
GET /api/todos/42 |
查看一个待办 | 200 或 404 |
POST /api/todos |
创建待办 | 201 |
PATCH /api/todos/42 |
修改部分字段 | 200 |
DELETE /api/todos/42 |
删除待办 | 204 |
REST 的价值是让资源名称、HTTP 方法和状态码形成稳定语法。看到 DELETE /api/todos/42,前端不需要打开文档才能猜出大意。
它不是宗教规则。某些复杂业务动作很难用 CRUD 表达,例如"重新发送邀请",使用 POST /api/invitations/42/resend 反而更清楚。好的 API 追求可理解和一致,而不是为了形式优雅牺牲业务含义。
一次请求真正经过的,不只是三层代码
在真实 Spring Boot 应用中,请求到达 Controller 前,通常还会经过:
- Filter:处理 requestId、日志、跨域等通用逻辑;
- Spring Security 过滤器链:确认登录身份和权限;
- 参数解析器:把字符串、JSON 转成 Java 类型;
- 参数校验:检查必填、长度和格式。
Controller 返回后,响应还会经过 JSON 序列化和异常转换。
所以,Controller、Service、Repository 是业务代码的主干,并不是网络请求的全部轨迹。理解这一点后,很多问题会更容易定位:
- 请求根本没进 Controller,先看路径、安全和参数解析;
- Controller 进了但规则不对,重点看 Service;
- 业务正确但数据异常,重点看 Mapper、SQL 和事务;
- 服务端有结果但前端解析失败,重点看响应结构和 JSON。
下一篇会站在调用方立场讨论:什么样的 API 会让前端觉得舒服?答案不只是字段命名,而是校验、状态码、异常、分页、接口文档和日志共同形成的一份长期合同。