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 Found Content-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
对象了。