在 Spring Boot 开发中,参数校验是拦截非法请求的第一道防线。实际项目里,一个对外接口往往要同时校验十几个字段:字符串非空、长度范围、枚举合法、日期格式、手机号、邮箱、身份证……如果把这些判断全部手写进 Service,不仅代码冗余,更可怕的是「漏校验」——某一个分支忘了判断,脏数据就直接落库,轻则业务异常,重则引发越权或注入类安全问题。Bean Validation 把「数据长什么样才算合法」用注解声明出来,框架在校验器里统一执行,业务代码只需关心「数据已经合法」。本文从最基础的使用讲起,一步步覆盖分组校验、嵌套级联、自定义注解,最后用全局异常处理把校验错误收敛成统一格式。
一、为什么要用声明式参数校验
很多初学者习惯在 Controller 里手动判断:用户名空了吗?年龄合法吗?邮箱格式对吗?一旦字段变多,代码会迅速膨胀且难以维护,更糟的是分散在各处的判断很容易漏掉一处。Jakarta Bean Validation(原 JSR-380)提供了一套标准注解(@NotBlank、@NotNull、@Email、@Min 等),配合 Spring 的校验机制,能在数据进入业务层之前自动拦截。它的价值在于:校验逻辑集中可维护、与业务解耦、可被单元测试覆盖,并且天然支持分组与嵌套。
二、@Valid 基础用法:几行注解搞定
先在 DTO 字段上加注解,这些注解来自 jakarta.validation.constraints 包(Spring Boot 3 起已经从 javax 迁移到 jakarta,注意别导错包)。常用注解中,@NotBlank 用于字符串非空且去空白后非空,@NotNull 用于对象引用非空,@Email 校验邮箱,@Size 限制字符串或集合长度,@Min/@Max 限制数值,@Pattern 用正则。注意 @NotNull 与 @NotBlank 的区别:前者允许空字符串,后者不允许纯空白。Controller 在 @RequestBody 参数前加 @Valid,Spring 会在绑定参数后自动调用校验器;失败默认抛出 MethodArgumentNotValidException 并返回 400。
public class UserDTO {
@NotBlank(message = "用户名不能为空")
private String username;
@Email(message = "邮箱格式不正确")
private String email;
@Min(value = 18, message = "年龄必须大于等于 18")
private Integer age;
// getter / setter
}
@RestController
@RequestMapping("/users")
public class UserController {
@PostMapping
public ResponseEntity<String> create(@Valid @RequestBody UserDTO dto) {
// 校验通过才会执行到这里
return ResponseEntity.ok("ok");
}
}
如果你不想抛异常、希望自己处理错误,可以在参数后追加 BindingResult,框架会把错误塞进去而不中断流程;但更推荐交给全局异常统一处理(见第七节),保持 Controller 干净。
三、@Valid 与 @Validated 到底有什么区别
两者都能触发校验,但定位不同。@Valid 是 JSR 标准注解,支持嵌套级联校验;@Validated 是 Spring 提供的注解,额外支持「分组校验」。实践中建议:做分组校验一律用 @Validated,纯嵌套级联用 @Valid 即可。另外 @Validated 还能标注在类上(如 @Service),配合 MethodValidationPostProcessor 实现方法参数与返回值的校验,非常适合在 Service 层拦截入参。
| 维度 | @Valid | @Validated |
|---|---|---|
| 来源 | JSR-380 标准 | Spring 框架 |
| 分组校验 | 不支持 | 支持(groups) |
| 嵌套级联 | 支持(需配合 @Valid) | 支持 |
| 使用位置 | 字段、方法参数、返回值 | 类、方法参数 |
四、分组校验:同一 DTO 复用于新增与更新
新增用户时 id 应为空,更新用户时 id 必须非空。与其写两个 DTO,不如用分组校验复用同一个类。先定义分组标记接口,再在约束上用 groups 指定生效场景:
public interface CreateGroup {}
public interface UpdateGroup {}
public class UserDTO {
@Null(groups = CreateGroup.class, message = "新增时不能传 id")
@NotNull(groups = UpdateGroup.class, message = "更新时 id 必填")
private Long id;
@NotBlank(groups = {CreateGroup.class, UpdateGroup.class})
private String username;
}
Controller 改用 @Validated 并指定分组,Spring 只会校验该分组下的约束。除了自定义分组,Bean Validation 还内置 Default 分组——字段不写 groups 时属于 Default,用不带分组的 @Validated 只校验 Default。更高级的用法是分组继承:让 UpdateGroup extends CreateGroup,更新时自动复用新增的约束,减少重复注解。
@PostMapping
public ResponseEntity<String> create(@Validated(CreateGroup.class) @RequestBody UserDTO dto) {
return ResponseEntity.ok("created");
}
@PutMapping
public ResponseEntity<String> update(@Validated(UpdateGroup.class) @RequestBody UserDTO dto) {
return ResponseEntity.ok("updated");
}
五、级联校验与集合校验
当 DTO 内嵌对象或集合时,必须在字段上加 @Valid 才会级联校验嵌套属性。典型场景:一个订单包含多个商品行,每行都要校验 sku 和数量。如果忘记 @Valid,Spring 默认不会递归校验集合元素,错误就会被悄悄放过。
public class OrderDTO {
@Valid // 关键:触发嵌套校验
private List<OrderItem> items;
}
public class OrderItem {
@NotBlank
private String sku;
@Min(1)
private Integer qty;
}
六、自定义校验注解:以手机号为例
内置注解不够用时,可自定义约束。实现分三步:① 定义注解并用 @Constraint(validatedBy = ...) 关联校验器;② 实现 ConstraintValidator<A, T>,T 是被校验的类型;③ 在 DTO 上使用。注意 isValid 返回 true 表示通过;对于允许为 null 的字段,通常先判空返回 true,把非空交给 @NotBlank 决定。你还可以通过 ConstraintValidatorContext 自定义错误模板、拿到插值变量。
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PhoneValidator.class)
public @interface Phone {
String message() default "手机号格式不正确";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class PhoneValidator implements ConstraintValidator<Phone, String> {
private static final String REGEX = "^1[3-9]\\d{9}$";
@Override
public boolean isValid(String value, ConstraintValidatorContext ctx) {
if (value == null) return true; // 允许空,由 @NotBlank 控制
return value.matches(REGEX);
}
}
七、全局异常处理:把 400 变成友好返回
默认校验失败会返回 400 和一堆堆栈信息,前端很难消费。用 @RestControllerAdvice 统一拦截,把字段错误收敛成结构化对象:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handle(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors()
.forEach(e -> errors.put(e.getField(), e.getDefaultMessage()));
Map<String, Object> body = new HashMap<>();
body.put("code", 400);
body.put("errors", errors);
return ResponseEntity.badRequest().body(body);
}
}
需要补充一点:如果你在 Service 层用 @Validated 做了方法参数校验,失败会抛 ConstraintViolationException 而非上面的异常,全局处理时要同时捕获两者。这种「校验 + 全局异常」的组合,是构建统一返回结构的关键一步。关于如何封装统一的响应体与异常体系,可参考本站 Spring Boot 统一异常处理与统一返回封装;登录注册类 DTO 的校验也常与 Spring Security 认证授权与 JWT 配合,在网关层就拦掉非法请求。
八、单元测试你的校验逻辑
校验规则也是业务逻辑,值得写测试。用 ValidatorFactory 直接校验 DTO 实例,断言是否按预期产生约束违反:
class UserDTOTest {
Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
@Test
void shouldRejectInvalidEmail() {
UserDTO dto = new UserDTO();
dto.setUsername("tom");
dto.setEmail("not-an-email");
dto.setAge(20);
var violations = validator.validate(dto);
assertTrue(violations.stream().anyMatch(v -> v.getPropertyPath().toString().equals("email")));
}
}
除了单元测试,集成测试中还可以用 MockMvc 直接打接口、断言返回 400 与错误信息,覆盖端到端链路。更完整的测试套路(断言、Mock、覆盖率)可看 Java 单元测试实战:JUnit 5 与 Mockito。
九、常见坑与最佳实践
- 校验不生效:忘加
@Valid/@Validated,Spring 不会自动校验,请求会直接进方法体。 - 分组必须用 @Validated:
@Valid不支持 groups,分组场景请统一用@Validated(Group.class)。 - 嵌套要加 @Valid:内嵌对象或集合字段前必须显式加
@Valid才会级联,否则错误被静默放过。 - 集合级联限制:直接在 List 字段上用
@Valid在部分 Spring 版本不生效,可用@Validated或封装一层包装对象。 - message 占位符:
message = "长度必须在{min}到{max}之间"支持{min}插值,能复用注解参数。 - 统一收口:用全局异常把校验错误转成友好结构,避免在每个接口里 try-catch。
十、与 OpenAPI 文档联动
在 DTO 注解上叠加 @Schema(SpringDoc / OpenAPI),框架会自动把约束(非空、长度、正则)生成到接口文档里,前端联调时直接看到字段规则,减少来回沟通。校验约束与文档同源,改一处即可两处同步,是提升协作效率的小技巧。
结语
Spring Boot 参数校验的核心,是用声明式注解把「数据合法性」从业务代码里剥离出来。掌握 @Valid 与 @Validated 的区别、分组校验、自定义注解与全局异常处理这四点,你的接口就能在入口处优雅地挡住绝大多数脏数据,既安全又清爽。把它和统一返回、单元测试、接口文档串起来,就形成了一套可复用的入参治理标准。




