Spring MVC学习(7)—Validation基于注解的声明式数据校验机制全解【一万字】

基于最新Spring 5.x,详细介绍了Spring的基于注解的声明式数据校验,包括基于javax.validation.Valid注解以及Spring的@Validated注解进行自动化数据校验的方式。

web项目中,后端对于前端传递的参数总是免不了进行校验,比如字符长度、字段大小、null校验等等,虽然有些校验前端也会去做,但是为了增加web应用的健壮性和安全性(比如,如果绕过前端发送的直接请求,这时参数就没法得到保证了),对于重要的接口,后端进行参数二次校验是非常有必要的!

以往的web项目中,对于参数的校验,要么是在控制器方法中通过一系列手写if语句来判断,要么是提取一个公共的校验工具类,然后手动调用工具类的方法来进行校验,对于大型项目来说,无论使用哪种方式,仍然具有不少的工作量!

在使用Spring MVC框架之后,在进行请求的数据绑定(data binding)成功之后,可以基于javax.validation.Valid注解或者Spring的@Validated注解进行自动化数据校验,并且支持配置全局和单个请求的校验器。使用起来非常方便和简单,大大减少了开发人员的负担!

Spring MVC学习 系列文章

Spring MVC学习(1)—MVC的介绍以及Spring MVC的入门案例

Spring MVC学习(2)—Spring MVC中容器的层次结构以及父子容器的概念

Spring MVC学习(3)—Spring MVC中的核心组件以及请求的执行流程

Spring MVC学习(4)—ViewSolvsolver视图解析器的详细介绍与使用案例

Spring MVC学习(5)—基于注解的Controller控制器的配置全解【一万字】

Spring MVC学习(6)—Spring数据类型转换机制全解【一万字】

Spring MVC学习(7)—Validation基于注解的声明式数据校验机制全解【一万字】

Spring MVC学习(8)—HandlerInterceptor处理器拦截器机制全解

Spring MVC学习(9)—项目统一异常处理机制详解与使用案例

Spring MVC学习(10)—文件上传配置、DispatcherServlet的路径配置、请求和响应内容编码

Spring MVC学习(11)—跨域的介绍以及使用CORS解决跨域问题

1 Bean Validation

Java Bean Validation 2.0后改名为Jakarta Bean Validation,2018年之后的新版本依赖均以Jakarta开头,官网是https://beanvalidation.org/,目前来说,它们的maven依赖源码基本一致,最大的区别就是最新依赖命名为jakarta.validation:jakarta.validation-api,并且最新的版本都是此依赖,以前的javax.validation旧版本依赖不再更新!

Jakarta Bean Validation提供了一套Java Bean验证规范,也称为JSR-303规范,提出了一种声明性的Bean验证约束的功能,可以使用规范中注解帮绑定到模型属性,然后由运行时强制执行这些约束条件,还可以自定义约束注解,如下示例:

public class PersonForm {

    @NotNull
    @Size(max=64)
    private String name;

    @Min(0)
    private int age;
}

Jakarta Bean Validation仅仅是一套规范,并没提供任何实现,但是到目前有些厂商已提供了自己的实现,我们只需要引入他们提供的maven坐标即可使用它们的实现,最常见的就是hibernate-validatorhibernate-validator和hibernate ORM框架没啥联系,唯一的联系就是它们都是由hibernate团队开发的,官网是:https://hibernate.org/validator/

1.1 内置约束注解

hibernate-validator提供了Jakarta Bean Validation的内置约束注解的全部实现,同时可以自定义约束条件!同时,hibernate-validatorJakarta Bean Validation的内置约束注解的使用范围进行了扩展,比如@Max适用于字符串。参见:https://docs.jboss.org/hibernate/validator/7.0/reference/en-US/html_single/#section-builtin-constraints

内置约束位于jakarta.validation.constraints包中!所有的注解都具有message、groups、payload属性:

  1. message:在违反约束时返回创建的错误消息,可以使用{key}设置默认的key,将会自动查找对应的value,如果没有对应的value,那么使用key的值作为默认错误消息。消息中可以通过{elementName}获取注解的元素值,也可以使用${validatedValue}获取注解标注的值,也可以使用其他el表达式。
  2. payload:可以将自定义有效Payload对象分配给约束,通常未使用。

此外它们还有如下特性:

