什么是JSR

JSR(Java Specification Request) 是 Java Community Process(JCP) 框架下的标准化提案机制,用于定义和演化 Java Platform Specifications(JSR 流程正式批准的 Java 技术标准文档,其实就是相当于是一种正式的规范)。

什么是JCP,简单地说,JCP是一个由 Java 开发者、供应商和其他组织组成的国际社区,JSR是这个社区的提案机制,JSR提案是为了形成正式的规范。

形成的规范,比如下面这样:

JSR 编号名称描述发布时间包前缀
JSR 380Bean Validation 2.0Bean 验证规范(支持 Java 8+)2017javax.validation.*
JSR 349Bean Validation 1.1Bean 验证规范(CDI 集成等)2013javax.validation.*
JSR 303Bean Validation 1.0Bean 验证规范(最初版本)2009javax.validation.*
JSR 370Java EE 8Java EE 8 平台规范2017多个包
JSR 366Java EE 8 (Platform)Java EE 8 平台(Servlet 4.0 等)2017多个包
JSR 376Java Platform Module SystemJava 9 模块系统(Project Jigsaw)2017java.lang.module
JSR 315Servlet 3.0Web 应用的 Servlet 规范2009javax.servlet.*
JSR 340Servlet 3.1Servlet 规范更新2013javax.servlet.*
JSR 369Servlet 4.0Servlet 规范(HTTP/2 支持)2017javax.servlet.*
JSR 220JPA 1.0Java 持久化 API(最初版本)2006javax.persistence.*
JSR 317JPA 2.0JPA 规范更新2009javax.persistence.*
JSR 338JPA 2.1JPA 规范(主流版本)2013javax.persistence.*
JSR 250Common Annotations 1.0通用注解(如 @Resource)2006javax.annotation.*
JSR 330Dependency Injection依赖注入规范(@Inject)2009javax.inject.*

其中Bean校验相关的就是JSR 303(Bean Validation 1.0)、JSR 349(Bean Validation 1.1)、JSR 380(Bean Validation 2.0)了。

他们定义了Java Bean验证的标准API和注解集(如 @NotNull、@Size、@Email 等注解)。

但是,JSR规范本身只是接口标准,并不包含具体实现。

而Hibernate Validator是Bean Validation规范的官方参考实现,提供了规范中定义的所有标准注解的实现。Hibernate Validator是Java生态中最流行的Bean Validation实现。

Hibernate Validator除了实现JSR规范的标准注解外,还提供了一些自定义扩展注解,例如:

  • @Length(用于字符串长度验证)
  • @Range(数值范围验证)
  • @URL(URL格式验证)
  • @CreditCardNumber(信用卡号验证)等

注意,这些扩展注解在org.hibernate.validator包下,不属于javax.validationjakarta.validation标准包。javax.validationjakarta.validation标准包?为什么说或呢?因为2017年Oracle 将 Java EE 捐赠给 Eclipse 基金会,在法律上有限制捐赠之后,validator不能使用 Oracle 的 javax.* 商标了,所以它改了包名。

在这里插入图片描述

我们现在用得比较多的也就是Bean Validation 2.0。所以,这里我们就介绍Bean Validation 2.0。

JSR标准的注解

JSR标准的注解定义在javax.validation.constraints或者jakarta.validation.constraints中。

一共定义了 22个注解(其中有好几个是Bean Validation 2.0定义的,比如@Positive、@Email等。其中 @Email 在 Bean Validation 1.0 中没有的,但是 Hibernate 扩展中扩展了,后面Bean Validation 2.0 把@Email吸纳为了标准)。

空和非空检查

  • @NotBlank :只能用于字符串不为 null ,并且字符串 #trim() 以后 length 要大于 0 ,即,如果值是tab 或者多个空格等都是被认定为空的。

  • @NotEmpty :如果一个字段被标记为 @NotEmpty,那么该字段的值不能是 null,同时也不能是空(例如空字符串、空集合、空数组等)。

  • @NotNull :不能为 null 。

  • @Null :必须为 null 。

后面的其他校验都是允许为null的,如果是null就直接通过了,上面这四个数对null有约束的,下面的几个都是对null没有约束的,即,null可以通过校验。

数值检查

  • @DecimalMax(value) :被注释的元素必须是一个数字,其值必须小于等于指定的最大值,如果字段值为 null,校验会通过。

  • @DecimalMin(value) :被注释的元素必须是一个数字,其值必须大于等于指定的最小值,如果字段值为 null,校验会通过。

  • @Digits(integer, fraction) :用于校验数字的整数部分和小数部分的位数,如果字段值为 null,校验会通过。比如,@Digits(integer = 3, fraction = 2)修饰一个属性,那么。合法示例:123.45(整数3位,小数2位)、12.3(整数2位,小数1位)、0.01,非法示例:1234.56(整数部分4位 > 3)、12.345(小数部分3位 > 2)。

  • @Positive :判断正数。如果字段值为 null,校验会通过。

  • @PositiveOrZero :判断正数或 0 。如果字段值为 null,校验会通过。

  • @Max(value) :该字段的值只能小于或等于该值。如果字段值为 null,校验会通过。

  • @Min(value) :该字段的值只能大于或等于该值。如果字段值为 null,校验会通过。

  • @Negative :判断负数。如果字段值为 null,校验会通过。

  • @NegativeOrZero :判断负数或 0 。如果字段值为 null,校验会通过。

Boolean 值检查

  • @AssertFalse 所注解的元素必须是Boolean或者boolean类型,且值为false。如果字段值为 null,校验会通过。

  • @AssertTrue 所注解的元素必须是Boolean或者boolean类型,且值为true。如果字段值为 null,校验会通过。

长度检查

  • @Size(max, min) :检查该字段的 size 是否在 min 和 max 之间,可以是字符串、数组、集合、Map 等。如果字段值为 null,校验会通过。

日期检查

  • @Future :被注释的元素必须是一个将来的时间。如果字段值为 null,校验会通过。

  • @FutureOrPresent :判断日期是否是将来或现在时间。如果字段值为 null,校验会通过。

  • @Past :检查该字段的时间是在过去。如果字段值为 null,校验会通过。

  • @PastOrPresent :判断日期是否是过去或现在时间。如果字段值为 null,校验会通过。

它们既适用于日期,也适用于时间,具体取决于字段的类型。

public class Example {
    // ✅ 纯日期场景
    @Past
    private LocalDate birthDate;          // 出生日期(过去日期)
    
    @Future
    private LocalDate deliveryDate;       // 配送日期(未来日期)
    
    // ✅ 日期时间场景  
    @Past
    private LocalDateTime orderTime;      // 下单时间(过去时刻)
    
    @Future
    private LocalDateTime appointmentTime; // 预约时间(未来时刻)
    
    // ✅ 纯时间场景(较少用)
    @Future
    private LocalTime reminderTime;       // 提醒时间(今天未来的时间)
}

其它检查

  • @Email :被注释的元素必须是电子邮箱地址。如果字段值为 null,校验会通过。

  • @Pattern(value) :被注释的元素必须符合指定的正则表达式。如果字段值为 null,校验会通过。

Hibernate Validator附加的约束注解

注解定义在org.hibernate.validator.constraints包中。这个包会扩展一些Hibernate Validator 一些好用的,但是没有在JSR规范中的注解。

