Spring11-校验框架:@Valid VS @Validated
一、@Valid VS @Validated
第一步:没有任何校验框架的时候
假设你写了一个用户注册的接口:
@RestController public class UserController { @PostMapping("/register") public String register(@RequestBody UserDTO user) { // 手动校验,一堆 if-else if (user.getUsername() == null || user.getUsername().isEmpty()) { return "用户名不能为空"; } if (user.getUsername().length() < 3 || user.getUsername().length() > 20) { return "用户名长度必须在3-20之间"; } if (user.getPassword() == null || user.getPassword().length() < 6) { return "密码不能少于6位"; } if (user.getEmail() == null || !user.getEmail().matches("...正则...")) { return "邮箱格式不正确"; } if (user.getAge() == null || user.getAge() < 0 || user.getAge() > 150) { return "年龄不合法"; } // 终于能写业务代码了 userService.register(user); return "注册成功"; } }这有什么问题?
| 问题 | 说明 |
|---|---|
| 校验逻辑与业务逻辑混杂 | 一个方法里 80% 的代码是校验,真正业务只有最后一行 |
| 重复劳动 | 每个接口都要重复写类似的if (xxx == null) |
| 错误信息不统一 | 有的返回字符串,有的抛异常,前端对接痛苦 |
| 复用性差 | 另一个接口也需要校验 UserDTO,你得把这段 if-else 复制过去 |
| 维护困难 | 规则变了(比如用户名最大长度从 20 改成 30),要全局搜索替换 |
第二步:JSR-303/380 标准注解(@NotNull 等)
Java 社区意识到这个问题,制定了Bean Validation规范(JSR-303,后来升级到 JSR-380),提供了一组注解:
public class UserDTO { @NotBlank(message = "用户名不能为空") @Size(min = 3, max = 20, message = "用户名长度必须在3-20之间") private String username; @NotBlank(message = "密码不能为空") @Size(min = 6, message = "密码不能少于6位") private String password; @Email(message = "邮箱格式不正确") private String email; @Min(value = 0, message = "年龄不能小于0") @Max(value = 150, message = "年龄不能大于150") private Integer age; // getter / setter }这样校验规则从业务代码里抽离出来,集中到了 DTO 上。
但是,这些注解只是"标记",它们不会自动生效。
你必须告诉框架:"请帮我检查这个对象上的注解,并执行校验。"
第三步:@Valid—— 标准的触发器
JSR-380 提供了@Valid注解,用来触发校验:
@PostMapping("/register") public String register(@Valid @RequestBody UserDTO user) { // 如果校验失败,Spring 会自动抛 MethodArgumentNotValidException userService.register(user); return "注册成功"; }@Valid放在方法参数上,Spring 在处理请求时,会自动调用 Validator 去检查UserDTO里的注解。
但@Valid有两个明显的局限:
局限 1:不支持"分组校验"
实际开发中,同一个 DTO 在不同场景下规则不同。
比如UserDTO:
注册时:用户名、密码、邮箱都要校验
登录时:只需要校验用户名和密码,不需要校验邮箱
更新时:id 不能为空,但密码可以不填
用@Valid你没法区分场景,它会把所有注解全部校验一遍。
局限 2:只能用在特定位置
@Valid标准定义里,主要用于:
方法参数(Spring MVC 支持)
方法返回值
嵌套对象(DTO 里的字段是另一个对象,需要递归校验)
但它不能直接在类级别声明,也不支持对普通 Service 层方法的参数进行校验(需要额外配置)。
第四步:@Validated—— Spring 的增强版
Spring 发现@Valid不够用,于是提供了自己的注解@Validated(org.springframework.validation.annotation.Validated)。
它解决了@Valid的核心痛点:
1. 支持分组校验
你先定义分组接口(空接口即可,只是标记):
public interface ValidGroup { interface Register {} interface Login {} interface Update {} }然后在 DTO 的注解上指定分组:
public class UserDTO { @NotBlank(message = "用户名不能为空", groups = {ValidGroup.Register.class, ValidGroup.Login.class}) @Size(min = 3, max = 20, message = "用户名长度必须在3-20之间", groups = ValidGroup.Register.class) private String username; @NotBlank(message = "密码不能为空", groups = {ValidGroup.Register.class, ValidGroup.Login.class}) @Size(min = 6, message = "密码不能少于6位", groups = ValidGroup.Register.class) private String password; @Email(message = "邮箱格式不正确", groups = ValidGroup.Register.class) private String email; @NotNull(message = "id不能为空", groups = ValidGroup.Update.class) private Long id; // getter / setter }Controller 里用@Validated指定分组:
@PostMapping("/register") public String register(@Validated(ValidGroup.Register.class) @RequestBody UserDTO user) { userService.register(user); return "注册成功"; } @PostMapping("/login") public String login(@Validated(ValidGroup.Login.class) @RequestBody UserDTO user) { return userService.login(user); }逻辑:
注册时,只校验标记了
Register分组的字段登录时,只校验标记了
Login分组的字段没有指定
groups的字段,默认不会被校验(除非用Default.class)
2. 支持类级别声明 + 方法级别校验
@Validated可以加在类上,让整个类的所有方法参数都参与校验:
@Service @Validated // 加在类上 public class UserService { // 这个方法的两个参数都会被校验 public String updateUser( @NotNull(message = "id不能为空") Long id, @NotBlank(message = "用户名不能为空") String username) { // 业务逻辑 return "更新成功"; } }注意:这种用法底层是 Spring AOP 代理实现的,需要配合MethodValidationPostProcessor这个 Bean。Spring Boot 自动配置已经帮你注册好了,你不需要手动写。
第五步:@Valid和@Validated对比
| 特性 | @Valid(JSR-380) | @Validated(Spring) |
|---|---|---|
| 来源 | 标准规范 (javax/jakarta.validation) | Spring 框架 (org.springframework.validation) |
| 分组校验 | ❌ 不支持 | ✅ 支持 |
| 类级别声明 | ❌ 不支持 | ✅ 支持(触发方法参数校验) |
| 嵌套对象校验 | ✅ 支持(字段上加@Valid) | ✅ 支持(需配合@Valid) |
| Controller 参数校验 | ✅ 支持 | ✅ 支持(更推荐,因为支持分组) |
| Service 方法参数校验 | ❌ 不直接支持 | ✅ 支持(类上加@Validated) |
第六步:一个完整的代码示例
// ========== 分组定义 ========== public interface ValidGroup { interface Register {} interface Login {} } // ========== DTO ========== @Data public class UserDTO { @NotBlank(groups = {ValidGroup.Register.class, ValidGroup.Login.class}) @Size(min = 3, max = 20, groups = ValidGroup.Register.class) private String username; @NotBlank(groups = {ValidGroup.Register.class, ValidGroup.Login.class}) @Size(min = 6, groups = ValidGroup.Register.class) private String password; @Email(groups = ValidGroup.Register.class) private String email; } // ========== Controller ========== @RestController public class UserController { @Autowired private UserService userService; @PostMapping("/register") public String register(@Validated(ValidGroup.Register.class) @RequestBody UserDTO user) { userService.register(user); return "注册成功"; } @PostMapping("/login") public String login(@Validated(ValidGroup.Login.class) @RequestBody UserDTO user) { return userService.login(user); } } // ========== 全局异常处理(统一返回错误信息) ========== @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) public Result handleValidation(MethodArgumentNotValidException e) { String message = e.getBindingResult().getFieldErrors().stream() .map(error -> error.getField() + ": " + error.getDefaultMessage()) .collect(Collectors.joining(", ")); return Result.fail(message); } @ExceptionHandler(ConstraintViolationException.class) public Result handleConstraintViolation(ConstraintViolationException e) { // 处理 @Validated 在 Service 层抛出的异常 String message = e.getConstraintViolations().stream() .map(ConstraintViolation::getMessage) .collect(Collectors.joining(", ")); return Result.fail(message); } }逻辑链条总结
手动 if-else 校验 → 代码臃肿、难维护 ↓ Bean Validation 规范定义了 @NotNull 等注解,把校验规则抽离到 DTO ↓ 但注解只是标记,需要触发器才能执行 ↓ @Valid 能触发校验,但不支持分组,也无法在类级别统一启用 ↓ Spring 提供 @Validated,支持分组校验和类级别方法参数校验 ↓ 最终:校验逻辑彻底从业务代码中剥离,按场景精确控制,统一异常处理一句话记住
@Valid是标准规范的"触发器",能干活但功能有限;@Validated是 Spring 的增强版,核心增加了分组校验和类级别方法校验,让你能在不同场景下复用同一个 DTO,且把校验从 Controller 下沉到 Service 层。
二、@Validated防止的位置说明
@Validated可以放在的位置
1.Controller 方法参数上(最常用)
@PostMapping("/register") public String register( @Validated(ValidGroup.Register.class) @RequestBody UserDTO user ) { // ... }效果:校验 HTTP 请求体(@RequestBody)或表单参数。校验失败抛MethodArgumentNotValidException。
2.类上(Service / Controller / Component 等)
@Service @Validated public class UserService { public String updateUser( @NotNull Long id, @NotBlank String name) { // ... } }效果:这个类里所有方法的参数都会被校验。底层靠Spring AOP 代理实现。
注意:
校验失败抛
ConstraintViolationException类内部方法互相调用时,不走代理,校验不生效(这是 AOP 的固有特性)
@Service @Validated public class UserService { public void methodA() { methodB(null); // ❌ 这里调用 methodB,校验不会触发! } public void methodB(@NotNull String name) { // ... } }3.接口上
@Validated public interface UserService { String updateUser(@NotNull Long id, @NotBlank String name); } @Service public class UserServiceImpl implements UserService { @Override public String updateUser(Long id, String name) { // ... } }效果:所有实现类的方法参数都会触发校验。
适合契约式编程,把校验规则定义在接口层面。
4.不能放的位置(常见误区)
| 位置 | 是否支持 | 说明 |
|---|---|---|
| 字段上 | ❌ | 字段校验用@NotNull等 JSR-380 注解,@Validated本身不是约束注解 |
| 普通 POJO 类上 | ❌ 无意义 | 加在普通类上不会自动触发校验,必须配合 Spring 容器管理 |
| 方法返回值上 | ⚠️ 语法支持但极少用 | @Validated设计目的是校验输入,返回值校验用@Valid更标准 |
一句话总结
@Validated放在类/接口上,控制整个类的方法参数校验;放在方法参数上,控制单次调用的入参校验。它不能放在字段上——字段的校验规则用@NotNull、@Size等约束注解,@Validated是触发器,不是规则本身。
三、@Valid 检验方法返回值
返回值校验的核心逻辑是:
类上放
@Validated启用方法级校验,方法返回值上放@Valid标记"这个返回值需要被校验"。
完整示例
1. DTO(带约束规则)
@Data public class UserDTO { @NotBlank(message = "用户名不能为空") private String username; @Email(message = "邮箱格式不正确") private String email; @Min(value = 0, message = "年龄不能小于0") @Max(value = 150, message = "年龄不能大于150") private Integer age; }2. Service(类上加@Validated,返回值上加@Valid)
@Service @Validated // 必须加!否则方法级别的校验(包括返回值)不会生效 public class UserService { /** * 返回值会被校验: * 如果返回的 UserDTO 违反了任何约束注解,会抛 ConstraintViolationException */ @Valid public UserDTO getUserById(Long id) { // 模拟从数据库查询,结果数据不完整 UserDTO user = new UserDTO(); user.setUsername(null); // 违反 @NotBlank user.setEmail("not-an-email"); // 违反 @Email user.setAge(-5); // 违反 @Min(0) return user; } }3. Controller 调用
@RestController public class UserController { @Autowired private UserService userService; @GetMapping("/user/{id}") public UserDTO getUser(@PathVariable Long id) { return userService.getUserById(id); // 这里会触发返回值校验 } }4. 全局异常处理(捕获返回值校验失败)
@RestControllerAdvice public class GlobalExceptionHandler { /** * 处理 @Validated 在类上时,方法参数/返回值校验失败的异常 */ @ExceptionHandler(ConstraintViolationException.class) public Result handleConstraintViolation(ConstraintViolationException e) { String message = e.getConstraintViolations().stream() .map(violation -> violation.getPropertyPath() + ": " + violation.getMessage()) .collect(Collectors.joining(", ")); // 输出示例:getUserById.<return value>: 用户名不能为空, getUserById.<return value>: 邮箱格式不正确... return Result.fail(message); } }关键点
| 要点 | 说明 |
|---|---|
@Validated必须在类/接口上 | 没有它,Spring 不会为这个方法创建校验代理 |
@Valid放在方法上 | 标记返回值需要被递归校验(包括嵌套对象) |
| 异常类型 | ConstraintViolationException,不是MethodArgumentNotValidException |
| 错误信息里的路径 | 会包含<return value>,告诉你这是返回值校验失败 |
为什么返回值也要校验?
通常用于:
防御性编程:下游接口返回的数据你不信任,确保流出你这一层的数据是干净的
数据转换后校验:比如从数据库查出来的原始数据经过组装后,确认组装逻辑没产生脏数据
对外暴露的 API:作为服务提供方,确保自己返回给调用方的数据符合契约