注解描述支持的类型(如果这些注解放在它们不支持的类型上,那么运行时将会抛出UnexpectedTypeException异常)
@AssertFalse检查注解的值是否为false。null值被认为验证通过。支持Boolean和boolean类型。
@AssertTrue检查注解的值是否为true。null值被认为验证通过。
@DecimalMax(value=, inclusive=)当inclusive=false,检查注解的值是否小于指定最大值,否则,检查该值是否小于或等于指定的最大值。参数value在内部会通过new BigDecimal(value)转换为的BigDecimal表现形式再进行比较。null值被认为校验通过。支持BigDecimal、BigInteger、CharSequence,byte 、short 、int 、long以及它们的包装类型的验证。请注意,由于舍入错误,因此不支持double和float。
此外hv的实现还支持Number及其子类型,以及javax.money.MonetaryAmount类型(如果存在JSR 354 的依赖)。还支持double和float,但是可能出现比较结果不准确的情况。
@DecimalMin(value=, inclusive=)当inclusive=false,检查注解的值是否大于指定最小值,否则,检查该值是否大于或等于指定的最小值。参数value在内部会通过new BigDecimal(value)转换为的BigDecimal表现形式再进行比较。null值被认为校验通过。
@Digits(integer=, fraction=)检查注解的值是否是属于最多integer整数位和fraction小数位范围内的数字。null值被认为校验通过。
@Max(value=)检查注解的值是否小于或等于指定的最大值。null值被认为校验通过。支持BigDecimal、BigInteger,byte 、short 、int 、long以及它们的包装类型的验证。请注意,由于舍入错误,不支持double和float。
此外,hv的实现还支持CharSequence及其子类型(由字符序列表示的数值计算),以及Number及其子类型以及javax.money.MonetaryAmount类型(如果存在JSR 354 的依赖)。还支持double和float,但是可能出现比较结果不准确的情况。
@Min(value=)检查注解的值是否小于或等于指定的最大值。null值被认为校验通过。
@Email检查注解的值是否是有效的电子邮件地址。可选参数 regexp 和flags允许指定电子邮件必须匹配的附加正则表达式(包括正则表达式标志)。支持CharSequence及其子类型。
@Future检查注解的值是否是一个将来的时间。null值被认为校验通过。支持java.util.Date, java.util.Calendar, java.time.Instant, java.time.LocalDate, java.time.LocalDateTime, java.time.LocalTime, java.time.MonthDay, java.time.OffsetDateTime, java.time.OffsetTime, java.time.Year, java.time.YearMonth, java.time.ZonedDateTime, java.time.chrono.HijrahDate, java.time.chrono.JapaneseDate, java.time.chrono.MinguoDate, java.time.chrono.ThaiBuddhistDate类型。
此外,hv的实现还支持Joda Time date/time类型(如果存在Joda Time依赖),以及支持ReadablePartial 和 ReadableInstant的任何实现。
@FutureOrPresent检查注解的值是否是当前时间或者将来时间。null值被认为校验通过。
@Past检查注解的值是否是一个过去的时间。null值被认为校验通过。
@PastOrPresent检查注解的值是否是当前时间或者过去时间。
@NotBlank检查注解的值是否不为null并且必须至少包含一个非空白字符。支持CharSequence及其子类型。
@NotEmpty检查注解的值是否不为null并且不为空。支持CharSequence及其子类型(计算字符序列的长度,即不能为""),以及Collection、Map、Array(至少包含一个元素/键值对)。
@NotNull检查注解的值是否不为null。支持任何类型。
@Null检查注解的值是否为null。
@Negative检查注解的值是否是严格的负数(0不通过)。null值被认为校验通过。支持BigDecimal、BigInteger,byte 、short 、int 、long 、float、double以及它们的包装类型。
此外,hv的实现还支持CharSequence及其子类型(由字符序列表示的数值计算),以及Number及其子类型以及javax.money.MonetaryAmount类型(如果存在JSR 354 的依赖)。
@NegativeOrZero检查注解的值是否是负数或0。null值被认为校验通过。null值被认为校验通过。
@Positive检查注解的值是否是严格的正数(0不通过)。null值被认为校验通过。
@PositiveOrZero检查注解的值是否是正数或0。null值被认为校验通过。
@Pattern(regex=, flags=)检查注解的值是否与给定的正则表达式匹配(regex),可以考虑给定的标记匹配项(falgs)null值被认为校验通过。支持CharSequence及其子类型。
@Positive检查注解的值是否是严格的正数(如果是0.0则通过,其他0则不通过)。null值被认为校验通过。支持BigDecimal、BigInteger,byte 、short 、int 、long 、float、double以及它们的包装类型。
此外,hv的实现还支持CharSequence及其子类型(由字符序列表示的数值计算),以及Number及其子类型以及javax.money.MonetaryAmount类型(如果存在JSR 354 的依赖)。
@PositiveOrZero检查注解的值是否是正数或0。null值被认为校验通过。
@Size(min=, max=)检查注解的值是否在给定的最小值(默认0)和最大值(默认Integer.MAX_VALUE)之间,包含两个端点值。null值被认为校验通过。支持CharSequence及其子类型(计算字符序列的长度),以及Collection、Map、Array(计算元素/键值对数量)。

1.2 其他约束注解

除了由Jakarta Bean Validation API定义的约束注解之外,Hibernate Validator还提供了几个有用的自定义约束注解,虽然可能用不到,但是可以看看以防不时之需!