这里有很多,我就简单介绍几个常用的:

  • Range(min = 1, max = 100):有效的值:1, 2, 3, …, 99, 100。用于校验数值类型的范围,相当于是 @Min 和 @Max 的组合。用于指定被注释的元素必须在合适的范围内。

  • @Length(min = 2, max = 50):专门用于校验字符串的长度,功能类似于标准的 @Size 注解,但语义更专一。用于指定被注释的字符串的大小必须在指定的范围内。边界值也是可以通过校验的。

  • @URL(protocol=“https”,host=“example.com”,port=443,regexp=“.*”,flags=Pattern.Flag.CASE_INSENSITIVE) :被注释的字符串必须是一个有效的 URL 。

例如:

// 基本校验(只检查 URL 格式)
@URL
private String website;

// ✅ 有效: "http://example.com", "https://www.google.com"
// ✅ 有效: "ftp://files.example.com", "mailto:test@example.com"
// ❌ 无效: "example.com", "www.google", "not-a-url"

// 指定端口号
@URL(protocol = "http", port = 8080)
private String localUrl;

// ✅ 有效: "http://localhost:8080", "http://example.com:8080"
// ❌ 无效: "http://example.com", "http://example.com:80"

// 只允许特定路径的 URL
@URL(regexp = ".*/api/.*")
private String apiUrl;

// ✅ 有效: "https://example.com/api/users"
// ✅ 有效: "http://localhost:8080/api/products"
// ❌ 无效: "https://example.com/home", "http://example.com"

依赖引入

<!-- Web 基础 -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- 验证功能。在 Spring Boot 2.3+ 版本后,需要自己显式引入 spring-boot-starter-validation 依赖。Spring Boot 2.3 之前spring-boot-starter-validation 作为 spring-boot-starter-web 的传递依赖自动引入 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Spring Boot 2.3 Release Notes 中的说明:

Spring Boot 2.3 has removed the dependency on validation starter from spring-boot-starter-web.
If you need validation features, you should add spring-boot-starter-validation as a dependency.

Spring Boot 2.3 及以后(≥ 2.3.0)spring-boot-starter-web 不再包含validation必须显式引入validation依赖。

说明:这里说的Spring Boot 2.3其实是指2.3.0版本的spring-boot-starter-web依赖,因为这些spring-boot-starter-web等springboot自己的依赖版本都是和springboot的版本配套的,所以习惯这么叫。

可能会看到的一些效果

我使用的是springboot2.4.5,引入了spring-boot-starter-web,但是我没有引入spring-boot-starter-validation,看到可以使用javax.validation.constraints中的注解(可以import),但是不能import到org.hibernate.validator.constraints中的注解,原因是什么?并且我虽然import到javax.validation.constraints中的注解,但是使用了没有任何校验的效果,但是我在pom.xml文件中引入了spring-boot-starter-validation依赖之后,发现javax.validation.constraints中的注解可以有校验效果了。

答:

  1. 可以import到javax.validation.constraints中的注解,是因为 starter-web 依赖了 spring-boot-starter,spring-boot-starter 依赖了 spring-boot-starter-validation。
  2. 引入spring-boot-starter-validation之前,javax.validation.constraints中的注解没有生效,是因为spring-boot-starter 虽然依赖了 spring-boot-starter-validation,但是starter-validation 只包含 API,不包含实现!
  3. 引入了spring-boot-starter-validation后,就生效了,是因为,引入之后,Spring中就有实现了,SpringBoot的自动装配效果就让它生效了。

Spring Validation、hibernate validation的关系

Spring Validation是对hibernate validation的二次封装,Spring Validation与 Spring MVC 无缝集成,支持分组校验实现不同场景使用不同的校验规则。

Java Bean Validation API (JSR-303/JSR-380) (规范标准)| 实现
    |
Hibernate Validator (实现)| 封装
    |
Spring Validation Framework

@Valid 和 @Validated

@Valid

@Valid 注解,是 JSR 所定义的注解。

它不支持分组功能。支持级联验证。

常见:

  1. 对controller层的形参中的复杂对象进行校验。无需类上面添加@Validated注解或者@Valid注解,只要在方法形参前面添加@Valid 注解就行,实体类内部添加校验注解就行。
  2. 实体类成员变量是复杂类型的。比如private UserDTO user;private List<ItemDTO> items;这样比较复杂的成员变量,你要校验的时候,就需要使用成员变量声明上添加 @Valid 注解,这样才能进行级联校验。即,会对这个成员变量中的UserDTO和ItemDTO进行校验。

不常用:

  1. @Valid也可以用于在Service层使用参数校验,但是必须在类上添加Spring的@Validated注解。注意,你Service的方法形参写@Valid,但是类上面不写@Validated,只在Controller层的类上面写@Validated,是没有用的哈,不是调用链上有写就行的哈。

    “在Service层(非Controller层)使用方法参数校验时,必须在类上添加Spring的@Validated注解,并且该类需要由Spring容器管理。这是因为:

    1. Controller层:Spring MVC框架通过HandlerMethodValidationInterceptor自动支持@Valid校验,无需显式添加@Validated。校验失败时抛出 MethodArgumentNotValidException或者BindException,具体什么时候抛出什么,后面会提到。
    2. Service层:Spring没有默认启用方法参数校验,需要通过@Validated显式开启AOP拦截。校验失败时抛出 ConstraintViolationException
    3. 异常差异的原因:Controller层校验面向Web请求,包含完整的BindingResult信息;Service层校验面向业务方法,使用通用的ConstraintViolation集合。两者由不同的拦截器实现。
    4. @Validated作用:它标记哪些Bean需要启用方法级别的参数校验拦截
    5. 校验原理:Spring为@Validated标注的Bean创建AOP代理,添加MethodValidationInterceptor,在执行方法前调用Validator进行参数校验。”
  2. 在方法上使用。效果是对方法的返回值进行验证,这种的做法用得很少。

例子1
// User.java - 嵌套对象
package com.example.dto;

import lombok.Data;
import javax.validation.constraints.*;

@Data
public class User {
    @NotBlank(message = "用户名不能为空")
    private String name;
    
    @Min(value = 18, message = "年龄必须≥18")
    private Integer age;
}
// Item.java - 另一个嵌套对象
package com.example.dto;

import lombok.Data;
import javax.validation.constraints.*;

@Data
public class Item {
    @NotBlank(message = "商品名不能为空")
    private String itemName;
    
    @NotNull(message = "价格不能为空")
    @Positive(message = "价格必须大于0")
    private Double price;
}
// OrderRequest.java - 主请求对象
package com.example.dto;

import lombok.Data;
import javax.validation.Valid;
import javax.validation.constraints.*;
import java.util.List;

@Data
public class OrderRequest {
    @NotBlank(message = "订单号不能为空")
    private String orderNo;
    
    @NotNull(message = "用户信息不能为空")
    @Valid  // 关键:启用对User对象的校验
    private User user;
    
    @NotEmpty(message = "商品列表不能为空")
    @Valid  // 关键:启用对List中每个Item的校验
    private List<Item> items;
}
package com.example.controller;

import com.example.dto.OrderRequest;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;

@RestController
@RequestMapping("/test")
public class TestController {
    
    /**
     * 创建订单 - 测试级联校验
     * 只需要在参数前加 @Valid
     */
    @PostMapping("/createOrder")
    public String createOrder(@Valid @RequestBody OrderRequest request) {
        return "校验通过,订单号:" + request.getOrderNo();
    }
}

