参考文献

Spring校验使用场景

  • Spring常规校验(Validator)
  • Spring数据绑定(DataBinder)
  • Spring Web参数绑定(WebDataBinder)
  • Spring Web MVC/Spring WebFlux处理方法参数校验

Validator接口设计

  • 接口职责

    • Spring内部校验器接口,通过编程的方式校验目标对象
  • 核心方法

    • support(Class): 校验目标类能否校验;
    • validate(Object,Errors): 校验目标对象,并将校验失败的内容输出至Errors对象
  • 配套组件

    • 错误收集器: org.springframework.validation.Errors

    • Validator 工具类:org.springframework.validation.ValidationUtils

Errors接口设计

  • 接口职责

    • 数据绑定和校验错误收集接口,与 Java Bean 和其属性有强关联性
  • 核心方法

    • reject 方法(重载):收集错误文案

    • rejectValue 方法(重载):收集对象字段中的错误文案

  • 配套组件

    • Java Bean 错误描述:org.springframework.validation.ObjectError
    • Java Bean 属性错误描述:org.springframework.validation.FieldError

Errors 文案来源

  • Errors 文案生成步骤
    • 选择 Errors实现(如:org.springframework.validation.BeanPropertyBindingResult
    • 调用 rejectrejectValue 方法
    • 获取 Errors 对象中 ObjectErrorFieldError
    • ObjectErrorFieldError 中的 codeargs,关联 MessageSource 实现(如:ResourceBundleMessageSource)

自定义 Validator

  • 实现 org.springframework.validation.Validator 接口
    • 实现 supports 方法
    • 实现 validate 方法
      • 通过 Errors 对象收集错误
        • ObjectError:对象(Bean)错误:
        • FieldError:对象(Bean)属性(Property)错误
      • 通过 ObjectErrorFieldError 关联 MessageSource 实现获取最终文案

Validator 的救赎

  • Bean ValidationValidator 适配
    • 核心组件 - org.springframework.validation.beanvalidation.LocalValidatorFactoryBean
    • 依赖 Bean Validation - JSR-303 or JSR-349 provider
    • Bean 方法参数校验 - org.springframework.validation.beanvalidation.MethodValidationPostProcessor

Hibernate Validator

注解

注解名 支持类型 作用
@Valid 被注解的元素是一个对象,需要检查此对象的所有字段
@AssertTrue Boolean, boolean 被注解的元素必须为true
@AsserFalse Boolean, boolean 被注解的元素必须为false
@Min(value) BigDecimal, BigInteger, CharSequence ,byte, short, int, long and the respective wrappers of the primitive types; 被注解的元素必须是一个数字,其值必须大于等于指定的最小值
@Max(value) BigDecimal, BigInteger, CharSequence ,byte, short, int, long and the respective wrappers of the primitive types; 被注解的元素必须是一个数字,其值必须小于等于指定的最大值
@DecimalMin(value) BigDecimal, BigInteger, CharSequence ,byte, short, int, long and the respective wrappers of the primitive types; 被注解的元素必须是一个数字,其值必须小于等于指定的最大值
@DecimalMax(value) BigDecimal, BigInteger, CharSequence ,byte, short, int, long and the respective wrappers of the primitive types; 被注解的元素必须是一个数字,其值必须小于等于指定的最大值
@Size(max,min) 被注解的元素的大小必须在指定的范围内
@Digits(integer,fraction) BigDecimal, BigInteger, CharSequence ,byte, short, int, long and the respective wrappers of the primitive types; 被注解的元素必须是一个数字,其值必须在可接受的范围内
@Past 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; 被注解的元素必须是一个过去的日期
@PastOrPresent 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;
@Future 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; 被注解的元素必须是一个将来的日期
@FutureOrPresent 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; 检查标注的日期是现在还是将来
@Pattern(value) 被注解的元素必须符合指定的正则表达式
@Email 被注解的元素必须是电子邮箱地址
@Length(min=,max=) CharSequence 被注解的字符串的大小必须在指定的范围内
@Range(min=,max=) BigDecimal, BigInteger, CharSequence ,byte, short, int, long and the respective wrappers of the primitive types; 被注解的元素必须在可接受的范围内
@Null Any type 被注解的元素必须为null
@NotNull Any type 被注解的元素必须不为null
@NotEmpty CharSequence, Collection, Map and arrays 被注解的元素必须非空
@NotBlank CharSequence 被注解的元素必须非空
@URL(protocol=,host=,port=,regexp=,flags=) 被注解的元素必须是一个有效的URL
@CreditCardNumber 被注解的字符串必须通过Luhn校验算法,银行卡,信用卡等号码一般都用Luhn计算合法性
@ScriptAssert(lang=,script=,alias=) 要有Java Scripting API
@SafeHtml(whitelistType,additionTags=) classpath中要有jsoup包
@Negative BigDecimal, BigInteger, CharSequence ,byte, short, int, long and the respective wrappers of the primitive types; 负数
@NegativeOrZero BigDecimal, BigInteger, CharSequence ,byte, short, int, long and the respective wrappers of the primitive types; 负数和0
@Positive BigDecimal, BigInteger, CharSequence ,byte, short, int, long and the respective wrappers of the primitive types; 正数
@PositiveOrZero BigDecimal, BigInteger, CharSequence ,byte, short, int, long and the respective wrappers of the primitive types; 正数和0
@DurationMax(days=, hours=, minutes=, seconds=, millis=, nanos=, inclusive=) java.time.Duration 前段参数传入参数以PT开头,如PT1H30M表示1小时30分钟的Duration值
@DurationMin(days=, hours=, minutes=, seconds=, millis=, nanos=, inclusive=) java.time.Duration
  • @NotNull,@NotEmpty,@NotBlank三个注解的区别
    • @NotNull:任何对象的value不能为null.
    • @NotEmpty:集合对象元素不为0,即集合不为空,也可以用于字符串不为null.
    • @NotBlank: 只能用于字符串不为null,并且字符串trim()以后length要大于0.

实战

RequestBody参数校验

  • POST,PUT请求一般会使用RequestBody传递参数,这种情况下,后端使用DTO对象进行接收。只要给DTO对象加上@Validated注解就能实现自动参数校验。
  • 如果校验失败,会抛出MethodArgumentNotValidException异常,Spring默认会将其转为400(Bad Request)请求。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package com.holelin.sundry.vo.request;

import lombok.Data;
import org.hibernate.validator.constraints.Length;

import javax.validation.constraints.NotNull;

/**
* @Description:
* @Author: HoleLin
* @CreateDate: 2022/7/18 22:55
* @UpdateUser: HoleLin
* @UpdateDate: 2022/7/18 22:55
* @UpdateRemark: 修改内容
* @Version: 1.0
*/
@Data
public class ValidTestOne {

private Long userId;

@NotNull
@Length(min = 2, max = 10)
private String userName;

@NotNull
@Length(min = 6, max = 20)
private String account;

@NotNull
@Length(min = 6, max = 20)
private String password;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package com.holelin.sundry.controller;

import com.holelin.sundry.vo.request.ValidTestOne;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
* @Description:
* @Author: HoleLin
* @CreateDate: 2022/7/18 22:54
* @UpdateUser: HoleLin
* @UpdateDate: 2022/7/18 22:54
* @UpdateRemark: 修改内容
* @Version: 1.0
*/
@RestController
@RequestMapping("/valid-test")
public class ValidTestController {

@RequestMapping("/test1")
public void test1(@RequestBody @Validated ValidTestOne request){

}
}

RequestParam/PathVariable参数校验

  • GET请求一般会使用RequestParam/PathVariable传参。如果参数比较多(比如超过6个),还是推荐使用DTO对象接收。
  • 否则,推荐将一个个参数平铺到方法入参中。在这种情况下,必须在Controller类上标注@Validated注解,并在入参上声明约束注解(如@Min等)。如果校验失败,会抛出ConstraintViolationException异常。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package com.holelin.sundry.controller;

import com.holelin.sundry.vo.request.ValidTestOne;
import org.hibernate.validator.constraints.Length;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;

/**
* @Description:
* @Author: HoleLin
* @CreateDate: 2022/7/18 22:54
* @UpdateUser: HoleLin
* @UpdateDate: 2022/7/18 22:54
* @UpdateRemark: 修改内容
* @Version: 1.0
*/
@RestController
@Validated
@RequestMapping("/valid-test")
public class ValidTestController {
/**
* 路径变量
*
* @param userId
*/
@GetMapping("/{userId}")
public void test2(@PathVariable("userId") @Min(10000000000000000L) Long userId) {
}


/**
* RequestParam
* @param name
*/
@GetMapping("/test3")
public void test3(@RequestParam("name") @Length(min = 1, max = 10) @NotNull String name) {
}
}

统一异常处理

  • 如果校验失败,会抛出MethodArgumentNotValidException或者ConstraintViolationException异常。在实际项目开发中,通常会用统一异常处理来返回一个更友好的提示。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
package com.holelin.gb.advice;

import com.alibaba.fastjson.JSON;
import com.holelin.gb.exception.ResultException;
import com.holelin.gb.reps.Result;
import com.holelin.gb.reps.ResultCode;
import com.holelin.gb.reps.ResultUtils;
import lombok.extern.slf4j.Slf4j;
import org.hibernate.exception.ConstraintViolationException;
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;

import javax.servlet.http.HttpServletRequest;
import java.util.List;
import java.util.NoSuchElementException;

/**
* ClassName: WebExceptionHandler
* web异常处理类
*
* @author HoleLin
* @version 1.0
* @date 2019/10/5
*/
@RestControllerAdvice
@ResponseBody
@Slf4j
public class WebExceptionHandler implements ResponseBodyAdvice {
private ThreadLocal<Object> modelHolder = new ThreadLocal<>();

@ExceptionHandler(MethodArgumentNotValidException.class)
public Result handleIllegalParamException(MethodArgumentNotValidException e) {
BindingResult bindingResult = e.getBindingResult();
StringBuilder sb = new StringBuilder("校验失败:");
// Map<String, String> errorMap = new HashMap<>(8);
// 将校验错误字段和错误信息提取到map中
// bindingResult.getFieldErrors().forEach(item -> errorMap.put(item.getField(),item.getDefaultMessage()));
for (FieldError fieldError : bindingResult.getFieldErrors()) {
sb.append(fieldError.getField()).append(":").append(fieldError.getDefaultMessage()).append(",");
}
return ResultUtils.warn(ResultCode.PARAMETER_ERROR, sb.toString());
}

@ExceptionHandler({ConstraintViolationException.class})
public Result handleConstraintViolationException(ConstraintViolationException ex) {
return ResultUtils.warn(ResultCode.PARAMETER_ERROR,ex.getMessage());
}

@ExceptionHandler(ResultException.class)
public Result handleResultException(ResultException e, HttpServletRequest request) {
log.debug("uri={} | requestBody={}", request.getRequestURI(),
JSON.toJSONString(modelHolder.get()));
return ResultUtils.warn(e.getResultCode());
}

@ExceptionHandler(IllegalArgumentException.class)
public Result handleIllegalArgumentException(IllegalArgumentException e, HttpServletRequest request) {
log.error("uri={} | requestBody={}", request.getRequestURI(),
JSON.toJSONString(modelHolder.get()), e);
return ResultUtils.warn(ResultCode.PARAMETER_ERROR);
}
@ExceptionHandler(NoSuchElementException.class)
public Result handleNoSuchElementException(NoSuchElementException e, HttpServletRequest request) {
log.error("uri={} | requestBody={}", request.getRequestURI(),
JSON.toJSONString(modelHolder.get()), e);
return ResultUtils.warn(ResultCode.RECORD_EXIST);
}
@InitBinder
public void initBinder(WebDataBinder webDataBinder) {
// ModelHolder 初始化
modelHolder.set(webDataBinder.getTarget());
}
@Override
public boolean supports(MethodParameter methodParameter, Class aClass) {
return true;
}

@Override
public Object beforeBodyWrite(Object body, MethodParameter methodParameter,
MediaType mediaType, Class aClass,
ServerHttpRequest serverHttpRequest,
ServerHttpResponse serverHttpResponse) {
// ModelHolder 清理
modelHolder.remove();
return body;
}
}

分组校验

  • 在实际项目中,可能多个方法需要使用同一个DTO类来接收参数,而不同方法的校验规则很可能是不一样的。这个时候,简单地在DTO类的字段上加约束注解无法解决这个问题。因此,Spring-Validation支持了分组校验的功能,专门用来解决这类问题。
  • 使用同一个DTO类来校验不同场景的参数校验,常见的如新增和更新.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package com.holelin.sundry.vo.request;

import lombok.Data;
import org.hibernate.validator.constraints.Length;

import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;

/**
* @Description:
* @Author: HoleLin
* @CreateDate: 2022/7/18 22:55
* @UpdateUser: HoleLin
* @UpdateDate: 2022/7/18 22:55
* @UpdateRemark: 修改内容
* @Version: 1.0
*/
@Data
public class ValidTestOne {

@Min(value = 10000000000000000L, groups = Update.class)
private Long userId;

@NotNull(groups = {Save.class, Update.class})
@Length(min = 2, max = 10, groups = {Save.class, Update.class})
private String userName;

@NotNull(groups = {Save.class, Update.class})
@Length(min = 6, max = 20, groups = {Save.class, Update.class})
private String account;

@NotNull(groups = {Save.class, Update.class})
@Length(min = 6, max = 20, groups = {Save.class, Update.class})
private String password;

/**
* 保存的时候校验分组
*/
public interface Save {
}

/**
* 更新的时候校验分组
*/
public interface Update {
}
}

1
2
3
4
5
6
7
8
9
@PostMapping("/save")
public void save(@RequestBody @Validated(ValidTestOne.Save.class) ValidTestOne request) {
// 校验通过,才会执行业务逻辑处理
}

@PostMapping("/update")
public void update(@RequestBody @Validated(ValidTestOne.Update.class) ValidTestOne request) {
// 校验通过,才会执行业务逻辑处理
}

嵌套校验

  • DTO类里面的字段都是基本数据类型和String类型。但是实际场景中,有可能某个字段也是一个对象,这种情况先,可以使用嵌套校验。
  • 需要注意的是,此时DTO类的对应字段必须标记@Valid注解。
  • 嵌套校验可以结合分组校验一起使用。还有就是嵌套集合校验会对集合里面的每一项都进行校验,例如List<Job>字段会对这个list里面的每一个Job对象都进行校验
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@Data
public class UserDTO {

@Min(value = 10000000000000000L, groups = Update.class)
private Long userId;

@NotNull(groups = {Save.class, Update.class})
@Length(min = 2, max = 10, groups = {Save.class, Update.class})
private String userName;

@NotNull(groups = {Save.class, Update.class})
@Length(min = 6, max = 20, groups = {Save.class, Update.class})
private String account;

@NotNull(groups = {Save.class, Update.class})
@Length(min = 6, max = 20, groups = {Save.class, Update.class})
private String password;

@NotNull(groups = {Save.class, Update.class})
// 校验复制参数
@Valid
private Job job;

@Data
public static class Job {

@Min(value = 1, groups = Update.class)
private Long jobId;

@NotNull(groups = {Save.class, Update.class})
@Length(min = 2, max = 10, groups = {Save.class, Update.class})
private String jobName;

@NotNull(groups = {Save.class, Update.class})
@Length(min = 2, max = 10, groups = {Save.class, Update.class})
private String position;
}

/**
* 保存的时候校验分组
*/
public interface Save {
}

/**
* 更新的时候校验分组
*/
public interface Update {
}
}

源码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
入口:org.springframework.web.method.support.InvocableHandlerMethod#invokeForRequest
...中间略
org.hibernate.validator.internal.metadata.BeanMetaDataManagerImpl#getBeanMetaData
-->
org.hibernate.validator.internal.metadata.BeanMetaDataManagerImpl#createBeanMetaData
-->
org.hibernate.validator.internal.metadata.BeanMetaDataManagerImpl#getBeanConfigurationForHierarchy
-->
org.hibernate.validator.internal.metadata.provider.AnnotationMetaDataProvider#getBeanConfiguration
-->
retrieveBeanConfiguration
-->
org.hibernate.validator.internal.metadata.provider.AnnotationMetaDataProvider#getFieldMetaData
-->
org.hibernate.validator.internal.metadata.provider.AnnotationMetaDataProvider#findPropertyMetaData
-->
org.hibernate.validator.internal.metadata.provider.AnnotationMetaDataProvider#findCascadingMetaData(org.hibernate.validator.internal.properties.javabean.JavaBeanField)
1
2
3
4
5
6
7
	private CascadingMetaDataBuilder getCascadingMetaData(JavaBeanAnnotatedElement annotatedElement,
Map<TypeVariable<?>, CascadingMetaDataBuilder> containerElementTypesCascadingMetaData) {
// 在组装 BeanMetaData 过程中,会根据成员字段是否标记了 @Valid 来决定(记录)这个
字段以后是否做级联校验
return CascadingMetaDataBuilder.annotatedObject( annotatedElement.getType(), annotatedElement.isAnnotationPresent( Valid.class ),
containerElementTypesCascadingMetaData, getGroupConversions( annotatedElement.getAnnotatedType() ) );
}

集合校验

  • 如果请求体直接传递了json数组给后台,并希望对数组中的每一项都进行参数校验。此时,如果我们直接使用java.util.Collection下的list或者set来接收数据,参数校验并不会生效!我们可以使用自定义list集合来接收参数:包装List类型,并声明@Valid注解
  • @Delegate注解受lombok版本限制,1.18.6以上版本可支持.如果校验不通过,会抛出NotReadablePropertyException,同样可以使用统一异常进行处理。
1
2
3
4
5
6
7
8
9
10
11
12
public class ValidationList<E> implements List<E> {

@Delegate // @Delegate是lombok注解
@Valid // 一定要加@Valid注解
public List<E> list = new ArrayList<>();

// 一定要记得重写toString方法
@Override
public String toString() {
return list.toString();
}
}
1
2
3
4
5
@PostMapping("/saveList")
public Result saveList(@RequestBody @Validated(UserDTO.Save.class) ValidationList<UserDTO> userList) {
// 校验通过,才会执行业务逻辑处理
return Result.ok();
}

自定义校验

  • 业务需求总是比框架提供的这些简单校验要复杂的多,我们可以自定义校验来满足我们的需求。

  • 自定义Spring Validation非常简单,假设我们自定义加密id(由数字或者a-f的字母组成,32-256长度)校验,主要分为两步:

    • 自定义约束注解

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      @Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
      @Retention(RUNTIME)
      @Documented
      @Constraint(validatedBy = {EncryptIdValidator.class})
      public @interface EncryptId {

      // 默认错误消息
      String message() default "加密id格式错误";

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

      // 负载
      Class<? extends Payload>[] payload() default {};
      }
    • 实现ConstraintValidator接口编写约束校验器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public class EncryptIdValidator implements ConstraintValidator<EncryptId, String> {

    private static final Pattern PATTERN = Pattern.compile("^[a-f\\d]{32,256}$");

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
    // 不为null才进行校验
    if (value != null) {
    Matcher matcher = PATTERN.matcher(value);
    return matcher.find();
    }
    return true;
    }
    }
校验枚举类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* 校验请求参数中的枚举类
*/
@Target(ElementType.FIELD) //METHOD, CONSTRUCTOR, etc.
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = EnumValidator.class)
public @interface ValidateEnum {
String message() default "invalidParam";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
Class<? extends Enum<?>> targetClassType();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import org.apache.commons.lang3.StringUtils;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.util.EnumSet;
import java.util.Set;
import java.util.stream.Collectors;

public class EnumValidator implements ConstraintValidator<ValidateEnum, String> {
private Set<String> allowedValues;

@SuppressWarnings({"unchecked", "rawtypes"})
@Override
public void initialize(ValidateEnum targetEnum) {
Class<? extends Enum> enumSelected = targetEnum.targetClassType();
allowedValues = (Set<String>) EnumSet.allOf(enumSelected).stream().map(e -> ((Enum<? extends Enum<?>>) e).name())
.collect(Collectors.toSet());
}

@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (StringUtils.isEmpty(value)) {
return true;
}
return allowedValues.contains(value);
}
}