注解描述支持的类型
@CreditCardNumber(ignoreNonDigitCharacters=)检查注解的值是否通过 Luhn 校验和测试,即美国信用卡号的格式是否正确。ignoreNonDigitCharacters表示是否允许忽略非数字字符,默认值为 false。null值被认为校验通过。支持CharSequence及其子类型。
@Currency(value=)检查注解的javax.money.MonetaryAmount的货币单位是否属于包含在指定的value单位数组中。null值被认为校验通过。支持javax.money.MonetaryAmount及其子类型(如果存在JSR 354 的依赖)。
@DurationMax(days=, hours=, minutes=, seconds=, millis=, nanos=, inclusive=)检查注解的java.time.Duration表示的时间差不大于注解指定的值,如果inclusive标志设置为 true,则允许相等。null值被认为校验通过。支持java.time.Duration类型。
@DurationMin(days=, hours=, minutes=, seconds=, millis=, nanos=, inclusive=)检查注解的java.time.Duration表示的时间差不小于注解指定的值,如果inclusive标志设置为 true,则允许相等。null值被认为校验通过。
@EAN检查注解的值是否是有效的EAN(商品用条形码)。type属性指定EAN类型,默认EAN-13。null值被认为校验通过。支持CharSequence及其子类型。
@ISBN检查注解的值是否是有效的ISBN(国际标准书号)。type属性指定ISBN类型,默认ISBN-13。null值被认为校验通过。支持CharSequence及其子类型。
@Length(min=, max=)检查注解的值长度是否在最小值和最大值之间,包含两个端点值。null值被认为校验通过。支持CharSequence及其子类型。
@CodePointLength(min=, max=, normalizationStrategy=)检查注解的值的代码点长度是否在最小值和最大值之间,包含两个端点值。如果设置了normalizationStrategy,则验证规范化值。null值被认为校验通过。支持CharSequence及其子类型。
@Range(min=, max=)检查注解的值是否位于指定最小值和最大值之间,包含两个端点值。null值被认为校验通过。支持BigDecimal、BigInteger,byte 、short 、int 、long 、float、double以及它们的包装类型。
@UniqueElements检查注解的集合是否仅包含唯一元素,通过equals()方法来比较元素是否相等。null值被认为校验通过。支持Collection及其子类型。
@URL(protocol=, host=, port=, regexp=, flags=)根据 RFC2396 规范检查检查注解的值是否为有效的 URL。如果在注解中指定了任何可选参数:协议、主机或端口,则相应的 URL 片段必须与指定的值匹配。可选参数 regexp 和flags允许指定URL必须匹配附加的正则表达式(包括正则表达式标志)。默认情况下,此约束使用java.net.URL的构造器来验证给定字符串是否表示有效的 URL。支持CharSequence及其子类型。

1.3 集成hibernate-validator

Spring支持和hibernate-validator非常方便的集成,执行Bean校验的核心就是javax.validation.Validator,Validator需要我们引入Bean Validation provider的依赖,也就是具体的Bean Validation的实现,例如hibernate-validator。在运行时,Validator依赖具体的Bean Validation的实现来进行校验。

对于普通项目,我们需要引入hibernate-validator或者其他引入Bean校验的相关实现依赖,对于Spring Boot项目则会自动引入了相关依赖,我们无需引入!

<!-- https://mvnrepository.com/artifact/org.hibernate.validator/hibernate-
validator -->
<!-- 引入hibernate-validator -->
<dependency>
    <groupId>org.hibernate.validator</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>6.0.18.Final</version>
</dependency>
<!-- https://mvnrepository.com/artifact/javax.el/javax.el-api -->
<!-- tomcat 7 可能需要该依赖 -->
<dependency>
    <groupId>org.glassfish</groupId>
    <artifactId>javax.el</artifactId>
    <version>3.0.1-b08</version>
</dependency>

注意,hibernate-validator 7的版本的Bean validation API均从javax.validation换成了jakarta.validation,因此如果切换版本,可能需要重新导入API的类路径。

1.4 配置Validator Bean

如果我们想要配置能够在其他地方手动调用的Validator,则可以通过Spring提供的LocalValidatorFactoryBean来快速将Validator配置为Spring管理的Bean!

如果需要手动校验,那么由于LocalValidatorFactoryBean 实现了 javax.validation.validatorFactory 和 javax.validation.Validator,以及 Spring 的org.springframework.validation.Validator,因此我们可以将对其中任一接口的引用注入需要手动调用验证逻辑的bean中。

@Bean
public LocalValidatorFactoryBean validator() {
    LocalValidatorFactoryBean localValidatorFactoryBean = new LocalValidatorFactoryBean();
    //即使不设置Provider,Spring也会自动查找Provider的实现
    //localValidatorFactoryBean.setProviderClass(HibernateValidator.class);
    return localValidatorFactoryBean;
}

2 Spring MVC参数属性校验

这里的配置仅仅是针对Spring MVC的控制器方法绑定的参数对象的属性进行校验,如果需要对于参数本身以及返回值进行校验,那么需要后面的介绍!

这里的数据校验是在数据绑定成功之后进行的,和数据绑定类似, Spring MVC 绑定参数的属性校验支持全局配置和局部配置。

2.1 全局和局部配置

对于普通项目中Spring MVC的参数绑定校验,我们只需要通过@EnableWebMvc或者<mvc:annotation-driven/>打开MVC配置,Spring MVC会自动尝试查找类路径中引入的ValidationProvider的实现并将第一个找到的Provider配置全局的Validator。从而无需我们手动配置。如果只引入了hibernate-validator的依赖 ,那么只有一个Provider的实现——HibernateValidator。对于Spring Boot项目则会自动开启MVC配置,同样无需我们手动开启。

如果我们需要手动指定Spring MVC使用的全局Validator,那么如下配置:

/**
 * @author lx
 */
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {
    /**
     * 提供自定义Validator验证器,而不是默认创建的验证器
     * 如果返回null,并且如果类路径中存在 JSR-303 Provider的实现,那么将会默认创建类型为
     * org.springframework.validation.beanvalidation.OptionalValidatorFactoryBean的Validator
     */
    @Override
    public Validator getValidator() {
        LocalValidatorFactoryBean localValidatorFactoryBean = new LocalValidatorFactoryBean();
        //即使不设置Provider,Spring也会自动查找类路径中的Provider的实现
        //localValidatorFactoryBean.setProviderClass(HibernateValidator.class);
        return localValidatorFactoryBean;
    }
}

下面的示例演示如何在 XML 中实现相同的配置:

