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

相关推荐
_waylau19 小时前
Spring Framework HTTP服务客户端详解
java·后端·网络协议·spring·http·spring cloud
tryxr1 天前
Spring Cloud eureka
spring·spring cloud·eureka
空中湖1 天前
Spring AI RAG 完整实战:从零搭建企业知识库问答系统
java·人工智能·spring
sun03222 天前
【笔记】Spring MVC 相关介绍
java·spring·mvc
XS0301062 天前
Spring AI 第二课-流式输出 & 运行时动态参数配置
java·人工智能·spring
额恩662 天前
阶段五:HttpOnly Cookie 登录持久化
java·spring
就是小王同学啊2 天前
Spring小技巧之设计模式
sql·spring·设计模式
weixin_436525072 天前
Spring + Dubbo + Zookeeper项目依赖注入
spring·dubbo·java-zookeeper
L-影2 天前
springboot启动流程
java·spring boot·spring