前端学 Spring Boot(2):一次点击,如何穿过整个后端?

用户在待办页面输入"理解依赖注入",然后点击保存。

前端发出请求:

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 却可能有十几个字段:iduserIdcreatedAtupdatedAtversion、内部删除标记等。如果把数据库对象直接暴露给前端,内部结构就变成了公开合同。

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 大致会做这些事:

  1. 扫描项目中的组件;
  2. 分析每个构造函数需要什么;
  3. 按依赖顺序创建对象;
  4. 把对象连接起来;
  5. 在整个应用生命周期中管理它们。

这套机制叫 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 会让前端觉得舒服?答案不只是字段命名,而是校验、状态码、异常、分页、接口文档和日志共同形成的一份长期合同。

相关推荐
乔伊酱1 小时前
我被列表查询折磨了 7 年,直到有一天我发现它不是 ORM 的事!
spring boot·graphql
AI多Agent协作实战派2 小时前
AI多Agent协作系统实战(二十三):Agent读HEARTBEAT.md不读AGENTS.md——openclaw的文件加载之谜
前端·数据库·人工智能·uni-app
2601_955760073 小时前
如何用 Claude Opus 5 API 批量扩展长尾关键词和文章选题
前端·python·搜索引擎
KaMeidebaby3 小时前
卡梅德生物技术快报|bli亲和力检测gst:告别批量跑胶:BLI实时酶切监测技术加速GST融合蛋白下游流程优化
前端·网络·数据库·人工智能·算法
程序员cxuan3 小时前
Loop 还没玩明白,Graph Engineering 又火了。
人工智能·后端·程序员
触底反弹3 小时前
🚀 删了数据刷新又回来?3 组件 × 4 回调 × 3 坑讲透 React 父子通信
前端·javascript·react.js
uzong4 小时前
把一面面试总结做成一个 Skill:重复的事,就别每次都重写
后端·面试
霸道流氓气质4 小时前
SpringBoot中基于 AES-GCM + KMS 密钥管理的数据加解密 Starter 实践
java·数据库·spring boot
耳东小鹿4 小时前
对象常用方法
前端