<!--validator属性用于指定Validator bean,该属性不是必须的,除非想要自定义
validator-->
<!--如果未指定,那么自动查找类路径中的JSR-303 provider实现来配置一个类型为-->
<!--org.springframework.validation.beanvalidation.OptionalValidatorFactoryBean的Validator-->
<mvc:annotation-driven validator="mvcValidator"/>

当然,我们同样可以为某些请求在注册一个局部的Validator:

/**
 * 添加局部的Validator
 */
@InitBinder
public void initBinder(WebDataBinder dataBinder) {
    //如果使用预定义的Validator,那么需要手动调用afterPropertiesSet初始化
    //如果是使用自定义的Validator实现,那么需要supports方法支持要校验的参数类型否则抛出异常
    LocalValidatorFactoryBean optionalValidatorFactoryBean = new LocalValidatorFactoryBean();
    optionalValidatorFactoryBean.afterPropertiesSet();
    //可以添加多个Validator
    dataBinder.addValidators(optionalValidatorFactoryBean);
}

如果同时添加了全局和局部的Validator,那么在校验时会形成validators链,其中全局Validator排在最前面,校验时会按照排序顺序依次调用每一个Validator进行校验,所有的Validator都通过才算通过!

2.2 测试案例

在开启MVC配置之后,将会自动配置Spring MVC的全局Validator,我们直接进行测试!我们采用lmbok的@Data注解来省略setter、getter、toString方法,如果不会那么请手写相关方法。

面是一个需要校验的实体,要求id、sex和name都不能为null,id要求为正数,sex要求为0或者1,name要求在1到是个字符之间:

@Data
public class User1 {

    @Positive
    @NotNull
    private Long id;

    @Range(min = 0, max = 1)
    @NotNull
    private Byte sex;

    @Size(min = 1, max = 10)
    @NotBlank
    private String name;
}

接下来是控制器方法,非常重要的一步就是,我们需要在对对象属性进行校验的对象参数前加上@Validated或者@Valid注解javax.validation.Valid注解可以标记在属性、方法参数或方法返回类型上以表示对标记的对象进行级联校验,也就是校验对象内部的属性,而org.springframework.validation.annotation.Validated则是Spring提供的注解,它具有和@Valid一样的功能,并且还新增了可选验证组的功能,后面会讲!

/**
 * @author lx
 */
@RestController
public class GlobalMvcValidationController {

    /**
     * 支持application/json请求的参数校验
     */
    @PostMapping("/pv1")
    @ResponseBody
    public User1 pv1(@Validated @RequestBody User1 user1) {
        System.out.println(user1);
        user1.setId(0L);
        return user1;
    }

    /**
     * 支持普通请求的参数校验
     */
    @GetMapping("/pv2")
    @ResponseBody
    public void pv2(@Valid User1 user1) {
        System.out.println(user1);
    }
}

访问/pv3?id=1&sex=2&name=name,很明显,sex属性的值不符合要求,服务器将会返回400响应码,意思就是客户端发送的请求有误:

在这里插入图片描述

控制台能够看到更加明确的异常信息(后面我们会讲如何把数据校验抛出的异常整合到全局异常处理中):

在这里插入图片描述

如果访问/pv3?id=-1&sex=2&name=name,我们会收到两条错误校验不通过的数据:

在这里插入图片描述

继续访问/pv3?id=1&sex=1&name=name,当然提交的数据是符合要求的:

在这里插入图片描述

2.3 级联校验

在上面我们就说了@Valid支持级联校验,如果校验的对象内部的属性中同样标注了@Valid注解,那么validation engine验证引擎将会自动递归跟进!同样,级联校验的对象如果为null,那么被忽略(算作校验通过)。

如下实体,内部具有一个对象属性,我们采用@Valid注解进行级联校验:

@Data
public class User2 {

    @Positive
    @NotNull
    private Long id;

    @Range(min = 0, max = 1)
    @NotNull
    private Byte sex;

    @Size(min = 1, max = 10)
    @NotBlank
    private String name;

    /**
     * 标注@Valid注解,对对象类型的属性进行级联校验
     */
    @Valid
    @NotNull
    private Address address;

    @Data
    public class Address {
        @NotBlank
        @Pattern(regexp = "\\d{6}")
        private String postcode;
        @NotBlank
        @Size(min = 10, max = 100)
        private String workAddress;
        @NotBlank
        @Size(min = 10, max = 100)
        private String homeAddress;

    }

}

控制器方法:

/**
 * 级联校验
 */
@PostMapping("/pv3")
@ResponseBody
public User2 pv3(@RequestBody @Valid User2 user2) {
    System.out.println(user2);
    return user2;
}

测试数据:

{
  "id": 1,
  "sex": 1,
  "name": "name",
  "address": {
    "postcode": "011111",
    "workAddress": "1111111111",
    "homeAddress": "1111111111"
  }
}

结果当然是校验通过:

在这里插入图片描述

将内部对象的postcode属性改为5个字符再次测试:

{
  "id": 1,
  "sex": 1,
  "name": "name",
  "address": {
    "postcode": "11111",
    "workAddress": "1111111111",
    "homeAddress": "1111111111"
  }
}

由于postcode不符合指定的模式,级联校验不通过:

在这里插入图片描述

2.4 容器校验

Java容器包括Collection、Map、Array等常见类型。