编程式校验

  • 在某些情况下,我们可能希望以编程方式调用验证。这个时候可以注入javax.validation.Validator对象,然后再调用其api。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Autowired
private javax.validation.Validator globalValidator;

// 编程式校验
@PostMapping("/saveWithCodingValidate")
public Result saveWithCodingValidate(@RequestBody UserDTO userDTO) {
Set<ConstraintViolation<UserDTO>> validate = globalValidator.validate(userDTO, UserDTO.Save.class);
// 如果校验通过,validate为空;否则,validate包含未校验通过项
if (validate.isEmpty()) {
// 校验通过,才会执行业务逻辑处理

} else {
for (ConstraintViolation<UserDTO> userDTOConstraintViolation : validate) {
// 校验失败,做其它逻辑
System.out.println(userDTOConstraintViolation);
}
}
return Result.ok();
}

快速失败(Fail Fast)

  • Spring Validation默认会校验完所有字段,然后才抛出异常。可以通过一些简单的配置,开启Fali Fast模式,一旦校验失败就立即返回。
1
2
3
4
5
6
7
8
9
@Bean
public Validator validator() {
ValidatorFactory validatorFactory = Validation.byProvider(HibernateValidator.class)
.configure()
// 快速失败模式
.failFast(true)
.buildValidatorFactory();
return validatorFactory.getValidator();
}

