Spring Boot 4 + JDK 21 开发标准 RESTful 接口
今天用一套生产级栈搭「标准 RESTful 接口」:Spring Boot 4.1.0 + JDK 21 + MyBatis-Plus + Druid + MySQL 8,对象转换用 MapStruct-Plus,接口测试用 Apifox。从建表到跑通测试,复制就能跑。
先说个坑:Spring Boot 4 必须用专门的 starter——MyBatis-Plus 要 mybatis-plus-spring-boot4-starter、Druid 要 druid-spring-boot-4-starter,用 SB3 的旧 starter 启动直接报 factoryBeanObjectType 类型错。下面会标出来。
一、先说规矩:标准长什么样
RESTful 不是教条,是让接口「不让人猜」的约定。记住这几条:
· 路径用名词复数:/api/users 而不是 /api/getUser
· 动词交给 HTTP 方法:GET 查、POST 增、PUT 改、DELETE 删
· 状态码要真:成功 200/201,参数错 400,找不到 404,服务器错 500
· 响应体统一结构:外层 code / msg / data 不变
· 出错也走统一结构:别把异常直接抛出去
二、pom 锁版本(SB4 专属 starter)
重点看 MyBatis-Plus 和 Druid 的 artifactId,都带 -boot4- / -boot-4-,用错就启动失败:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version>
<relativePath/>
</parent>
<properties>
<java.version>21</java.version>
<mybatis-plus.version>3.5.16</mybatis-plus.version>
<druid.version>1.2.28</druid.version>
<mapstruct-plus.version>1.5.1</mapstruct-plus.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- MyBatis-Plus:SB4 必须用 -boot4-starter -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot4-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- Druid:SB4 必须用 -boot-4-starter(1.2.28 起支持) -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-4-starter</artifactId>
<version>${druid.version}</version>
</dependency>
<!-- MySQL 8 驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- MapStruct-Plus:已内置 MapStruct,别再单独引 mapstruct -->
<dependency>
<groupId>io.github.linpeilie</groupId>
<artifactId>mapstruct-plus-spring-boot-starter</artifactId>
<version>${mapstruct-plus.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>21</source>
<target>21</target>
<annotationProcessorPaths>
<path>
<groupId>io.github.linpeilie</groupId>
<artifactId>mapstruct-plus-processor</artifactId>
<version>${mapstruct-plus.version}</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
三、建表 + 配数据源
application.yml 配 Druid 连接池 + MyBatis-Plus:
spring: datasource: type: com.alibaba.druid.pool.DruidDataSource driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/demo?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8 username: root password: root druid: initial-size: 5 min-idle: 5 max-active: 20 max-wait: 60000 stat-view-servlet: enabled: true url-pattern: /druid/* mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: id-type: auto server: port: 8080
启动后访问 http://localhost:8080/druid 能看到 Druid 监控台,说明连接池就位了。
四、实体 + DTO,MapStruct-Plus 自动转
实体类用 MyBatis-Plus 注解;DTO 上加 @AutoMapper,编译期自动生成双向转换,运行时零反射:
@TableName("user")
@Data
public class User {
@TableId(type = IdType.AUTO)
private Long id;
private String username;
private String email;
private Integer age;
private LocalDateTime createTime;
}
// 出参 VO:标 @AutoMapper,自动生成 User <-> UserVO
@Data
@AutoMapper(target = User.class)
public class UserVO {
private Long id;
private String username;
private String email;
private Integer age;
private LocalDateTime createTime;
}
// 入参 Request:标 @AutoMapper,自动生成 CreateUserRequest -> User
@Data
@AutoMapper(target = User.class)
public class CreateUserRequest {
@NotBlank private String username;
@Email private String email;
@Min(0) @Max(150) private Integer age;
}
五、Mapper + Service + 分页配置
Mapper 继承 BaseMapper,单表 CRUD 直接白嫖;Service 继承 ServiceImpl,连手写都不用:
@Mapper public interface UserMapper extends BaseMapper<User> {} @Service public class UserService extends ServiceImpl<UserMapper, User> {}
分页要用 MyBatis-Plus 的分页拦截器,否则 page() 查全表不生效:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }
六、统一响应 + 通用分页 + Controller
先包一层 Result<T>,前端解析逻辑只写一套:
import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @Data @AllArgsConstructor @NoArgsConstructor public class Result<T> { // 响应码:200成功,500失败 private Integer code; // 响应提示信息 private String msg; // 响应数据 private T data; public static <T> Result<T> success(T data) { return new Result<>(200, "操作成功", data); } public static <T> Result<T> success() { return new Result<>(200, "操作成功", null); } public static <T> Result<T> error(String msg) { return new Result<>(500, msg, null); } }
分页再包一层通用 PageResult<T>——纯数据类,不绑死 MyBatis-Plus,前端解析统一;UserPageQuery 是分页入参,GET 接口 Spring 自动把 query string 绑到字段上:
@Data @AllArgsConstructor @NoArgsConstructor public class PageResult<T> { private long total; // 总条数 private long pageNum; // 当前页码 private long pageSize; // 每页条数 private List<T> records; // 当前页数据 } @Data public class UserPageQuery { private Integer pageNum = 1; private Integer pageSize = 10; private String username; // 可选:用户名模糊查询 }
Controller 里 GET/POST/PUT/DELETE 各就各位,用 Converter 做 Entity↔DTO 转换:
@RestController @RequestMapping("/api/users") @RequiredArgsConstructor public class UserController { private final UserService userService; private final Converter converter; @GetMapping public Result<PageResult<UserVO>> list(UserPageQuery query) { Page<User> page = new Page<>(query.getPageNum(), query.getPageSize()); LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<User>() .like(StringUtils.hasText(query.getUsername()), User::getUsername, query.getUsername()) .orderByDesc(User::getId); userService.page(page, wrapper); List<UserVO> vos = converter.convert(page.getRecords(), UserVO.class); return Result.success(new PageResult<>(page.getTotal(), page.getCurrent(), page.getSize(), vos)); } @GetMapping("/{id}") public Result<UserVO> get(@PathVariable Long id) { User user = userService.getById(id); if (user == null) throw new NotFoundException("用户不存在: " + id); return Result.success(converter.convert(user, UserVO.class)); } @PostMapping public Result<UserVO> create(@Valid @RequestBody CreateUserRequest req) { User user = converter.convert(req, User.class); userService.save(user); return Result.success(converter.convert(user, UserVO.class)); } @PutMapping("/{id}") public Result<UserVO> update(@PathVariable Long id, @Valid @RequestBody CreateUserRequest req) { if (userService.getById(id) == null) throw new NotFoundException("用户不存在: " + id); User user = converter.convert(req, User.class); user.setId(id); userService.updateById(user); return Result.success(converter.convert(userService.getById(id), UserVO.class)); } @DeleteMapping("/{id}") public Result<Void> delete(@PathVariable Long id) { userService.removeById(id); return Result.success(); } }
LambdaQueryWrapper 的 like 第一个参数是条件开关——username 有值才拼 like,否则查全表,省得为「带条件/不带条件」写两个接口。
七、全局异常处理:错误也统一
一个 @RestControllerAdvice 兜住所有异常,HTTP 状态码区分错误类型(400/404/500),body 统一走 Result.error(msg):
public class NotFoundException extends RuntimeException { public NotFoundException(String msg) { super(msg); } } @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<Result<Void>> handleValid(MethodArgumentNotValidException ex) { String msg = ex.getBindingResult().getFieldErrors().stream() .map(e -> e.getField() + ": " + e.getDefaultMessage()) .collect(Collectors.joining("; ")); return ResponseEntity.status(400).body(Result.error(msg)); } @ExceptionHandler(NotFoundException.class) public ResponseEntity<Result<Void>> handleNotFound(NotFoundException ex) { return ResponseEntity.status(404).body(Result.error(ex.getMessage())); } @ExceptionHandler(Exception.class) public ResponseEntity<Result<Void>> handleOther(Exception ex) { return ResponseEntity.status(500).body(Result.error("服务器开小差了")); } }
八、Apifox 跑一遍
接口写完得验。Apifox 比 curl 直观,能存集合、写断言、批量跑。步骤:
· 建环境:项目设置 → 环境 → 新建「本地」,baseUrl = http://localhost:8080
· 建集合:新建「用户接口」集合,依次加 5 个请求:
POST {{baseUrl}}/api/users · GET {{baseUrl}}/api/users · GET {{baseUrl}}/api/users/1 · PUT {{baseUrl}}/api/users/1 · DELETE {{baseUrl}}/api/users/1
POST 请求 Body 选 JSON,填:
{ "username": "竹雨", "email": "zy@demo.com", "age": 28 }
在后置脚本里加断言,校验统一响应结构:
// 期望 code=200 且返回了 id pm.test("创建成功", function () { var json = pm.response.json(); pm.expect(json.code).to.eql(200); pm.expect(json.data.id).to.be.above(0); });
再建一条「非法参数」用例(name 空、email 非法、age 越界),断言 body.code=500(失败)且 HTTP 状态码 400。点集合「运行」批量跑一遍,全绿就算达标。
分页接口单独测:先 POST 几条数据,再请求 GET {{baseUrl}}/api/users?pageNum=1&pageSize=10,带条件就加 &username=竹。断言分页结构:
pm.test("分页成功", function () {
var json = pm.response.json();
pm.expect(json.code).to.eql(200);
pm.expect(json.data.total).to.be.above(0);
pm.expect(json.data.records).to.be.an("array");
pm.expect(json.data.records.length).to.be.at.most(json.data.pageSize);
});
兼容性提示(避坑)
· MyBatis-Plus:SB4 必须用 mybatis-plus-spring-boot4-starter(3.5.16),用 -boot-starter 或 -boot3-starter 会报 Invalid value type for attribute 'factoryBeanObjectType'。
· Druid:SB4 用 druid-spring-boot-4-starter(1.2.28 起),别用 -3-starter。
· MapStruct-Plus:1.5.1 官方标注支持到 SB2~3 / JDK8~17。SB4 + JDK21 下,注解处理器(编译期生成代码)不依赖 Spring 运行时、可正常用;starter 的自动配置基本兼容,落地前先本地跑一次转换单测,确认 Converter 能正常注入再上业务。
· web 依赖:SB4 已废弃 spring-boot-starter-web(deprecated),改用 spring-boot-starter-webmvc(仅 MVC 服务端)。若用到 RestTemplate / RestClient,再单独加 spring-boot-starter-restclient。
状态码对、结构齐、错误不裸奔——这套接口就算「达标」了。JDK 21 的虚拟线程也已经正式 GA,接口要扛并发,application.properties 里加一行 spring.threads.virtual.enabled=true 就能白捡一层并发红利。
你们项目接口现在是哪套栈?MyBatis-Plus 还是 JPA,转换还在手写吗?
MySQL8 里建张 user 表:
CREATE TABLE `user` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `username` VARCHAR(64) NOT NULL, `email` VARCHAR(128) DEFAULT NULL, `age` INT DEFAULT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

浙公网安备 33010602011771号