@Valid同样支持容器元素的内部属性校验,任何类型的容器属性可以加上@Valid 注解,这将导致校验每个元素进行级联校验(map包括key和value)。但是hibernate validator不推荐这么做,而是希望添加在容器的具体的泛型类型上,这样的好处是校验的目的更加明确。对于数组的校验似乎有一定的限制,比如目前测试无法校验null元素。

对容器元素进行校验时,任何元素不满足条件就算做校验不通过!

如下实体:

@Data
public class User3 {

    /**
     * list至少包含两个元素
     * 元素不能为null且进行级联校验
     */
    @Size(min = 2)
    @NotNull
    @Valid
    private List<@NotNull InnerClass> user1s;

    /**
     * map不能为空
     * key的长度至少为2个字符且不是空白字符,value不能为null且进行级联校验
     */
    @NotEmpty
    private Map<@NotBlank @Size(min = 2) String, @NotNull @Valid InnerClass> stringUser1Map;

    /**
     * 数组不能为空,且对元素进行级联校验
     * 校验似乎有一定的限制,比如目前测试无法通过@NotNull校验null元素
     */
    @NotEmpty
    @Valid
    @NotNull
    private @NotNull InnerClass[] user2s;

    @Data
    public static class InnerClass {
        @NotNull
        @Min(1)
        private Long id;

        @NotBlank
        @Size(min = 5)
        private String name;
    }
}

一个控制器方法:

/**
 * 容器元素校验
 */
@PostMapping("/pv4")
@ResponseBody
public User3 pv4(@RequestBody @Valid User3 user3) {
    System.out.println(user3);
    return user3;
}

测试数据:

{
	"user1s": [{
		"id": 1,
		"name": "11111"
	}, null],
	"user2s": [{
		"id": 1,
		"name": "11111"
	}, null],
	"stringUser1Map": {
		"11": {
			"id": 1,
			"name": "11111"
		},
		"1": {
			"id": 1,
			"name": "11111"
		}
	}
}

3 Spring驱动方法校验

3.1 Spring MVC参数属性校验的局限性

在上面 “Spring MVC参数属性校验” 的部分中,通过MVC配置的默认校验,仅仅是针对Spring MVC的控制器方法绑定的参数对象的属性进行校验,如果还需要对于方法参数本身进行或者方法的返回值进行校验,比如校验方法参数和返回值本身不为null,或者需要对属于非控制器的方法进行同样的Bean validation校验,那么我们需要配置Spring 驱动方法校验。Spring 驱动方法校验的校验规则和上面的Spring MVC参数属性校验的校验规则都是一样的,只不过它的应用范围更加广泛,在普通项目中应该手动配置Spring 驱动方法校验。同样,如果是Spring Boot的web项目,那么Spring Boot为我们自动配置好了,我们无需任何配置!

下面,我们测试默认Spring MVC参数属性校验的局限性!

如下GlobalValidationController控制器类,前两个方法需要接收String、Long类型的参数,实际上对于这种非自定义类型的参数,我们只能将校验的注解写在控制器方法的参数上,后两个方法虽然是我们自定义的User1类型的参数,但是第三个方法我们希望校验这个传递进来的User1对象本身不为null,对于第四个方法则是希望校验这个集合类型的参数本身的元素数量最少为2个等,这种情况下,校验注解同样只能写在控制器方法的参数上!

/**
 * @author lx
 */
@RestController
public class GlobalValidationController {

    /**
     * str参数不能是空字符串,且长度为2到5个字符
     */
    @GetMapping("/pv5")
    @ResponseBody
    public String pv5(@Valid @Size(min = 2, max = 5) @NotBlank String str) {
        System.out.println(str);
        return str;
    }

    /**
     * id参数不能为null,且最小值为333
     */
    @GetMapping("/pv6/{id}")
    @ResponseBody
    public Long pv6(@PathVariable @Valid @Min(333) @NotNull Long id) {
        System.out.println(id);
        return id;
    }

    /**
     * User1参数本身不能为null
     */
    @PostMapping("/pv7")
    @ResponseBody
    public User1 pv7(@RequestBody(required = false) @Valid @NotNull User1 user1) {
        System.out.println(user1);
        return user1;
    }

    /**
     * List<User1>参数本身不能为null,且至少包括两个元素,且内部元素不能为null
     */
    @PostMapping("/pv8")
    @ResponseBody
    public List<User1> pv7(@RequestBody @Valid @Size(min = 2) List<@NotNull User1> user1List) {
        System.out.println(user1List);
        return user1List;
    }
}

在Spring MVC参数属性校验的配置下,我们访问接口!

首先访问/pv5?str=2,明显是不符合校验要求的,但是结果却正常返回了:

在这里插入图片描述

接着访问/pv6/111,明显是不符合校验要求的,但是仍然正常返回:

在这里插入图片描述

接着访问/pv7并且不传递数据,参数将为null导致不符合校验要求,但是仍然正常返回:

在这里插入图片描述

接着访问/pv8并且不传递数据,参数传递一个空集合,明显不符合校验要求,但是仍然正常返回:

在这里插入图片描述

3.2 Spring驱动方法校验配置和原理

为了让校验的适用性更加广泛,我们需要配置Spring驱动方法校验,配置的方法非常简单,只需要在Spring 容器中配置一个MethodValidationPostProcessor的bean即可:

/**
 * @author lx
 */
@Configuration
public class SpringConfig {

    @Bean
    public MethodValidationPostProcessor validationPostProcessor() {
        return new MethodValidationPostProcessor();
    }

}