测试数据:

{
  "orderNo": "ORD001",
  "user": {
    "name": "张三",
    "age": 17  // 会校验失败:年龄必须≥18
  },
  "items": [
    {
      "itemName": "手机",
      "price": 2999.00
    },
    {
      "itemName": "",  // 会校验失败:商品名不能为空
      "price": null    // 会校验失败:价格不能为空
    }
  ]
}

输出:

{
  "code": 2001001002,
  "message": "请求参数不合法:商品名不能为空;年龄必须≥18;价格不能为空",
  "data": null
}
例子2
package com.yimeng.multiplemodules.demoadmin.controller;

import com.yimeng.multiplemodules.demoadmin.domain.Order;
import com.yimeng.multiplemodules.demoadmin.service.OrderService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.validation.Valid;
import java.util.List;

@RestController
@RequestMapping("/order")
@Validated // 要求添加@Validated,batchCreate上的@Valid才能生效,这一点需要注意一下,但是List的这种校验很少见。但是create、detail、list三个方法不需要类上添加@Validated注解
public class OrderController {

    @Resource
    private OrderService orderService;

    @PostMapping("/create")
    public String create(@RequestBody Order order) {
        orderService.createOrder(order);  // Service会校验
        return "订单创建成功";
    }

    @GetMapping("/detail")
    public Order detail(@RequestParam String orderNo) {
        return orderService.getOrder(orderNo);  // Service会校验
    }

    @GetMapping("/list")
    public List<Order> list() {
        return orderService.listOrders();
    }

    // 如果是要校验List中的每个元素,那么需要写两个@Valid,List前写一个,并且范型前也要写一个。Order内部要添加校验注解(如@NotBlank、@NotNull等)
    @PostMapping("/batch-create")
    public String batchCreate(@Valid @RequestBody List<@Valid Order> orders) {
        // orderService.batchCreateOrders(orders);
        return "批量创建订单成功,共" + orders.size() + "个订单";
    }
}
package com.yimeng.multiplemodules.demoadmin.service;

import com.yimeng.multiplemodules.demoadmin.domain.Order;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Positive;

public interface OrderService {
    // 复杂类型就必须要用@Valid,在controller层复杂参数你使用@Validated也行,因为controller层做了兼容,但是这里不行,还是得用@Valid
    void createOrder(@Valid Order order);  // 校验订单对象

    Order getOrder(@NotNull @Positive(message = "订单ID必须大于0")  String orderNo);  // 校验字符串参数
  
    // 返回值进行校验
    @NotNull(message = "返回的订单不能为null")
    @Size(min = 1, message = "至少返回一个订单")
    List<Order> listOrders();
}
package com.yimeng.multiplemodules.demoadmin.service.impl;

import com.yimeng.multiplemodules.demoadmin.domain.Order;
import com.yimeng.multiplemodules.demoadmin.service.OrderService;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;  // 关键
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

@Service
@Validated  // 如果不加@Validated注解,那么接口上的校验注解将会无效。虽然,这个注解加在接口上也行,但是建议加在实现类上,实现了决定是否要启用校验。具体怎么校验的@NotNull等注解,建议加在接口上。这样对调用方来说比较友好,查看接口就知道如何正确调用
public class OrderServiceImpl implements OrderService {

    @Override
    public void createOrder(Order order) {
        // 校验通过才执行
        System.out.println("创建订单:" + order.getOrderNo());
    }

    @Override
    public Order getOrder(String orderNo) {
        // orderNo已自动校验不为空
        Order order = new Order();
        order.setOrderNo(orderNo);
        order.setAmount(new BigDecimal("100.00"));
        return order;
    }

    @Override
    public List<Order> listOrders() {
        // 这个方法只测试返回值校验
        // 如果返回null,会触发@NotNull校验失败
        // 如果返回空列表,会触发@Size(min=1)校验失败

        // 测试时可以修改这里:
//         return null;                    // 触发 @NotNull 校验
         return new ArrayList<>();       // 触发 @Size(min=1) 校验

        // 正常返回
//        List<Order> orders = new ArrayList<>();
//        Order order = new Order();
//        order.setOrderNo("ORD001");
//        order.setAmount(new BigDecimal("100.00"));
//        orders.add(order);
//        return orders;
    }
}
package com.yimeng.multiplemodules.demoadmin.domain;

import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Positive;
import java.math.BigDecimal;

@Data
public class Order {
    @NotBlank(message = "订单号不能为空")
    private String orderNo;

    @NotNull(message = "金额不能为空")
    @Positive(message = "金额必须大于0")
    private BigDecimal amount;
}

结果:

接口:/order/detail?orderNo=0
结果:
{
  "code": 2001001002,
  "message": "请求参数不合法:订单ID必须大于0",
  "data": null
}

接口:/order/detail?orderNo=10
结果:
{
  "orderNo": "10",
  "amount": 100
}

接口:/order/list
结果:
{
  "code": 2001001002,
  "message": "请求参数不合法:至少返回一个订单",
  "data": null
}

接口:/order/batch-create
请求体:
[
  {
    "amount": 0,
    "orderNo": "1"
  },
  {
    "amount": 0,
    "orderNo": ""
  }
]  

结果:
{
  "code": 2001001002,
  "message": "请求参数不合法:金额必须大于0;订单号不能为空;金额必须大于0",
  "data": null
}

@Validated

@Validated 注解,是Spring 框架提供的增强版。@Validated 是 Spring 框架独有的注解,不是 Hibernate 的,也不是 JSR 标准的一部分。他支持分组功能,但是不能做到级联校验,即,要级联校验,你还是得使用@Valid才行。但是注意,我看到在controller层的方法形参前,对复杂对象进行级联校验的时候,我们使用@Validated和@Valid的效果一样。这个是Spring做了一个兼容设计,让你controller层方法形参前写@Validated也能达到在方法形参前写@Valid一样的效果。底层把 @Validated 当作 @Valid 处理了。虽然,Spring做了兼容,但是尽量我们在controller层要对方法形参中的复杂对象进行级联校验的时候,还是用@Valid比较好,这样规范一点。还有就是要注意,虽然controller层做了兼容,但是在实体类中的复杂成员变量,你加 @Validated 是无效的,idea的编译都无法通过。并且你在Service层的形参前添加@Validated,也无效,即只有在controller层的方法形参前,你用@Validated和@Valid是一样的,抛出的异常也是一样的,和使用@Valid一样都是MethodArgumentNotValidException。

@Validated最显著的特性是支持分组校验,这是标准@Valid注解所不具备的。

常用:

  1. controller类上面添加@Validated,启用单参数校验。启用之后,就可以对方法形参的简单参数进行校验了,形参前可以添加@Min等注解对简单参数进行校验。注意,如果形参是复杂对象,那么就要添加@Valid了,这个是属于前面说的级联校验了,controller层的级联校验不需要在类上添加@Validated的。controller类上面添加@Validated,启用单参数校验之后,简单参数的校验没有通过,会抛出ConstraintViolationException异常。
  2. 需要分组校验时,在controller层的方法形参前使用。如果是复杂对象,但是是要进行分组的,我们可以在方法形参前用类似@Validated(ValidationGroups.Create.class)这样的注解进行分组级联校验(可以看到这里也用@Validated代替了@Valid,因为Spring做了兼容,在controller层使用@Validated和@Valid都能进行级联校验。这里这个分组使用@Validated代替了@Valid进行复杂对象的校验才是Spring做控制层@Validated兼容@Valid的原因吧)。

