Spring Boot 接口参数校验从入门到精通

写接口时,你是不是还在用一堆 if 判断参数是否为空、格式对不对?这篇文章带你用更优雅的方式搞定一切。

示例

小张刚入职时,负责开发一个用户注册接口。他非常认真,编写了如下代码:

java 复制代码
@PostMapping("/register")
public String register(User user) {
    // 手动校验每个字段
    if (user.getUsername() == null || user.getUsername().isEmpty()) {
        return "用户名不能为空";
    }
    if (user.getPassword() == null || user.getPassword().length() < 6) {
        return "密码长度不能小于6位";
    }
    if (user.getEmail() == null || !user.getEmail().contains("@")) {
        return "邮箱格式不正确";
    }
    if (user.getAge() == null || user.getAge() < 0 || user.getAge() > 150) {
        return "年龄不合法";
    }
    // ... 继续业务逻辑
}

这样写有什么问题?

问题 说明
代码臃肿 校验代码比业务逻辑还多
重复劳动 每个接口都要写一遍类似的校验
难以维护 新增字段要改多处代码
不统一 不同人写的校验格式五花八门

专业做法是:使用 Java 的 Bean Validation(又叫 JSR-303)规范,通过注解优雅地完成参数校验。

五分钟快速入门

第一步:引入依赖

Spring Boot 2.3+ 需要手动引入校验依赖(老版本自带):

java 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

第二步:在实体类上加注解

java 复制代码
import javax.validation.constraints.*;
public class UserRegisterDTO {
@NotBlank(message = "用户名不能为空")
private String username;

@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 20, message = "密码长度必须在6-20位之间")
private String password;

@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;

@NotNull(message = "年龄不能为空")
@Min(value = 1, message = "年龄最小为1岁")
@Max(value = 150, message = "年龄最大为150岁")
private Integer age;

// getter / setter 省略
}

第三步:在Controller中使用 @Valid

java 复制代码
@RestController
@RequestMapping("/api/user")
public class UserController {
@PostMapping("/register")
public Result register(@Valid @RequestBody UserRegisterDTO dto) {
    // 如果校验不通过,根本不会执行到这里
    // 这里只管写业务逻辑
    userService.register(dto);
    return Result.success("注册成功");
}
}

就这样简单三步,所有 if 校验都不需要写了! 当参数不符合规则时,Spring Boot 会自动抛出异常,并返回400错误。

常用校验注解大全(新人必看)

空值校验

注解 适用类型 说明
@NotNull 任意类型 不能为 null
@NotBlank String 不能为 null、空字符串、纯空格
@NotEmpty String、Collection、Map、数组 不能为 null 或空

使用建议

  • 字符串字段优先用 @NotBlank

  • 集合/Map 字段用 @NotEmpty

  • 包装类型(如 IntegerLong)用 @NotNull

数值校验

注解 适用类型 说明
@Min(value) 数值类型 最小值(含)
@Max(value) 数值类型 最大值(含)
@DecimalMin(value) 数值类型 最小值(支持小数)
@DecimalMax(value) 数值类型 最大值(支持小数)
@Digits(integer, fraction) 数值类型 整数位数和小数位数限制
@Positive 数值类型 正数(>0)
@PositiveOrZero 数值类型 正数或0
@Negative 数值类型 负数
@NegativeOrZero 数值类型 负数或0

示例

java 复制代码
@Min(value = 1, message = "数量至少为1")
@Max(value = 999, message = "数量不能超过999")
private Integer quantity;
@DecimalMin(value = "0.01", message = "金额至少为0.01")
@DecimalMax(value = "999999.99", message = "金额不能超过999999.99")
private BigDecimal amount;

字符串校验

注解 说明
@Size(min, max) 字符串长度范围
@Email 邮箱格式
@Pattern(regexp) 正则表达式匹配
@URL URL格式

示例

