1. [Web 核心] Spring MVC 与 RESTful API 摘要 : 在掌握了 Spring Boot 的基础之后,本章我们将揭开 spring-boot-starter-web
的神秘面纱,深入其核心——Spring MVC 框架。我们将学会在 Spring Boot 的“羽翼”下,轻松创建出第一个 RESTful API 接口,为后续的实战项目打下坚实的基础。
1.1. 承上启下:Spring Boot 与 Spring MVC 的关系 在正式开始学习 Spring MVC 的具体功能前,我们首先需要精确理解其在 Spring Boot 项目中的角色与关系。请您回顾在 第一、二章中我们创建的 Spring Boot 项目 ,其 pom.xml
文件中包含一项关键依赖:
1 2 3 4 <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-web</artifactId > </dependency >
该依赖是 Spring Boot 提供的“场景启动器” (Starter)。它的核心作用主要有两点:依赖传递 与 触发自动配置 。
依赖传递 : spring-boot-starter-web 会将构建 Web 应用所需的一整套相关库(JAR 包)自动引入到我们的项目中。这其中包括了 spring-webmvc
、spring-web
等 Spring MVC 框架的核心,同时也内嵌了 Tomcat 服务器作为默认的 Servlet 容器。
触发自动配置 : 更重要的是,当 Spring Boot 检测到 spring-boot-starter-web
存在于类路径中时,其强大的 自动配置 机制便会生效。它会在后台为我们自动配置好所有在传统 Spring MVC 开发中需要手动处理的核心组件,例如:
DispatcherServlet
(前端控制器) * HandlerMapping
(处理器映射器) * HandlerAdapter
(处理器适配器) * 多种 HttpMessageConverter
(用于处理 JSON、表单等数据的消息转换器)因此,我们可以这样精准地定义二者的关系:
Spring MVC :是一个功能强大且成熟的 Web 框架 ,它提供了构建 Web 应用所需的全套组件和清晰的架构模式。Spring Boot :是一个 集成与简化框架 。它并非 Spring MVC 的替代品,而是通过自动配置技术,免去了我们手动配置 Spring MVC 的所有繁琐步骤,使我们能够直接专注于编写业务代码。总结:我们接下来的学习,本质上是在 Spring Boot 提供的“全自动化”环境中,深入使用 Spring MVC 这个核心 Web 框架的各项功能。理解这一点,将有助于我们更好地把握后续所有知识点。
1.2. 第一个 API 接口 在第二章中,我们为了快速体验 Spring Boot 的 Web 功能,已经创建过一个 HelloController
。当时我们只关注了运行结果,并未深究其工作原理。
现在,让我们以 Spring MVC 的专业视角,回顾并深度解析 这段我们已经很熟悉的代码。
文件路径 : src/main/java/com/example/springbootdemo/controller/HelloController.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 package com.example.demo.controller;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RestController;@RestController public class HelloController { @GetMapping("/hello") public String sayHello () { return "Hello, 这是我的第一个 Spring MVC API!" ; } }
如代码注释所示,@RestController
和 @GetMapping
这两个注解的组合,便构成了 Spring MVC 中最基础的 API 接口。前者负责声明类的身份和数据响应模式,后者负责将具体的 URL 路径映射到处理方法上。
正是因为 Spring Boot 的自动配置为我们处理了所有底层细节,我们才能如此简洁地实现这一功能。在下一节,我们将简要地探讨一下这个请求在其内部的流转过程。
1.3. 自动配置的背后:DispatcherServlet 流程简述 在上一节,我们看到仅用两个注解就成功创建了一个 API 接口。现在,我们自然会产生一个疑问:当我们启动应用并在浏览器中访问 /hello
时,这个请求是如何精确地找到并执行我们编写的 sayHello()
方法的?
答案的核心,在于一个由 Spring Boot 自动为我们配置和注册的组件——DispatcherServlet 。
您可以将它理解为 Spring MVC 框架在 Web 应用中的 总调度中心 或 前端控制器 。所有进入我们应用的 HTTP 请求,都会首先被它拦截。它接收到请求后,会像一位经验丰富的交通警察,遵循一套固定的、高效的流程来处理和分发请求。
我们可以将这个流程简化为以下几个关键步骤:
浏览器或 API 工具发出 GET /hello
请求,被 Spring Boot 内嵌的 Tomcat 服务器接收。
Tomcat 将请求转交给 Spring MVC 的总指挥 DispatcherServlet 。
DispatcherServlet 询问:“谁能处理 /hello
这个请求?”。它通过查询 HandlerMapping (处理器映射器),找到了 HelloController.sayHello()
方法这个最终的处理器。
DispatcherServlet 通过 HandlerAdapter (处理器适配器),去适配并调用我们编写的 sayHello()
方法。
sayHello()
方法执行,并返回字符串 "Hello, 这是我的第一个 Spring MVC API!"
。
由于 HelloController
类上有 @RestController
注解,DispatcherServlet 知道这是一个 API 请求。它会选择一个合适的 HttpMessageConverter
(消息转换器),将返回的字符串直接写入 HTTP 响应体。
这个看起来复杂的流程,在 Spring Boot 的帮助下,我们一行配置代码都不需要写。DispatcherServlet 及其配套的 HandlerMapping 、HandlerAdapter 等所有组件,都由 spring-boot-starter-web
自动配置完成。我们只需要专注编写 @RestController
里的业务逻辑即可。
1.4. 深入请求映射:@RequestMapping 全方位解析 我们已经掌握了 @GetMapping
的基础用法,但 Spring MVC 的请求映射远不止于此。@RequestMapping
及其衍生注解提供了一套强大而灵活的工具集,能够让我们应对各种复杂的 URL 映射场景。接下来,我们将逐一探索这些高级用法。
1.4.1. 组合运用:类级别与方法级别映射 为什么需要它? 当项目逐渐变大,一个模块(如用户管理)可能会包含多个相关的 API 接口。如果所有 URL 映射都直接写在方法上,会显得杂乱且容易产生路径冲突。通过在类上添加 @RequestMapping
,我们可以为该控制器的所有接口定义一个统一的“命名空间”或“父路径”。
如何使用? 在控制器类上添加 @RequestMapping("/some-prefix")
,那么该类中所有方法的映射路径都会自动带上这个前缀。
代码示例 我们创建一个 UserController
,并将其所有接口都归属在 /users
路径下。
文件路径 : src/main/java/com/example/demo/controller/UserController.java
(新增文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 package com.example.springbootdemo.controller;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;@RestController @RequestMapping("/users") public class UserController { @GetMapping("/all") public String getAllUsers () { return "返回所有用户列表" ; } }
运行验证 启动 Spring Boot 应用,使用 cURL 或 Postman 访问 http://localhost:8080/users/all
。
1 curl http://localhost:8080/users/all
1.4.2. 动态路径:@PathVariable 与路径占位符 为什么需要它? 在 RESTful API 设计中,我们经常需要通过 URL 来指定要操作的资源,例如,通过用户 ID 来获取特定用户的信息。这时,URL 中就会包含动态变化的部分。
如何使用? 在 @RequestMapping
的路径中使用 {}
来定义一个路径变量(占位符),然后在方法参数中使用 @PathVariable
注解来获取这个变量的值。
代码示例 我们在 UserController
中添加一个根据 ID 查询用户的方法。
文件路径 : src/main/java/com/example/demo/controller/UserController.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @RestController @RequestMapping("/users") public class UserController { @GetMapping("/{id}") public String getUserById (@PathVariable("id") Long id) { return "正在查询 ID 为: " + id + " 的用户" ; } }
如果方法参数名与路径占位符的名称完全相同,@PathVariable
的 ("id")
部分可以省略,直接写 @PathVariable Long id
即可。
运行验证 访问 http://localhost:8080/users/101
。
1 curl http://localhost:8080/users/101
1.4.3. 模糊匹配:Ant 风格路径 为什么需要它? 有时我们需要一个方法能处理一类相似但不完全相同的 URL,而不是为每个 URL 都写一个方法。Ant 风格的通配符就提供了这种模糊匹配的能力。
如何使用? @RequestMapping
支持三种 Ant 风格的通配符:
?
:匹配任意 单个 字符。*
:匹配任意数量(0 或多个)的字符,但不包括 /
。**
:匹配任意数量(0 或多个)的字符,可以包括 /
。代码示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 package com.example.springbootdemo.controller;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RestController;@RestController public class AntController { @GetMapping("/ant/test?") public String testAnt1 () { return "Ant-style match: ?" ; } @GetMapping("/ant/test*") public String testAnt2 () { return "Ant-style match: *" ; } @GetMapping("/ant/**/any") public String testAnt3 () { return "Ant-style match: **" ; } }
重要 : 在 Spring Framework 6.x (Spring Boot 3.x) 及更高版本中,出于安全性考虑,不再允许 **
通配符出现在路径的中间部分。它通常只能用在末尾,例如 /ant/**
。
运行验证 访问 http://localhost:8080/ant/testABC
。
1 curl http://localhost:8080/ant/testABC
为什么需要它? 有时,仅通过 URL 路径还不足以区分请求。我们可能需要根据请求中是否包含 特定的参数 或 特定的请求头 ,来决定由哪个方法处理。这在 API 版本控制或根据特定条件路由时非常有用。
如何使用? 在 @RequestMapping
及其衍生注解中,使用 params
或 headers
属性来添加匹配条件。
代码示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 package com.example.springbootdemo.controller;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RestController;@RestController public class PreciseController { @GetMapping(value = "/precise", params = "version") public String testParams () { return "Match with params 'version'" ; } @GetMapping(value = "/precise", params = "version=2") public String testParamsWithValue () { return "Match with params 'version=2'" ; } @GetMapping(value = "/precise", headers = "X-API-VERSION") public String testHeaders () { return "Match with header 'X-API-VERSION'" ; } }
运行验证 使用 cURL 的 -H
选项来添加请求头,验证 headers
属性。
1 curl -H "X-API-VERSION: v1.0" http://localhost:8080/precise
1 Match with header 'X-API-VERSION'
如果请求不满足 params
或 headers
的匹配条件,客户端通常会收到一个 404 Not Found 或 400 Bad Request 的错误,因为 Spring MVC 认为没有找到合适的处理器方法来处理该请求。
1.5. 优雅地获取请求参数 我们已经学会了如何将不同的 URL 映射到控制器方法上。接下来的关键一步,是学习如何从这些请求中 获取客户端传递过来的数据 。无论是 URL 中的查询参数,还是请求体中的 JSON 数据,Spring MVC 都提供了极为便捷的方式来获取它们。
1.5.1. 获取 URL 参数:@RequestParam 为什么需要它? URL 查询参数是在 ?
之后,以 key=value
形式拼接的参数,是 GET 请求传递少量数据的最常见方式。例如,在一个搜索功能中,URL 可能是 /users/search?keyword=admin
。我们需要一种方式来获取 keyword
的值。
如何使用? @RequestParam
注解可以精确地将 URL 查询参数绑定到控制器方法的参数上。
代码示例 我们在 UserController
中添加一个搜索方法来演示。
文件路径 : src/main/java/com/example/springbootdemo/controller/UserController.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 package com.example.springbootdemo.controller;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.PathVariable;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RequestParam;import org.springframework.web.bind.annotation.RestController;@RestController @RequestMapping("/users") public class UserController { @GetMapping("/search") public String searchUsers ( // 这里一样的,可以省略 @RequestParam String keyword, @RequestParam(value = "page", required = false, defaultValue = "1") Integer page) { return "正在搜索用户,关键词: " + keyword + ", 页码: " + page; } }
运行验证 使用 cURL 访问 /users/search
路径,并附带查询参数。
1 2 curl "http://localhost:8080/users/search?keyword=admin"
1 正在搜索用户,关键词: admin, 页码: 1
1 2 curl "http://localhost:8080/users/search?keyword=admin&page=3"
1 正在搜索用户,关键词: admin, 页码: 3
1.5.2. 处理请求体:@RequestBody 与 JSON 为什么需要它? 当需要提交的数据结构比较复杂时(例如创建一个新用户,包含姓名、密码、邮箱等多个字段),通常会将这些数据作为一个整体,放在 HTTP 请求体(Request Body)中发送,而 JSON 是当今最主流的数据格式。
如何使用? @RequestBody
注解告诉 Spring MVC:请获取完整的请求体内容,并使用内置的 HttpMessageConverter
(通常是 Jackson)将其反序列化成一个指定的 Java 对象(POJO)。
代码示例 首先,我们需要创建一个 User
类来承载数据。
文件路径 : src/main/java/com/example/springbootdemo/model/User.java
(新增文件)
1 2 3 4 5 6 7 8 9 10 package com.example.springbootdemo.model;import lombok.Data;@Data public class User { private Long id; private String username; private String email; }
然后,在 UserController
中添加一个创建用户的方法。
文件路径 : src/main/java/com/example/springbootdemo/controller/UserController.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 package com.example.springbootdemo.controller;import com.example.springbootdemo.model.User;import org.springframework.web.bind.annotation.*;@RestController @RequestMapping("/users") public class UserController { @PostMapping("/create") public String createUser (@RequestBody User user) { return "成功创建用户: " + user.toString(); } }
重要信息 : @RestController
是类级注解,让类成为返回数据的控制器;@RequestBody
是方法参数注解,能把请求体数据转成对象供方法使用,这两者是不同的,需要严格区分!
运行验证 我们使用 cURL 模拟一个 POST
请求,并通过 -H
指定 Content-Type
为 application/json
,使用 -d
传入 JSON 数据。
1 2 3 4 curl -X POST \ http://localhost:8080/users/create \ -H 'Content-Type: application/json' \ -d '{"id":1, "username":"zhangsan", "email":"zhangsan@example.com"}'
1 成功创建用户: User(id =1, username=zhangsan, email=zhangsan@example.com)
1.5.3. 自动封装:使用 POJO 接收参数 为什么需要它? 除了接收 JSON 请求体,Spring MVC 还提供了一种更便捷的方式来处理多个普通的 URL 查询参数或表单参数——直接用一个 POJO 对象来接收。
如何使用? 当控制器方法的参数是一个 没有 被 @RequestBody
注解的 POJO 时,Spring MVC 会自动尝试将请求中的 同名参数 (无论是 URL 查询参数还是 x-www-form-urlencoded
表单参数)赋值给这个 POJO 对象的相应属性。
代码示例 我们为 UserController
添加一个更复杂的、支持多条件筛选的查询方法。
文件路径 : src/main/java/com/example/springbootdemo/controller/UserController.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 package com.example.springbootdemo.controller;import com.example.springbootdemo.model.User;import org.springframework.web.bind.annotation.GetMapping;@RestController @RequestMapping("/users") public class UserController { @GetMapping("/filter") public String filterUsers (User user) { return "根据条件筛选用户: " + user.toString(); } }
运行验证 我们像调用普通 GET 请求一样,在 URL 后面附上多个查询参数。
1 curl "http://localhost:8080/users/filter?username=lisi&email=lisi@example.com"
1 根据条件筛选用户: User(id =null, username=lisi, email=lisi@example.com)
核心区别 : @RequestBody
用于处理一个 单一的、完整的请求体 (通常是 JSON 或 XML)。而 POJO 直接接收参数的方式,则用于处理 零散的、多个的请求参数 (通常是 URL 查询参数或表单)。一个方法中,@RequestBody
注解最多只能使用一次。
2. [专业实战] 分层架构与用户 CRUD API 摘要 : 欢迎来到项目的核心实战章节。我们将彻底告别简单的“玩具代码”,引入后端开发中至关重要的 分层解耦思想 和 领域对象模型 (VO/DTO/PO) 。本章,我们将搭建一个标准的 Controller-Service-Mapper
三层架构,并在这个坚实的基础上,遵循 先单元测试、后 API 测试 的严谨流程,完成用户管理模块的全套 CRUD 接口开发。
2.1. 严谨的分层架构:VO, DTO, PO 的职责与转换 在开始编写业务代码前,我们必须先解决一个核心的架构问题:我们的数据应该如何在不同层之间流转?
一个常见的、但 不推荐 的做法是,只创建一个 User
实体类,让它从数据库一直贯穿到前端。这种“一招鲜,吃遍天”的模式,在项目初期看似便捷,但随着业务变复杂,会迅速带来一系列问题:
数据冗余 :查询用户列表时,前端可能只需要用户的 id
和 username
,但实体类通常包含 password
, create_time
, update_time
等全部 20 个字段,这会造成不必要的数据库查询和网络传输开销。安全性问题 :实体类直接映射数据库,通常包含密码、盐值等敏感信息。如果不慎将其直接序列化并返回给前端,将造成严重的安全漏洞。耦合度高 :前端的一个展示需求变更(比如需要一个新的组合字段,displayName = username + nickname
),可能会迫使我们去修改数据库实体类,这严重违反了各层独立、职责单一的设计原则。为了解决这些问题,专业的后端开发(如《阿里巴巴 Java 开发手册》中强制规定)都会遵循“领域模型”分层的思想,为不同场景创建不同的 Java 对象。在我们的项目中,将严格遵循以下约定:
对象类型 全称 约定包名 核心职责 PO Persistent Object entity
持久化对象 。与数据库中的表结构一一对应,一个 PO 对象就是数据库中的一条记录。它只应出现在数据访问层(Mapper)与服务层(Service)之间。DTO Data Transfer Object dto
数据传输对象 。用于在各个层之间传递数据,我们主要用它来 接收前端传递到 Controller 的请求数据 。它的字段完全根据业务操作的需求来定义。VO View Object vo
视图对象 。由 Controller 层 返回给前端的数据对象 。它的字段完全根据前端界面的展示需求来定制,可以隐藏敏感字段,也可以组合多个 PO 的数据。
转换的挑战与解决方案 看到这里,您可能会想:在这么多对象之间转换数据,会不会非常麻烦?我们用一个真实的场景来直面这个挑战。
场景设定 :假设数据库中的 User
(PO) 包含 username
和 status
(整型 1
代表“正常”,2
代表“禁用”)。而前端展示时,需要的是 name
字段和文本描述 statusText
(“正常”, “已禁用”)。
首先,我们来定义这两个类。
文件路径 : src/main/java/com/example/springbootdemo/entity/User.java
(新增文件,替换之前的 model 包)
1 2 3 4 5 6 7 8 9 10 package com.example.springbootdemo.entity;import lombok.Data;@Data public class User { private Long id; private String username; private String password; private Integer status; }
文件路径 : src/main/java/com/example/springbootdemo/vo/UserVO.java
(新增文件)
1 2 3 4 5 6 7 8 9 10 11 package com.example.springbootdemo.vo;import lombok.Data;@Data public class UserVO { private Long id; private String name; private String statusText; }
如果我们天真地直接使用 BeanUtil.copyProperties
,由于 username
和 name
名称不匹配,且 status
和 statusText
类型和逻辑都不同,转换会失败。
接下来,我们将在一个模拟的 Service 和 Controller 中,演示处理这个问题的三种专业方案。
方案一:手动设置(最清晰直接) 这是最基础、最直观的方法。先用 BeanUtil.copyProperties
拷贝同名属性,再对不匹配或需要逻辑处理的属性进行手动的 set
操作。
文件路径 : src/main/java/com/example/springbootdemo/service/UserService.java
(新增文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 package com.example.springbootdemo.service;import cn.hutool.core.bean.BeanUtil;import com.example.springbootdemo.entity.User;import com.example.springbootdemo.vo.UserVO;import org.springframework.stereotype.Service;import java.util.HashMap;import java.util.Map;@Service public class UserService { private static final Map<Long, User> userDatabase = new HashMap <>(); static { User user1 = new User (); user1.setId(1L ); user1.setUsername("zhangsan" ); user1.setPassword("123456" ); user1.setStatus(1 ); userDatabase.put(1L , user1); } public User getUserById (Long id) { return userDatabase.get(id); } public UserVO convertToVOManual (User user) { if (user == null ) { return null ; } UserVO userVO = new UserVO (); BeanUtil.copyProperties(user, userVO); userVO.setName(user.getUsername()); if (user.getStatus() != null ) { userVO.setStatusText(user.getStatus() == 1 ? "正常" : "已禁用" ); } return userVO; } }
文件路径 : src/main/java/com/example/springbootdemo/controller/UserController.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 package com.example.springbootdemo.controller;import com.example.springbootdemo.entity.User;import com.example.springbootdemo.service.UserService;import com.example.springbootdemo.vo.UserVO;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.PathVariable;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;@RestController @RequestMapping("/users") public class UserController { @Autowired private UserService userService; @GetMapping("/vo/manual/{id}") public UserVO getUserVOManual (@PathVariable Long id) { User user = userService.getUserById(id); return userService.convertToVOManual(user); } }
启动应用,访问 http://localhost:8080/users/vo/manual/1
。
1 curl http://localhost:8080/users/vo/manual/1
1 2 3 4 5 { "id" : 1 , "name" : "zhangsan" , "statusText" : "正常" }
Hutool 提供了一个非常方便的 @Alias
注解,专门用来解决属性名不一致的问题。
文件路径 : src/main/java/com/example/springbootdemo/vo/UserVO.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 package com.example.springbootdemo.vo;import cn.hutool.core.annotation.Alias;import lombok.Data;@Data public class UserVO { private Long id; @Alias("username") private String name; private String statusText; }
文件路径 : src/main/java/com/example/springbootdemo/service/UserService.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @Service public class UserService { public UserVO convertToVOWithAlias (User user) { if (user == null ) { return null ; } UserVO userVO = new UserVO (); BeanUtil.copyProperties(user, userVO); if (user.getStatus() != null ) { userVO.setStatusText(user.getStatus() == 1 ? "正常" : "已禁用" ); } return userVO; } }
文件路径 : src/main/java/com/example/springbootdemo/controller/UserController.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 @RestController @RequestMapping("/users") public class UserController { @GetMapping("/vo/alias/{id}") public UserVO getUserVOWithAlias (@PathVariable Long id) { User user = userService.getUserById(id); return userService.convertToVOWithAlias(user); } }
启动应用,访问 http://localhost:8080/users/vo/alias/1
,结果与方案一完全相同。此方案让 Service 层的代码更简洁,将映射关系维护在了 VO 定义中,权责更分明。
对于复杂的、大量的 DTO 转换,MapStruct 是业界公认的最佳实践。它在 编译期 生成映射代码,性能极高(接近手写 get/set
),并且功能强大。MapStruct 功能强大但需要额外配置,在本系列笔记的早期,我们将主要采用 方案一和方案二 。后续高级篇章中,我们再深入探讨其详细配置与使用。
对象类型 全称 约定包名 核心职责 PO Persistent Object entity
持久化对象 。与数据库中的表结构一一对应,一个 PO 对象就是数据库中的一条记录。它只应出现在数据访问层(Mapper)与服务层(Service)之间。DTO Data Transfer Object dto
数据传输对象 。用于在各个层之间传递数据,我们主要用它来 接收前端传递到 Controller 的请求数据 。它的字段完全根据业务操作的需求来定义。VO View Object vo
视图对象 。由 Controller 层 返回给前端的数据对象 。它的字段完全根据前端界面的展示需求来定制,可以隐藏敏感字段,也可以组合多个 PO 的数据。BO Business Object service
/bo
业务对象 。封装了核心的业务逻辑,是业务规则的载体。在复杂业务中,Service 层会处理 BO,并由 BO 完成具体的业务计算和状态变更。QO Query Object dto
/query
查询对象 。用于封装复杂的查询条件,通常作为 Controller 方法的参数,接收前端传递的筛选、排序、分页等请求参数。
您可能已经注意到,上表中除了我们使用的 PO, DTO, VO 之外,还出现了两个更高级的对象:BO 和 QO。了解它们将有助于我们建立更完整的后端分层思想。
BO (Business Object): 业务对象 这是纯粹的业务层核心,封装了最核心的业务逻辑和规则。在一个非常复杂的系统中(例如,包含复杂计价、风控、状态流转的订单系统),Service 层可能会创建和操作 BO 来执行业务计算。比如,一个 OrderBO
可能有一个 calculateTotalPrice()
方法,其中包含了复杂的折扣、优惠券和运费计算逻辑。
在我们当前的用户管理 CRUD 项目中,业务逻辑相对简单(主要是数据库操作的组合),我们将直接在 Service
层中实现这些逻辑,因此 暂时不会创建独立的 BO 类 ,但您需要理解这个概念,它对于驾驭复杂系统至关重要。
QO (Query Object): 查询对象 当我们的查询条件变得复杂时,QO 就派上了大用场。想象一下,如果我们需要根据用户名、邮箱、状态、注册时间范围来筛选用户,并且还要支持分页和排序,Controller 的方法签名可能会有七八个 @RequestParam
参数,显得非常臃肿。
此时,我们可以创建一个 UserQuery
对象,将所有这些查询参数都作为其属性。这样,Controller 方法就只需要接收一个 UserQuery
对象即可,代码会变得非常整洁和易于扩展。我们将在后续实现复杂查询功能时,正式引入 QO。
2.2. 项目骨架搭建与持久化整合 在上一节,我们明确了 VO, DTO, PO 等领域对象的职责。现在,我们将正式动手“施工”。本节的目标是双重的:首先,我们将搭建起一个专业、可扩展的三层架构骨架;其次,我们将为项目接入真实的 MySQL 数据库,为后续的 CRUD 实战做好万全准备。
2.2.1. 搭建三层架构的包与类结构 我们再次明确各层的职责:
Controller 层 : Web 入口,负责处理 HTTP 请求、参数校验、调用 Service 并返回响应。Service 层 : 业务核心,负责实现业务逻辑、管理事务。Mapper 层 : 数据持久层,负责与数据库直接交互。首先,请按照下图的结构,在您的项目中创建或重构出相应的包。一个清晰的目录结构是项目可维护性的第一保障。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 . 📂 src/main/java/com/example/springbootdemo ├── 📂 controller <- Controller 层 │ └── 📄 UserController.java ├── 📂 dto <- DTOs (Data Transfer Objects) ├── 📂 entity <- POs (Persistent Objects), 即实体类 │ └── 📄 User.java ├── 📂 mapper <- Mapper 层 (数据访问接口) │ └── 📄 UserMapper.java ├── 📂 service <- Service 接口层 │ └── 📄 UserService.java ├── 📂 service/impl <- Service 实现层 │ └── 📄 UserServiceImpl.java ├── 📂 vo <- VOs (View Objects) │ └── 📄 UserVO.java └── 📄 SpringBootDemoApplication.java
现在,请将我们在 2.1
节中定义的 User.java
类移动到 entity
包下,将 UserVO.java
类移动到 vo
包下。
接下来,我们创建 Service 层的接口和它的实现类骨架。
文件路径 : src/main/java/com/example/springbootdemo/service/UserService.java
(新增文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 package com.example.springbootdemo.service;import com.example.springbootdemo.vo.UserVO;import java.util.List;public interface UserService { UserVO findUserById (Long id) ; List<UserVO> findAllUsers () ; }
文件路径 : src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.java
(新增文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 package com.example.springbootdemo.service.impl;import com.example.springbootdemo.service.UserService;import com.example.springbootdemo.vo.UserVO;import org.springframework.stereotype.Service;import java.util.List;@Service public class UserServiceImpl implements UserService { @Override public UserVO findUserById (Long id) { return null ; } @Override public List<UserVO> findAllUsers () { return List.of(); } }
2.2.2. 整合 MyBatis-Plus 与数据库连接 项目骨架已经搭好,现在我们来为它注入灵魂——连接真实的数据库。
1. 添加 Maven 依赖 文件路径 : pom.xml
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <dependency > <groupId > cn.hutool</groupId > <artifactId > hutool-all</artifactId > <version > 5.8.13</version > </dependency > <dependency > <groupId > com.baomidou</groupId > <artifactId > mybatis-plus-spring-boot3-starter</artifactId > <version > 3.5.5</version > </dependency > <dependency > <groupId > com.mysql</groupId > <artifactId > mysql-connector-j</artifactId > <scope > runtime</scope > </dependency >
2. 配置数据源 文件路径 : src/main/resources/application.yml
(修改)
1 2 3 4 5 6 7 8 9 10 11 spring: datasource: url: jdbc:mysql://localhost:3306/springboot_demo?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver
请确保您已创建名为 springboot_demo
的数据库,并将用户名和密码替换为您自己的配置。
3. 改造实体类并建表 请在您的 springboot_demo
数据库中执行以下 SQL 语句来创建 t_user
表:
1 2 3 4 5 6 7 8 9 10 CREATE TABLE `t_user` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID' , `username` varchar (255 ) DEFAULT NULL COMMENT '用户名' , `password` varchar (255 ) DEFAULT NULL COMMENT '密码(后续章节会加密)' , `email` varchar (255 ) DEFAULT NULL COMMENT '邮箱' , `status` int DEFAULT NULL COMMENT '状态: 1-正常, 2-禁用' , PRIMARY KEY (`id`) ) ENGINE= InnoDB;
查看插入信息 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 INSERT INTO `t_user` (`username`, `password`, `email`, `status`) VALUES ('张三' , 'e10adc3949ba59abbe56e057f20f883e' , 'zhangsan@163.com' , 1 ), ('李四' , 'e10adc3949ba59abbe56e057f20f883e' , 'lisi_dev@qq.com' , 1 ), ('王五' , 'e10adc3949ba59abbe56e057f20f883e' , 'wang.wu@gmail.com' , 2 ), ('赵六' , 'e10adc3949ba59abbe56e057f20f883e' , 'zhaoliu@outlook.com' , 1 ), ('孙悟空' , 'e10adc3949ba59abbe56e057f20f883e' , 'sunwukong@huaguoshan.com' , 1 ), ('陈晓明' , 'e10adc3949ba59abbe56e057f20f883e' , 'chen.xm@126.com' , 1 ), ('刘静' , 'e10adc3949ba59abbe56e057f20f883e' , 'liujing88@hotmail.com' , 1 ), ('周伟' , 'e10adc3949ba59abbe56e057f20f883e' , 'zhouwei_cool@qq.com' , 1 ), ('吴磊' , 'e10adc3949ba59abbe56e057f20f883e' , 'wulei.actor@gmail.com' , 2 ), ('郑秀丽' , 'e10adc3949ba59abbe56e057f20f883e' , 'zhengxiuli@163.net' , 1 ), ('马云' , 'e10adc3949ba59abbe56e057f20f883e' , 'jack.ma@alibaba-inc.com' , 1 ), ('黄蓉' , 'e10adc3949ba59abbe56e057f20f883e' , 'huangrong@taohuadao.net' , 1 ), ('郭靖' , 'e10adc3949ba59abbe56e057f20f883e' , 'guojing@xiangyang.gov' , 1 ), ('杨过' , 'e10adc3949ba59abbe56e057f20f883e' , 'yangguo_daxia@gumu.org' , 2 ), ('林黛玉' , 'e10adc3949ba59abbe56e057f20f883e' , 'lin.daiyu@rongguofu.com' , 1 ), ('贾宝玉' , 'e10adc3949ba59abbe56e057f20f883e' , 'jia.baoyu@rongguofu.com' , 1 ), ('曹操' , 'e10adc3949ba59abbe56e057f20f883e' , 'caocao.ceo@san.guo' , 1 ), ('诸葛亮' , 'e10adc3949ba59abbe56e057f20f883e' , 'zhuge.liang@shuhan.cn' , 1 ), ('陆小凤' , 'e10adc3949ba59abbe56e057f20f883e' , 'luxiaofeng@sitaimei.com' , 1 ), ('花满楼' , 'e10adc3949ba59abbe56e057f20f883e' , 'huamanlou@baihuayuan.com' , 1 );
现在,我们修改 User.java
实体类,为其添加 MyBatis-Plus 的注解。
文件路径 : src/main/java/com/example/springbootdemo/entity/User.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 package com.example.springbootdemo.entity;import com.baomidou.mybatisplus.annotation.IdType;import com.baomidou.mybatisplus.annotation.TableId;import com.baomidou.mybatisplus.annotation.TableName;import lombok.Data;@Data @TableName("t_user") public class User { @TableId(value = "id", type = IdType.AUTO) private Long id; private String username; private String password; private String email; private Integer status; }
4. 创建 Mapper 接口 文件路径 : src/main/java/com/example/springbootdemo/mapper/UserMapper.java
(新增文件)
1 2 3 4 5 6 7 package com.example.springbootdemo.mapper;import com.baomidou.mybatisplus.core.mapper.BaseMapper;import com.example.springbootdemo.entity.User;public interface UserMapper extends BaseMapper <User> {}
仅需继承 BaseMapper<User>
,UserMapper
就立刻拥有了强大的 CRUD 能力。
5. 启用 Mapper 扫描 最后一步,让 Spring Boot 知道去哪里查找我们的 Mapper 接口。
文件路径 : src/main/java/com/example/springbootdemo/SpringBootDemoApplication.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 package com.example.springbootdemo;import org.mybatis.spring.annotation.MapperScan;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplication @MapperScan("com.example.springbootdemo.mapper") public class SpringBootDemoApplication { public static void main (String[] args) { SpringApplication.run(SpringBootDemoApplication.class, args); } }
至此,我们已经完成了项目最关键的奠基工作。我们不仅拥有了专业的分层结构,还成功地将应用与 MySQL 数据库连接了起来。现在,我们的项目已经万事俱备,只待我们去实现真正的业务功能。下一节,我们将从用户查询功能开始,正式进入 CRUD 接口的开发。
2.3. [R] 用户查询功能开发 项目骨架和持久化层已经准备就绪。现在,我们正式开始实现第一个核心功能——用户查询。我们将严格遵循我们制定的专业流程,并在实践中应用我们刚刚学到的分层思想。
2.3.1. DTO/QO 设计:封装查询参数 在 2.1
节,我们明确了 DTO 的职责之一是 接收前端的请求数据 。当查询条件变得复杂时,我们通常会使用一种特殊的 DTO——查询对象 (Query Object, QO) 来封装这些参数。
为什么需要 QO? 设想一下,我们的“查询所有用户”功能未来肯定需要支持 分页 ,甚至可能需要根据 用户名关键词 进行筛选。如果直接在 Controller 方法里写多个 @RequestParam
参数,代码会显得非常臃肿且难以扩展。
因此,我们首先创建一个 UserPageQuery
,用于封装分页查询的参数。
文件路径 : src/main/java/com/example/springbootdemo/dto/User/UserPageQuery.java
(新增文件)
1 2 3 4 5 6 7 8 9 10 11 12 package com.example.springbootdemo.dto.User;import lombok.Data;@Data public class UserPageQuery { private int pageNo = 1 ; private int pageSize = 10 ; }
2.3.2. 配置 MyBatis-Plus 分页插件 在使用 MyBatis-Plus 的分页功能(即 selectPage
方法)之前,我们需要先通过配置来启用它的 分页插件 。这是保证分页查询能够正常工作的关键一步。
文件路径 : src/main/java/com/example/springbootdemo/config/MybatisPlusConfig.java
(新增文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 package com.example.springbootdemo.config;import com.baomidou.mybatisplus.annotation.DbType;import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor () { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor (); interceptor.addInnerInterceptor(new PaginationInnerInterceptor (DbType.MYSQL)); return interceptor; } }
2.3.3. Service 层开发与单元测试 1. Service 层功能实现 现在,我们来填充 UserServiceImpl
中的业务逻辑。首先,确保 UserService
接口的定义是正确的。
文件路径 : src/main/java/com/example/springbootdemo/service/UserService.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 package com.example.springbootdemo.service;import com.example.springbootdemo.dto.User.UserPageQuery;import com.example.springbootdemo.vo.UserVO;import java.util.List;public interface UserService { UserVO findUserById (Long id) ; List<UserVO> findAllUsers (UserPageQuery query) ; }
接下来,实现 UserServiceImpl
。
文件路径 : src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 package com.example.springbootdemo.service.impl;import cn.hutool.core.bean.BeanUtil;import com.baomidou.mybatisplus.extension.plugins.pagination.Page;import com.example.springbootdemo.dto.User.UserPageQuery;import com.example.springbootdemo.entity.User;import com.example.springbootdemo.mapper.UserMapper;import com.example.springbootdemo.service.UserService;import com.example.springbootdemo.vo.UserVO;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Service;import java.util.Collections;import java.util.List;@Service public class UserServiceImpl implements UserService { @Autowired private UserMapper userMapper; @Override public UserVO findUserById (Long id) { User user = userMapper.selectById(id); if (user == null ) { return null ; } return convertToVO(user); } @Override public List<UserVO> findAllUsers (UserPageQuery query) { Page<User> page = new Page <>(query.getPageNo(), query.getPageSize()); Page<User> userPage = userMapper.selectPage(page, null ); return userPage.getRecords().stream() .map(this ::convertToVO) .toList(); } private UserVO convertToVO (User user) { if (user == null ) { return null ; } UserVO userVO = new UserVO (); BeanUtil.copyProperties(user, userVO); if (user.getStatus() != null ) { userVO.setStatusText(user.getStatus() == 1 ? "正常" : "已禁用" ); } return userVO; } }
2. Service 层单元测试 文件路径 : src/test/java/com/example/springbootdemo/service/UserServiceTest.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 package com.example.springbootdemo.service;import com.example.springbootdemo.dto.User.UserPageQuery;import com.example.springbootdemo.vo.UserVO;import org.junit.jupiter.api.Assertions;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.context.SpringBootTest;import java.util.List;@SpringBootTest public class UserServiceTest { @Autowired private UserService userService; @Test void testFindAllUsers_Pagination () { UserPageQuery query = new UserPageQuery (); query.setPageNo(1 ); query.setPageSize(5 ); List<UserVO> users = userService.findAllUsers(query); Assertions.assertNotNull(users); Assertions.assertEquals(5 , users.size()); System.out.println("第一页,5条数据:" + users); } }
2.3.4. Controller 层开发与优雅响应 Service 层通过单元测试后,我们来编写 Controller。
文件路径 : src/main/java/com/example/springbootdemo/controller/UserController.java
(新增)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 package com.example.springbootdemo.controller;import com.example.springbootdemo.dto.User.UserPageQuery;import com.example.springbootdemo.service.UserService;import com.example.springbootdemo.vo.UserVO;import io.swagger.v3.oas.annotations.Operation;import io.swagger.v3.oas.annotations.Parameter;import io.swagger.v3.oas.annotations.tags.Tag;import lombok.RequiredArgsConstructor;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;import java.util.List;@RestController @RequestMapping("/users") @RequiredArgsConstructor public class UserController { private final UserService userService; @GetMapping public List<UserVO> getAllUsers (UserPageQuery query) { return userService.findAllUsers(query); } @GetMapping("/{id}") public ResponseEntity<UserVO> getUserById (@PathVariable Long id) { UserVO userVO = userService.findUserById(id); return userVO != null ? ResponseEntity.ok(userVO) : ResponseEntity.notFound().build(); } }
在 getUserById
方法中,我们使用了一行关键代码:
1 `return userVO != null ? ResponseEntity.ok(userVO) : ResponseEntity.notFound().build();`
这行代码背后,体现了专业 API 设计的核心思想:精确控制 HTTP 响应。
为什么需要它? 如果我们直接返回 UserVO
对象,当查询的用户不存在时,userService
会返回 null
。Spring MVC 默认会将 null
转换为空的响应体,并返回 200 OK
状态码。这对前端来说是有歧义的:是成功地查到了一个“空”的用户,还是用户根本不存在?这不符合 RESTful 的设计原则。
ResponseEntity<T>
是什么? 它是 Spring 提供的一个泛型类,用于 完整地封装一个 HTTP 响应 。通过它,我们可以随心所欲地控制:
响应状态码 (Status Code) : 如 200 OK
, 404 Not Found
, 400 Bad Request
等。响应头 (Headers) : 如 Content-Type
, Location
等。响应体 (Body) : 我们实际返回的数据(比如我们的 UserVO
)。通过使用 ResponseEntity
,我们的 API 变得更加健壮和语义化,能够清晰地向客户端传达操作的结果。
2.3.5. API 接口测试 现在,启动您的 Spring Boot 主应用,并使用 cURL 或 Apifox 等工具,手动对我们刚刚完成的接口进行验证。
测试用例 1:分页查询用户
请求方法 : GET
请求 URL : http://localhost:8080/users?pageNo=1&pageSize=5
1 curl "http://localhost:8080/users?pageNo=1&pageSize=5"
1 2 3 4 5 6 7 [ { "id" : 1 , "name" : "张三" , "statusText" : "正常" } , { "id" : 2 , "name" : "李四" , "statusText" : "正常" } , { "id" : 3 , "name" : "王五" , "statusText" : "已禁用" } , { "id" : 4 , "name" : "赵六" , "statusText" : "正常" } , { "id" : 5 , "name" : "孙悟空" , "statusText" : "正常" } ]
测试用-例 2:查询不存在的用户
请求方法 : GET
请求 URL : http://localhost:8080/users/999
1 curl -i "http://localhost:8080/users/999"
1 2 3 HTTP/1.1 404 Not FoundContent-Length : 0...
通过手动测试,我们验证了接口的正确性。但您可能也发现了,每次都需要手动构建 URL、查看 JSON 响应,当接口变多、参数变复杂时,这个过程会变得相当繁琐且容易出错。有没有更高效、更直观的方式呢?下一节,我们将正式引入 SpringDoc 来彻底解决这个“痛点” 。
2.4. API 文档与测试:SpringDoc 的引入与实践 随着 UserController
中的查询功能编写完毕,一个现实的团队协作问题摆在了我们面前:
我们如何将这些 API 接口的信息,准确、高效地传递给其他人(比如前端同事,或者未来的自己)?
2.4.1. 痛点:为什么需要自动化 API 文档? 在没有自动化工具的时代,我们通常依赖以下方式,但它们都存在明显弊端:
手动编写文档 (如 Word, Wiki) : 极其繁琐、容易出错,而且一旦代码更新,文档几乎总是会忘记同步,导致文档与代码不一致,造成更大的困扰。口头沟通或发送消息 : 效率低下,信息零散,无法作为可靠的、可追溯的技术凭证。代码注释 : 虽然必要,但无法提供一个全局的、可交互的 API 视图。这些痛点最终都指向一个核心需求:我们需要一个能够 与代码自动同步、标准化且支持在线交互 的 API 文档解决方案。
2.4.2 解决方案:SpringDoc 与 OpenAPI 3 OpenAPI 3 : 它是当今 RESTful API 领域的事实标准规范(前身是 Swagger 2.0 规范)。它定义了一套标准的、与语言无关的格式(通常是 YAML 或 JSON),用于完整地描述 API 的所有细节。Swagger UI : 这是一个开源工具,它可以解析符合 OpenAPI 规范的文档,并生成一个美观、可交互的 HTML 界面,让开发者可以直接在浏览器中浏览和测试 API。SpringDoc : 这是一个 Java 库,它能够 自动扫描 我们的 Spring Boot 应用中的 @RestController
等注解,并 自动生成 符合 OpenAPI 3 规范的 API 文档。简而言之,我们的工作流程是:编写代码 -> SpringDoc 自动生成文档 -> Swagger UI 可视化文档 。
2.4.3 技术选型:为什么是 SpringDoc,而不是传统的 Swagger (SpringFox)? 在 Spring Boot 2.x 时代,SpringFox
是集成 Swagger 2 的主流选择。但随着技术发展,SpringDoc
已经成为当下的最佳实践,原因如下:
特性 SpringDoc (我们选择的) SpringFox (旧方案) 核心规范 OpenAPI 3.x Swagger 2.0 Spring Boot 兼容性 完美兼容 Spring Boot 3.x / 2.x 对 Spring Boot 3.x 支持停滞,存在兼容性问题 社区活跃度 持续活跃开发与维护 社区已基本停止维护 配置 依赖更少,自动化配置程度更高 配置相对繁琐
结论 : SpringDoc
是面向未来的、与 Spring Boot 生态结合最紧密的选择,因此我们毫无疑问地选择它。
2.4.4 实战:集成 SpringDoc 到我们的项目中 第一步:添加 Maven 依赖
我们只需添加一个依赖,即可同时拥有 OpenAPI 文档生成和 Swagger UI 界面的能力。
文件路径 : pom.xml
(修改)
1 2 3 4 5 <dependency > <groupId > org.springdoc</groupId > <artifactId > springdoc-openapi-starter-webmvc-ui</artifactId > <version > 2.8.9</version > </dependency >
第二步:丰富 API 注解
为了让生成的文档信息更丰富、更易读,我们可以使用 SpringDoc 提供的注解来“装饰”我们的 Qo 和 Controller 。
文件路径 : src/main/java/com/example/springbootdemo/dto/UserPageQuery.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 package com.example.springbootdemo.dto;import io.swagger.v3.oas.annotations.media.Schema;import lombok.Data;@Schema(description = "用户分页查询参数") @Data public class UserPageQuery { @Schema(description = "页码,从0开始", example = "0", defaultValue = "0") private int pageNo = 0 ; @Schema(description = "每页大小", example = "10", defaultValue = "10") private int pageSize = 10 ; }
文件路径 : src/main/java/com/example/springbootdemo/controller/UserController.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 package com.example.springbootdemo.controller;import com.example.springbootdemo.dto.User.UserPageQuery;import com.example.springbootdemo.service.UserService;import com.example.springbootdemo.vo.UserVO;import io.swagger.v3.oas.annotations.Operation;import io.swagger.v3.oas.annotations.Parameter;import io.swagger.v3.oas.annotations.media.Schema;import io.swagger.v3.oas.annotations.tags.Tag;import lombok.RequiredArgsConstructor;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;import java.util.List;@Tag(name = "用户管理", description = "提供用户相关的CRUD接口") @RestController @RequestMapping("/users") @RequiredArgsConstructor public class UserController { private final UserService userService; @GetMapping @Operation(summary = "查询所有用户列表") public List<UserVO> getAllUsers ( @Parameter( description = "分页查询参数", required = true, schema = @Schema(implementation = UserPageQuery.class) ) UserPageQuery query) { return userService.findAllUsers(query); } @GetMapping("/{id}") @Operation(summary = "根据ID查询单个用户") public ResponseEntity<UserVO> getUserById ( // 新增 @Parameter(description = "用户ID", required = true, example = "1") @PathVariable Long id ) { UserVO userVO = userService.findUserById(id); return userVO != null ? ResponseEntity.ok(userVO) : ResponseEntity.notFound().build(); } }
@Tag
: 在类上使用,用于对整个 Controller 的接口进行 分组 。@Operation
: 在方法上使用,用于 一句话描述 该接口的功能。@Parameter
: 在方法参数上使用,用于 描述参数 的含义、是否必需等信息。@Schema
:在属性上使用,用于描述 参数的示例值 提供给接口调用传参使用第三步:启动与验证
现在,请 重启 您的 Spring Boot 主应用。
无需打开 Apifox,直接在浏览器中访问:
1 http://localhost:8080/swagger-ui.html
您将会看到一个专业、美观且功能强大的 API 文档界面。在这里,您可以清晰地看到我们定义的接口分组、描述、参数等信息,并可以直接点击 “Try it out” 按钮,在线发起请求并查看实时响应。这套动态文档将成为我们后续开发和测试的“指挥中心”。
这仅仅是 SpringDoc 的入门。在后续章节中,当我们遇到 DTO 的详细描述、统一的认证配置等更复杂的场景时,我们还会学习 @Schema
、@ApiResponse
等更多高级注解,让我们的 API 文档变得更加专业和完善。
2.5. 统一响应封装:构建全局 Result 返回值 在 2.3
节的查询功能中,我们已经能成功返回 UserVO
列表或单个 UserVO
。但这还不够“专业”。一个成熟的后端 API,其所有接口都应该返回 结构统一 的响应数据。本节,我们将引入企业级开发中的一项最佳实践——构建全局响应封装。
2.5.1. 为什么要统一响应格式? 想象一下前端同事在调用我们的 API 时的场景,如果没有统一的响应格式,他们会遇到以下痛点:
前端处理困难 :前端开发者需要为每个接口编写不同的逻辑来处理成功和失败。GET /users
成功时返回一个 UserVO[]
数组,而 GET /users/1
成功时返回一个 UserVO
对象,失败时又可能返回空或 404
。这种不一致性会极大地增加前端的处理逻辑复杂度。成功与否判断不清晰 :仅通过 HTTP 状态码(200
)无法区分所有业务场景。例如,“用户名已存在”是一个业务逻辑上的失败,但 HTTP 状态码可能依然是 200
,前端无法仅凭状态码判断操作是否真正成功。缺乏元信息 :响应中只包含业务数据 data
,缺少了像 业务状态码 code
和 提示信息 message
这样的元数据,不利于前端进行统一的提示或错误处理。为了解决这些问题,我们需要定义一个通用的响应体结构,通常称为 Result
或 ApiResponse
。
2.5.2. 创建通用响应类 Result < T > 我们首先创建一个通用的、支持泛型的 Result<T>
类,并约定所有 API 接口都返回这个结构的对象。
我们先创建一个枚举来标准化业务状态码。
文件路径 : src/main/java/com/example/springbootdemo/common/ResultCode.java
(新增文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 package com.example.springbootdemo.common;import lombok.Getter;import lombok.AllArgsConstructor;@Getter @AllArgsConstructor public enum ResultCode { SUCCESS(200 , "操作成功" ), BAD_REQUEST(400 , "错误的请求" ), UNAUTHORIZED(401 , "未经授权" ), NOT_FOUND(404 , "资源未找到" ), ERROR(500 , "服务器内部错误" ), SERVICE_UNAVAILABLE(503 , "服务不可用" ), UserNotFound(1001 , "用户未找到" ), UserAlreadyExists(1002 , "用户已存在" ), UserNotLogin(1003 , "用户未登录" ); private final int code; private final String message; }
接下来,创建 Result<T>
类。
文件路径 : src/main/java/com/example/springbootdemo/common/Result.java
(新增文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 package com.example.springbootdemo.common;import lombok.Getter;import lombok.AccessLevel;import lombok.AllArgsConstructor;import java.io.Serializable;@Getter @AllArgsConstructor(access = AccessLevel.PRIVATE) public final class Result <T> implements Serializable { private final Integer code; private final String message; private final T data; public static <T> Result<T> success () { return new Result <>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), null ); } public static <T> Result<T> success (T data) { return new Result <>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), data); } public static <T> Result<T> error (ResultCode resultCode) { return new Result <>(resultCode.getCode(), resultCode.getMessage(), null ); } public static <T> Result<T> error (String message) { return new Result <>(ResultCode.ERROR.getCode(), message, null ); } public static <T> Result<T> error (Integer code, String message) { return new Result <>(code, message, null ); } public static <T> Result<T> error () { return error(ResultCode.ERROR); } }
2.5.3. ResponseEntity 深度应用 在 2.3.4
节,我们初步接触了 ResponseEntity
。现在,我们将深度挖掘它的潜力,学习如何将我们自定义的 Result<T>
对象与精确的 HTTP 状态码结合,构建出真正专业的 API 响应。
我们需要明确两者之间的职责分工:
Result<T>
(响应体 Body) : 负责承载 业务层面 的信息。code
和 message
反映的是业务的成功、失败或校验结果。ResponseEntity
(HTTP 响应) : 负责承载 HTTP 协议层面 的信息。它的 Status Code
反映的是 HTTP 请求本身的处理结果(如 200 OK
, 404 Not Found
, 500 Internal Server Error
)。最佳实践映射关系 :
操作场景 HTTP Status (ResponseEntity) 响应体 (Result Body) Controller 返回类型 查询成功 (GET) 200 OK
Result.success(data)
ResponseEntity<Result<UserVO>>
创建成功 (POST) 201 Created
Result.success()
ResponseEntity<Result<Void>>
更新成功 (PUT) 200 OK
Result.success(updatedData)
ResponseEntity<Result<UserVO>>
删除成功 (DELETE) 204 No Content
(无响应体) ResponseEntity<Void>
客户端错误 400 Bad Request
Result.error(错误信息)
ResponseEntity<Result<Void>>
资源未找到 404 Not Found
Result.error("用户不存在")
ResponseEntity<Result<Void>>
服务端异常 500 Internal Server Error
Result.error("系统异常")
ResponseEntity<Result<Void>>
现在,我们就根据上方的实践关系来修改我们的 Controller
业务代码
文件路径 : src/main/java/com/example/springbootdemo/controller/UserController.java
(修改文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 package com.example.springbootdemo.controller;import com.example.springbootdemo.common.Result;import com.example.springbootdemo.dto.User.UserPageQuery;import com.example.springbootdemo.service.UserService;import com.example.springbootdemo.vo.UserVO;import io.swagger.v3.oas.annotations.Operation;import io.swagger.v3.oas.annotations.Parameter;import io.swagger.v3.oas.annotations.media.Schema;import io.swagger.v3.oas.annotations.tags.Tag;import lombok.RequiredArgsConstructor;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;import java.util.List;@Tag(name = "用户管理", description = "提供用户相关的CRUD接口") @RestController @RequestMapping("/users") @RequiredArgsConstructor public class UserController { private final UserService userService; @GetMapping @Operation(summary = "查询所有用户列表") public ResponseEntity<Result<List<UserVO>>> getAllUsers ( @Parameter( description = "分页查询参数", required = true, schema = @Schema(implementation = UserPageQuery.class) ) UserPageQuery query) { List<UserVO> users = userService.findAllUsers(query); return ResponseEntity.ok(Result.success(users)); } @GetMapping("/{id}") @Operation(summary = "根据ID查询单个用户") public ResponseEntity<Result<UserVO>> getUserById ( @Parameter(description = "用户ID", required = true, example = "1") @PathVariable Long id ) { UserVO userVO = userService.findUserById(id); if (userVO != null ) { return ResponseEntity.ok(Result.success(userVO)); } else { return ResponseEntity.ok(Result.error("用户不存在" )); } } }
现在我们打开 http://localhost : 8080/swagger-ui/index.html 即可查看到如下的内容信息:
可以看到我们的返回值完全符合大型级别返回规范,且前端可以根据不同的返回状态码和信息去接收数据!
2.6. [C] 新增功能开发 (POST) 完成了查询(Read)功能后,我们来继续实现 CRUD 中的创建(Create)功能。我们将严格遵循之前确立的 分层 、DTO 和 “单元测试 -> API 测试” 的严谨流程。
2.6.1. DTO 设计与 Service 层开发 1. DTO 设计 为“新增用户”操作创建一个专门的 UserSaveDTO
是一个非常好的实践。它的职责是精确地承载创建用户时 所有必需 的、且 允许客户端提供 的数据。
为什么需要独立的 UserSaveDTO
?
安全性 : 防止客户端通过 API 请求传递一些不应由他们设置的字段,例如 id
(应由数据库生成)、status
(可能有默认值或由特定逻辑控制)等。职责单一 : DTO 的字段完全为“新增”这个业务场景服务。文件路径 : src/main/java/com/example/springbootdemo/dto/User/UserSaveDTO.java
(新增文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 package com.example.springbootdemo.dto.User;import io.swagger.v3.oas.annotations.media.Schema;import lombok.Data;@Data @Schema(description = "用户新增数据传输对象") public class UserSaveDTO { @Schema(description = "用户名", required = true, example = "newuser") private String username; @Schema(description = "密码", required = true, example = "123456") private String password; @Schema(description = "邮箱", example = "newuser@example.com") private String email; }
2. Service 层功能实现 首先,更新 UserService
接口,添加 saveUser
方法。
文件路径 : src/main/java/com/example/springbootdemo/service/UserService.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 package com.example.springbootdemo.service;import com.example.springbootdemo.dto.User.UserPageQuery;import com.example.springbootdemo.dto.UserSaveDTO; import com.example.springbootdemo.vo.UserVO;import java.util.List;public interface UserService { UserVO findUserById (Long id) ; List<UserVO> findAllUsers (UserPageQuery query) ; Long saveUser (UserSaveDTO dto) ; }
接下来,在 UserServiceImpl
中实现该方法。
文件路径 : src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 package com.example.springbootdemo.service.impl;import cn.hutool.core.bean.BeanUtil;import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;import com.baomidou.mybatisplus.extension.plugins.pagination.Page;import com.example.springbootdemo.dto.User.UserPageQuery;import com.example.springbootdemo.dto.UserSaveDTO; import com.example.springbootdemo.entity.User;import com.example.springbootdemo.mapper.UserMapper;import com.example.springbootdemo.service.UserService;import com.example.springbootdemo.vo.UserVO;import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service;import java.util.Collections;import java.util.List;import java.util.Optional;@Service @RequiredArgsConstructor public class UserServiceImpl implements UserService { private final UserMapper userMapper; @Override public Long saveUser (UserSaveDTO dto) { User existingUser = userMapper.selectOne( new QueryWrapper <User>().lambda().eq(User::getUsername, dto.getUsername()) ); Assert.isNull(existingUser, "用户名 [{}] 已存在,请更换!" , dto.getUsername()); User user = Convert.convert(User.class, dto); user.setStatus(1 ); userMapper.insert(user); return user.getId(); } }
这里细心的朋友可能会发现,我在代码里面使用了 Hutool
中的 Convert.convert
而不使用原来的 ConvertToVo
方法,其实他们的差距就如下表所示
简单来说:
Convert.convert(User.class, dto)
能成功,是因为 UserSaveDTO
和 User
之间的属性(如 username
, password
)是同名同类型的,可以直接复制。Convert.convert(UserVO.class, user)
无法 正确生成 statusText
字段,因为它不知道 User
里的 status
字段和 UserVO
里的 statusText
字段之间存在 1 -> "正常"
的映射关系。2.6.2. Service 层单元测试 我们必须为新的 saveUser
方法编写单元测试,以确保其逻辑的正确性。
文件路径 : src/test/java/com/example/springbootdemo/service/UserServiceTest.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 package com.example.springbootdemo.service;import cn.hutool.core.util.IdUtil;import cn.hutool.core.util.StrUtil;import com.example.springbootdemo.dto.User.UserPageQuery;import com.example.springbootdemo.dto.User.UserSaveDTO;import com.example.springbootdemo.vo.UserVO;import org.junit.jupiter.api.Assertions;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.context.SpringBootTest;import java.util.List;@SpringBootTest public class UserServiceTest { @Autowired private UserService userService; @Test void testSaveUser () { UserSaveDTO dto = new UserSaveDTO (); String username = IdUtil.fastSimpleUUID(); dto.setUsername(username); dto.setPassword("123456" ); dto.setEmail(username + "@test.com" ); Long newUserId = userService.saveUser(dto); Assertions.assertNotNull(newUserId); UserVO savedUser = userService.findUserById(newUserId); Assertions.assertNotNull(savedUser); Assertions.assertEquals(username, savedUser.getName()); System.out.println(StrUtil.format("用户 {} 保存成功" , username)); } @Test void testSaveUser_UsernameExists () { UserSaveDTO dto = new UserSaveDTO (); dto.setUsername("张三" ); dto.setPassword("123456" ); userService.saveUser(dto); } }
2.6.3. Controller 层开发与 API 测试 (SpringDoc) Service 层逻辑验证无误后,我们来创建对应的 Controller 接口。
文件路径 : src/main/java/com/example/springbootdemo/controller/UserController.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 package com.example.springbootdemo.controller;import com.example.springbootdemo.common.Result;import com.example.springbootdemo.dto.UserSaveDTO;import org.springframework.http.HttpStatus;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;@Tag(name = "用户管理", description = "提供用户相关的CRUD接口") @RestController @RequestMapping("/users") @RequiredArgsConstructor public class UserController { private final UserService userService; @Operation(summary = "新增用户") @PostMapping public ResponseEntity<Result<Long>> saveUser (@RequestBody UserSaveDTO dto) { Long userId = userService.saveUser(dto); return ResponseEntity.status(HttpStatus.CREATED).body(Result.success(userId)); } }
API 接口测试 (使用 SpringDoc) 重启您的 Spring Boot 应用,并访问 http://localhost:8080/swagger-ui.html
。您会看到新增的 POST /users
接口。
展开 POST /users
接口。 点击 “Try it out” 。 在 Request body 的 JSON 编辑区中,输入以下内容:1 2 3 4 5 { "username" : "new_user_from_swagger" , "password" : "password123" , "email" : "swagger@example.com" }
点击 “Execute” 。 您将会看到如下的响应结果,这表明用户已成功创建。
data
字段中的 21
是数据库为新用户生成的自增 ID,您的实际结果可能会不同。同时请注意,我们遵循 RESTful 最佳实践,为“创建成功”返回了 201 Created
状态码。
2.7. [U] 修改功能开发 (PUT) 完成了新增(Create)和查询(Read)之后,我们来继续实现 CRUD 中的更新(Update)功能。我们将继续遵循之前确立的严谨流程。
2.7.1. DTO 设计与 Service 层开发 1. DTO 设计 与新增操作类似,为“修改用户”创建一个专门的 UserUpdateDTO
也至关重要。
为什么需要独立的 UserUpdateDTO
?
明确意图 : DTO 的字段清晰地表明了哪些信息是允许被修改的。例如,我们通常不允许用户修改他们的 username
,这个规则就可以在 DTO 的字段定义中体现。数据绑定 : DTO 中必须包含 id
字段,以便 Service 层知道要更新的是哪一条记录。文件路径 : src/main/java/com/example/springbootdemo/dto/User/UserUpdateDTO.java
(新增文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 package com.example.springbootdemo.dto.User;import io.swagger.v3.oas.annotations.media.Schema;import lombok.Data;@Data @Schema(description = "用户修改数据传输对象") public class UserUpdateDTO { @Schema(description = "用户ID", required = true, example = "1") private Long id; @Schema(description = "密码", example = "new_password_123") private String password; @Schema(description = "邮箱", example = "new_email@example.com") private String email; @Schema(description = "状态: 1-正常, 2-禁用", example = "2") private Integer status; }
2. Service 层功能实现 首先,更新 UserService
接口。
文件路径 : src/main/java/com/example/springbootdemo/service/UserService.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 package com.example.springbootdemo.service;import com.example.springbootdemo.dto.User.UserPageQuery;import com.example.springbootdemo.dto.User.UserSaveDTO;import com.example.springbootdemo.dto.User.UserUpdateDTO; import com.example.springbootdemo.vo.UserVO;import java.util.List;public interface UserService { Long saveUser (UserSaveDTO dto) ; void updateUser (UserUpdateDTO dto) ; }
接下来,在 UserServiceImpl
中实现该方法。
文件路径 : src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 package com.example.springbootdemo.service.impl;import cn.hutool.core.bean.BeanUtil;import cn.hutool.core.convert.Convert;import cn.hutool.core.lang.Assert;import com.example.springbootdemo.dto.User.UserUpdateDTO;import com.example.springbootdemo.entity.User;@Service @RequiredArgsConstructor public class UserServiceImpl implements UserService { private final UserMapper userMapper; @Override public void updateUser (UserUpdateDTO dto) { User user = userMapper.selectById(dto.getId()); Assert.notNull(user, "用户 ID [{}] 不存在,无法修改!" , dto.getId()); User updatedUser = Convert.convert(User.class, dto); userMapper.updateById(updatedUser); } }
2.7.2. Service 层单元测试 文件路径 : src/test/java/com/example/springbootdemo/service/UserServiceTest.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 package com.example.springbootdemo.service;import cn.hutool.core.util.IdUtil;import cn.hutool.core.util.StrUtil;import com.example.springbootdemo.dto.User.UserPageQuery;import com.example.springbootdemo.dto.User.UserSaveDTO;import com.example.springbootdemo.dto.User.UserUpdateDTO;import com.example.springbootdemo.vo.UserVO;import org.junit.jupiter.api.Assertions;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.context.SpringBootTest;import java.util.List;@SpringBootTest public class UserServiceTest { @Autowired private UserService userService; @Test void testUpdateUser () { UserUpdateDTO dto = new UserUpdateDTO (); dto.setId(1L ); String newEmail = IdUtil.fastSimpleUUID() + "@updated.com" ; dto.setEmail(newEmail); dto.setStatus(2 ); userService.updateUser(dto); UserVO updatedUser = userService.findUserById(1L ); System.out.println(StrUtil.format("用户 {} 更新成功" , updatedUser.getName())); } @Test void testUpdateUser_NotFound () { UserUpdateDTO dto = new UserUpdateDTO (); dto.setId(9999L ); dto.setEmail("test@test.com" ); Assertions.assertThrows(IllegalArgumentException.class, () -> { userService.updateUser(dto); }); } }
2.7.3. Controller 层开发与 API 测试 (SpringDoc) 文件路径 : src/main/java/com/example/springbootdemo/controller/UserController.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 package com.example.springbootdemo.controller;import com.example.springbootdemo.common.Result;import com.example.springbootdemo.dto.User.UserUpdateDTO;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;@Tag(name = "用户管理", description = "提供用户相关的CRUD接口") @RestController @RequestMapping("/users") @RequiredArgsConstructor public class UserController { private final UserService userService; @Operation(summary = "修改用户信息") @PutMapping public ResponseEntity<Result<Void>> updateUser (@RequestBody UserUpdateDTO dto) { userService.updateUser(dto); return ResponseEntity.ok(Result.success()); } }
API 接口测试 (使用 SpringDoc) 重启您的 Spring Boot 应用,并访问 http://localhost:8080/swagger-ui.html
。您会看到新增的 PUT /users
接口。
展开 PUT /users
接口。 点击 “Try it out” 。 在 Request body 的 JSON 编辑区中,输入以下内容来修改 ID 为 2
的用户:1 2 3 4 5 { "id" : 2 , "email" : "lisi_updated@example.com" , "status" : 2 }
点击 “Execute” 。 您将会看到一个 200 OK
的成功响应,表示用户数据已成功更新。您可以再次调用 GET /users/2
接口来验证数据是否真的发生了变化。
2.8. [D] 删除功能开发 (DELETE) 现在,我们来实现用户管理 CRUD 功能的最后一部分:删除指定的用户。我们将继续遵循之前确立的严谨流程,确保代码的健壮性和专业性。
2.8.1. Service 层开发与单元测试 对于删除操作,我们不需要设计新的 DTO,因为通常只需要一个 id
即可唯一确定要删除的资源。
1. Service 层功能实现 首先,在 UserService
接口中添加我们的新方法。
文件路径 : src/main/java/com/example/springbootdemo/service/UserService.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 package com.example.springbootdemo.service;public interface UserService { void updateUser (UserUpdateDTO dto) ; void deleteUserById (Long id) ; }
接下来,在 UserServiceImpl
中实现该方法。
文件路径 : src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 package com.example.springbootdemo.service.impl;import cn.hutool.core.lang.Assert;@Service @RequiredArgsConstructor public class UserServiceImpl implements UserService { private final UserMapper userMapper; @Override public void deleteUserById (Long id) { User user = userMapper.selectById(id); Assert.notNull(user, "用户 ID [{}] 不存在,无法删除!" , id); userMapper.deleteById(id); } }
2. Service 层单元测试 为确保删除逻辑及其前置校验的正确性,我们需要编写相应的单元测试。
文件路径 : src/test/java/com/example/springbootdemo/service/UserServiceTest.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 package com.example.springbootdemo.service;import cn.hutool.core.util.IdUtil;import com.example.springbootdemo.entity.User;import com.example.springbootdemo.mapper.UserMapper;@SpringBootTest public class UserServiceTest { @Autowired private UserService userService; @Autowired private UserMapper userMapper; @Test void testDeleteUser () { User testUser = new User (); testUser.setUsername(IdUtil.fastSimpleUUID()); testUser.setPassword("to_be_deleted" ); userMapper.insert(testUser); Long newUserId = testUser.getId(); Assertions.assertNotNull(newUserId, "测试数据插入失败" ); userService.deleteUserById(newUserId); UserVO deletedUser = userService.findUserById(newUserId); Assertions.assertNull(deletedUser); System.out.println(StrUtil.format("用户 ID [{}] 删除成功" , newUserId)); } @Test void testDeleteUser_NotFound () { Long nonExistentUserId = 9999L ; Assertions.assertThrows(IllegalArgumentException.class, () -> { userService.deleteUserById(nonExistentUserId); }); } }
2.8.2. Controller 层开发与 API 测试 (SpringDoc) 文件路径 : src/main/java/com/example/springbootdemo/controller/UserController.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 package com.example.springbootdemo.controller;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;@Tag(name = "用户管理", description = "提供用户相关的CRUD接口") @RestController @RequestMapping("/users") @RequiredArgsConstructor public class UserController { private final UserService userService; @Operation(summary = "根据ID删除用户") @DeleteMapping("/{id}") public ResponseEntity<Void> deleteUser ( @Parameter(description = "用户ID", required = true, example = "20") @PathVariable Long id) { userService.deleteUserById(id); return ResponseEntity.noContent().build(); } }
RESTful 最佳实践 : 对于 DELETE
操作,如果成功执行,最佳实践是返回 HTTP 204 No Content
状态码。这个状态码表示服务器成功处理了请求,但响应体中 没有内容 。ResponseEntity.noContent().build()
正是用于构建这种标准响应。注意,由于没有响应体,我们返回的类型是 ResponseEntity<Void>
,也就不再需要包装 Result
对象了。
3. [深度交互] 高级请求处理与数据绑定 摘要 : 在第二章的实战中,我们已经搭建了项目骨架并实现了核心的 CRUD 功能。这让我们对 Spring MVC 的基础工作流程有了扎实的体感。从本章开始,我们将深入框架的“毛细血管”,探索那些能让我们的代码更灵活、更健壮、更专业的高级功能。
3.1. 自定义类型转换器:实现枚举参数绑定 3.1.1. 需求分析:实现按状态筛选用户 在 2.x
版本中,我们的用户查询接口只能进行简单的分页。现在,产品经理提出了新需求:在查询用户列表时,能够根据用户状态(正常/禁用)进行筛选 。
从 API 设计的角度,一个理想的请求 URL 应该是这样的:GET /users?status=1
,其中 1
代表“正常”。
在后端,为了代码的可读性和健壮性,我们不希望在代码里到处使用 1
、2
这样的“难懂数字”,而是倾向于使用更具语义的枚举 (Enum) 来代表用户状态。这就带来了一个问题:
Spring MVC 默认不知道如何将前端传来的字符串 "1"
转换为我们后端定义的 UserStatusEnum
枚举。 本节,我们就来优雅地解决这个问题。
3.1.2. 改造实践:在 DTO 与 Service 中使用枚举 1. 创建状态枚举 首先,我们创建一个代表用户状态的枚举类。
文件路径 : src/main/java/com/example/springbootdemo/enums/UserStatusEnum.java
(新增文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 package com.example.springbootdemo.enums;import lombok.AllArgsConstructor;import lombok.Getter;@Getter @AllArgsConstructor public enum UserStatusEnum { NORMAL(1 , "正常" ), DISABLED(2 , "已禁用" ); private final int code; private final String description; public static UserStatusEnum fromCode (int code) { for (UserStatusEnum status : values()) { if (status.getCode() == code) { return status; } } return null ; } }
2. 更新查询 DTO 接下来,我们在分页查询 DTO 中,添加 status
字段,并将其类型定义为我们刚刚创建的枚举。
文件路径 : src/main/java/com/example/springbootdemo/dto/User/UserPageQuery.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 package com.example.springbootdemo.dto.User;import com.example.springbootdemo.enums.UserStatusEnum;import io.swagger.v3.oas.annotations.media.Schema;import lombok.Data;@Data @Schema(description = "用户分页查询参数") public class UserPageQuery { @Schema(description = "页码,从1开始", example = "1") private int pageNo = 1 ; @Schema(description = "每页条数", example = "10") private int pageSize = 10 ; @Schema(description = "用户状态: 1-正常, 2-禁用", example = "1") private UserStatusEnum status; }
3. 更新 Service 层 现在,我们修改 Service 层的 findAllUsers
方法,让它能够根据传入的 status
参数,动态地构建查询条件。
文件路径 : src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 package com.example.springbootdemo.service.impl;import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;import com.example.springbootdemo.entity.User;@Service @RequiredArgsConstructor public class UserServiceImpl implements UserService { private final UserMapper userMapper; @Override public List<UserVO> findAllUsers (UserPageQuery query) { Page<User> page = new Page <>(query.getPageNo(), query.getPageSize()); LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper <>(); UserStatusEnum status = query.getStatus(); if (ObjectUtil.isNotEmpty(status)) { queryWrapper.eq(User::getStatus, status.getCode()); } Page<User> pageResult = userMapper.selectPage(page, queryWrapper); return pageResult.getRecords().stream() .map(this ::convertToVO) .collect(Collectors.toList()); } }
3.1.3. 核心技术:实现并注册自定义 Converter 完成了业务逻辑的改造,现在我们来解决最核心的问题:搭建起前端传入的字符串 “1”
和后端 UserStatusEnum.NORMAL
之间的桥梁。
我们需要实现 Spring 提供的 Converter<S, T>
接口,其中 S
是源类型(String
),T
是目标类型(UserStatusEnum
)。
文件路径 : src/main/java/com/example/springbootdemo/converter/StringToUserStatusEnumConverter.java
(新增文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 package com.example.springbootdemo.converter;import com.example.springbootdemo.enums.UserStatusEnum;import org.springframework.stereotype.Component;import org.springframework.core.convert.converter.Converter;@Component public class StringToUserStatusEnumConverter implements Converter <String, UserStatusEnum> { @Override public UserStatusEnum convert (String source) { if (source == null || source.isEmpty()) { return null ; } int code = Integer.parseInt(source); return UserStatusEnum.fromCode(code); } }
自动注册的魔力 :因为我们将这个转换器声明为了一个 @Component
Bean,Spring Boot 的自动配置机制会扫描到它,并自动将其添加到全局的转换服务中。这意味着我们无需任何额外配置 ,这个转换规则就会对所有 Controller 生效。
最妙的是,我们的 UserController
中的 getAllUsers
方法无需任何改动 。Spring MVC 在进行参数绑定时,会自动发现并使用我们自定义的 StringToUserStatusEnumConverter
,将 status
请求参数(String 类型)转换为 UserPageQuery
对象中的 status
字段(UserStatusEnum
类型)。
示例流程如下图所示:
一个 HTTP 请求所承载的信息,远不止 URL 查询参数和请求体。请求头(Headers)和 Cookies 也是传递上下文信息的重要载体。本节,我们将通过一系列真实的业务场景,来学习如何通过注解,轻松地获取这些位置的数据。
@RequestHeader
注解用于将请求头(Request Header)中的字段值,绑定到控制器方法的参数上。
场景一:API 版本控制 在 API 开发中,我们经常通过请求头来传递版本号,以便后端可以针对不同版本的客户端返回不同的数据结构或执行不同的逻辑。
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 package com.example.springbootdemo.controller;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestHeader;import org.springframework.web.bind.annotation.RestController;@RestController public class VersionController { @GetMapping("/version") public String getApiVersion ( @RequestHeader(value = "X-API-Version", defaultValue = "1.0") String apiVersion) { return "当前请求的 API 版本号是: " + apiVersion; } }
解释: getApiVersion
方法通过 @RequestHeader("X-API-VERSION")
注解获取请求头中的版本信息,并提供了一个默认值 "1.0"
。
场景二:链路追踪 在微服务架构中,为了追踪一个请求在多个服务之间的调用链,通常会在初始请求时生成一个唯一的追踪ID(Trace ID),并通过请求头(如 X-Trace-Id
)在后续服务间传递。
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 package com.example.springbootdemo.controller;import cn.hutool.core.util.StrUtil;import lombok.extern.slf4j.Slf4j;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestHeader;import org.springframework.web.bind.annotation.RestController;@RestController @Slf4j public class TraceController { @GetMapping("/trace") public String getTraceInfo ( @RequestHeader(value = "X-Trace-Id", required = false) String traceId) { if (StrUtil.isBlank(traceId)) { traceId = cn.hutool.core.util.IdUtil.fastSimpleUUID(); } log.info("处理业务逻辑, Trace ID: {}" , traceId); return "请求已处理, Trace ID: " + traceId; } }
解释: getTraceInfo
方法获取一个可选的 X-Trace-Id
请求头。我们可以在日志中记录它,这对于问题排查至关重要。
3.2.2. @CookieValue:获取 Cookie 信息 @CookieValue
注解是 Spring 框架中用于获取 HTTP 请求中 Cookie 值的便捷工具。
场景一:用户认证 在传统的会话管理中,用户的会话ID(Session ID)通常存储在 Cookie 中。通过 @CookieValue
注解,可以轻松获取用户的会话信息。
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 package com.example.springbootdemo.controller;import org.springframework.web.bind.annotation.CookieValue;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RestController;@RestController public class AuthController { @GetMapping("/auth/info") public String getUserInfo (@CookieValue("session-id") String sessionId) { String userInfo = getUserInfoFromSession(sessionId); return "获取到用户信息: " + userInfo; } private String getUserInfoFromSession (String sessionId) { return "User_" + sessionId.substring(0 , 6 ); } }
解释: getUserInfo
方法通过 @CookieValue("session-id")
注解获取用户的会话 ID,并根据会话 ID 获取用户信息。
场景二:语言偏好设置 在多语言应用中,通常会将用户的语言偏好(如 en-US
, zh-CN
)存储在 Cookie 中。
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 package com.example.springbootdemo.controller;import org.springframework.web.bind.annotation.CookieValue;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RestController;@RestController public class LanguageController { @GetMapping("/language") public String getLanguagePreference ( @CookieValue(value = "language", defaultValue = "zh-CN") String language) { return "您当前的语言偏好是: " + language; } }
解释: getLanguagePreference
方法通过 @CookieValue("language")
注解获取用户的语言偏好,并优雅地使用了默认值 "zh-CN"
。
3.2.3. @PathVariable: 路径变量回顾 最后,我们再次回顾一个已经熟练使用的注解——@PathVariable
,以形成完整的知识体系。它专门用于从 URL 路径 中提取动态片段。
回顾代码 文件路径 : src/main/java/com/example/springbootdemo/controller/UserController.java
(回顾)
1 2 3 4 5 6 7 8 9 10 @Operation(summary = "根据ID查询单个用户") @GetMapping("/{id}") public ResponseEntity<Result<UserVO>> getUserById ( @Parameter(description = "用户ID", required = true, example = "1") @PathVariable Long id // @PathVariable 从路径 /users/{id} 中提取 id ) { }
总结:参数绑定的位置 至此,我们已经掌握了从 HTTP 请求不同位置获取数据的核心注解:
@PathVariable
: 从 URL 路径 (/users/{id}
) 中获取。@RequestParam
: 从 URL 查询参数 (?name=value
) 中获取。@RequestHeader
: 从 请求头 (Headers
) 中获取。@CookieValue
: 从 Cookie 中获取。@RequestBody
: 从 请求体 (Request Body
) 中获取。3.3. 解构请求体:@RequestBody 与 Jackson 定制 在第二章,我们已经成功地使用 @RequestBody
将前端传来的 JSON 数据自动绑定到了 UserSaveDTO
上。这个过程之所以能自动完成,是因为 Spring Boot 默认集成的 Jackson
库在背后默默地承担了“反序列化”(JSON -> Java 对象)的工作。
然而,在真实的业务场景中,我们经常会遇到前端约定的 JSON 格式与后端 Java 对象的属性不完全一致的情况。例如,字段命名风格不同(下划线 vs. 驼峰)、日期格式需要特殊处理、某些字段需要被忽略等。本节,我们就将深入学习如何通过 Jackson 提供的注解,来精确地定制 JSON 与 Java 对象之间的相互转换,进一步优化我们的用户管理 API。
3.3.1. 需求升级:定制 JSON 字段与格式 现在,我们的项目收到了来自前端团队的两个新需求:
命名风格统一 :前端团队习惯使用下划线命名法 (snake_case
),他们希望所有 API 交互的 JSON 字段都遵循此规范。例如,Java 中的 username
属性,在 JSON 中应该显示为 user_name
。日期格式化 :我们需要为用户添加一个创建时间 createTime
字段。在查询用户时,需要将这个 LocalDateTime
类型的字段格式化为 yyyy-MM-dd HH:mm:ss
的标准字符串格式返回给前端。安全增强 :在任何情况下,用户的 password
字段都绝对不能 出现在返回给前端的 JSON 数据中。3.3.2. 改造实践:在 VO 与 DTO 中应用 Jackson 注解 1. 更新数据库与实体类 首先,我们需要为 t_user
表添加 create_time
字段。请在您的数据库中执行以下 SQL 语句:
1 2 ALTER TABLE `t_user`ADD COLUMN `create_time` datetime NULL COMMENT '创建时间' AFTER `status`;
接着,更新 User
实体类。
文件路径 : src/main/java/com/example/springbootdemo/entity/User.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 package com.example.springbootdemo.entity;import java.time.LocalDateTime;@Data @TableName("t_user") public class User { private Integer status; private LocalDateTime createTime; }
2. 定制 VO (View Object) 现在,我们来改造 UserVO
,以满足前端的输出格式 需求。
文件路径 : src/main/java/com/example/springbootdemo/vo/UserVO.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 package com.example.springbootdemo.vo;import cn.hutool.core.annotation.Alias;import com.fasterxml.jackson.annotation.JsonFormat;import com.fasterxml.jackson.annotation.JsonInclude;import com.fasterxml.jackson.annotation.JsonProperty;import lombok.Data;import java.time.LocalDateTime;@Data @JsonInclude(JsonInclude.Include.NON_NULL) public class UserVO { private Long id; @JsonProperty("user_name") @Alias("username") private String name; private String statusText; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createTime; }
3. 定制 DTO (Data Transfer Object) 同样,我们也需要改造 UserSaveDTO
,以正确接收前端传递的输入数据 。
文件路径 : src/main/java/com/example/springbootdemo/dto/User/UserSaveDTO.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 package com.example.springbootdemo.dto.User;import com.fasterxml.jackson.annotation.JsonProperty;import io.swagger.v3.oas.annotations.media.Schema;import lombok.Data;@Data @Schema(description = "用户新增数据传输对象") public class UserSaveDTO { @Schema(description = "用户名", required = true, example = "newuser") @JsonProperty("user_name") private String username; @Schema(description = "密码", required = true, example = "123456") private String password; @Schema(description = "邮箱", example = "newuser@example.com") private String email; }
4. 更新 Service 层 最后,我们需要在 Service 层中处理 createTime
字段的赋值和转换。
文件路径 : src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 import java.time.LocalDateTime;@Service @RequiredArgsConstructor public class UserServiceImpl implements UserService { private final UserMapper userMapper; @Override public Long saveUser (UserSaveDTO dto) { User user = Convert.convert(User.class, dto); user.setStatus(1 ); user.setCreateTime(LocalDateTime.now()); userMapper.insert(user); return user.getId(); } private UserVO convertToVO (User user) { if (user == null ) { return null ; } UserVO userVO = new UserVO (); BeanUtil.copyProperties(user, userVO, "username" ); userVO.setName(user.getUsername()); if (user.getStatus() != null ) { userVO.setStatusText(user.getStatus() == 1 ? "正常" : "已禁用" ); } userVO.setCreateTime(user.getCreateTime()); return userVO; } }
3.3.3. 核心技术:Jackson 核心注解详解 我们刚刚在实战中使用了几个强大的 Jackson 注解,现在来系统性地总结一下:
注解 作用 常用场景 @JsonProperty
在 Java 属性和 JSON 字段之间建立双向映射 关系。 解决 Java(驼峰)与 JSON(下划线)的命名不一致问题。 @JsonFormat
在序列化 时,将日期时间类型格式化为指定的字符串样式。 将 LocalDateTime
格式化为 yyyy-MM-dd HH:mm:ss
。 @JsonIgnore
在序列化和反序列化时,完全忽略 某个属性。 防止密码等敏感信息泄露到前端。 @JsonInclude
在序列化 时,可以指定包含属性的条件,最常用的是 NON_NULL
。 忽略值为 null
的字段,精简 API 响应体。
3.3.4. 回归测试:验证定制效果 重启应用并访问 http://localhost:8080/swagger-ui.html
。
测试新增接口 (POST) 在 Swagger UI 中,展开 POST /users
接口。 验证 :您会发现 Request body
的 Schema 示例中,字段名已经变成了 user_name
。使用 { "user_name": "jackson_user", "password": "123", "email": "jackson@test.com" }
作为请求体执行请求。 请求会成功,证明我们的后端已能正确接收 user_name
字段。 测试查询接口 (GET) 在 Swagger UI 中,执行 GET /users/{id}
,查询我们刚刚新增的记录。 验证 :您会看到响应的 JSON 中,createTime
字段被格式化为了 "2025-08-17 10:30:00"
,由于我们之前的Vo对象并不期望3.4. 数据校验:Validation API 最佳实践 目前,我们的新增(saveUser
)和修改(updateUser
)接口存在一个严重的安全隐患:我们对前端传来的数据完全信任 。这会导致数据库中出现大量的“垃圾数据”,甚至引发程序异常。
本节,我们将学习如何通过 Jakarta Bean Validation API 和 Spring 的 @Validated
注解,实现声明式的、自动化的参数校验。
3.4.1. 关键一步:引入 Validation Starter 要使校验注解生效,我们必须首先在 pom.xml
中显式地添加 spring-boot-starter-validation
依赖。
文件路径 : pom.xml
(修改)
1 2 3 4 <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-validation</artifactId > </dependency >
3.4.2. 改造实践:为 DTO 添加 Validation 注解 现在,我们为 DTO 的字段添加上具体的校验规则。
文件路径 : src/main/java/com/example/springbootdemo/dto/User/UserSaveDTO.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 package com.example.springbootdemo.dto.User;import com.fasterxml.jackson.annotation.JsonProperty;import io.swagger.v3.oas.annotations.media.Schema;import jakarta.validation.constraints.Email;import jakarta.validation.constraints.NotBlank;import jakarta.validation.constraints.Size;import lombok.Data;@Data @Schema(description = "用户新增数据传输对象") public class UserSaveDTO { @Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED, example = "newuser") @JsonProperty("user_name") @NotBlank(message = "用户名不能为空") private String username; @Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456") @NotBlank(message = "密码不能为空") @Size(min = 6, max = 20, message = "密码长度必须在6-20位之间") private String password; @Schema(description = "邮箱", example = "newuser@example.com") @Email(message = "邮箱格式不正确") private String email; }
文件路径 : src/main/java/com/example/springbootdemo/dto/User/UserUpdateDTO.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 package com.example.springbootdemo.dto.User;import io.swagger.v3.oas.annotations.media.Schema;import jakarta.validation.constraints.Email;import jakarta.validation.constraints.NotNull;import lombok.Data;@Data @Schema(description = "用户修改数据传输对象") public class UserUpdateDTO { @Schema(description = "用户ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") @NotNull(message = "用户ID不能为空") private Long id; @Schema(description = "邮箱", example = "new_email@example.com") @Email(message = "邮箱格式不正确") private String email; }
3.4.3. 核心技术:在 Controller 中使用 @Validated 激活校验 仅仅在 DTO 中添加注解还不够,我们还需要在 Controller 中明确地开启校验。
在 UserController
类 上添加 @Validated
注解。 在需要校验的 @RequestBody
参数前,同样使用 @Validated
注解。 文件路径 : src/main/java/com/example/springbootdemo/controller/UserController.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 package com.example.springbootdemo.controller;import org.springframework.http.HttpStatus;import org.springframework.http.ResponseEntity;import org.springframework.validation.annotation.Validated;import org.springframework.web.bind.annotation.*;@Tag(name = "用户管理", description = "提供用户相关的CRUD接口") @RestController @RequestMapping("/users") @RequiredArgsConstructor @Validated public class UserController { private final UserService userService; @Operation(summary = "新增用户") @PostMapping public ResponseEntity<Result<Long>> saveUser (@Validated @RequestBody UserSaveDTO dto) { Long userId = userService.saveUser(dto); return ResponseEntity.status(HttpStatus.CREATED).body(Result.success(userId)); } @Operation(summary = "修改用户信息") @PutMapping public ResponseEntity<Result<Void>> updateUser (@Validated @RequestBody UserUpdateDTO dto) { userService.updateUser(dto); return ResponseEntity.ok(Result.success()); } }
3.4.4. 回归测试:验证校验效果 重启应用并访问 http://localhost:8080/swagger-ui.html
。
展开 POST /users
接口,点击 “Try it out” 。 在请求体中输入用户名为空格的非法数据:1 2 3 4 5 { "user_name" : " " , "password" : "password123" , "email" : "swagger@example.com" }
点击 “Execute” 。 预期结果 这一次,请求会被成功拦截 ,您会看到服务器返回了一个 400 Bad Request
错误,响应体中包含了详细的、由 Spring Boot 默认格式化的校验失败信息
虽然校验成功了,但这个默认的错误响应格式并不清晰,对前端并不友好。在 第四章 ,我们将学习如何通过全局异常处理 来捕获这类 MethodArgumentNotValidException
异常,并返回我们自定义的、结构统一的 Result
错误信息,从而完美解决这个问题。
3.4.5. 进阶:分组校验与 @Validated 痛点 :我们当前的校验有一个潜在问题。@Validated
会触发 DTO 内所有它能找到的校验注解。但如果未来我们的 UserSaveDTO
和 UserUpdateDTO
中有同名字段,但校验规则却略有不同呢?或者,我们想创建一个包含所有字段的 UserDTO
,然后根据是“新增”还是“修改”场景,来执行不同的校验规则,应该怎么做?
解决方案 :使用 @Validated
注解独有的分组校验 功能
定义校验分组接口 :文件路径 : src/main/java/com/example/springbootdemo/validation/ValidationGroups.java
(新增文件)
1 2 3 4 5 package com.example.springbootdemo.validation;public interface ValidationGroups { interface Save {} interface Update {} }
我们将通过一次代码重构,来真正体验分组校验的强大之处。我们的目标是:废弃 UserSaveDTO
和 UserUpdateDTO
,只用一个 UserEditDTO
来同时服务于新增和修改两个场景。
1. 创建统一的 UserEditDTO
这个新的 DTO 将包含新增和修改所需的所有字段,并通过 groups
属性为每个字段的校验规则打上“场景标签”。
文件路径 : src/main/java/com/example/springbootdemo/dto/User/UserEditDTO.java
(新增文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 package com.example.springbootdemo.dto.User;import com.example.springbootdemo.validation.ValidationGroups;import com.fasterxml.jackson.annotation.JsonProperty;import io.swagger.v3.oas.annotations.media.Schema;import jakarta.validation.constraints.Email;import jakarta.validation.constraints.NotBlank;import jakarta.validation.constraints.NotNull;import jakarta.validation.constraints.Size;import lombok.Data;@Data @Schema(description = "用户编辑(新增/修改)数据传输对象") public class UserEditDTO { @Schema(description = "用户ID,修改时必填", example = "1") @NotNull(message = "用户ID不能为空", groups = ValidationGroups.Update.class) private Long id; @Schema(description = "用户名,新增时必填", example = "newuser") @JsonProperty("user_name") @NotBlank(message = "用户名不能为空", groups = ValidationGroups.Save.class) private String username; @Schema(description = "密码,新增时必填,修改时可选", example = "123456") @NotBlank(message = "密码不能为空", groups = ValidationGroups.Save.class) @Size(min = 6, max = 20, message = "密码长度必须在6-20位之间", groups = {ValidationGroups.Save.class, ValidationGroups.Update.class}) private String password; @Schema(description = "邮箱", example = "newuser@example.com") @Email(message = "邮箱格式不正确", groups = {ValidationGroups.Save.class, ValidationGroups.Update.class}) private String email; }
注解解析 :
@NotNull(groups = ValidationGroups.Update.class)
: id
字段只在 Update
这个场景下才校验非空。@NotBlank(groups = ValidationGroups.Save.class)
: username
和 password
字段只在 Save
这个场景下才校验非空。@Size(groups = {Save.class, Update.class})
: 密码长度的校验,在 Save
和 Update
两种场景下都会生效(前提是 password
字段不为 null
)。2. 重构 Service 层 现在,我们修改 UserService
接口和实现类,让它们都使用这个新的 UserEditDTO
。
文件路径 : src/main/java/com/example/springbootdemo/service/UserService.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 package com.example.springbootdemo.service;import com.example.springbootdemo.dto.User.UserEditDTO; import com.example.springbootdemo.dto.User.UserPageQuery;import com.example.springbootdemo.vo.UserVO;import java.util.List;public interface UserService { Long saveUser (UserEditDTO dto) ; void updateUser (UserEditDTO dto) ; void deleteUserById (Long id) ; }
文件路径 : src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import com.example.springbootdemo.dto.User.UserEditDTO;@Service @RequiredArgsConstructor public class UserServiceImpl implements UserService { @Override public Long saveUser (UserEditDTO dto) { } @Override public void updateUser (UserEditDTO dto) { } }
3. 重构 Controller 层 (见证奇迹) 最后,我们来修改 UserController
。
文件路径 : src/main/java/com/example/springbootdemo/controller/UserController.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 package com.example.springbootdemo.controller;import com.example.springbootdemo.dto.User.UserEditDTO;import com.example.springbootdemo.validation.ValidationGroups;@Tag(name = "用户管理", description = "提供用户相关的CRUD接口") @RestController @RequestMapping("/users") @RequiredArgsConstructor @Validated public class UserController { private final UserService userService; @Operation(summary = "新增用户") @PostMapping public ResponseEntity<Result<Long>> saveUser ( @Validated(ValidationGroups.Save.class) @RequestBody UserEditDTO dto) { Long userId = userService.saveUser(dto); return ResponseEntity.status(HttpStatus.CREATED).body(Result.success(userId)); } @Operation(summary = "修改用户信息") @PutMapping public ResponseEntity<Result<Void>> updateUser ( @Validated(ValidationGroups.Update.class) @RequestBody UserEditDTO dto) { userService.updateUser(dto); return ResponseEntity.ok(Result.success()); } }
4. 清理与验证 现在,您可以安全地删除 UserSaveDTO.java
和 UserUpdateDTO.java
这两个文件了。
重启应用并访问 Swagger UI:
测试新增 (POST /users
) :如果您不提供 user_name
或 password
,请求将被 400 Bad Request
拦截。 如果您提供了 id
,它会被忽略。 测试修改 (PUT /users
) :如果您不提供 id
,请求将被 400 Bad Request
拦截。 您可以不提供 password
,只修改 email
,请求会成功。 如果您提供了 user_name
,它会被忽略(因为 DTO 到 PO 的转换不会处理这个字段)。 这才是分组校验的真正威力! 我们通过一个 UserEditDTO
,结合 @Validated
注解中不同的分组,实现了对“新增”和“修改”两个不同业务场景的精准校验,极大地提升了代码的复用性和可维护性。
4. [高级特性] 全局处理与特殊场景 摘要 : 一个健壮的 API 不仅要能正确处理成功的情况,更要能优雅地应对各种异常。在本章,我们将为项目引入全局异常处理机制 ,解决之前章节中遗留的“错误响应不统一”的问题。同时,我们还会处理前后端分离架构中常见的跨域(CORS)问题,并为项目增加文件上传下载 这一实用的高级功能。
4.1. 全局异常处理:@RestControllerAdvice 4.1.1. 痛点回顾与核心技术 在之前的章节中,我们的 API 在遇到错误时,会暴露两个典型的问题:
业务异常返回 500
:当我们在 Service 层检测到“用户名已存在”并抛出 IllegalArgumentException
时,前端收到的是一个笼统的 500 Internal Server Error
,这既不准确,也没有清晰地告诉前端失败的原因。参数校验返回默认格式 :当 @Validated
校验失败时,前端收到的虽然是 400 Bad Request
,但其 JSON 结构是 Spring Boot 默认的,与我们精心设计的 Result<T>
格式完全不符。这两个问题都指向了同一个需求:我们需要一个全局的、统一的机制 来捕获所有 Controller 抛出的异常,并按照我们自己的 Result<T>
格式,将它们转换为对前端友好的、标准化的响应。
Spring MVC 为此提供了一套极其优雅的组合拳:@RestControllerAdvice
与 @ExceptionHandler
。
@RestControllerAdvice
: 将一个类声明为全局控制器增强器,它会“监听”所有 @RestController
中抛出的异常。@ExceptionHandler
: 在 @RestControllerAdvice
类中的方法上使用,声明该方法是用于处理特定类型的异常。4.1.2. 实战:创建全局异常处理器 我们将创建一个 GlobalExceptionHandler
,并在其中一步步地添加针对不同异常的处理逻辑。
1. 捕获自定义业务异常 首先,我们来处理像“用户名已存在”这类由我们的业务逻辑主动抛出的异常。
第一步:创建自定义业务异常 一个良好的实践是定义一个自己的 BusinessException
,用于封装所有业务层面的错误。
文件路径 : src/main/java/com/example/springbootdemo/exception/BusinessException.java
(新增文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 package com.example.springbootdemo.exception;import com.example.springbootdemo.common.ResultCode;import lombok.Getter;@Getter public class BusinessException extends RuntimeException { private final ResultCode resultCode; public BusinessException (ResultCode resultCode) { super (resultCode.getMessage()); this .resultCode = resultCode; } }
第二步:重构 Service 层以抛出新异常 修改 UserServiceImpl
,当用户名已存在时,抛出我们自定义的 BusinessException
。
文件路径 : src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 import com.example.springbootdemo.common.ResultCode;import com.example.springbootdemo.exception.BusinessException;import com.example.springbootdemo.entity.User;import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;@Service @RequiredArgsConstructor public class UserServiceImpl implements UserService { private final UserMapper userMapper; @Override public Long saveUser (UserEditDTO dto) { User existingUser = userMapper.selectOne(new QueryWrapper <User>().lambda().eq(User::getUsername, dto.getUsername())); if (existingUser != null ) { throw new BusinessException (ResultCode.UserAlreadyExists); } } @Override public void updateUser (UserEditDTO dto) { User user = userMapper.selectById(dto.getId()); if (user == null ) { throw new BusinessException (ResultCode.UserNotFound); } } }
第三步:创建全局异常处理器并添加业务异常处理逻辑
文件路径 : src/main/java/com/example/springbootdemo/advice/GlobalExceptionHandler.java
(新增文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 package com.example.springbootdemo.advice;import com.example.springbootdemo.common.Result;import com.example.springbootdemo.exception.BusinessException;import lombok.extern.slf4j.Slf4j;import org.springframework.http.HttpStatus;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.ExceptionHandler;import org.springframework.web.bind.annotation.RestControllerAdvice;@Slf4j @RestControllerAdvice(basePackages = "com.example.springbootdemo.controller") public class GlobalExceptionHandler { @ExceptionHandler(BusinessException.class) public ResponseEntity<Result<Void>> handleBusinessException (BusinessException ex) { log.error("业务异常: {}" , ex.getMessage(), ex); return ResponseEntity .status(HttpStatus.BAD_REQUEST) .body(Result.error(ex.getResultCode())); } }
2. 美化参数校验异常 接下来,我们在同一个处理器中,增加对 @Validated
校验失败异常的处理。
文件路径 : src/main/java/com/example/springbootdemo/advice/GlobalExceptionHandler.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 import org.springframework.web.bind.MethodArgumentNotValidException;import java.util.stream.Collectors;@Slf4j @RestControllerAdvice(basePackages = "com.example.springbootdemo.controller") public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<Result<Void>> handleValidationException (MethodArgumentNotValidException ex) { String message = ex.getBindingResult().getFieldErrors().stream() .map(fieldError -> fieldError.getField() + ": " + fieldError.getDefaultMessage()) .collect(Collectors.joining("; " )); log.warn("参数校验失败: {}" , message); return ResponseEntity .status(HttpStatus.BAD_REQUEST) .body(Result.error(message)); } }
3. 捕获未知系统异常 最后,我们需要一个“兜底”方案,来处理所有未预料到的服务器内部错误。
文件路径 : src/main/java/com/example/springbootdemo/advice/GlobalExceptionHandler.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @Slf4j @RestControllerAdvice(basePackages = "com.example.springbootdemo.controller") public class GlobalExceptionHandler { @ExceptionHandler(Exception.class) public ResponseEntity<Result<Void>> handleUnknownException (Exception ex) { log.error("系统未知异常: {}" , ex.getMessage(), ex); return ResponseEntity .status(HttpStatus.INTERNAL_SERVER_ERROR) .body(Result.error("系统异常,请联系管理员" )); } }
4.1.3. 回归测试:验证统一错误响应 重启应用,并使用 SpringDoc 重新测试我们之前遇到的所有错误场景。
测试用例 1:新增已存在的用户
操作 : 调用 POST /users
接口,Request body
中使用一个已存在的用户名。验证 : 响应码为 400 Bad Request
,响应体为:1 2 3 4 5 { "code" : 1002 , "message" : "用户已存在" , "data" : null }
测试用例 2:新增用户时参数校验失败
操作 : 调用 POST /users
接口,Request body
中 user_name
字段为空字符串。验证 : 响应码为 400 Bad Request
,响应体为:1 2 3 4 5 { "code" : 500 , "message" : "username: 用户名不能为空" , "data" : null }
通过创建 GlobalExceptionHandler
,我们成功地将所有异常处理逻辑集中到了一个地方。现在,无论我们的 API 遇到业务异常、参数校验异常还是未知的系统异常,都能向前端返回统一、规范、友好的 Result
响应。这极大地提升了 API 的健壮性和专业性。
4.2. 跨域配置:CORS (Cross-Origin Resource Sharing) 随着我们的后端 API 功能日益完善,前端同事已经准备好对接我们的接口了。然而,当他们在自己的开发环境(例如 http://localhost:5173
)中尝试调用我们部署在 http://localhost:8080
上的 API 时,浏览器的控制台无情地报出了一个经典错误:
1 2 Access to fetch at 'http://localhost:8080/users' from origin 'http://localhost:5173' has been blocked by CORS policy...
这就是前后端分离开发中几乎必然会遇到的“拦路虎”——跨域问题 。
4.2.1. 理论:浏览器的同源策略与 CORS 工作原理 1. 同源策略 这是浏览器的一个核心安全机制。它规定,一个源(origin)的网页脚本,只能访问与其同源 的资源,而不能访问不同源 的资源。
什么是“源”? 一个源由协议 (protocol) 、域名 (domain) 和端口 (port) 三者共同定义。只有当这三者完全相同时,两个 URL 才被认为是同源的。
URL 1 URL 2 是否同源 原因 http://example.com/page.html
http://example.com/api/data
是 协议、域名、端口(默认80)都相同 http://example.com
https://example.com
否 协议不同 (http vs https) http://www.example.com
http://api.example.com
否 域名不同 (www vs api) http://example.com
http://example.com:8080
否 端口不同 (默认80 vs 8080)
在我们的场景中,前端应用运行在 http://localhost:5173
,而后端 API 运行在 http://localhost:8080
,因为端口不同 ,所以它们是不同源 的。因此,浏览器出于安全考虑,默认禁止了这次请求。
2. CORS (跨域资源共享) CORS 是一种 W3C 标准,它允许服务器在响应头中添加一些特殊的 Access-Control-*
字段,从而“告诉”浏览器,我允许来自指定不同源的请求访问我的资源。
当浏览器发起一个跨域的“非简单请求”(例如,PUT
, DELETE
,或者带有自定义请求头的 POST
)时,它会自动先发送一个 OPTIONS
方法的预检请求 到服务器。服务器需要在响应中明确告知浏览器,它允许哪些源、哪些 HTTP 方法、哪些请求头进行跨域访问。浏览器验证通过后,才会发送真正的业务请求。
虽然我们可以在每个 @RestController
或每个 @RequestMapping
上使用 @CrossOrigin
注解来单独开启跨域,但这会导致配置分散,难以维护。最佳实践是进行全局 CORS 配置 。
我们将创建一个 WebConfig
配置类,通过实现 WebMvcConfigurer
接口来一站式地解决整个应用的跨域问题。
文件路径 : src/main/java/com/example/springbootdemo/config/WebConfig.java
(新增文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 package com.example.springbootdemo.config;import org.springframework.context.annotation.Configuration;import org.springframework.web.servlet.config.annotation.CorsRegistry;import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addCorsMappings (CorsRegistry registry) { registry.addMapping("/**" ) .allowedOrigins("http://localhost:5173" ) .allowedMethods("GET" , "POST" , "PUT" , "DELETE" , "OPTIONS" ) .allowCredentials(true ) .maxAge(3600 ); } }
代码解析 :
addMapping("/**")
: /**
表示此 CORS 配置将应用于我们应用中的所有 API 接口。.allowedOrigins("http://localhost:5173")
: 这是最核心的配置,它明确地告诉浏览器,我只允许来自 http://localhost:5173
这个源的跨域请求。在生产环境中,您应该将其替换为您的前端应用的实际域名。.allowedMethods(...)
: 允许跨域的 HTTP 方法列表。.allowCredentials(true)
: 是否允许客户端在跨域请求中携带凭证信息(如 Cookies)。.maxAge(3600)
: 设置预检请求(Preflight Request)的缓存时间,单位为秒。在此时间内,浏览器对相同的跨域请求将不再发送预检请求。4.2.3. 前端验证:构建 Vue3 应用测试 CORS 为了验证我们后端的 CORS 配置是否成功,我们将快速搭建一个基于 Vite
+ Vue 3
的前端应用,并使用 axios
库来尝试调用我们部署在 8080
端口的 /users
接口。
1. 环境与项目创建 在开始之前,请确保您的开发环境满足以下要求:
Node.js : 16.0 或更高版本包管理器 : 本教程使用 pnpm
如果尚未安装 pnpm
,可以通过 npm install -g pnpm
命令进行全局安装。
现在,我们来创建前端项目:
1 2 3 4 5 6 7 8 pnpm create vite cors-test-app --template vue cd cors-test-apppnpm install
2. 安装核心依赖 我们需要为项目添加 Tailwind CSS
, DaisyUI
用于美化界面,以及 axios
用于发起 HTTP 请求。
1 2 3 4 5 6 7 8 pnpm add -D tailwindcss @tailwindcss/vite pnpm add -D daisyui pnpm add axios
3. 项目配置 第一步:配置 Vite (vite.config.js
) 编辑项目根目录下的 vite.config.js
文件,引入 Tailwind CSS 插件。
文件路径 : cors-test-app/vite.config.js
(修改)
1 2 3 4 5 6 7 8 9 10 import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import tailwindcss from '@tailwindcss/vite' export default defineConfig ({ plugins : [ vue (), tailwindcss (), ], })
第二步:创建并引入 CSS (style.css
& main.js
) 在 src
目录下创建 style.css
文件。
文件路径 : cors-test-app/src/style.css
(新增文件)
1 2 @import "tailwindcss" ;@plugin "daisyui" ;
然后,在 main.js
中引入这个样式文件。
文件路径 : cors-test-app/src/main.js
(修改)
1 2 3 4 5 import { createApp } from 'vue' import App from './App.vue' import './style.css' createApp (App ).mount ('#app' )
4. 编写接口调用代码 现在,我们来修改 App.vue
,添加一个按钮,点击后调用后端的 /users
接口并展示数据。
文件路径 : cors-test-app/src/App.vue
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 <script setup> import { ref } from 'vue' import axios from 'axios' const users = ref([]) const loading = ref(false) const error = ref(null) const fetchUsers = async () => { loading.value = true error.value = null users.value = [] try { // 调用我们部署在 8080 端口的后端 API const response = await axios.get('http://localhost:8080/users?pageNo=1&pageSize=5') users.value = response.data.data } catch (err) { console.error('API 调用失败:', err) error.value = '获取用户数据失败!请检查浏览器控制台(F12)以确认是否为 CORS 错误。' } finally { loading.value = false } } </script> <template> <div class="container mx-auto p-8"> <h1 class="text-3xl font-bold mb-6">后端 CORS 配置验证</h1> <div class="card bg-base-100 shadow-xl"> <div class="card-body"> <div class="card-actions justify-start"> <button class="btn btn-primary" @click="fetchUsers" :disabled="loading"> <span v-if="loading" class="loading loading-spinner"></span> {{ loading ? '正在加载...' : '获取用户列表 (GET /users)' }} </button> </div> <div v-if="error" class="alert alert-error mt-4"> <span>{{ error }}</span> </div> <div v-if="users.length > 0" class="overflow-x-auto mt-4"> <table class="table w-full table-zebra"> <thead> <tr> <th>ID</th> <th>用户名 (user_name)</th> <th>状态</th> <th>创建时间</th> </tr> </thead> <tbody> <tr v-for="user in users" :key="user.id"> <th>{{ user.id }}</th> <td>{{ user.user_name }}</td> <td>{{ user.statusText }}</td> <td>{{ user.createTime }}</td> </tr> </tbody> </table> </div> </div> </div> </div> </template>
5. 运行与验证 确保您的 Spring Boot 后端应用正在运行 。在前端项目 cors-test-app
的根目录下,执行启动命令: Vite 通常会启动一个运行在 5173
端口的开发服务器。请在浏览器中打开它提供的地址(如 http://localhost:5173
)。 点击页面上的 “获取用户列表” 按钮。 验证结果 :
成功 : 如果您能看到用户列表被成功加载并显示在表格中,那么恭喜您,后端的全局跨域配置已完全生效!失败 : 如果您看到红色的错误提示,并且在浏览器控制台(按 F12 打开)中看到了关于 CORS policy
的错误信息,请回到 4.2.2
节检查您的后端 WebConfig.java
配置是否正确,并确保前端应用的运行端口(5173
)与后端配置中 .allowedOrigins()
的值一致。4.3. 文件处理:上传、下载与静态资源访问 4.3.1. 后端:配置与静态资源映射 1. 添加配置 (YAML 格式) 首先,我们在 application.yml
中启用文件上传并定义存储目录。
文件路径 : src/main/resources/application.yml
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 servlet: multipart: enabled: true max-file-size: 2MB max-request-size: 10MB app: upload: dir: D:/springboot-uploads/
请将 app.upload.dir
的值修改为您计算机上一个真实存在的、用于存放上传文件的目录路径,并确保路径以 /
结尾。
2. 配置静态资源映射 接下来,我们配置一个 URL 路径(如 /springboot-uploads/**
)直接映射到我们的物理存储目录,这是最高效的文件预览 方式。
文件路径 : src/main/java/com/example/springbootdemo/config/WebConfig.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 package com.example.springbootdemo.config;import org.springframework.beans.factory.annotation.Value;import org.springframework.context.annotation.Configuration;import org.springframework.web.servlet.config.annotation.CorsRegistry;import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;@Configuration public class WebConfig implements WebMvcConfigurer { @Value("${app.upload.dir}") private String uploadDir; @Override public void addCorsMappings (CorsRegistry registry) { } @Override public void addResourceHandlers (ResourceHandlerRegistry registry) { registry.addResourceHandler("/springboot-uploads/**" ) .addResourceLocations("file:" + uploadDir); } }
4.3.2. 后端:FileController
功能实现 FileController
将负责三个核心功能:处理上传、提供文件列表、处理强制下载请求。
文件路径 : src/main/java/com/example/springbootdemo/controller/FileController.java
(新增文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 package com.example.springbootdemo.controller;import cn.hutool.core.io.FileUtil;import cn.hutool.core.util.IdUtil;import com.example.springbootdemo.common.Result;import io.swagger.v3.oas.annotations.Operation;import io.swagger.v3.oas.annotations.tags.Tag;import org.springframework.beans.factory.annotation.Value;import org.springframework.core.io.FileSystemResource;import org.springframework.core.io.Resource;import org.springframework.http.HttpHeaders;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;import org.springframework.web.multipart.MultipartFile;import java.io.File;import java.io.IOException;import java.util.Arrays;import java.util.Collections;import java.util.List;@Tag(name = "文件管理", description = "提供文件上传下载接口") @RestController @RequestMapping("/files") public class FileController { @Value("${app.upload.dir}") private String uploadDir; @Operation(summary = "文件上传") @PostMapping("/upload") public Result<String> upload (@RequestParam("file") MultipartFile file) throws IOException { if (file.isEmpty()) { return Result.error("文件名不能为空" ); } String originalFilename = file.getOriginalFilename(); String extName = FileUtil.extName(originalFilename); String uniqueId = IdUtil.fastSimpleUUID(); String newFileName = uniqueId + "." + extName; File uploadPath = new File (uploadDir); if (!uploadPath.exists()) { uploadPath.mkdir(); } File destFile = new File (uploadPath, newFileName); file.transferTo(destFile); return Result.success(newFileName); } @Operation(summary = "获取文件列表") @GetMapping("/list") public Result<List<String>> listFiles () { File uploadDirFile = new File (uploadDir); File[] files = uploadDirFile.listFiles(); if (files == null ) { return Result.success(Collections.emptyList()); } List<String> filenames = Arrays.stream(files).map(File::getName).toList(); return Result.success(filenames); } @Operation(summary = "文件下载(强制附件)") @GetMapping("/download/{filename}") public ResponseEntity<Resource> download (@PathVariable String filename) throws IOException { File file = new File (uploadDir, filename); if (!file.exists()) { return ResponseEntity.notFound().build(); } Resource resource = new FileSystemResource (file); HttpHeaders headers = new HttpHeaders (); headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"" ); headers.add(HttpHeaders.CONTENT_TYPE, "application/octet-stream" ); return ResponseEntity.ok() .headers(headers) .body(resource); } }
4.3.3. 前端联动:组件化实现文件管理 现在我们来构建前端界面,将所有功能整合在一起。
1. 文件上传组件 (FileUpload.vue
) 文件路径 : cors-test-app/src/components/FileUpload.vue
(新增文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 <script setup> import { ref } from 'vue' import axios from 'axios' const selectedFile = ref(null) const uploadResult = ref(null) const isUploading = ref(false) // 定义组件可以发出的事件 const emit = defineEmits(['upload-success']) const handleFileChange = (event) => { selectedFile.value = event.target.files[0] uploadResult.value = null } const handleUpload = async () => { if (!selectedFile.value) { alert('请选择要上传的文件') return } isUploading.value = true // FormData 对象:用于将文件和其他数据打包成一个整体,以便发送给后端 const formData = new FormData() formData.append('file', selectedFile.value) try { const response = await axios.post('http://localhost:8080/files/upload', formData) uploadResult.value = response.data if (response.data.code === 200) { emit('upload-success', response.data.data) } } catch (error) { console.error('上传失败:', error) } finally { isUploading.value = false } } </script> <template> <div class="card bg-base-100 shadow-xl mt-8"> <div class="card-body"> <h2 class="card-title">文件上传</h2> <div class="form-control w-full max-w-xs"> <input type="file" @change="handleFileChange" class="file-input file-input-bordered w-full max-w-xs" /> </div> <div class="card-actions justify-start mt-4"> <button class="btn btn-secondary" @click="handleUpload" :disabled="isUploading"> <span v-if="isUploading" class="loading loading-spinner"></span> {{ isUploading ? '正在上传...' : '上传文件' }} </button> </div> </div> </div> </template>
2. 文件列表组件 (FileList.vue
) 文件路径 : cors-test-app/src/components/FileList.vue
(新增文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 <script setup> import { ref, onMounted } from 'vue' import axios from 'axios' const fileList = ref([]) const isLoading = ref(false) // 获取文件列表 const fetchFileList = async () => { isLoading.value = true try { const response = await axios.get('http://localhost:8080/files/list') if (response.data.code === 200) { fileList.value = response.data.data || [] console.log(fileList.value) } } catch (err) { console.error('获取文件列表失败:', err) } finally { isLoading.value = false } } // 下载文件 const downloadFile = (filename) => { const downloadUrl = `http://localhost:8080/files/download/${filename}` const link = document.createElement('a') link.href = downloadUrl link.download = filename document.body.appendChild(link) link.click() document.body.removeChild(link) } // 组件挂载时获取文件列表 onMounted(() => { fetchFileList() }) // 暴露刷新方法给父组件 defineExpose({ refresh: fetchFileList }) </script> <template> <div class="card bg-base-100 shadow-xl mt-8"> <div class="card-body"> <h2 class="card-title mb-4">文件列表</h2> <!-- 加载状态 --> <div v-if="isLoading" class="flex justify-center py-8"> <span class="loading loading-spinner loading-lg"></span> </div> <!-- 文件列表 --> <div v-else-if="fileList.length > 0" class="space-y-2"> <div v-for="filename in fileList" :key="filename" class="flex items-center justify-between p-3 bg-base-200 rounded-lg"> <div class="font-medium">{{ filename }}</div> <button class="btn btn-primary btn-sm" @click="downloadFile(filename)"> 下载 </button> </div> </div> <!-- 空状态 --> <div v-else class="text-center py-8 text-base-content/60"> <div class="text-4xl mb-2">📁</div> <div>暂无文件</div> </div> </div> </div> </template>
3. 主应用重构 (App.vue
) App.vue
负责组合这两个组件,并实现它们之间的联动。
文件路径 : cors-test-app/src/App.vue
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 <script setup> import { ref } from 'vue' import FileUpload from './components/FileUpload.vue' import FileList from './components/FileList.vue' const fileListRef = ref(null) const handleUploadSuccess = (filename) => { console.log('父组件收到上传成功事件,文件名:', filename) // 上传成功后刷新文件列表 if (fileListRef.value) { fileListRef.value.refresh() } } </script> <template> <div class="container mx-auto p-8"> <h1 class="text-3xl font-bold mb-6 text-center">文件管理系统</h1> <!-- 文件上传组件 --> <FileUpload @upload-success="handleUploadSuccess" /> <!-- 文件列表组件 --> <FileList ref="fileListRef" /> </div> </template>
运行验证 重启 后端 Spring Boot 应用和前端 Vite 应用。验证 :页面现在显示文件上传和文件列表两个模块。 文件列表会自动加载 D:/springboot-uploads/
目录下的所有文件。 点击“预览”可以在新标签页打开图片(由 WebConfig
的静态资源映射处理)。 点击“下载”会直接下载文件(由 FileController
的 /download
接口处理)。 联动测试 :上传一个新文件,成功后,文件列表会自动刷新并显示出刚刚上传的文件。功能闭环 :通过后端 Controller
逻辑、WebConfig
静态资源映射和前端组件化的协同工作,我们实现了一套功能完整、体验良好的文件管理功能,并深刻理解了不同场景下的文件处理策略。
5. [幕后探秘] HttpMessageConverter 工作原理 摘要 : 在前面的实战中,我们已经看到了 @RequestBody
和 @ResponseBody
的强大威力,它们似乎能“魔法般”地在 JSON 和 Java 对象之间进行自动转换。本章,我们将扮演一次侦探,深入 Spring MVC 的内部,揭开这个“魔法”的秘密——彻底搞懂其背后真正的功臣 HttpMessageConverter
(HTTP 消息转换器) 的核心工作原理。
5.1. 核心面试题:揭秘 @RequestBody 与 @ResponseBody 在面试中,HttpMessageConverter
是一个非常高频的考点,因为它直接关系到 Spring MVC 的核心工作原理。本节,我们将通过一场模拟面试,来彻底揭开它神秘的面纱。
面试官深度剖析:HttpMessageConverter
今天 上午 10:00
在 Spring MVC 项目中,我们经常在 Controller 方法的参数上使用 @RequestBody
,或者在类上使用 @RestController
。当一个 JSON 请求过来,或者一个方法返回一个 Java 对象时,Spring MVC 究竟在背后做了什么,能实现这种自动转换?
我
面试官您好。这个自动化转换的核心,在于 Spring MVC 的 HttpMessageConverter (HTTP 消息转换器) 机制。它是一个策略接口,专门负责在 HTTP 请求体/响应体和 Java 对象之间进行序列化与反序列化。
很好。那你能具体讲讲这个 HttpMessageConverter
接口吗?它通过什么方法来判断自己是否能处理一个请求,又是通过什么方法来执行转换的?
我
当然。HttpMessageConverter
接口主要通过两对核心方法来工作:
我
第一对是 canRead()
和 canWrite()
,用于能力检测 。Spring MVC 会用它们来询问转换器能否处理特定的 Java 类型(如 UserVO.class
)和媒体类型(如 application/json
)。
我
第二对是 read()
和 write()
,用于执行转换 。一旦能力检测通过,就会调用这两个方法进行真正的读写操作。read()
负责将请求体转换为 Java 对象(@RequestBody
的工作),write()
负责将 Java 对象转换为响应体(@ResponseBody
的工作)。
明白了。那在一个典型的 Spring Boot Web 应用中,都注册了哪些常见的 HttpMessageConverter
实现呢?我们最常用的 JSON 转换是由哪个来完成的?
我
Spring Boot 会根据类路径上的依赖,自动配置一个转换器“家族”。其中最重要的几个包括:
我
StringHttpMessageConverter : 负责处理纯 String
类型的转换,比如我们第一章的 HelloController
。
我
FormHttpMessageConverter : 负责处理 application/x-www-form-urlencoded
类型的表单数据。
我
MappingJackson2HttpMessageConverter : 这是我们的绝对主力 。只要项目中存在 Jackson 库,它就会被注册,并负责所有 application/json
相关的转换。我们项目中所有的 DTO/VO 与 JSON 之间的转换,都是它的功劳。
我
此外,还有处理二进制数据的 ByteArrayHttpMessageConverter
等,在我们第四章的文件下载功能中,它就扮演了重要角色。
非常清晰。最后一个问题:当一个请求进来,比如请求头 Accept
是 application/json
,而 Controller 方法返回了一个 UserVO
对象,Spring MVC 是如何从这么多转换器中,精确地选择 MappingJackson2HttpMessageConverter
来工作的呢?
我
这是一个很好的问题,它涉及到了 Spring MVC 的内容协商 (Content Negotiation) 机制。简单来说,Spring MVC 会遍历所有已注册的转换器,结合客户端请求的 Accept
头和 Controller 方法返回的 Java 对象类型,调用每个转换器的 canWrite()
方法进行“招标”。在这个场景下,MappingJackson2HttpMessageConverter
会“中标”,于是框架就最终调用它的 write()
方法来完成工作。关于这个流程的更多细节,我们可以在下一节深入探讨。
5.2 工作流程:内容协商 在上一节的模拟面试中,我们提到了一个关键机制——内容协商 。正是这个机制,使得 Spring MVC 能够智能地从众多 HttpMessageConverter
中,挑选出最合适的一个来处理当前的请求。
“协商”一词非常形象,它指的是客户端(浏览器、App等)和服务器端(我们的 Spring MVC 应用)之间,就资源的表现形式 (即数据格式,如 JSON、XML、HTML 等)进行“商议”的过程。
1. 客户端的“诉求”:Accept
与 Content-Type
请求头 客户端通过两个核心的 HTTP 请求头来表达自己的“诉求”:
Accept
: 用于响应 协商。它告诉服务器:“我能理解以下几种格式的数据,请你按照这个列表的优先级,返回我能处理的一种。”例如: Accept: application/json, application/xml;q=0.9, */*;q=0.8
这表示:我最希望得到 json
格式的数据。如果不行,xml
也勉强可以(权重q=0.9)。如果前两者都没有,那就随便给我一种你有的格式吧(权重q=0.8)。 Content-Type
: 用于请求 协商。它告诉服务器:“我这次发送给你的请求体,是这种格式的数据,请你按照这个格式来解析。”例如: Content-Type: application/json
这表示:我用 @RequestBody
发送过来的数据是一个 JSON 字符串。 2. 服务器的“决策”:HttpMessageConverter
的选择流程 现在,我们以一个完整的响应过程 为例,详细拆解 Spring MVC 的内部决策流程。
场景 : 客户端发起 GET /users/1
请求,其请求头中包含 Accept: application/json
。
请求路由 : DispatcherServlet
接收到请求,并通过 HandlerMapping
找到 UserController
的 getUserById
方法。方法执行 : getUserById
方法被调用,执行完毕后,返回了一个 UserVO
对象。触发内容协商 : 因为 UserController
被 @RestController
注解(包含了 @ResponseBody
),Spring MVC 知道需要将这个 UserVO
对象写入 HTTP 响应体。此时,内容协商机制 正式启动。遍历转换器 : Spring MVC 获取到内部注册的所有 HttpMessageConverter
实例列表(这个列表是有优先级的,通常 MappingJackson2HttpMessageConverter
优先级较高)。“能力”检测 (canWrite) : Spring MVC 开始从头到尾遍历这个列表,对每一个转换器进行“垂询”:问询 ByteArrayHttpMessageConverter
: 调用其 canWrite(UserVO.class, MediaType.APPLICATION_JSON)
方法。它一看,自己只处理 byte[]
,处理不了 UserVO
,于是返回 false
。问询 StringHttpMessageConverter
: 调用其 canWrite(UserVO.class, MediaType.APPLICATION_JSON)
方法。它一看,自己只处理 String
,处理不了 UserVO
,也返回 false
。问询 MappingJackson2HttpMessageConverter
: 调用其 canWrite(UserVO.class, MediaType.APPLICATION_JSON)
方法。它内部逻辑判断:“首先,我能处理任意的 POJO(UserVO
满足);其次,我支持 application/json
这个媒体类型。太棒了!” 于是,它返回 true
。锁定并执行 : Spring MVC 找到了第一个“中标”的转换器——MappingJackson2HttpMessageConverter
,于是停止遍历。它立即调用该转换器的 write()
方法,将 UserVO
对象和 MediaType
传入。write()
方法内部再调用 Jackson 库,将 UserVO
对象序列化为 JSON 字符串,并写入 HTTP 响应的输出流。响应完成 : 最终,客户端收到了一个 Content-Type
为 application/json
、响应体为用户 JSON 数据的 HTTP 响应。对于 @RequestBody 的处理流程也是完全类似的,只不过 Spring MVC 依据的是请求头中的 Content-Type,并调用转换器的 canRead() 和 read() 方法。
6. [API安全] 拦截器与 Token 认证 摘要 : 到目前为止,我们的 API 功能已经非常完备,但它正处于“不设防”的状态,任何人都可以随意调用所有接口。这在真实世界中是绝对不可接受的。本章,我们将聚焦于 API 的核心安全问题,引入 Spring MVC 强大的**拦截器(Interceptor)**机制,并结合 JWT (JSON Web Token) 这一现代化的认证方案,为我们的 API 构建一套专业、无状态的用户认证和权限校验体系。
6.1. 核心技术:HandlerInterceptor 详解 1. 痛点:重复的通用逻辑 随着项目发展,我们可能会遇到一些需要对多个接口生效的通用需求,例如:
权限校验 :某些接口(如修改、删除用户)必须在用户登录后才能调用。日志记录 :需要记录每个接口的请求路径、执行耗时等信息,用于监控和性能分析。通用处理 :为所有请求注入一些通用的上下文信息。我们当然不希望在每个 Controller 的每个方法里都重复编写这些逻辑,这会造成大量的代码冗余。我们需要一种能够在请求处理流程中“切入”的机制,这正是 Spring MVC 提供的拦截器 (Interceptor) 。
拦截器是一种强大的 AOP (面向切面编程) 的体现,它允许我们在请求进入 Controller 方法之前 、方法执行之后 以及整个请求处理完毕之后 这三个关键节点,执行我们自定义的通用逻辑。
2. HandlerInterceptor
接口 要创建一个拦截器,我们需要实现 org.springframework.web.servlet.HandlerInterceptor
接口。这个接口定义了三个核心方法(在 Java 8 之后,它们都是 default
方法,我们只需按需重写即可):
方法 (Method) 执行时机 核心作用 preHandle
Controller 方法执行前 请求拦截 。返回 false
可中断请求。postHandle
Controller 方法执行后 ,视图渲染前 修改响应 。可以修改 ModelAndView
。(API开发中较少使用)afterCompletion
整个请求处理完毕后 资源清理 。无论是否发生异常都会执行。
3. 实战:创建并注册一个日志拦截器 为了直观地感受拦截器的工作流程,我们先来创建一个简单的日志拦截器,用于计算并打印每个请求的处理耗时。
第一步:创建拦截器实现类
文件路径 : src/main/java/com/example/springbootdemo/interceptor/LogInterceptor.java
(新增文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 package com.example.springbootdemo.interceptor;import jakarta.servlet.http.HttpServletRequest;import jakarta.servlet.http.HttpServletResponse;import lombok.extern.slf4j.Slf4j;import org.springframework.stereotype.Component;import org.springframework.web.servlet.HandlerInterceptor;@Slf4j @Component public class LogInterceptor implements HandlerInterceptor { @Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { long startTime = System.currentTimeMillis(); request.setAttribute("startTime" , startTime); log.info("开始处理请求: {} {}" , request.getMethod(), request.getRequestURI()); return true ; } @Override public void afterCompletion (HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { long startTime = (Long) request.getAttribute("startTime" ); long endTime = System.currentTimeMillis(); long duration = endTime - startTime; log.info("请求处理完毕: {} {}, 耗时: {}ms" , request.getMethod(), request.getRequestURI(), duration); } }
第二步:注册拦截器 我们需要在 WebConfig
中,将我们创建的拦截器注册到 Spring MVC 的拦截器链中。
文件路径 : src/main/java/com/example/springbootdemo/config/WebConfig.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 package com.example.springbootdemo.config;import com.example.springbootdemo.interceptor.LogInterceptor;import lombok.RequiredArgsConstructor;import org.springframework.context.annotation.Configuration;import org.springframework.web.servlet.config.annotation.InterceptorRegistry;import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;@Configuration @RequiredArgsConstructor public class WebConfig implements WebMvcConfigurer { private final LogInterceptor logInterceptor; @Override public void addInterceptors (InterceptorRegistry registry) { registry.addInterceptor(logInterceptor) .addPathPatterns("/**" ); } }
代码解析 :
addInterceptor(logInterceptor)
: 将我们的 LogInterceptor
Bean 注册到拦截器注册表中。.addPathPatterns("/**")
: 指定这个拦截器要拦截的 URL 模式。/**
是一个通配符,表示拦截所有进入应用的请求。与之相对的,还有一个 .excludePathPatterns("/login")
方法,用于指定需要排除的路径。重启应用,并使用 SpringDoc 或 cURL 调用任意一个 /users
接口(例如 GET /users/1
)。然后观察您的应用控制台日志 。
1 2 3 ... INFO com.e.s.interceptor.LogInterceptor : 开始处理请求: GET /users/1 ... (省略 Controller 和 Service 的日志) ... INFO com.e.s.interceptor.LogInterceptor : 请求处理完毕: GET /users/1, 耗时: 18ms
这证明我们的拦截器已经成功地“切入”了请求处理流程。
通过这个简单的日志拦截器,我们已经掌握了拦截器的基本创建和注册流程。preHandle
方法的 boolean
返回值是实现权限校验 的关键。在下一节,我们将利用这一点,结合 JWT 技术,来构建一个真正的用户认证拦截器。
6.2. 实战:实现基于 JWT 的 Token 认证 现在我们知道了拦截器是进行权限校验的“关卡”,那么下一个问题就是:我们用什么作为“通行凭证”呢?
6.2.1. 认证方案选择:为什么是 JWT? 在传统的 Web 应用中,我们常用 Session-Cookie 机制来管理用户状态。但它在现代前后端分离、分布式、移动优先的架构下,暴露了一些弊端:
服务端状态化 :服务器需要为每个登录用户维护一份 Session 数据,当在线用户量巨大时,会消耗大量内存。扩展性差 :Session 数据默认存储在单台服务器上。在多台服务器做负载均衡时,需要额外处理 Session 共享问题(如使用 Sticky Session 或 Session 复制),增加了架构复杂度。跨域与移动端不友好 :基于 Cookie 的 Session 机制在跨域场景和非浏览器客户端(如手机 App)上处理起来较为棘手。为了解决这些问题,基于 Token 的无状态认证 方案应运而生,而 JWT (JSON Web Token) 是其中最主流、最优秀的事实标准。
JWT 的核心思想 :服务器在用户登录成功后,不再保存任何 Session 信息,而是根据用户信息生成一个加密签名的、自包含的字符串(即 Token),返还给客户端。客户端在后续的每次请求中,都需要在请求头里携带这个 Token。服务器收到请求后,只需验证 Token 签名的合法性,即可确认用户的身份,无需查询数据库或任何会话存储。
特性 Session-Cookie (传统方案) JWT Token (现代方案) 状态 有状态 (Stateful) 无状态 (Stateless) 存储 服务端存储 Session 客户端存储 Token 扩展性 差,依赖 Session 共享 好 ,天然支持分布式部署适用性 仅限浏览器 通用 ,浏览器、App、小程序均适用
6.2.2. 完整的认证授权流程 一个标准的 Token 认证流程包含以下三个步骤,我们必须在脑海中建立起这个闭环:
客户端(前端)使用用户名和密码调用登录接口 (/auth/login
)。
服务器验证身份成功后,使用 Hutool-JWT 生成一个包含用户信息的 Token ,并将其返回给客户端。
客户端将获取到的 Token 存储起来(例如,在 localStorage
中)。在后续访问所有受保护的接口 (如 /users
)时,必须在 HTTP 请求头 Authorization
中携带这个 Token,格式通常为 Bearer <token>
。
服务器端的拦截器 会捕获每一个请求,检查 Authorization
头是否存在且 Token 是否合法有效。
如果 Token 有效,则放行请求至 Controller;如果 Token 无效或不存在,则拦截请求并返回 401 Unauthorized
错误。
6.3. 实战:登录接口与 Token 校验 6.3.1. 准备工作:添加 JWT 配置 文件路径 : src/main/resources/application.yml
(修改)
1 2 3 4 5 6 7 app: jwt: secret: your-super-strong-and-long-secret-key-for-hs256-jwt
安全警告 : JWT 的安全性完全依赖于密钥的保密性。在生产环境中,绝对不能 将密钥硬编码在配置文件中。最佳实践是将其配置在环境变量或专门的密钥管理服务中。
文件路径 : com/example/springbootdemo/dto/auth/LoginDTO.java
(新增)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 package com.example.springbootdemo.dto.auth;import io.swagger.v3.oas.annotations.media.Schema;import jakarta.validation.constraints.NotBlank;import lombok.Data;@Data @Schema(description = "登录数据传输对象") public class LoginDTO { @Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED, example = "admin") @NotBlank(message = "用户名不能为空") private String username; @Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456") @NotBlank(message = "密码不能为空") private String password; }
6.3.2. 实现登录接口 (签发 Token) 我们将重构 AuthController
,使用 Hutool 的 JWTUtil
来生成 Token。
文件路径 : src/main/java/com/example/springbootdemo/controller/AuthController.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 package com.example.springbootdemo.controller;import cn.hutool.jwt.JWTUtil;import com.example.springbootdemo.common.Result;import com.example.springbootdemo.common.ResultCode;import com.example.springbootdemo.dto.auth.LoginDTO;import com.example.springbootdemo.exception.BusinessException;import io.swagger.v3.oas.annotations.Operation;import io.swagger.v3.oas.annotations.tags.Tag;import lombok.RequiredArgsConstructor;import org.springframework.beans.factory.annotation.Value;import org.springframework.http.ResponseEntity;import org.springframework.validation.annotation.Validated;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import java.util.HashMap;@Tag(name = "认证管理", description = "提供用户登录认证接口") @RestController @RequestMapping("/auth") @RequiredArgsConstructor @Validated public class AuthController { @Value("${app.jwt.secret}") private String jwtSecret; @Operation(summary = "用户登录") @PostMapping("/login") public ResponseEntity<Result<String>> login (@Validated @RequestBody LoginDTO loginDTO) { if ("admin" .equals(loginDTO.getUsername()) && "123456" .equals(loginDTO.getPassword())) { HashMap<String, Object> payload = new HashMap <>(); payload.put("username" , loginDTO.getUsername()); String token = JWTUtil.createToken(payload, jwtSecret.getBytes()); return ResponseEntity.ok( Result.success(token) ); } else { throw new BusinessException (ResultCode.UNAUTHORIZED); } } }
6.3.3. 编写 Token 校验拦截器 现在我们来创建真正的“门卫”——AuthInterceptor
。
文件路径 : src/main/java/com/example/springbootdemo/interceptor/AuthInterceptor.java
(新增文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 package com.example.springbootdemo.interceptor;import cn.hutool.core.util.StrUtil;import cn.hutool.jwt.JWTValidator;import com.example.springbootdemo.common.ResultCode;import com.example.springbootdemo.exception.BusinessException;import jakarta.servlet.http.HttpServletRequest;import jakarta.servlet.http.HttpServletResponse;import lombok.RequiredArgsConstructor;import org.springframework.beans.factory.annotation.Value;import org.springframework.stereotype.Component;import org.springframework.web.servlet.HandlerInterceptor;@Component @RequiredArgsConstructor public class AuthInterceptor implements HandlerInterceptor { @Value("${app.jwt.secret}") private String jwtSecret; @Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization" ); try { if (StrUtil.isBlank(token) || !token.startsWith("Bearer " )) { throw new BusinessException (ResultCode.UNAUTHORIZED); } token = token.substring(7 ); JWTValidator.of(token).validateDate(); } catch (Exception e) { throw new BusinessException (ResultCode.ERROR); } return true ; } }
6.3.4. 注册拦截器 文件路径 : src/main/java/com/example/springbootdemo/config/WebConfig.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 import com.example.springbootdemo.interceptor.AuthInterceptor;@Configuration @RequiredArgsConstructor public class WebConfig implements WebMvcConfigurer { private final AuthInterceptor authInterceptor; @Override public void addInterceptors (InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns("/**" ) .excludePathPatterns( "/auth/login" , "/files/**" , "/springboot-uploads/**" , "/swagger-ui/**" , "/v3/api-docs/**" ); } }
6.4. 联动:配置 SpringDoc 支持 JWT 认证 我们已经创建了登录接口和拦截器,但 Swagger UI 并不知道我们的 /users
接口需要一个 Authorization
请求头。因此,它既不会在接口上显示需要认证的“小锁”图标,也没有提供地方让我们输入 Token。这使得我们无法通过这个便捷的工具来测试受保护的接口。
为了解决这个问题,我们需要通过注解,明确地告诉 SpringDoc 我们项目所采用的认证方案。
1. 定义安全方案 (@SecurityScheme
) 首先,我们需要定义一个全局的安全方案,告诉 SpringDoc 我们使用的是基于 HTTP 的 Bearer Token 认证。最佳实践是创建一个专门的配置类来做这件事。
文件路径 : src/main/java/com/example/springbootdemo/config/SpringDocConfig.java
(新增文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 package com.example.springbootdemo.config;import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;import io.swagger.v3.oas.annotations.security.SecurityScheme;import org.springframework.context.annotation.Configuration;@Configuration @SecurityScheme( name = "bearerAuth", // 这是安全方案的唯一名称,后续将通过此名称引用 type = SecuritySchemeType.HTTP, // 认证类型为 HTTP scheme = "bearer", // 具体的认证方案为 Bearer, 表示令牌类型 bearerFormat = "JWT" // 提示 Token 的格式为 JWT ) public class SpringDocConfig {}
2. 应用安全方案 (@SecurityRequirement
) 定义好安全方案后,我们还需要将其应用 到需要保护的 Controller 上。
文件路径 : src/main/java/com/example/springbootdemo/controller/UserController.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 package com.example.springbootdemo.controller;import io.swagger.v3.oas.annotations.security.SecurityRequirement;@Tag(name = "用户管理", description = "提供用户相关的CRUD接口") @RestController @RequestMapping("/users") @RequiredArgsConstructor @Validated @SecurityRequirement(name = "bearerAuth") public class UserController { }
通过在 UserController
类上添加 @SecurityRequirement(name = "bearerAuth")
,我们告诉 SpringDoc,这个 Controller 下的所有接口 都需要使用我们刚刚定义的 bearerAuth
认证方案。当然,这个注解也可以用在单个方法上,以实现更细粒度的控制。
7. [架构重构] 迈向企业级:Maven 多模块项目 摘要 : 随着我们功能的不断累加,我们最初的单体项目正变得日益臃肿和混乱。本章,我们将进行一次意义深远的架构重构,借鉴 RuoYi 等企业级框架的设计思想,亲手将我们的项目改造为一个职责清晰、高度解耦的 Maven 多模块工程。这次重构将极大提升项目的可维护性和扩展性,为未来承载更复杂的业务打下坚实的基础。
7.1. 痛点分析与模块化思想 7.1.1. 现状:单体应用的困境 在开始重构之前,我们首先要清醒地认识到当前项目存在的“成长烦恼”。请看我们目前的项目包结构:
虽然我们遵循了 controller
, service
, mapper
等内部分层,但从整体工程角度看,它依然是一个单体应用 。随着项目规模的扩大,这种结构逐渐暴露出一系列问题:
包结构扁平化 : advice
, common
, config
, controller
, converter
, dto
, entity
… 所有这些包都挤在同一个 src
目录下。当未来我们新增“订单模块”、“产品模块”时,这个列表将变得越来越长,不同业务模块的代码会混杂在一起,难以管理。依赖关系混乱 : 我们的 pom.xml
文件中,既有 spring-boot-starter-web
这样的 Web 框架依赖,也有 mybatis-plus
这样的数据持久化依赖,还有 hutool-all
这样的通用工具依赖。所有依赖都打包在一起,职责不清,我们很难说清楚哪个模块具体需要哪个依赖。高度耦合 : 所有代码都在一个模块中,理论上任何一个类的改动(即使是一个工具类),都可能需要对整个项目进行重新的编译、测试和部署,模块间的边界是模糊的。复用性差 : 如果我们想在另一个新项目中复用当前的 common
包或 util
包下的工具类,除了复制粘贴代码,没有更优雅的方式。7.1.2. 蓝图:借鉴 RuoYi 的模块化设计 要解决以上问题,我们需要引入软件工程领域一个非常重要的思想——模块化 ,而在 Maven 项目中,实现模块化的最佳实践就是构建**多模块项目 **。
我们将借鉴 RuoYi 这类成熟企业级框架的设计精髓,将我们的单体应用“分而治之”,拆分为以下四个核心模块:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 📂 spring-boot-demo (父模块, pom) ├── 📄 pom.xml (统一管理版本和模块) │ ├── 📂 demo-common (通用工具模块, jar) │ └── ... (存放 Result, BusinessException, JwtUtil 等) │ ├── 📂 demo-framework (框架核心模块, jar) │ └── ... (存放 WebConfig, MybatisPlusConfig, GlobalExceptionHandler, 拦截器等) │ ├── 📂 demo-system (系统业务模块, jar) │ └── ... (存放所有用户管理相关的 Controller, Service, Mapper, DTO, VO 等) │ └── 📂 demo-admin (启动与部署模块, jar) └── ... (仅存放 SpringBootDemoApplication.java 启动类)
各模块核心职责 :
模块 核心职责 详细说明 demo-common
通用工具与核心定义 存放与具体业务无关的、可被所有其他模块复用的公共类,如 Result
封装、自定义异常、Hutool
依赖、枚举、常量等。它是一个高度抽象的底层工具包。 demo-framework
框架核心配置与增强 存放与 Spring 框架本身相关的配置和增强功能,如 WebMvcConfigurer
(CORS, 拦截器)、MybatisPlusConfig
、SpringDocConfig
、全局异常处理器等。它为业务模块提供技术支撑。 demo-system
具体业务模块 存放具体业务模块的代码。目前我们只有一个“用户管理”功能,所以所有 User
相关的 Controller, Service, Mapper, DTO, VO 都会放在这里。未来新增“订单模块”时,我们会创建一个新的 demo-order
业务模块。 demo-admin
应用启动入口 这是一个非常轻量的“胶水”模块。它本身几乎没有代码,只包含 main
启动类。它的主要作用是通过 Maven 依赖,将 framework
和 system
等模块组装起来,最终打包成一个可运行的 Spring Boot 应用。
通过这样的拆分,我们的项目结构将变得异常清晰。每个模块都有明确的边界和单一的职责,实现了物理层面的解耦。这为我们后续的开发、测试和维护工作,奠定了坚实的企业级工程基础。
7.2. 实战:父工程改造与依赖管理 理论学习完毕,我们现在正式开始对项目进行“大手术”。第一步,也是最关键的一步,就是将我们当前的 spring-boot-demo
项目,从一个可运行的 jar
工程,改造为一个只负责管理、不包含任何代码的 pom
父工程 。
7.2.1. 修改 packaging 为 pom 请打开项目根目录下的 pom.xml
文件。我们需要做的第一件事,就是将 <packaging>
标签的内容,从默认的 jar
修改为 pom
。
文件路径 : pom.xml
(修改)
1 2 3 4 5 <groupId > com.example</groupId > <artifactId > spring-boot-demo</artifactId > <version > 0.0.1-SNAPSHOT</version > <packaging > pom</packaging > <name > spring-boot-demo</name >
这个小小的改动,从根本上改变了 pom.xml
的性质。它告诉 Maven:“我不再是一个需要被打成 jar 包的普通应用了,我的新身份是一个父工程,我的职责是管理我的子模块们”。
修改后,您可能会发现项目中的 src
目录在 IDE 中变成了灰色或普通文件夹图标,这是完全正常的,因为 pom
类型的父工程本身不包含业务代码。
7.2.2. 统一版本:使用 <properties>
和 <dependencyManagement>
目前,我们所有的依赖都直接写在 <dependencies>
标签下,版本号散落在各处。当项目模块变多时,这会导致版本混乱和难以升级。专业的做法是使用 <dependencyManagement>
在父工程中进行统一的版本声明。
我们将进行两步操作:
使用 <properties>
标签将所有版本号提取为变量。 将整个 <dependencies>
块移动到 <dependencyManagement>
块内部。 文件路径 : pom.xml
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 <?xml version="1.0" encoding="UTF-8" ?> <project xmlns ="http://maven.apache.org/POM/4.0.0" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation ="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" > <modelVersion > 4.0.0</modelVersion > <parent > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-parent</artifactId > <version > 3.5.4</version > </parent > <groupId > com.example</groupId > <artifactId > spring-boot-demo</artifactId > <version > 0.0.1-SNAPSHOT</version > <name > spring-boot-demo</name > <packaging > pom</packaging > <modules > <module > demo-common</module > <module > demo-framework</module > <module > demo-system</module > <module > demo-admin</module > </modules > <properties > <java.version > 17</java.version > <lombok.version > 1.18.32</lombok.version > <hutool.version > 5.8.27</hutool.version > <mybatis-plus.version > 3.5.7</mybatis-plus.version > <springdoc.version > 2.7.0</springdoc.version > <jjwt.version > 0.11.5</jjwt.version > </properties > <dependencyManagement > <dependencies > <dependency > <groupId > com.example</groupId > <artifactId > demo-common</artifactId > <version > ${project.version}</version > </dependency > <dependency > <groupId > com.example</groupId > <artifactId > demo-framework</artifactId > <version > ${project.version}</version > </dependency > <dependency > <groupId > org.projectlombok</groupId > <artifactId > lombok</artifactId > <version > ${lombok.version}</version > </dependency > <dependency > <groupId > cn.hutool</groupId > <artifactId > hutool-all</artifactId > <version > ${hutool.version}</version > </dependency > <dependency > <groupId > com.baomidou</groupId > <artifactId > mybatis-plus-spring-boot3-starter</artifactId > <version > ${mybatis-plus.version}</version > </dependency > <dependency > <groupId > org.springdoc</groupId > <artifactId > springdoc-openapi-starter-webmvc-ui</artifactId > <version > ${springdoc.version}</version > </dependency > <dependency > <groupId > io.jsonwebtoken</groupId > <artifactId > jjwt-api</artifactId > <version > ${jjwt.version}</version > </dependency > </dependencies > </dependencyManagement > </project >
代码解析 :
<properties>
: 我们将所有第三方库的版本号都提取到了这里,便于未来统一升级。<dependencyManagement>
: 这就像一个“依赖版本仲裁中心”。在这里声明的依赖,并不会 被实际引入,它只是一个“版本清单”。清空 <dependencies>
: 父 pom
作为一个管理者,它本身不应该包含任何具体的实现代码,因此也不需要直接依赖任何 jar
包。经过改造后,所有子模块未来在引入这些依赖时,只需要提供 groupId
和 artifactId
,无需再指定 version
,它们会自动继承父工程中声明的版本。这保证了整个项目所有模块使用的依赖版本是高度统一的。
最后,父工程需要知道它到底管理了哪些子模块。我们在 pom.xml
的顶层(与 <properties>
同级)添加 <modules>
标签,并在其中声明我们规划好的四个子模块。
7.3. 实战:拆分核心模块 父工程的改造完成后,现在就如同我们已经画好了建筑蓝图。接下来的任务,就是按照蓝图,一砖一瓦地搭建起每个独立的模块(房间),并将我们现有的代码(家具)搬运到它们各自正确的位置。
在 IDEA 中,您可以通过右键点击项目根目录 -> New
-> Module...
来创建新的 Maven 子模块。请确保在创建时,它能被正确识别为当前父工程的子模块。
7.3.1. 创建并迁移 demo-common
(通用工具模块) 这个模块是我们的基础工具库,存放与具体业务无关的、可被所有其他模块复用的公共代码。
第一步:创建模块与 pom.xml
首先,在项目根目录下创建 demo-common
文件夹,并在其中创建 pom.xml
文件。
文件路径 : demo-common/pom.xml
(修改文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 <?xml version="1.0" encoding="UTF-8" ?> <project xmlns ="http://maven.apache.org/POM/4.0.0" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation ="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" > <modelVersion > 4.0.0</modelVersion > <parent > <groupId > com.example</groupId > <artifactId > spring-boot-demo</artifactId > <version > 0.0.1-SNAPSHOT</version > </parent > <artifactId > demo-common</artifactId > <dependencies > <dependency > <groupId > cn.hutool</groupId > <artifactId > hutool-all</artifactId > </dependency > <dependency > <groupId > org.projectlombok</groupId > <artifactId > lombok</artifactId > </dependency > <dependency > <groupId > io.jsonwebtoken</groupId > <artifactId > jjwt-api</artifactId > </dependency > </dependencies > </project >
注意 :在子模块的 pom.xml
中,我们引入依赖时无需再指定 <version>
和 <groupId>
,因为它会从父工程的 <dependencyManagement>
中自动继承,这正是父工程的价值所在!
第二步:迁移代码
现在,我们将主项目中所有通用的代码包,整体移动到 demo-common
模块的 src/main/java/
目录下。
迁移清单 :
common
包 (包含 Result.java
, ResultCode.java
)enums
包 (包含 UserStatusEnum.java
)exception
包 (包含 BusinessException.java
)util
包 (目前没有文件)7.3.2. 创建并迁移 demo-framework
(框架核心模块) 这个模块负责所有与 Spring 框架相关的配置和增强功能。
第一步:创建模块与 pom.xml
文件路径 : demo-framework/pom.xml
(修改文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 <?xml version="1.0" encoding="UTF-8" ?> <project xmlns ="http://maven.apache.org/POM/4.0.0" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation ="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" > <modelVersion > 4.0.0</modelVersion > <parent > <groupId > com.example</groupId > <artifactId > spring-boot-demo</artifactId > <version > 0.0.1-SNAPSHOT</version > </parent > <artifactId > demo-framework</artifactId > <dependencies > <dependency > <groupId > com.example</groupId > <artifactId > demo-common</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-web</artifactId > </dependency > <dependency > <groupId > com.baomidou</groupId > <artifactId > mybatis-plus-spring-boot3-starter</artifactId > </dependency > <dependency > <groupId > org.springdoc</groupId > <artifactId > springdoc-openapi-starter-webmvc-ui</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-test</artifactId > <scope > test</scope > </dependency > </dependencies > </project >
第二步:迁移代码
迁移清单 :
advice
包 (包含 GlobalExceptionHandler.java
)config
包 (包含 MybatisPlusConfig.java
, SpringDocConfig.java
, WebConfig.java
)converter
包 (包含 StringToUserStatusEnumConverter.java
)interceptor
包 (包含 AuthInterceptor.java
, LogInterceptor.java
)7.3.3. 创建并迁移 demo-system
(系统业务模块) 这个模块是我们的核心业务模块,存放所有与“用户管理”相关的代码。
最理想的结构是让 demo-system 依赖 demo-framework,而不是重复声明 spring-boot-starter-web、mybatis-plus 等依赖。这样可以形成清晰的依赖链:admin -> system -> framework -> common
第一步:创建模块与 pom.xml
文件路径 : demo-system/pom.xml
(新增文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 <?xml version="1.0" encoding="UTF-8" ?> <project xmlns ="http://maven.apache.org/POM/4.0.0" xmlns:xsi ="http://www.w.org/2001/XMLSchema-instance" xsi:schemaLocation ="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" > <modelVersion > 4.0.0</modelVersion > <parent > <groupId > com.example</groupId > <artifactId > spring-boot-demo</artifactId > <version > 0.0.1-SNAPSHOT</version > </parent > <artifactId > demo-system</artifactId > <dependencies > <dependency > <groupId > com.example</groupId > <artifactId > demo-framework</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-test</artifactId > <scope > test</scope > </dependency > </dependencies > </project >
第二步:迁移代码
迁移清单 :
controller
包 (所有 Controller)dto
包 (所有 DTO)entity
包 (所有 Entity)mapper
包 (所有 Mapper)service
包 (所有 Service 及其 impl)validation
包 (所有校验分组)vo
包 (所有 VO)在修复代码之前,必须先让项目能正确加载。
打开根目录下的 pom.xml
(spring-boot-demo/pom.xml
)。 找到 <modules>
标签。 注释或删除 那一行 <module>demo-admin</module>
,因为这个模块还不存在。在 IDEA 右侧的 Maven 面板中,点击刷新按钮,确保项目能无错误地加载。 步骤 1: 执行全局搜索和替换 现在,我们要把所有旧的包引用 com.example.springbootdemo.*
替换成新的包名。
按下快捷键 Ctrl + Shift + R
(Windows/Linux) 或 Cmd + Shift + R
(Mac)。这将打开全局搜索和替换窗口。
执行第一次替换 :
因为我们的新包结构是 com.example.democommon
, com.example.demosystem
等,而不是 com.example.springbootdemo.common
。原来的所有 DTO, VO, Service 等都引用了 com.example.springbootdemo
下的类。
建议开启自动导包,对于之前结构的包都进行删除并全部自动导入的操作
步骤 2: 优化 Imports 替换完成后,可能还有一些多余或无效的 import
语句。
在项目根目录上右键。 选择 “Optimize Imports”。IDEA 会自动清理所有文件中未使用的导入。 你也可以使用快捷键 Ctrl + Alt + O
(Windows/Linux) 或 Cmd + Option + O
(Mac) 在单个文件中操作。 7.4. 实战:创建 Admin 启动模块 7.4.1. 创建 demo-admin
模块与 pom.xml
首先,请按照同样的方式,创建第四个子模块:demo-admin
。
这个模块的 pom.xml
非常关键,它不直接依赖具体的第三方库,而是依赖于我们自己创建的 framework
和 system
模块,像胶水一样将它们粘合在一起。同时,它需要 spring-boot-maven-plugin
插件来将整个应用打包成一个可执行的 jar
。
文件路径 : demo-admin/pom.xml
(新增文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 <?xml version="1.0" encoding="UTF-8" ?> <project xmlns ="http://maven.apache.org/POM/4.0.0" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation ="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd" > <modelVersion > 4.0.0</modelVersion > <parent > <groupId > com.example</groupId > <artifactId > spring-boot-demo</artifactId > <version > 0.0.1-SNAPSHOT</version > </parent > <artifactId > demo-admin</artifactId > <dependencies > <dependency > <groupId > com.example</groupId > <artifactId > demo-framework</artifactId > <version > ${project.version}</version > </dependency > <dependency > <groupId > com.example</groupId > <artifactId > demo-system</artifactId > <version > ${project.version}</version > </dependency > <dependency > <groupId > com.mysql</groupId > <artifactId > mysql-connector-j</artifactId > <scope > runtime</scope > </dependency > </dependencies > <build > <plugins > <plugin > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-maven-plugin</artifactId > </plugin > </plugins > </build > </project >
7.4.2. 迁移并改造启动类 现在,我们将父工程根目录下那个“光杆司令” SpringBootDemoApplication.java
移动到 demo-admin
模块中。
同时,我们需要对它进行一个小小的改造,以确保 Spring Boot 能够扫描到我们所有子模块中的组件(Bean)。
文件路径 : demo-admin/src/main/java/com/example/springbootdemo/SpringBootDemoApplication.java
(迁移并修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 package com.example.demoadmin;import org.mybatis.spring.annotation.MapperScan;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplication(scanBasePackages = "com.example") @MapperScan("com.example.*.mapper") public class SpringBootDemoApplication { public static void main (String[] args) { SpringApplication.run(SpringBootDemoApplication.class, args); } }
7.4.3. 清理项目 重要信息 : 记得将配置文件搬迁到 demo-admin
上
为了让我们的项目结构达到最终的完美形态,请执行以下清理操作:
删除父工程根目录下的 src
文件夹 : 因为 SpringBootDemoApplication.java
已经被移动到了 demo-admin
模块,父工程根目录下的 src
文件夹现在是多余的,可以安全删除 。删除子模块中多余的启动类 : 您在 demo-common
, demo-framework
和 demo-system
中由 IDE 自动生成的 Demo...Application.java
和 ...Tests.java
文件是不需要的,也请一并删除,因为整个应用只有一个启动入口和一套集成的测试环境。7.4.4. 回归测试:验证重构结果 现在,整个项目的结构已经焕然一新。请确保您在 IDE 中选择的启动目标是 demo-admin
模块中的 SpringBootDemoApplication.java
。
为了不影响读者的阅读和可能读者操作失误,这里提供一个整理好的仓库供读者快速Clone Spring_Mvc_Study: 教学用的SpringMVC文件
启动应用 后,请打开 Swagger UI 或 Postman,尝试调用一个我们之前写好的接口,例如 GET /users/1
。
如果接口能够正常返回数据,那么——
恭喜您! 您已经成功地将一个单体应用,重构为了一个结构清晰、职责分明、高度解耦的企业级多模块项目!这次重构的成功,为您未来驾驭大型复杂项目奠定了坚实的工程基础。