注意:String、Boolean、Integer、Long、LocalDateTime等都属于基本类型/简单参数。

不常用的场景:

  1. 在controller层外的类上使用@Validated。比如,前面说过的,在Service层要想要支持校验,就必须在类上面写@Validated注解,这样Service层方法的简单类型形参前面写的@Min这样的注解才有用,复杂的对象前面的@Valid才有用。
例子1
package com.yimeng.multiplemodules.demoadmin.controller;

import com.yimeng.multiplemodules.demoadmin.domain.Product;
import com.yimeng.multiplemodules.demoadmin.group.ValidationGroups;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;

@RestController
@RequestMapping("/product")
@Validated  // 启用单参数校验。开启简单参数校验需要在类上添加这个注解。
public class ProductController {

    // 1. 简单参数校验
    @GetMapping("/stock")
    public String checkStock(
            @NotNull(message = "商品ID不能为空")
            @Min(value = 1000, message = "商品ID最小1000")
            Long productId) {
        return "商品库存正常";
    }

    // 2. 默认分组校验(分组校验没有要求类上一定要加@Validated)
    @PutMapping("/update")
    public String updateProduct(
            @Validated(ValidationGroups.Update.class) @RequestBody Product product) {
        return "更新成功";
    }

    // 3. 创建分组校验(分组校验没有要求类上一定要加@Validated)
    @PostMapping("/create")
    public String createProduct(
            @Validated(ValidationGroups.Create.class)
            @RequestBody Product product) {
        return "创建成功";
    }
}
package com.yimeng.multiplemodules.demoadmin.domain;

import com.yimeng.multiplemodules.demoadmin.group.ValidationGroups;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Positive;

@Data
public class Product {
    // 默认分组(这个注解没有配置groups,那么这个校验注解就属于默认分组(Default.class),因为ValidationGroups.Create.class和ValidationGroups.Update.class都继承了javax.validation.groups.Default,所以,@Validated(ValidationGroups.Create.class)和@Validated(ValidationGroups.Update.class会校验这个字段)
    @NotNull(message = "商品ID不能为空")
    private Long id;

    // 创建分组(只对开启@Validated(ValidationGroups.Create.class)的controller方法有效。)
    @NotBlank(message = "商品名称必填", groups = ValidationGroups.Create.class)
  	// @NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class})// 如果要校验多个,可以这样写
    private String name;

    // 更新分组(只对开启@Validated(ValidationGroups.Update.class)的controller方法有效。)
    @Positive(message = "价格必须大于0", groups = ValidationGroups.Update.class)
    private Double price;
}
package com.yimeng.multiplemodules.demoadmin.group;

import javax.validation.groups.Default;

public interface ValidationGroups {
    interface Create extends Default {}  // 创建时校验组
    interface Update extends Default{}  // 更新时校验组
}

执行效果:

接口:http://localhost:8080/product/stock?productId=99
结果:
{
  "code": 2001001002,
  "message": "请求参数不合法:商品ID最小1000",
  "data": null
}

接口:http://localhost:8080/product/stock?productId=1001
结果:
商品库存正常

接口:http://localhost:8080/product/create
请求体:
{
  "id": null,
  "name": " ",
  "price": -1 // 看到没有校验price,只校验了name和id
}
结果:
{
  "code": 2001001002,
  "message": "请求参数不合法:商品ID不能为空;商品名称必填",
  "data": null
}

接口:http://localhost:8080/product/update
请求体:
{
  "id": null,
  "name": " ",
  "price": -1 // 看到校验了price和id,没有校验name
}
结果:
{
  "code": 2001001002,
  "message": "请求参数不合法:价格必须大于0;商品ID不能为空",
  "data": null
}

Controller层三种校验场景

场景参数注解绑定方式是否需要@Validated异常类型
@RequestBody@RequestBody @ValidRequestResponseBodyMethodProcessor❌ 不需要MethodArgumentNotValidException
@ModelAttribute@ValidServletModelAttributeMethodProcessor❌ 不需要BindException
单参数@Min@NotBlank各种ArgumentResolver✅ 需要ConstraintViolationException

注意:BindException是 MethodArgumentNotValidException 的父类。

// Controller层的三种校验场景:

// 1. @RequestBody + @Valid(JSON请求)
@PostMapping("/json")
public Result jsonMethod(@RequestBody @Valid DTO dto) {
    // 失败抛出:MethodArgumentNotValidException
    // 不需要@Validated
}

// 2. 无注解对象参数 + @Valid(表单/GET请求)
@PostMapping("/form")  
public Result formMethod(@Valid DTO dto) {
    // 自动作为@ModelAttribute
    // 失败抛出:BindException
    // 不需要@Validated
}

// 3. 单参数校验(@RequestParam/@PathVariable等)
@GetMapping("/single/{id}")
public Result singleMethod(
        @PathVariable @Min(1) Long id,
        @RequestParam @NotBlank String name) {
    // 失败抛出:ConstraintViolationException
    // 需要类上添加@Validated ✅
}

全局异常处理器

package com.yimeng.multiplemodules.democommon.exception;

import javax.servlet.http.HttpServletRequest;
import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.validation.BindException;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import com.yimeng.multiplemodules.democommon.domain.CommonResult;
import com.yimeng.multiplemodules.democommon.enums.ServiceExceptionEnum;

@ControllerAdvice(basePackages = "com.yimeng.multiplemodules.demoadmin.controller")
public class GlobalExceptionHandler {

    private final Logger logger = LoggerFactory.getLogger(getClass());

    /**
     * 处理 SpringMVC 请求参数缺失异常
     * 触发场景:
     * 1. @RequestParam(required = true) 且前端未传该参数
     * 2. 例如:@RequestParam String name 但请求中没有name参数
     * 注意:这是Spring MVC框架异常,不是校验框架异常
     */
    @ResponseBody
    @ExceptionHandler(value = MissingServletRequestParameterException.class)
    public CommonResult missingServletRequestParameterExceptionHandler(HttpServletRequest req, MissingServletRequestParameterException ex) {
        logger.info("========进入了 missingServletRequestParameterExceptionHandler,出现MissingServletRequestParameterException异常======");
        logger.error("[missingServletRequestParameterExceptionHandler]", ex);
        return CommonResult.error(ServiceExceptionEnum.MISSING_REQUEST_PARAM_ERROR.getCode(),
                ServiceExceptionEnum.MISSING_REQUEST_PARAM_ERROR.getMessage());
    }

    /**
     * 处理参数校验失败异常(ConstraintViolationException)
     * 触发场景:
     * 1. Controller类上有@Validated,且方法参数使用@NotNull、@Min等单参数校验失败
     * 2. Service层类上有@Validated,且方法参数校验失败
     * 3. 例如:@GetMapping public void test(@Min(1) Long id) 传id=0
     * 特点:针对单个简单参数的校验异常
     */
    @ResponseBody
    @ExceptionHandler(value = ConstraintViolationException.class)
    public CommonResult constraintViolationExceptionHandler(HttpServletRequest req, ConstraintViolationException ex) {
        logger.info("========进入了 constraintViolationExceptionHandler,出现ConstraintViolationException异常======");
        logger.error("[constraintViolationExceptionHandler]", ex);
        StringBuilder detailMessage = new StringBuilder();
        for (ConstraintViolation<?> constraintViolation : ex.getConstraintViolations()) {
            if (detailMessage.length() > 0) {
                detailMessage.append(";");
            }
            detailMessage.append(constraintViolation.getMessage());
        }
        return CommonResult.error(ServiceExceptionEnum.INVALID_REQUEST_PARAM_ERROR.getCode(),
                ServiceExceptionEnum.INVALID_REQUEST_PARAM_ERROR.getMessage() + ":" + detailMessage.toString());
    }