java 复制代码
@Past(message = "生日必须是过去的时间")
private LocalDate birthday;
@Future(message = "有效期必须晚于当前时间")
private LocalDateTime expireTime;
@AssertTrue(message = "必须同意用户协议")
private Boolean agreeProtocol;

分组校验:同一个对象,不同场景不同规则

同一个 DTO 可能在不同接口中使用,校验规则不一样。比如:新增用户 时密码必填,更新用户时密码可选。

第一步:定义分组接口(只是两个空接口)

java 复制代码
public interface CreateGroup {}   // 新增分组
public interface UpdateGroup {}   // 更新分组

第二步:在注解中指定分组

java 复制代码
public class UserDTO {
@NotNull(message = "ID不能为空", groups = UpdateGroup.class)
private Long id;

@NotBlank(message = "用户名不能为空", groups = {CreateGroup.class, UpdateGroup.class})
private String username;

@NotBlank(message = "密码不能为空", groups = CreateGroup.class)  // 新增时必填
@Size(min = 6, max = 20, message = "密码长度6-20位")
private String password;

@Email(message = "邮箱格式不正确")
private String email;  // 没指定分组,默认在所有分组都生效
}

第三步:在 Controller 中指定使用的分组

java 复制代码
@RestController
@RequestMapping("/api/user")
public class UserController {
@PostMapping("/create")   // 新增时使用 CreateGroup
public Result create(@Validated(CreateGroup.class) @RequestBody UserDTO dto) {
    // 此时会校验:id(不校验,因为没有在CreateGroup中标记)、username(校验)、password(校验)
    userService.create(dto);
    return Result.success();
}

@PutMapping("/update")    // 更新时使用 UpdateGroup
public Result update(@Validated(UpdateGroup.class) @RequestBody UserDTO dto) {
    // 此时会校验:id(校验)、username(校验)、password(不校验,因为没有在UpdateGroup中标记)
    userService.update(dto);
    return Result.success();
}
}

注意: 分组校验时要用 @Validated 而不是 @Valid@Validated 才能指定分组。

高级技巧:自定义校验注解

第一步:定义注解

java 复制代码
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.*;
@Documented
@Constraint(validatedBy = GenderValidator.class)  // 指定校验器
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Gender {
String message() default "性别只能是 MALE 或 FEMALE";

Class&lt;?&gt;[] groups() default {};

Class&lt;? extends Payload&gt;[] payload() default {};
}

第二步:实现校验器

java 复制代码
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
public class GenderValidator implements ConstraintValidator<Gender, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
    if (value == null) {
        return true;  // 允许为空,由 @NotNull 控制是否必填
    }
    return "MALE".equals(value) || "FEMALE".equals(value);
}
}

第三步:使用

java 复制代码
public class UserDTO {
    @Gender(message = "性别只能填 MALE 或 FEMALE")
    private String gender;
}

全局统一处理校验异常

默认情况下,校验失败会返回400错误和默认的报错信息。为了让前端收到统一格式的响应,需要全局异常处理。

java 复制代码
import org.springframework.http.HttpStatus;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
 * 处理 @Valid 校验失败异常
 */
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result handleValidationException(MethodArgumentNotValidException e) {
    // 收集所有字段的校验失败信息
    Map&lt;String, String&gt; errors = new HashMap&lt;&gt;();
    e.getBindingResult().getAllErrors().forEach(error -&gt; {
        String fieldName = ((FieldError) error).getField();
        String errorMessage = error.getDefaultMessage();
        errors.put(fieldName, errorMessage);
    });
    return Result.error(400, "参数校验失败", errors);
}
}

返回给前端的格式

java 复制代码
{
    "code": 400,
    "message": "参数校验失败",
    "data": {
        "username": "用户名不能为空",
        "password": "密码长度必须在6-20位之间"
    }
}

常见问题与避坑指南

坑一:@Valid 不生效

原因: 没有引入 spring-boot-starter-validation 依赖(Spring Boot 2.3+ 需要手动引入)。

