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;
posted @ 2026-07-22 14:39  狗艳艳花  阅读(113)  评论(0)    收藏  举报