    /**
     * 处理参数绑定异常(BindException)
     * 触发场景:
     * 1. GET请求使用@ModelAttribute绑定对象时校验失败
     * 2. 表单提交时数据绑定失败
     * 例如:@GetMapping public void test(@Valid Model model)
     * 注意:这是数据绑定过程中的校验异常
     */
    @ResponseBody
    @ExceptionHandler(value = BindException.class)
    public CommonResult bindExceptionHandler(HttpServletRequest req, BindException ex) {
        logger.info("========进入了 bindExceptionHandler,出现BindException异常======");
        logger.error("[bindExceptionHandler]", ex);
        StringBuilder detailMessage = new StringBuilder();
        for (ObjectError objectError : ex.getAllErrors()) {
            if (detailMessage.length() > 0) {
                detailMessage.append(";");
            }
            detailMessage.append(objectError.getDefaultMessage());
        }
        return CommonResult.error(ServiceExceptionEnum.INVALID_REQUEST_PARAM_ERROR.getCode(),
                ServiceExceptionEnum.INVALID_REQUEST_PARAM_ERROR.getMessage() + ":" + detailMessage.toString());
    }

    /**
     * 处理请求参数校验失败异常(MethodArgumentNotValidException)
     * 触发场景:
     * 1. POST/PUT请求使用@RequestBody,且对象校验失败
     * 2. 使用@Valid或@Validated校验复杂对象失败
     * 例如:@PostMapping public void create(@Valid @RequestBody User user)
     * 特点:这是Controller层处理复杂对象校验的主要异常类型
     */
    @ResponseBody
    @ExceptionHandler(value = MethodArgumentNotValidException.class)
    public CommonResult MethodArgumentNotValidExceptionHandler(HttpServletRequest req, MethodArgumentNotValidException ex) {
        logger.info("========进入了 MethodArgumentNotValidExceptionHandler,出现MethodArgumentNotValidException异常======");
        logger.error("[MethodArgumentNotValidException]", ex);
        StringBuilder detailMessage = new StringBuilder();
        for (ObjectError objectError : ex.getBindingResult().getAllErrors()) {
            if (detailMessage.length() > 0) {
                detailMessage.append(";");
            }
            detailMessage.append(objectError.getDefaultMessage());
        }
        return CommonResult.error(ServiceExceptionEnum.INVALID_REQUEST_PARAM_ERROR.getCode(),
                ServiceExceptionEnum.INVALID_REQUEST_PARAM_ERROR.getMessage() + ":" + detailMessage.toString());
    }

    /**
     * 处理其它 Exception 异常
     * @param req 请求
     * @param e 异常
     * @return 返回统一错误结果
     */
    @ResponseBody
    @ExceptionHandler(value = Exception.class)
    public CommonResult exceptionHandler(HttpServletRequest req, Exception e) {
        logger.info("========进入了 exceptionHandler,出现Exception异常。======");
        logger.error("[exceptionHandler]", e);
        return CommonResult.error(ServiceExceptionEnum.SYS_ERROR.getCode(),
                ServiceExceptionEnum.SYS_ERROR.getMessage());
    }
}


package com.yimeng.multiplemodules.democommon.enums;

/**
 * 业务异常枚举
 * 
 * @author shiliang liu
 * @version 1.0.0
 * @since 2026-01-20
 */
public enum ServiceExceptionEnum {

    // ========== 系统级别 ==========
    SYS_ERROR(2001001000, "服务端发生异常"),
    MISSING_REQUEST_PARAM_ERROR(2001001001, "参数缺失"),
    INVALID_REQUEST_PARAM_ERROR(2001001002, "请求参数不合法"),

    // ========== 用户模块 ==========
    USER_NOT_FOUND(1001002000, "用户不存在"),

    // ========== 订单模块 ==========

    // ========== 商品模块 ==========
    ;

    /**
     * 错误码
     */
    private final int code;
    /**
     * 错误提示
     */
    private final String message;

    ServiceExceptionEnum(int code, String message) {
        this.code = code;
        this.message = message;
    }

    public int getCode() {
        return code;
    }

    public String getMessage() {
        return message;
    }

}

自定义校验注解和校验规则(例1)

package com.yimeng.multiplemodules.demoadmin.annotation;

import com.yimeng.multiplemodules.demoadmin.validator.StrongPasswordValidator;
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.*;

/**
 * 密码强度校验注解
 * 简单版:只检查长度和是否包含数字字母
 */
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = StrongPasswordValidator.class)
@Documented
public @interface StrongPassword {

    // 默认错误信息
    String message() default "密码强度不足";

    // 校验分组
    Class<?>[] groups() default {};

    // 负载信息
    Class<? extends Payload>[] payload() default {};

    // 最小长度(默认8位)
    int minLength() default 8;

    // 最大长度(默认20位)
    int maxLength() default 20;

    // 是否需要包含数字(默认需要)
    boolean requireDigit() default true;

    // 是否需要包含字母(默认需要)
    boolean requireLetter() default true;

    // 是否允许特殊字符(默认允许)
    boolean allowSpecialChar() default true;
}
package com.yimeng.multiplemodules.demoadmin.validator;

import com.yimeng.multiplemodules.demoadmin.annotation.StrongPassword;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

/**
 * 密码强度校验器
 */
public class StrongPasswordValidator
        implements ConstraintValidator<StrongPassword, String> {

    private int minLength;
    private int maxLength;
    private boolean requireDigit;
    private boolean requireLetter;
    private boolean allowSpecialChar;

    @Override
    public void initialize(StrongPassword constraintAnnotation) {
        // 获取注解配置
        this.minLength = constraintAnnotation.minLength();
        this.maxLength = constraintAnnotation.maxLength();
        this.requireDigit = constraintAnnotation.requireDigit();
        this.requireLetter = constraintAnnotation.requireLetter();
        this.allowSpecialChar = constraintAnnotation.allowSpecialChar();
    }

    @Override
    public boolean isValid(String password, ConstraintValidatorContext context) {
        // 1. 如果密码为空,由 @NotBlank 处理
        if (password == null) {
            return true;
        }

        // 2. 检查长度
        if (password.length() < minLength) {
            setErrorMessage(context, "密码长度至少" + minLength + "位");
            return false;
        }

        if (password.length() > maxLength) {
            setErrorMessage(context, "密码长度不能超过" + maxLength + "位");
            return false;
        }

        // 3. 检查是否包含数字
        if (requireDigit && !containsDigit(password)) {
            setErrorMessage(context, "密码必须包含数字");
            return false;
        }

        // 4. 检查是否包含字母
        if (requireLetter && !containsLetter(password)) {
            setErrorMessage(context, "密码必须包含字母");
            return false;
        }

        // 5. 检查是否允许特殊字符
        if (!allowSpecialChar && containsSpecialChar(password)) {
            setErrorMessage(context, "密码不能包含特殊字符");
            return false;
        }

        return true;
    }

    // 检查是否包含数字
    private boolean containsDigit(String str) {
        for (char c : str.toCharArray()) {
            if (Character.isDigit(c)) {
                return true;
            }
        }
        return false;
    }

    // 检查是否包含字母
    private boolean containsLetter(String str) {
        for (char c : str.toCharArray()) {
            if (Character.isLetter(c)) {
                return true;
            }
        }
        return false;
    }

    // 检查是否包含特殊字符
    private boolean containsSpecialChar(String str) {
        String specialChars = "!@#$%^&*()_+-=[]{}|;:'\",.<>?/`~";
        for (char c : str.toCharArray()) {
            if (specialChars.indexOf(c) != -1) {
                return true;
            }
        }
        return false;
    }

    // 设置自定义错误信息
    private void setErrorMessage(ConstraintValidatorContext context, String message) {
        context.disableDefaultConstraintViolation();
        context.buildConstraintViolationWithTemplate(message)
                .addConstraintViolation();
    }
}
package com.yimeng.multiplemodules.demoadmin.domain;