解决方案: 检查 pom.xml 是否有该依赖。

坑二:对 List 集合校验无效

错误写法:

java 复制代码
@PostMapping("/batch")
public Result batch(@Valid @RequestBody List<UserDTO> userList) {  // List 不支持 @Valid
    // ...
}

正确写法: 用包装类

java 复制代码
@Data
public class UserListDTO {
    @Valid
    private List<UserDTO> userList;
}
@PostMapping("/batch")
public Result batch(@Valid @RequestBody UserListDTO dto) {
// ...
}

坑三:嵌套对象校验失效

错误写法:

java 复制代码
public class OrderDTO {
    @NotNull
    private Long userId;
    // 没有加 @Valid,Address 内部的校验不生效
    private AddressDTO address;
}

正确写法:

java 复制代码
public class OrderDTO {
    @NotNull
    private Long userId;
@Valid  // 必须加 @Valid 才能触发嵌套校验
private AddressDTO address;
}

坑四:整数类型的 @NotNull 无法校验 0

@NotNull 只校验是否为 null,不校验值的大小。如果要排除 0,需要配合 @Min(1) 使用。

坑五:日志记录时泄露敏感信息

校验失败时如果直接把整个DTO对象打印到日志,可能泄露密码等信息。

错误做法:

java 复制代码
logger.error("校验失败,参数:{}", dto);  // 可能包含密码

正确做法:

java 复制代码
logger.error("校验失败,用户:{},字段:{}", dto.getUsername(), errors);

完整项目包结构

把校验相关代码放在应有的位置:

检查清单

为了帮助你快速检查自己的项目是否已正确使用校验功能,这里提供一个检查清单表格:

检查项 说明
项目中已引入 spring-boot-starter-validation 依赖 Spring Boot 2.3+ 需要手动引入
所有接口参数都用 DTO 接收,配合 @Valid@Validated 校验 避免在 Controller 中写大量 if 判断
同一个 DTO 在不同接口有不同校验规则时,使用了分组校验 通过 @Validated(Group.class) 指定分组
内置注解无法满足时,写了自己的自定义注解 实现 ConstraintValidator 接口
全局异常处理器统一处理 MethodArgumentNotValidException 返回统一格式的错误响应
嵌套对象校验用了 @Valid 确保嵌套对象内部的注解生效
List 集合校验用了包装类 直接对 List<T> 使用 @Valid 无效
没有在日志中记录密码等敏感信息 避免泄露用户隐私

最后

从手动写 if 校验到使用注解校验,代码量减少 80% 以上,可读性和维护性却大幅提升。这就是用好工具的价值------把时间花在真正的业务逻辑上,而不是重复的校验劳动上。

相关推荐
码农大叔的博客1 小时前
golang示例:switch
开发语言·后端·golang
Zane19941 小时前
多开几个线程,为什么算数字反而没变快?一文讲透 CPython 的 GIL
后端·python
一只叫煤球的猫1 小时前
Spring AI 2.0 源码解析(二):Starter 如何自动装配 ChatClient ?
后端·面试·langchain
饼干哥哥1 小时前
重生之我是导演:爆改成「牛来版」黑客帝国?附3D预演台保姆级教程!
人工智能·后端·深度学习
MacroZheng1 小时前
完美替代 Navicat!这款内置 AI 的数据库工具,太香了!
java·后端·mysql
世界哪有真情2 小时前
AI 写代码两年多,我发现自己越来越"看不进去"了
前端·后端·ai编程
星栈2 小时前
被 Rust async 纠正的三个异步认知
前端·后端·rust
夏雪coding2 小时前
openpyxl 对账实战:金额浮点、前导零丢失、20 位单号科学计数法
人工智能·后端
程序员老赵2 小时前
Docker 部署 ZLMediaKit:轻松搭建高性能流媒体服务平台
前端·javascript·后端