如果开始了MVC配置并且开启了Spring MVC参数属性校验,那么对于控制器方法进行级联校验时将采用MVC配置中的校验器!

MethodValidationPostProcessor是一个方便的BeanPostProcessor实现,它委托给JSR-303 Provider对带注解的方法执行方法级校验。支持包括方法参数、参数内部的属性、方法返回值(标注可以标注在方法上)的校验,比如:

public @NotNull Object myValidMethod(@NotNull String arg1, @Max(10) int arg2)

需要进行校验的方法所属的目标类/接口上应该标注Spring 的@Validated注解(指定@Valid无效),这样Spring才能搜索其内部的方法以进行方法校验。也可以指定@Validated验证组,默认情况下,JSR-303 将仅针对其默认组进行校验。

MethodValidationPostProcessor的原理很简单,在其本身初始化的时候会在内部创建一个Validator以及一个包含该Validator的一个Advisor通知器,由于它作为一个BeanPostProcessor,因此在postProcessAfterInitialization()方法中会判断每一个Spring管理的bean实例并且对于符合条件的bean传递其内部的Advisor通知器,随后创建代理bean对象并返回,实际上注入的是一个当代理对象的方法被调用的时候,将会调用Advisor通知器内部的MethodValidationInterceptor拦截器对目标方法进行拦截并且利用Validator对方法进行校验。

也就是说,最终还是通过Spring AOP的机制创建代理对象,通过在目标方法的执行前后增加了校验的逻辑来实现参数和返回值校验的,因此它与是否是控制器类无关,只要是被Spring管理的Bean都有继承成了校验的对象!

不过,既然是基于Spring AOP控制的参数校验,那么Spring AOP的限制在此同样有效:

  1. AOP的机制导致了同一个AOP类中的需要校验的方法互相调用时,被调用方法的校验不会生效,因为Spring AOP的代理机制最终还是通过原始目标对象本身去调用目标方法的,这样被调用的方法就会因为是原始对象调用的而不被拦截,当然也有解决办法,那就是获取代理对象,通过代理对象去调用内层方法!
  2. 无论是基于JDK动态代理还是CGLIB代理,由于本身的缺陷,它们代理的方法的增强都具有限制。对于JDK的代理,目标类必须实现符合规则的接口(不是说只要是实现了接口就会使用JDK代理,具体规则在AOP源码部分有讲解),并且只能代理实现的接口的方法,而对于CGLIB的代理,目标类不能是final的,并且需要代理的方法也不能是private/final/static的。这些AOP代理的限制也是校验增强方法的限制。具体的代理方式是JDK代理优先,然后是尝试CGLIB的代理,我们在Spring AOP部分已经讲过了,目前无法手动指定。
  3. @Async注解一样,Spring不能为@Validated注解标注的类解决setter方法和反射字段注解的循环依赖注入(包括自己注入自己),将会抛出:“……This means that said other beans do not use the final version of the bean……”异常,根本因为这个AOP代理对象不是使用通用的AbstractAutoProxyCreator的方法创建的,而是使用MethodValidationPostProcessor后处理器来创建的,Spring目前没有解决这个问题。解决办法是在引入的依赖项上加一个@Lazy注解,原理就是再给它加一层AOP代理……。而其他的,Spring可以解决比如由于事物或者自定义的AOP机制创建的AOP代理的循环依赖。

3.3 测试案例

校验的方式都是差不多的,特别要注意的就是我们要在需要Spring驱动方法校验的类上使用Spring的@Validated注解,这样MethodValidationPostProcessor在才会对该类的bean进行增强!

我们配置一个MethodValidationPostProcessor:

/**
 * @author lx
 */
@Configuration
public class SpringConfig {

    @Bean
    public MethodValidationPostProcessor validationPostProcessor() {
        return new MethodValidationPostProcessor();
    }

}

然后,在刚才的GlobalValidationController类上加上@Validated:

在这里插入图片描述

再次访问/pv5?str=2,神奇的事情发生了,我们的校验注解生效了:

在这里插入图片描述

在这里插入图片描述

访问/pv5?str=12,此时响应正常返回:

在这里插入图片描述

接着访问/pv6/111,我们的校验注解同样生效了:

在这里插入图片描述

在这里插入图片描述

接着访问/pv7并且不传递数据,参数将为null,我们的校验注解同样生效了:

在这里插入图片描述

传递一个不合格的JSON字符串:

{
  "id": 10,
  "sex": 2,
  "name": "1"
}

结果同样会被校验出来:

在这里插入图片描述

接着访问/pv8并且参数传递一个空集合,我们的校验注解同样生效了:

在这里插入图片描述

在这里插入图片描述

如果传递两个null参数,那么@NotNull注解同样生效了:

在这里插入图片描述

在这里插入图片描述

3.4 返回值校验

返回值的校验注解可以标在方法上或者返回值的类型之前!另外需要注意的是,即使返回值校验不通过,此时的业务代码也肯定是被执行过了的!

/**
 1. 返回值不能为null
 */
@GetMapping("/pv9")
@ResponseBody
public @NotNull Date pv7() {
    System.out.println("-----业务逻辑-----");
    return null;
}

访问/pv9,结果如下:

在这里插入图片描述

3.5 非控制器类校验

因为Spring驱动方法校验基于Spring AOP,因此即使是非控制器类比如说service层的bean同样支持方法校验!