import com.yimeng.multiplemodules.demoadmin.annotation.StrongPassword;
import lombok.Data;
import javax.validation.constraints.NotBlank;

@Data
public class UserRegisterDTO {

    @NotBlank(message = "用户名不能为空")
    private String username;

    @NotBlank(message = "密码不能为空")
    @StrongPassword(
            minLength = 6,
            maxLength = 16,
            allowSpecialChar = false,
            message = "密码必须是6-16位字母数字组合"
    )
    private String password;
}

测试1(controller来测试,controller会自动进行校验的。):

package com.yimeng.multiplemodules.demoadmin.controller;

import com.yimeng.multiplemodules.demoadmin.domain.UserRegisterDTO;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;

@RestController
public class TestController {

    @PostMapping("/test-password")
    public String testPassword(@Valid @RequestBody UserRegisterDTO dto) {
        return "密码校验通过: " + dto.getUsername();
    }
}

测试2(使用测试类来测试,没有controller,那么需要手动来校验):

package com.yimeng.multiplemodules.demoadmin.validation;

import com.yimeng.multiplemodules.demoadmin.domain.UserRegisterDTO;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import javax.validation.ConstraintViolation;
import javax.validation.Validator;
import java.util.Set;

@SpringBootTest
class StrongPasswordValidatorTest {

    @Autowired
    private Validator validator;

    @Test
    void testValidPassword() {
        UserRegisterDTO dto = new UserRegisterDTO();
        dto.setUsername("test");
        dto.setPassword("Abc12345");  // 有效密码

        Set<ConstraintViolation<UserRegisterDTO>> violations =
                validator.validate(dto);

        System.out.println("有效密码测试:");
        if (violations.isEmpty()) {
            System.out.println("✓ 密码校验通过");
        } else {
            violations.forEach(v -> System.out.println("✗ " + v.getMessage()));
        }
    }

    @Test
    void testInvalidPassword() {
        UserRegisterDTO dto = new UserRegisterDTO();
        dto.setUsername("test");
        dto.setPassword("123");  // 太短,没有字母

        Set<ConstraintViolation<UserRegisterDTO>> violations =
                validator.validate(dto);

        System.out.println("\n无效密码测试:");
        if (!violations.isEmpty()) {
            violations.forEach(v -> System.out.println("✗ " + v.getMessage()));
        }
    }

    @Test
    void testPasswordScenarios() {
        System.out.println("\n各种密码测试:");

        testPassword("123456", "应该失败:没有字母");
        testPassword("abcdef", "应该失败:没有数字");
        testPassword("Abc123", "应该通过:有字母和数字");
        testPassword("Abc@123", "应该通过:有特殊字符");
        testPassword("A1", "应该失败:太短");
        testPassword("A1b2c3d4e5f6g7h8i9j0", "应该失败:太长");
    }

    private void testPassword(String password, String scenario) {
        UserRegisterDTO dto = new UserRegisterDTO();
        dto.setUsername("test");
        dto.setPassword(password);

        Set<ConstraintViolation<UserRegisterDTO>> violations =
                validator.validate(dto);

        System.out.println(String.format("%-30s: %s",
                scenario,
                violations.isEmpty() ? "✓ 通过" : "✗ " + violations.iterator().next().getMessage()
        ));
    }
}

自定义校验注解和校验规则(例2)

package com.yimeng.multiplemodules.demoadmin.controller;

import com.yimeng.multiplemodules.demoadmin.domain.UserDTO;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;

@RestController
@RequestMapping("/user/V2")
public class UserControllerV2 {
    /**
     * 创建用户 - 演示枚举值校验
     */
    @PostMapping("/create")
    public String createUser(@Valid @RequestBody UserDTO userDTO) {
        return "用户创建成功";
    }
}
package com.yimeng.multiplemodules.demoadmin.domain;

import com.yimeng.multiplemodules.democommon.enums.UserGender;
import com.yimeng.multiplemodules.democommon.validation.EnumValue;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
public class UserDTO {

    @NotBlank(message = "用户名不能为空")
    private String username;

    @NotNull(message = "年龄不能为空")
    private Integer age;

    @EnumValue(enumClass = UserGender.class, enumMethod = "getCode", message = "性别不合法")
    private Integer gender;
}
package com.yimeng.multiplemodules.democommon.validation;

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.*;

@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = EnumValueValidator.class)
@Documented
public @interface EnumValue {

    String message() default "值不在枚举范围内";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    // 枚举类
    Class<? extends Enum<?>> enumClass();

    // 是否允许为空
    boolean nullable() default false;

    // 获取枚举值的方法名(默认使用name())
    String enumMethod() default "name";
}
package com.yimeng.multiplemodules.democommon.enums;

import lombok.Getter;

@Getter
public enum UserGender {

    UNKNOWN(0, "未知"),
    MALE(1, "男"),
    FEMALE(2, "女");

    private final Integer code;
    private final String desc;

    UserGender(Integer code, String desc) {
        this.code = code;
        this.desc = desc;
    }
}
package com.yimeng.multiplemodules.democommon.validation;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.lang.reflect.Method;

/**
 * 枚举值校验器
 */
public class EnumValueValidator implements ConstraintValidator<EnumValue, Object> {

    private Class<? extends Enum<?>> enumClass;
    private boolean nullable;
    private String enumMethod;

    @Override
    public void initialize(EnumValue constraintAnnotation) {
        this.enumClass = constraintAnnotation.enumClass();
        this.nullable = constraintAnnotation.nullable();
        this.enumMethod = constraintAnnotation.enumMethod();
    }

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {
        if (value == null) {
            return nullable;
        }

        try {
            // 获取枚举的所有值
            Object[] enumValues = enumClass.getEnumConstants();
            if (enumValues == null) {
                return false;
            }

            // 反射调用指定的方法获取枚举值
            Method method = enumClass.getMethod(enumMethod);

            for (Object enumValue : enumValues) {
                Object enumValueStr = method.invoke(enumValue);
                if (value.toString().equals(enumValueStr.toString())) {
                    return true;
                }
            }

            return false;

        } catch (Exception e) {
            return false;
        }
    }
}