@Valid@Validated区别

区别 @Valid @Validated
提供者 JSR-303规范 Spring
是否支持分组 不支持 支持
标注位置 ElementType.METHOD, ElementType.FIELD, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE ElementType.TYPE, ElementType.METHOD, ElementType.PARAMETER
嵌套校验 支持 不支持

实现原理

  • Spring-MVC中,RequestResponseBodyMethodProcessor是用于解析@RequestBody标注的参数以及处理@ResponseBody标注方法的返回值的.显然,执行参数校验的逻辑肯定就在解析参数的方法resolveArgument()中:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    /**
    * Throws MethodArgumentNotValidException if validation fails.
    * @throws HttpMessageNotReadableException if {@link RequestBody#required()}
    * is {@code true} and there is no body content or if there is no suitable
    * converter to read the content with.
    */
    @Override
    public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
    NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {

    parameter = parameter.nestedIfOptional();
    // 将请求封装到对应的DTO对象中
    Object arg = readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType());
    String name = Conventions.getVariableNameForParameter(parameter);

    if (binderFactory != null) {
    WebDataBinder binder = binderFactory.createBinder(webRequest, arg, name);
    if (arg != null) {
    // 执行数据校验
    validateIfApplicable(binder, parameter);
    if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) {
    throw new MethodArgumentNotValidException(parameter, binder.getBindingResult());
    }
    }
    if (mavContainer != null) {
    mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX + name, binder.getBindingResult());
    }
    }

    return adaptArgumentIfNecessary(arg, parameter);
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
      
    /**
    * Validate the binding target if applicable.
    * <p>The default implementation checks for {@code @javax.validation.Valid},
    * Spring's {@link org.springframework.validation.annotation.Validated},
    * and custom annotations whose name starts with "Valid".
    * @param binder the DataBinder to be used
    * @param parameter the method parameter descriptor
    * @since 4.1.5
    * @see #isBindExceptionRequired
    */
    protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {
    // 获取参数注解,比如@RequestBody、@Valid、@Validated
    Annotation[] annotations = parameter.getParameterAnnotations();
    for (Annotation ann : annotations) {
    Object[] validationHints = ValidationAnnotationUtils.determineValidationHints(ann);
    if (validationHints != null) {
    binder.validate(validationHints);
    break;
    }
    }
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    /**
    * Determine any validation hints by the given annotation.
    * <p>This implementation checks for {@code @javax.validation.Valid},
    * Spring's {@link org.springframework.validation.annotation.Validated},
    * and custom annotations whose name starts with "Valid".
    * @param ann the annotation (potentially a validation annotation)
    * @return the validation hints to apply (possibly an empty array),
    * or {@code null} if this annotation does not trigger any validation
    */
    @Nullable
    public static Object[] determineValidationHints(Annotation ann) {
    Class<? extends Annotation> annotationType = ann.annotationType();
    String annotationName = annotationType.getName();
    if ("javax.validation.Valid".equals(annotationName)) {
    return EMPTY_OBJECT_ARRAY;
    }
    Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class);
    if (validatedAnn != null) {
    Object hints = validatedAnn.value();
    return convertValidationHints(hints);
    }
    if (annotationType.getSimpleName().startsWith("Valid")) {
    Object hints = AnnotationUtils.getValue(ann);
    return convertValidationHints(hints);
    }
    return null;
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    /**
    * Invoke the specified Validators, if any, with the given validation hints.
    * <p>Note: Validation hints may get ignored by the actual target Validator.
    * @param validationHints one or more hint objects to be passed to a {@link SmartValidator}
    * @since 3.1
    * @see #setValidator(Validator)
    * @see SmartValidator#validate(Object, Errors, Object...)
    */
    public void validate(Object... validationHints) {
    Object target = getTarget();
    Assert.state(target != null, "No target to validate");
    BindingResult bindingResult = getBindingResult();
    // Call each validator with the same binding result
    for (Validator validator : getValidators()) {
    if (!ObjectUtils.isEmpty(validationHints) && validator instanceof SmartValidator) {
    ((SmartValidator) validator).validate(target, bindingResult, validationHints);
    }
    else if (validator != null) {
    validator.validate(target, bindingResult);
    }
    }
    }

方法级别的参数校验实现原理

  • 上面提到的将参数一个个平铺到方法参数中,然后在每个参数前面声明约束注解的校验方式,就是方法级别的参数校验。

    实际上,这种方式可用于任何Spring Bean的方法上,比如Controller/Service等。其底层实现原理就是AOP,具体来说是通过MethodValidationPostProcessor动态注册AOP切面,然后使用MethodValidationInterceptor对切点方法织入增强。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    public class MethodValidationPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor
    implements InitializingBean {

    private Class<? extends Annotation> validatedAnnotationType = Validated.class;

    @Nullable
    private Validator validator;
    // ...略

    @Override
    public void afterPropertiesSet() {
    //为所有@Validated标注的Bean创建切面
    Pointcut pointcut = new AnnotationMatchingPointcut(this.validatedAnnotationType, true);
    // 创建Advisor进行增强
    this.advisor = new DefaultPointcutAdvisor(pointcut, createMethodValidationAdvice(this.validator));
    }

    /**
    * Create AOP advice for method validation purposes, to be applied
    * with a pointcut for the specified 'validated' annotation.
    * @param validator the JSR-303 Validator to delegate to
    * @return the interceptor to use (typically, but not necessarily,
    * a {@link MethodValidationInterceptor} or subclass thereof)
    * @since 4.2
    */
    protected Advice createMethodValidationAdvice(@Nullable Validator validator) {
    return (validator != null ? new MethodValidationInterceptor(validator) : new MethodValidationInterceptor());
    }

    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    public class MethodValidationInterceptor implements MethodInterceptor {

    private final Validator validator;

    @Override
    @Nullable
    public Object invoke(MethodInvocation invocation) throws Throwable {
    // Avoid Validator invocation on FactoryBean.getObjectType/isSingleton
    // 无需增强的方法,直接跳过
    if (isFactoryBeanMetadataMethod(invocation.getMethod())) {
    return invocation.proceed();
    }
    // 获取分组信息
    Class<?>[] groups = determineValidationGroups(invocation);

    // Standard Bean Validation 1.1 API
    ExecutableValidator execVal = this.validator.forExecutables();
    Method methodToValidate = invocation.getMethod();
    Set<ConstraintViolation<Object>> result;

    Object target = invocation.getThis();
    Assert.state(target != null, "Target must not be null");

    try {

    result = execVal.validateParameters(target, methodToValidate, invocation.getArguments(), groups);
    }
    catch (IllegalArgumentException ex) {
    // Probably a generic type mismatch between interface and impl as reported in SPR-12237 / HV-1011
    // Let's try to find the bridged method on the implementation class...
    methodToValidate = BridgeMethodResolver.findBridgedMethod(
    ClassUtils.getMostSpecificMethod(invocation.getMethod(), target.getClass()));
    result = execVal.validateParameters(target, methodToValidate, invocation.getArguments(), groups);
    }
    if (!result.isEmpty()) {
    throw new ConstraintViolationException(result);
    }

    Object returnValue = invocation.proceed();

    result = execVal.validateReturnValue(target, methodToValidate, returnValue, groups);
    if (!result.isEmpty()) {
    throw new ConstraintViolationException(result);
    }

    return returnValue;
    }

    private boolean isFactoryBeanMetadataMethod(Method method) {
    Class<?> clazz = method.getDeclaringClass();

    // Call from interface-based proxy handle, allowing for an efficient check?
    if (clazz.isInterface()) {
    return ((clazz == FactoryBean.class || clazz == SmartFactoryBean.class) &&
    !method.getName().equals("getObject"));
    }

    // Call from CGLIB proxy handle, potentially implementing a FactoryBean method?
    Class<?> factoryBeanType = null;
    if (SmartFactoryBean.class.isAssignableFrom(clazz)) {
    factoryBeanType = SmartFactoryBean.class;
    }
    else if (FactoryBean.class.isAssignableFrom(clazz)) {
    factoryBeanType = FactoryBean.class;
    }
    return (factoryBeanType != null && !method.getName().equals("getObject") &&
    ClassUtils.hasMethod(factoryBeanType, method));
    }

    /**
    * Determine the validation groups to validate against for the given method invocation.
    * <p>Default are the validation groups as specified in the {@link Validated} annotation
    * on the containing target class of the method.
    * @param invocation the current MethodInvocation
    * @return the applicable validation groups as a Class array
    */
    protected Class<?>[] determineValidationGroups(MethodInvocation invocation) {
    Validated validatedAnn = AnnotationUtils.findAnnotation(invocation.getMethod(), Validated.class);
    if (validatedAnn == null) {
    Object target = invocation.getThis();
    Assert.state(target != null, "Target must not be null");
    validatedAnn = AnnotationUtils.findAnnotation(target.getClass(), Validated.class);
    }
    return (validatedAnn != null ? validatedAnn.value() : new Class<?>[0]);
    }
    }