SpringBoot 参数校验?别再让 @Valid 变成"摆设"了!
你是不是也遇到过这种情况:
接口加了 @Valid
,实体类上写了 @NotNull
、@Size
,前端传了个空字符串,后端日志里却一脸平静:"200 OK",连个警告都没有?
你查了三遍注解有没有写错,确认了 validation-api
和 hibernate-validator
都在依赖里,甚至重启了三次服务......
结果?校验压根没生效。
不是 SpringBoot 懒,也不是你命不好------是你还没搞懂:@Valid
不是魔法咒语,它是一把需要正确握持的手术刀。
今天,我就带你把 SpringBoot 参数校验的"潜规则"扒个底朝天。
不讲废话,不堆配置,只讲那些让你半夜改 Bug 时想砸键盘的坑 ,和真正能让你代码稳如老狗的正解。
原理浅析:@Valid
是怎么"被调用"的?
很多人以为:只要在 Controller 参数上加 @Valid
,Spring 就会自动帮你校验。
错!
它不是"自动",而是"被动触发"------触发的条件,比你想象的苛刻得多。
我们来画个流程图,看看一次请求从接收到响应,校验器到底在哪个环节"被唤醒":
Client DispatcherServlet HandlerMethodArgumentResolver Validator Controller HTTP 请求(含 JSON) 查找参数解析器 检测参数是否有 @Valid / @Validated 返回校验结果(BindingResult) 注入参数 + BindingResult 返回响应 200 OK(即使有错误!) Client DispatcherServlet HandlerMethodArgumentResolver Validator Controller
关键点来了 :
Spring 的校验机制,只在参数解析阶段生效 ,且必须通过 Spring 的参数解析器(HandlerMethodArgumentResolver)触发 。
如果你绕过了它------比如在方法内部手动 new 一个对象、用 @RequestBody
传了个 Map
、或者在 Service 层直接调用 Controller 方法------校验器就彻底"失联"了。
更致命的是:校验失败不会抛异常!它只会把错误塞进 BindingResult
里 。
如果你忘了检查 BindingResult.hasErrors()
,那就等于在高速公路上闭眼开车------系统不报错,不代表你没撞墙。
八大坑点代码实录
❌ 坑1:@Valid
加在 Controller 方法参数上,但没处理 BindingResult
java
// ❌ 错误示范:校验了,但没管结果
@PostMapping("/user")
public ResponseEntity<String> createUser(@Valid @RequestBody UserDto userDto) {
// 校验失败了?没关系,继续执行!
userService.save(userDto); // 即使 email 为空,也照存不误!
return ResponseEntity.ok("success");
}
你以为加了 @Valid
就万事大吉?
错!
Spring 会执行校验,但不会自动抛异常 。它把错误信息封装在 BindingResult
里,默认行为是忽略。
✅ 正确做法:显式检查 BindingResult
java
@PostMapping("/user")
public ResponseEntity<?> createUser(@Valid @RequestBody UserDto userDto, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
List<String> errors = bindingResult.getFieldErrors().stream()
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
.collect(Collectors.toList());
return ResponseEntity.badRequest().body(errors);
}
userService.save(userDto);
return ResponseEntity.ok("success");
}
💡 最佳实践 :封装一个全局异常处理器,统一处理
MethodArgumentNotValidException
,别在每个接口里写if (bindingResult.hasErrors())
------ 后面我会给你模板。
❌ 坑2:在 Service 层手动 new 对象,然后传给 @Valid
方法
java
// ❌ 错误示范:校验失效的"经典陷阱"
@Service
public class UserService {
@Autowired
private UserController userController; // 别学这个!这是反模式!
public void registerUser(String email, String name) {
UserDto userDto = new UserDto(); // 手动 new!
userDto.setEmail(email);
userDto.setName(name);
// ❌ 这里调用 Controller 方法,但 Spring 代理失效!
userController.createUser(userDto); // @Valid 完全没生效!
}
}
你以为你在调用 @Valid
方法,其实你调的是原始对象的方法 ,绕过了 Spring AOP 代理!
Spring 的 @Valid
校验依赖于 Spring MVC 的参数解析器 ,而你直接 new
+ this.method()
,相当于跳过了整个 Spring 生命周期。
✅ 正确做法:校验逻辑下沉,统一在 Service 层校验
java
@Service
public class UserService {
@Autowired
private Validator validator; // 注入标准 JSR-303 Validator
public void registerUser(String email, String name) {
UserDto userDto = new UserDto();
userDto.setEmail(email);
userDto.setName(name);
Set<ConstraintViolation<UserDto>> violations = validator.validate(userDto);
if (!violations.isEmpty()) {
throw new ValidationException(violations.stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.collect(Collectors.joining("; ")));
}
userRepository.save(userDto);
}
}
✅ 你可能会问:
Validator
从哪来?在 Spring Boot 中,它会自动注册为 Bean。你直接
@Autowired
就行。
❌ 坑3:用 @Valid
校验 Map
、List
或非 POJO 参数
java
// ❌ 错误示范:对 Map 使用 @Valid,以为能校验内容
@PostMapping("/batch")
public ResponseEntity<?> batchCreate(@Valid @RequestBody Map<String, Object> params) {
// ❌ 完全无效!@Valid 对 Map 无感!
String email = (String) params.get("email");
String name = (String) params.get("name");
// ... 你得自己写 if(email == null) ...
return ResponseEntity.ok("ok");
}
@Valid
只对Java Bean 有效。
Map
、List
、String
、Integer
......这些都不是 Java Bean,Spring 根本不会递归校验它们内部的值。
✅ 正确做法:用封装类包装复杂结构
java
// ✅ 正确:定义一个校验友好的 DTO
public class BatchCreateRequest {
@Valid
@NotNull
@Size(min = 1, max = 100)
private List<UserDto> users;
// getter / setter
}
@PostMapping("/batch")
public ResponseEntity<?> batchCreate(@Valid @RequestBody BatchCreateRequest request) {
// ✅ 这里会递归校验 List<UserDto> 中每个元素
request.getUsers().forEach(user -> userService.save(user));
return ResponseEntity.ok("ok");
}
🌟 更进一步:如果你要校验
Map<String, UserDto>
,可以定义一个包装类:
java
public class UserMapWrapper {
@Valid
@NotNull
private Map<String, UserDto> users;
// getter/setter
}
Spring 会递归校验 map 的每一个 value!
高阶避坑指南:让校验系统真正"生产级可用"
✅ 1. 全局异常处理器:告别 BindingResult
的重复代码
java
@RestControllerAdvice
public class GlobalValidationHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(MethodArgumentNotValidException ex) {
ErrorResponse error = ErrorResponse.builder()
.code("VALIDATION_FAILED")
.message("参数校验失败")
.details(ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(
fe -> fe.getField(),
fe -> fe.getDefaultMessage()
)))
.build();
return ResponseEntity.badRequest().body(error);
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorResponse> handleConstraintViolation(ConstraintViolationException ex) {
ErrorResponse error = ErrorResponse.builder()
.code("VALIDATION_FAILED")
.message("参数校验失败")
.details(ex.getConstraintViolations().stream()
.collect(Collectors.toMap(
v -> v.getPropertyPath().toString(),
v -> v.getMessage()
)))
.build();
return ResponseEntity.badRequest().body(error);
}
}
这样,你再也不用在每个接口里写 if (bindingResult.hasErrors())
,校验失败自动返回 400 + 结构化错误,前端直接能用。
✅ 2. 自定义校验注解:别再用 @Pattern(regexp = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")
了!
java
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = EmailFormatValidator.class)
public @interface ValidEmail {
String message() default "邮箱格式不正确";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class EmailFormatValidator implements ConstraintValidator<ValidEmail, String> {
private static final String EMAIL_PATTERN = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$";
@Override
public boolean isValid(String email, ConstraintValidatorContext context) {
return email != null && email.matches(EMAIL_PATTERN);
}
}
然后在 DTO 里:
java
@ValidEmail
private String email;
可读性爆炸提升,团队协作效率翻倍。
✅ 3. 校验分组:同一个 DTO,不同场景,不同规则
java
public interface CreateGroup {}
public interface UpdateGroup {}
public class UserDto {
@NotNull(groups = CreateGroup.class)
private Long id;
@NotBlank(groups = {CreateGroup.class, UpdateGroup.class})
private String name;
@Email(groups = CreateGroup.class)
private String email;
}
// Controller 中指定分组
@PostMapping("/create")
public ResponseEntity<?> create(@Validated(CreateGroup.class) @RequestBody UserDto userDto) {
// 只校验 CreateGroup 的规则,id 必须非空,email 必须合法
}
@PostMapping("/update")
public ResponseEntity<?> update(@Validated(UpdateGroup.class) @RequestBody UserDto userDto) {
// id 可以为 null,但 name 必须存在
}
这才是真正的"生产级校验",不是"一招鲜吃遍天"。
总结:校验不是加个注解就完事了
误区 | 真相 |
---|---|
@Valid 是魔法 |
它是 Spring MVC 的"触发器",不是"执行器" |
校验失败会抛异常 | 它只会塞进 BindingResult ,你得主动查 |
@Valid 能校验 Map/List |
它只能校验 Java Bean,嵌套对象要包装 |
Service 里调 Controller 方法能校验 | 你绕过了代理,校验器根本看不见你 |
只要加了依赖就生效 | 你得确保校验器被正确注入、被正确触发 |
真正的高手,从不依赖"自动" 。
他们知道:任何"自动"背后,都是有人在默默处理边界 。
你写的每一行 @Valid
,都应该有对应的 BindingResult
、有清晰的异常处理、有可复用的校验逻辑。
别再让校验变成"看上去很美"的装饰品了。
让它成为你系统的第一道防火墙。
下次再有人问你:"为什么我的校验没生效?"
你可以微笑着,递上一杯咖啡,然后说:
"兄弟,你是不是又
new
了一个对象?"