请求:

{
  "age": 0,
  "gender": 1,
  "username": "1"
}

结果:

用户创建成功

请求:

{
  "age": 0,
  "gender": 3,
  "username": "1"
}

结果:

{
  "code": 2001001002,
  "message": "请求参数不合法:性别不合法",
  "data": null
}

跨字段校验

@AssertTrue注解 + Lombok

package com.yimeng.multiplemodules.demoadmin.controller;

import com.yimeng.multiplemodules.demoadmin.domain.UserQueryDTOV1;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;

@RestController
@RequestMapping("/api/v1/user")
public class UserQueryControllerV1 {

    /**
     * 查询用户列表 - 使用@AssertTrue跨字段校验
     */
    @PostMapping("/query")
    public String queryUsers(@Valid @RequestBody UserQueryDTOV1 queryDTO) {
        // 如果校验失败(如开始时间晚于结束时间),会抛出MethodArgumentNotValidException
        // 不会执行到这里
        return "查询成功";
    }
}
package com.yimeng.multiplemodules.demoadmin.domain;

import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.validation.constraints.*;
import java.time.LocalDateTime;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserQueryDTOV1 {

    @NotBlank(message = "用户名不能为空")
    private String username;

    @NotNull(message = "开始时间不能为空")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime startTime;

    @NotNull(message = "结束时间不能为空")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime endTime;

    // 跨字段校验:开始时间不能晚于结束时间
    @AssertTrue(message = "开始时间不能晚于结束时间")
    public boolean isTimeRangeValid() {
        if (startTime == null || endTime == null) {
            return true; // 空值由@NotNull处理
        }
        return !startTime.isAfter(endTime);
    }
}

请求:

{
  "endTime": "2026-01-02 00:00:00",
  "startTime": "2026-01-02 02:23:12",
  "username": "张三"
}

结果:

{
  "code": 2001001002,
  "message": "请求参数不合法:开始时间不能晚于结束时间",
  "data": null
}

工作原理:

  1. 校验触发时机:当Spring验证框架处理DTO对象时
  2. 执行流程
    • Hibernate Validator扫描所有带有 @AssertTrue 注解的方法
    • 反射调用该方法(方法名任意,只要返回boolean)
    • 如果返回 false,则校验失败

使用自定义注解

package com.yimeng.multiplemodules.demoadmin.domain;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.yimeng.multiplemodules.democommon.validation.DateRangeV2;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.validation.constraints.*;
import java.time.LocalDateTime;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@DateRangeV2(
        startField = "startTime",
        endField = "endTime",
        message = "查询时间范围无效:开始时间不能晚于结束时间"
)
public class UserQueryDTOV2 {

    @NotBlank(message = "用户名不能为空")
    private String username;

    @NotNull(message = "开始时间不能为空")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime startTime;

    @NotNull(message = "结束时间不能为空")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime endTime;
}
package com.yimeng.multiplemodules.demoadmin.controller;

import com.yimeng.multiplemodules.demoadmin.domain.UserQueryDTOV2;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;

@RestController
@RequestMapping("/api/v2/user")
public class UserQueryControllerV2 {
    /**
     * 查询用户列表 - 使用@DateRangeV2自定义注解跨字段校验
     */
    @PostMapping("/query")
    public String queryUsers(@Valid @RequestBody UserQueryDTOV2 queryDTO) {
        // 如果校验失败(如开始时间晚于结束时间),会抛出MethodArgumentNotValidException
        // 不会执行到这里
        return "查询用户列表成功";
    }
}

package com.yimeng.multiplemodules.democommon.validation;

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DateRangeValidatorV2.class)
@Documented
public @interface DateRangeV2 {

    String message() default "开始时间不能晚于结束时间";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    String startField();

    String endField();
}
package com.yimeng.multiplemodules.democommon.validation;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.lang.reflect.Field;
import java.time.LocalDateTime;

public class DateRangeValidatorV2 implements ConstraintValidator<DateRangeV2, Object> {

    private String startField;
    private String endField;
    private String message;

    @Override
    public void initialize(DateRangeV2 constraintAnnotation) {
        this.startField = constraintAnnotation.startField();
        this.endField = constraintAnnotation.endField();
        this.message = constraintAnnotation.message();
    }

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {
        try {
            Field start = value.getClass().getDeclaredField(startField);
            Field end = value.getClass().getDeclaredField(endField);

            start.setAccessible(true);
            end.setAccessible(true);

            LocalDateTime startTime = (LocalDateTime) start.get(value);
            LocalDateTime endTime = (LocalDateTime) end.get(value);

            if (startTime == null || endTime == null) {
                return true;
            }

            if (startTime.isAfter(endTime)) {
                context.disableDefaultConstraintViolation();
                context.buildConstraintViolationWithTemplate(message)
                        .addPropertyNode(startField)
                        .addConstraintViolation();
                return false;
            }

            return true;

        } catch (Exception e) {
            return false;
        }
    }
}

请求:

{
  "endTime": "2026-01-02 00:00:00",
  "startTime": "2026-01-02 00:12:12",
  "username": "李四"
}

结果:

{
  "code": 2001001002,
  "message": "请求参数不合法:查询时间范围无效:开始时间不能晚于结束时间",
  "data": null
}

原理:

┌─────────────────────────────────────────────────────────────────┐
│                        HTTP请求到达                               │
└─────────────────────────────────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                    DispatcherServlet接收请求                      │
└─────────────────────────────────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                找到对应的Controller方法并准备参数                   │
│             发现@Valid注解,触发校验机制                            │
└─────────────────────────────────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                    Spring Validation启动                         │
│              (集成Hibernate Validator作为实现)                    │
└─────────────────────────────────────────────────────────────────┘
                                │
                                ▼
                ┌───────────────────────────────┐
                │       递归遍历对象属性           │
                │  对每个字段/类检查是否有注解       │
                └───────────────────────────────┘
                                │
                                ▼
                ┌───────────────────────────────┐
                │      发现 @DateRange 注解       │
                │     (注解在类级别,标记整个类)     │
                └───────────────────────────────┘
                                │
                                ▼
                ┌───────────────────────────────┐
                │    执行自定义校验的核心流程        │
                │   (见下方详细步骤1-6)            │
                └───────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                     校验结果处理                                  │
