Spring11-校验框架:@Valid VS @Validated

一、@Valid VS @Validated

第一步:没有任何校验框架的时候

假设你写了一个用户注册的接口:

java 复制代码
@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),提供了一组注解:

java 复制代码
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 注解,用来触发校验

java 复制代码
@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 不够用,于是提供了自己的注解 @Validatedorg.springframework.validation.annotation.Validated)。

它解决了 @Valid 的核心痛点:

1. 支持分组校验

你先定义分组接口(空接口即可,只是标记):

java 复制代码
public interface ValidGroup {
    interface Register {}
    interface Login {}
    interface Update {}
}

然后在 DTO 的注解上指定分组:

java 复制代码
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 指定分组:

java 复制代码
@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 可以加在类上 ,让整个类的所有方法参数都参与校验:

java 复制代码
@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

第六步:一个完整的代码示例

java 复制代码
// ========== 分组定义 ==========
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 方法参数上 (最常用)

java 复制代码
@PostMapping("/register")
public String register(
    @Validated(ValidGroup.Register.class) @RequestBody UserDTO user
) {
    // ...
}

效果: 校验 HTTP 请求体(@RequestBody)或表单参数。校验失败抛 MethodArgumentNotValidException


2. 类上(Service / Controller / Component 等)

java 复制代码
@Service
@Validated
public class UserService {
    
    public String updateUser(
            @NotNull Long id,
            @NotBlank String name) {
        // ...
    }
}

效果: 这个类里所有方法的参数都会被校验。底层靠 Spring AOP 代理实现。

注意:

  • 校验失败抛 ConstraintViolationException

  • 类内部方法互相调用时,不走代理,校验不生效(这是 AOP 的固有特性)

java 复制代码
@Service
@Validated
public class UserService {
    
    public void methodA() {
        methodB(null);  // ❌ 这里调用 methodB,校验不会触发!
    }
    
    public void methodB(@NotNull String name) {
        // ...
    }
}

3. 接口上

java 复制代码
@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(带约束规则)

java 复制代码
@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

java 复制代码
@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 调用

java 复制代码
@RestController
public class UserController {
    
    @Autowired
    private UserService userService;

    @GetMapping("/user/{id}")
    public UserDTO getUser(@PathVariable Long id) {
        return userService.getUserById(id);  // 这里会触发返回值校验
    }
}

4. 全局异常处理(捕获返回值校验失败)

java 复制代码
@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:作为服务提供方,确保自己返回给调用方的数据符合契约

相关推荐
BUG指挥官14 小时前
Sa-Token和Spring Security对比
java·后端·spring
geminigoth14 小时前
Spring AI Alibaba 入门开发一(备份)
java·人工智能·spring
xbgRS17 小时前
spring整合mybaits
java·spring·mybatis
ruleslol17 小时前
静态依赖注入 VS 动态依赖查询
spring
墨雨晨曦8819 小时前
Spring AI总结
java·人工智能·spring
用户31268748772021 小时前
Spring Boot 异常处理到底怎么玩的?从 DispatcherServlet 到全局兜底的全链路拆解
spring
不才不才不不才1 天前
Spring 源码系列(17): HandlerMapping 与 HandlerAdapter 两大体系
java·后端·spring
(轻舟已过万重山)1 天前
第40章 Spring AI 实战:企业级 AI 应用架构
人工智能·spring·架构
堕落年代2 天前
Ollama CPU 推理大提示词优化实测报告(细致化数据版)
java·后端·spring
cfm_29142 天前
基于OAuth2.0实现微服务SSO单点登录
后端·spring·微服务·架构