虽然controller层通常是没有接口的设计,但是如果web项目的其他层(比如service层)存在着接口-实现类的设计方式,那么有以下注意点:

  1. 参数上的约束注解应该定义在被重写的接口/父类方法上,或者即使重写的方法上的存在约束注解也应该和接口/父类方法上的约束注解完全一致,否则运行时将抛出ConstraintDeclarationException异常!
  2. @Validated标注在实现类或者接口/父类上均可。标注在接口/父类上,表示任何实现类和子类都会开启方法校验,如果标注在某个实现类上标志只有该类会开启方法校验。
  3. 返回值的约束注解,标注在实现类或者接口/父类的方法上均可,如果都标注了注解(除非注解完全一致),那么这些注解将会全部进行校验,都满足才算满足!

如下Service:

public interface OtherValidationService {

    void parameterValidation(@Valid @NotNull User1 user1);

    @Size(min = 5) String returnValidation();
}




@Service
@Validated
public class OtherValidationServiceImpl implements OtherValidationService {
    
    @Override
    public void parameterValidation(User1 user1) {
        System.out.println(user1);
    }

    @Override
    public @NotBlank @Size(min = 4) String returnValidation() {
        return "";
    }

}

控制器类:

@RestController
@Validated
public class OtherValidationController {

    @Resource
    private OtherValidationService otherValidationService;

    @GetMapping("/parameterValidation")
    public void user1() {
        otherValidationService.parameterValidation(null);
    }

    @GetMapping("/returnValidation")
    public String returnTest() {
        return otherValidationService.returnValidation();
    }
}

访问/parameterValidation,结果如下:

在这里插入图片描述

访问/returnValidation,结果如下:

在这里插入图片描述

4 配置自定义约束

内置的约束注解已经能够满足几乎所有的校验需要了,但是总有些特别的需求需要定义自己的校验规则,如果要创建自定义约束,那么也很简单,需要执行以下三个步骤:

  1. 创建自定义的约束注解,使用@Constraint作为元注解;
  2. 创建一个validator验证器,通常是实现javax.validation.ConstraintValidator接口,并与@Constraint关联;
  3. 定义默认错误消息;

下面我们尝试创建自定义约束注解,为了方便,我将所有的实现都定义在一个注解中。

/**
 * 该自定义约束注解用于判断奇偶性,可以标注在方法、字段、参数、类型 上面
 * <p>
 * 基于规范,当被校验对象为null时,校验为通过
 *
 * @author lx
 */
@Target({METHOD, FIELD, PARAMETER, TYPE_USE})
@Retention(RUNTIME)
@Constraint(validatedBy = Odevity.MyConstraintValidator.class)
public @interface Odevity {

    /**
     * 在违反约束时返回创建错误消息的默认key
     */
    String message() default "{com.spring.mvc.config.Odevity.message}";

    /**
     * 允许此约束所属的规范验证组
     */
    Class<?>[] groups() default {};

    /**
     * 可以将自定义有效Payload对象分配给约束,通常未使用
     */
    Class<? extends Payload>[] payload() default {};

    /**
     * 设置校验的模式
     * ODD——奇数,EVEN——偶数
     */
    OdevityMode value();


    /**
     * Validator校验器的实现,真正的校验的逻辑
     */
    class MyConstraintValidator implements ConstraintValidator<Odevity, Long> {

        private OdevityMode odevityMode;

        /**
         * 初始化Validator校验器
         *
         * @param constraintAnnotation 当前Odevity注解实例
         */
        @Override
        public void initialize(Odevity constraintAnnotation) {
            odevityMode = constraintAnnotation.value();
        }

        /**
         * 执行校验的路基
         *
         * @param value   注解的数据的值
         * @param context 校验上下文
         * @return 是否校验通过,false不通过,true通过
         */
        @Override
        public boolean isValid(Long value, ConstraintValidatorContext context) {
            if (value == null) {
                return true;
            }
            boolean flag = value % 2 == 0;
            return flag && odevityMode == OdevityMode.EVEN ||
                    (!flag && odevityMode == OdevityMode.ODD);
        }
    }

    /**
     * 奇偶性的枚举常量
     */
    enum OdevityMode {
        /**
         * 奇数
         */
        ODD,

        /**
         * 偶数
         */
        EVEN;
    }

}

@Odevity是我们自定义的一个约束注解,用于校验值是奇数还是偶数!注解之上有几个元注解:

  1. @Target:表示该注解可以标志的位置,包括方法、字段、参数、类型(比如返回值类型、泛型类型等等)这四个位置!
  2. @Retention:表示在什么级别保存该注解的信息,RUNTIME的意思就是在编译时会将注解信息记录到class文件,运行时仍然保留注解,因此可以通过反射获取注解的信息。
  3. @javax.validation.Constraint:这个注解是Java Bean validation提供的元注解,用来指示@Odevity注解是一个约束注解,并且指定用于校验使用@Odevity注解标注的数据的validator验证器的Class。如果约束注解可用于多个数据类型,则可以指定多个验证器的Class,每个校验器对应一个可验证的数据类型,对于不同的类型将会调用对应的校验器。如果两个校验器对应同一个可验证的数据类型,那么将会抛出异常。