│  ┌─────────────┐              ┌─────────────┐                  │
│  │  校验通过    │ ───────────> │进入Controller│                  │
│  └─────────────┘              └─────────────┘                  │
│        │                                                       │
│        ▼                                                       │
│  ┌─────────────┐              ┌─────────────┐                  │
│  │  校验失败    │ ───────────> │ 抛出异常      │                  │
│  └─────────────┘              │ MethodArgumentNotValidException│
│                               └─────────────┘                  │
└─────────────────────────────────────────────────────────────────┘
步骤1:查找Constraint定义
┌─────────────────────────────────────────────────────────────┐
│ @Target({ElementType.TYPE})                                  │
│ @Retention(RetentionPolicy.RUNTIME)                          │
│ @Constraint(validatedBy = DateRangeValidator.class)  ← 关键! │
│ public @interface DateRange {                                 │
│     String message();                                        │
│     String startField();                                     │
│     String endField();                                       │
│ }                                                            │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
步骤2Validator实例化(首次调用时)
┌─────────────────────────────────────────────────────────────┐
│ ConstraintValidatorManager缓存检查                            │
│                                                              │
│ if (缓存中没有DateRangeValidator的实例) {                      │
│     // 通过反射创建新实例                                      │Class<?> validatorClass = DateRangeValidator.class;      │
│     DateRangeValidator validator =                            │
│         (DateRangeValidator) validatorClass.newInstance();   │
│                                                              │
│     // 放入缓存,下次直接使用                                   │
│     cache.put(key, validator);                               │
│ }                                                            │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
步骤3:调用initialize()方法
┌─────────────────────────────────────────────────────────────┐
│ // Hibernate Validator内部调用                                 │
│ validator.initialize(annotation);  ← 传入注解实例              │
│                                                              │
│ public class DateRangeValidator implements                   │
│         ConstraintValidator<DateRange, Object> {            │
│                                                              │
│     private String startField;                               │
│     private String endField;                                 │
│                                                              │
│     @Override                                                │
│     public void initialize(DateRange annotation) {           │
│         // 从注解中获取参数值                                  │this.startField = annotation.startField();"startTime"this.endField = annotation.endField();"endTime"this.message = annotation.message();                 │
│     }                                                        │
│ }                                                            │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
步骤4:调用isValid()执行校验
┌─────────────────────────────────────────────────────────────┐
│ // Hibernate Validator调用                                    │boolean isValid = validator.isValid(dtoObject, context);    │
│                                                              │
│ @Override                                                    │
│ public boolean isValid(Object value,                         │
│                       ConstraintValidatorContext context) { │
│     try {                                                    │
│         // value就是被校验的DTO对象                            │// 1. 通过反射获取字段值                               │Field startField = value.getClass()                  │
│             .getDeclaredField(this.startField);  // "startTime"
│         startField.setAccessible(true);                      │
│         LocalDateTime startTime =                             │
│             (LocalDateTime) startField.get(value);           │
│                                                              │
│         Field endField = value.getClass()                    │
│             .getDeclinedField(this.endField);    // "endTime" 
│         endField.setAccessible(true);                        │
│         LocalDateTime endTime =                               │
│             (LocalDateTime) endField.get(value);             │
│                                                              │
│         // 2. 执行业务校验逻辑                                 │if (startTime.isAfter(endTime)) {                    │
│             // 3. 校验失败,构建错误信息                        │
│             context.disableDefaultConstraintViolation();    │
│             context.buildConstraintViolationWithTemplate(    │
│                 this.message)                                │
│                 .addPropertyNode(startField)                 │
│                 .addConstraintViolation();                   │
│             return false;                                    │
│         }                                                    │
│         return true;                                         │
│     } catch (Exception e) {                                  │
│         return false;                                        │
│     }                                                        │
│ }                                                            │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
步骤5ConstraintValidatorContext的作用
┌─────────────────────────────────────────────────────────────┐
│ // 这个接口用于构建详细的错误信息                               │public interface ConstraintValidatorContext {                │
│     // 禁用默认的约束违例信息                                  │void disableDefaultConstraintViolation();                │
│                                                              │
│     // 构建新的约束违例信息                                    │ConstraintViolationBuilder                                │
│         buildConstraintViolationWithTemplate(String message);│
│ }                                                            │
│                                                              │
│ // 使用示例:                                                  │
│ context.disableDefaultConstraintViolation();                 │
│ context.buildConstraintViolationWithTemplate(                │
│         "开始时间不能晚于结束时间")                            │
│     .addPropertyNode("startTime")  // 指定哪个字段出错        │.addConstraintViolation();                               │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
步骤6:返回结果给验证框架
┌─────────────────────────────────────────────────────────────┐
│ // Hibernate Validator接收返回值                              │if (!isValid) {                                              │
│     // 从context中获取构建好的错误信息                         │Set<ConstraintViolation<?>> violations =                 │
│         context.getConstraintViolations();                   │
│                                                              │
│     // 将这些违例添加到最终结果中                              │
│     validationResult.addAll(violations);                     │
│ }                                                            │
│                                                              │
│ // 最终,如果有任何违例,抛出异常                               │if (!validationResult.isEmpty()) {                           │
│     throw new MethodArgumentNotValidException(               │
│         parameter, validationResult);                        │
│ }                                                            │
└─────────────────────────────────────────────────────────────┘

提问

  1. @Valid 能不能放在方法上,表示对方法返回值进行校验。

    答:可以。

  2. @Validated 能不能放在类中的某个方法上,而不是放整个在类上,表示对这一个方法进行校验。是不是要求方法一定是public的。

    答:不能放在某个方法上,必须放在类上。是要求方法必须是public的,这样才能AOP。

  3. 使用@Validated的时候,是不是要求类必须是Spring管理的

    答:是的,因为要使用AOP。

  4. @Valid校验的实体类内部可以使用的注解不会只能是JSR的注解吧,能不能使用Hibernate Validator的注解,能不能使用自己定义的校验注解,自己定义校验规则?

    答:@Valid校验的实体类内部可以使用JSR的注解,也可以使用Hibernate扩展的注解,还能使用自己定义的校验注解,并且使用你自己定义的规则对这个注解进行校验。

  5. 如果一个实体类有多个成员变量上的注解校验不通过,会提示某一个校验注解的message信息还是全部不通过的注解的message。

    答:校验器会检查所有约束条件,不会因为第一个失败就停止。它会把所有的不通过的校验的提示内容都给你组合起来一起给你,然后给你一个异常。

我总结的最佳实践

最佳实践:

  1. 控制层校验。如果有简单参数校验,类上添加@Validated,形参前添加@Min(1)等校验注解,如果有复杂参数校验,形参前面添加@Valid,如果需要使用分组校验,那么我们就使用@Validated代替@Valid(controller层的@Validated是可以做复杂类型的实体类校验的),并且实体类中添加@Min(1)等校验注解,如果实体类中有复杂类型属性、数组属性、集合属性,要在属性上面添加@Valid,才能进行嵌套校验。
  2. excel导入时的校验,你可以在实体类的属性上添加校验注解,然后手动调用validator.validate(dto);进行基础校验,然后自己写代码进行业务校验。因为导入不好在controller方法形参上进行自动校验,形参可能是这样的public Result importExcel(MultipartFile file),而不是你要的实体类,而手动调用validator.validate(dto);其实就是相当于去使用dto上的校验注解进行校验,只是非导入的Spring MVC参数校验是内部自动调用validator.validate(dto);的,这里我们是自己手动调用的而已。Spring MVC 自动校验的实质就是,Spring 内部自动执行了validator.validate(dto);而我们手动调用其实就是模拟了SpringMVC自动校验的这个过程:Set<ConstraintViolation<ImportDTO>> violations = validator.validate(dto);
  3. 创建全局异常处理器,对MethodArgumentNotValidException、BindException、ConstraintViolationException进行处理。
  4. 如果是新增、修改不同操作要进行的校验是不同的,我们可以把校验注解进行分类,比如@NotNull(groups = UpdateGroup.class)给校验添加到UpdateGroup组,然后在controller层的更新API的复杂参数前添加@Validated(UpdateGroup.class)这样的注解。
  5. 跨字段校验、项目中可能会重复使用的特定格式校验、条件性校验(当字段A为特定值时,字段B必填)、不涉及业务逻辑的特定格式校验(比如密码强度校验)这些情况,我们可以选择自定义校验注解来完成校验。
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