根据Jakarta Bean Validation API,任何自定义的约束注解,都应该包含一下三个元素:

  1. message:在违反约束时返回创建的错误消息,可以使用{key}设置默认的key,将会自动查找对应的value,如果没有对应的value,那么使用key的值作为默认错误消息。消息中可以通过{elementName}获取注解的元素值,也可以使用${validatedValue}获取注解标注的值,也可以使用其他el表达式。
  2. attribute:允许此约束所属的规范验证组。
  3. payload:可以将自定义有效Payload对象分配给约束,通常未使用。

MyConstraintValidator是一个约束校验器,通常校验延器需要实现Jakarta Bean Validation的javax.validation.ConstraintValidator接口!通常校验器被单独定义为一个外部类,这里为了方便直接定义在注解中。

ConstraintValidator接口中具有两个泛型参数第一个参数指定要校验的注解类型(Odevity),第二个指定一个要校验的数据类型,表示验证器可以处理这些类型,这里是Long类型,因此如果@Odevity标记在其它数据类型上,那么将会抛出UnexpectedTypeException异常:

在这里插入图片描述

校验器的实现非常简单。initialize()方法允许我们访问校验约束注解的属性值,我们可以将它们存储在自定义验证器的字段中,如示例中所示。
isValid()方法包含实际的验证逻辑,第一个参数表示需要校验的数据的值。请注意,Jakarta Bean Validation 规范建议将 null 值视为有效值。如果 null 不是元素的有效值(视作校验通过),则应显式使用@NotNull注解。

错误消息的生成依赖于isValid()方法返回true或者false,返回false表示没有通过校验!使用传递的第二个参数 ConstraintValidatorContext 对象,可以添加其他错误消息或完全禁用默认错误消息生成并仅定义自定义错误消息,一般不常用。

另外,实现了ConstraintValidator的类,会被自动加入到Spring容器中进行统一管理,因此我们无需手动初始化,也无须加上@Compent或者其他组件注解

最后,我们在项目的resources目录下添加一个国际化的错误消息文件ValidationMessages_zh_CN.properties,其内部的key就是@Odevity中的message的默认值:

# 消息中可以通过{elementName}获取注解的元素值,也可以使用${validatedValue}获取注解
标注的值,也可以使用其他el表达式
# 对 "校验值:${validatedValue}.不符合校验的要求:{value}"进行了中文unicode编码
com.spring.mvc.config.Odevity.message=\u6821\u9a8c\u503c:${validatedValue}.\u4e0d\u7b26\u5408\u6821\u9a8c\u7684\u8981\u6c42\uff1a{value}

添加国际化消息文件不是必须的,因为大部分项目都没有国际化的需求,我们直接将错误消息定义为message元素的默认值即可!

4.1 测试案例

测试控制器:

@RestController
@Validated
public class MyValidationController {

    /**
     * 自定义约束注解测试案例,要求参数必须是奇数
     */
    @GetMapping("/odevity/{num}")
    public Long odevity(@PathVariable @Odevity(Odevity.OdevityMode.ODD) Long num) {
        System.out.println(num);
        return num;
    }

}

访问/odevity/1,当然校验通过:

在这里插入图片描述

访问/odevity/2,校验不通过:

在这里插入图片描述

5 统一异常处理

Spring异常处理校验如果不通过,通常会抛出三个异常,在执行统一异常处理的时候,可以将这三个异常捕获,并且提取里面的信息,封装为result返回给客户端:

  1. Spring 驱动方法校验异常:ConstraintViolationException
  2. Spring MVC的JSON请求参数对象内部属性校验异常:MethodArgumentNotValidException
  3. Spring MVC的普通请求参数对象内部属性校验异常:BindException

统一异常处理的具体的实现方式,我们会在后面的单独的文章中介绍!

相关文章:

  1. https://spring.io/
  2. Spring Framework 5.x 学习
  3. Spring Framework 5.x 源码

如有需要交流,或者文章有误,请直接留言。另外希望点赞、收藏、关注,我将不间断更新各种Java学习博客!

  • 14
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 7
    评论
Spring MVC中有许多常用的注解,用于标记和配置控制器、请求映射、数据绑定、视图解析等功能。以下是一些常见的Spring MVC注解: 1. @Controller: 标记一个类为Spring MVC的控制器,处理请求并返回响应。 2. @RequestMapping: 用于映射请求URL到控制器的处理方法。可以用在类级别上标记控制器,也可以用在方法级别上标记处理方法。 3. @GetMapping, @PostMapping, @PutMapping, @DeleteMapping: 是@RequestMapping的缩写,分别用于标记处理GET、POST、PUT、DELETE请求的方法。 4. @PathVariable: 用于将URL中的路径参数绑定到方法的参数上。 5. @RequestParam: 用于将请求参数绑定到方法的参数上。 6. @RequestBody: 用于将请求体中的数据绑定到方法的参数上,常用于接收JSON或XML格数据。 7. @ResponseBody: 用于将方法的返回值直接写入响应体,常用于返回JSON或XML格数据。 8. @ModelAttribute: 用于将请求参数绑定到模型对象上,常用于表单提交时的数据绑定。 9. @Valid: 用于开启对模型对象的数据校验,通常与javax.validation中的注解一起使用。 10. @SessionAttributes: 用于将模型中的属性暂存到会话(Session)中,以供多个请求之间共享。 11. @InitBinder: 用于配置数据绑定器,可以自定义数据绑定的规则和格。 这些只是Spring MVC中的一部分常用注解,还有其他更多的注解用于处理拦截器、异常处理、视图解析等功能。详细的注解使用可以参考Spring MVC的官方文档或相关教程。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

刘Java

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值