SpringBoot参数校验

568 阅读10分钟

1. 参数校验

1.1 Spring参数校验快速开始

1.1.1 导入依赖

 <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-validation</artifactId>
  </dependency>

1.1.2 使用

参数校验使用分三步

  • 在Controller层将要校验的参数上标注@Vaild注解
  • 在要校验对象属性加上具体的约束
  • 对异常信息进行统一处理

例子

  1. 我要校验登录的用户名和密码不能为空,那么我就在参数loginUserDTO前加上@valid注解。

    @RestController
    public class testVaildController {
        @Resource
        public LoginService loginService;
        @PostMapping("/login")
        public ResponseResult login(@RequestBody @Valid LoginUserDTO loginUserDTO){
            return ResponseResult.success(loginService.login(loginUserDTO.getUserName(),loginUserDTO.getPassword()));
        }
    }
    
  2. 然后在LoginUserDTO类中加上具体的约束

    public class LoginUserDTO {
       @NotBlank(message = "用户名不能为空")
       private String userName = "";
       @NotBlank(message = "密码不能为空")
       private String password = "";
    
       
       public String getUserName() {
          return this.userName;
       }
    
       public void setUserName(String userName) {
          this.userName = userName;
       }
    
       public String getPassword() {
          return this.password;
       }
    
       public void setPassword(String password) {
          this.password = password;
       }
    }
    

    这样参数校验就可以生效了,测试接口结果如下:

    {
        "timestamp": "2024-05-24T09:15:58.364+00:00",
        "status": 400,
        "error": "Bad Request",
        "path": "/login"
    }
    
  3. 异常捕获

    为了更有效的传递信息以及进行结果统一返回日志记录等操作,我们通常会定义异常处理器来统一处理参数校验的异常(BindException是什么见下文异常信息)

    @RestControllerAdvice
    public class GlobalExceptionHandler {
        Logger logger = LoggerFactory.getLogger("GlobalExceptionHandler");
        //抛出的异常是MethodArgumentNotValidException,BindException是它的父类
        @ExceptionHandler(BindException.class)
        public ResponseResult bindExceptionHandler(BindException e) {
            //具体返回什么据情况而定,这里直接返回所有defalutMessage(写在约束注解里的信息)了
            List<String> list = new ArrayList<>();
            BindingResult bindingResult = e.getBindingResult();
            for (ObjectError objectError : bindingResult.getAllErrors()) {
                FieldError fieldError = (FieldError) objectError;
                //这里直接使用默认appender打印日志在控制台了,真实线上可以同步到本地、ELK等地方
                logger.error("参数 {} ,{} 校验错误:{}", fieldError.getField(), fieldError.getRejectedValue(), fieldError.getDefaultMessage());
                list.add(fieldError.getDefaultMessage());
            }
            return ResponseResult.error(AppHttpCodeEnum.SYSTEM_ERROR.getCode(),list.toString());
        }
    }
    

测试数据与结果如下:

{
    "userName": "",
    "password": ""
}

{
    "data": null,
    "code": 500,
    "msg": "[用户名不能为空, 密码不能为空]"
}

1.2 常用的校验注解

  1. 控制检查

    注解说明
    @NotBlank用于字符串,字符串不能为null也不能为空
    @NotEmpty字符串同上,集合不能为空,必须有元素
    @NotNull不能为null
    @Null必须为null
  2. 数值检查

    注解说明
    @DecimalMax(value)被标注元素必须是数字,必须小于等于value
    @DecimalMin(value)被标注元素必须是数字,必须大于等于value
    @Digits(integer,fraction)被标注的元素必须为数字,其值的整数部分精度为 integer,小数部分精度为 fraction
    @Positive被标注的元素必须为正数
    @PositiveOrZero被标注的元素必须为正数或 0
    @Max(value)被标注的元素必须小于等于指定的值
    @Min(value)被标注的元素必须大于等于指定的值
    @negative被标注的元素必须为负数
    NegativeOrZero被标注的元素必须为负数或 0
  3. Boolean检查(不太用的到的样子)

    注解说明
    @AssertFalse被标注的元素必须值为 false
    @AssertTrue被标注的元素必须值为 true
  4. 长度检查

    注解说明
    @Size(min,max)被标注的元素长度必须在 minmax 之间,可以是 String、Collection、Map、数组
  5. 日期检查

    注解说明
    @Future被标注的元素必须是一个将来的日期
    @FutureOrPresent被标注的元素必须是现在或者将来的日期
    @Past被标注的元素必须是一个过去的日期
    @PastOrPresent被标注的元素必须是现在或者过去的日期
  6. 其他

    注解说明
    @Email被标注的元素必须是电子邮箱地址
    @Pattern(regexp)被标注的元素必须符合正则表达式

1.3 @Vaild与@Vaildated

在第一步加注解的时候,可以明显的看到还有一个可能也是参数校验的注解@Validated,把@Vaild换成@Validated,我们惊奇的发现参数校验也能正常的工作,接下来我们就来看看这两注解之间的联系与区别。

1.3.1 来源

一个是Spring的,一个是javax,了解过@Autowired@Resource区别的老哥可能很快就反应过来了,就像这两个注解一样。一个是JSR规范的,一个是Spring规范的

import org.springframework.validation.annotation.Validated;
import javax.validation.Valid;

1.3.2 定义区别


@Target({ElementType.TYPE, ElementType.METHOD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Validated {
    Class<?>[] value() default {};
}

@Target({ElementType.METHOD, ElementType.FIELD, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Valid {
}

差异主要有两点

  • 能标注的地方

    可以看到@Valid注解除了能标注在类、方法、方法参数上还能标注在类里面属性、任何使用类型的地方

  • 属性

    Validated注解里面比Valid多了一个value

接下来我们来结合定义区别看看它们的功能差异

1.3.3 功能差异

嵌套校验

上文校验的时候我们在类属性上加上相关的限制注解就可以了,但是如果属性是一个类的实例,我们想校验这个作为属性的实例里面的字段,我们就只能使用@Vaild加在属性上,标注这是需要校验的属性。@Validated不能标注在属性上,自然也就不支持嵌套校验

@PostMapping("/buy")
    public ResponseResult buy(@RequestBody @Valid ShoppingCart shoppingCart){
        return ResponseResult.success(payService.pay(shoppingCart.getUserId(),shoppingCart.getItemList()));
    }
//购物车类
public class ShoppingCart {
    @Positive(message = "用户id必须大于0")
    private Long userId;
    @NotEmpty(message = "不能为空")
    @Valid
    private List<Item> itemList;

    public Long getUserId() {
        return userId;
    }

    public void setUserId(Long userId) {
        this.userId = userId;
    }

    public List<Item> getItemList() {
        return itemList;
    }

    public void setItemList(List<Item> itemList) {
        this.itemList = itemList;
    }
}
//物品类
public class Item {
    @Positive(message = "价格必须大于0")
    private BigDecimal price;
    @NotBlank(message = "物品名不能为空")
    private String name;
    @Positive(message = "数量必须大于0")
    private int number;

    public BigDecimal getPrice() {
        return price;
    }

    public void setPrice(BigDecimal price) {
        this.price = price;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getNumber() {
        return number;
    }

    public void setNumber(int number) {
        this.number = number;
    }
}
//...服务自行模拟

结果

{
    "userId": "1",
    "itemList":[
        {
        "price": "142.0",
        "name": "小风车",
        "number": 1
        },
        {
        "price": "-2",
        "name": "",
        "number": -1
        }
    ]
}

{
    "data": null,
    "code": 500,
    "msg": "[数量必须大于0, 价格必须大于0, 物品名不能为空]"
}

可以看到校验成功了,当然这里的异常捕获逻辑比较简单,具体生产环境里可以将返回具体的校验信息,进行更清晰的信息提示。

杂记

  • @Valid作用域比较广还可以标注在许多意向不到的位置

    //比如,(可以自行观察一下运行结果,都是可以正常校验的)
    private  List<@Valid Item> itemList;
    private @Valid List< Item> itemList;
    
  • @Valid@Validated可以混用

    //controller里面注解改为@Validated依然可以生效 
    @PostMapping("/buy")
        public  ResponseResult  buy(@RequestBody @Validated ShoppingCart  shoppingCart){
            return ResponseResult.success(payService.pay(shoppingCart.getUserId(),shoppingCart.getItemList()));
        }
    
  • 消息的顺序不固定

    {
        "data": null,
        "code": 500,
        "msg": "[数量必须大于0, 价格必须大于0, 物品名不能为空]" //这三条消息打印的顺序是不固定的
    }
    

分组校验

上面看注解代码的时候可以明显注意到@Validated注解里面有个value属性。@Valid没有value属性,自然也就不支持分组校验

@Target({ElementType.TYPE, ElementType.METHOD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Validated {
    //传入分组,默认是Defalut.class
    Class<?>[] value() default {};
}
//Default是个接口,只起到标记作用
package javax.validation.groups;

public interface Default {
}

我们可以自定义分组来指定需要校验的时机。

//定义Group类,里面两个接口用作校验(也可以用类,不过和原生的贴合一点比较好)
public class Group {
   public interface GroupTest1{}
   public interface GroupTest2{}
}

测试

public class LoginUserDTO {
    //只有分组属于这两个才校验
   @NotBlank(message = "账户不能为空",groups = {Group.GroupTest1.class, Default.class})
   private String userName = "";
   @NotBlank(message = "密码不能为空")
   private String password = "";

   
   public String getUserName() {
      return this.userName;
   }

   public void setUserName(String userName) {
      this.userName = userName;
   }

   public String getPassword() {
      return this.password;
   }

   public void setPassword(String password) {
      this.password = password;
   }
}
//controller
	@PostMapping("/login")
    public ResponseResult login(@RequestBody @Validated(value = {Group.GroupTest2.class}) LoginUserDTO loginUserDTO){
        return ResponseResult.success(loginService.login(loginUserDTO.getUserName(),loginUserDTO.getPassword()));
    }

结果

//不属于分组,不校验userName(其他情况自行尝试
{
    "userName":"",
    "password":"123456"
}
{
    "data": "登录成功",
    "code": 200,
    "msg": "操作成功"
}

2. 深入理解@Valid与@Validated

因为@Valid@Validated能混合使用,我们可以大胆猜测一下,一定有一个Adapter来承担两者的适配工作,搜搜Valid相关的Adapter,还真有一个。

2.1 SpringValidtorAdapter

public class SpringValidatorAdapter implements SmartValidator, javax.validation.Validator {
    	@Nullable
	private javax.validation.Validator targetValidator;
    //没有返回值,重写Spring中Validator的方法
	@Override
	public void validate(Object target, Errors errors, Object... validationHints) {
		if (this.targetValidator != null) {
			processConstraintViolations(
					this.targetValidator.validate(target, asValidationGroups(validationHints)), errors);
		}
	}
    //有返回值,重写的javax中Validator的方法
    @Override
	public <T> Set<ConstraintViolation<T>> validate(T object, Class<?>... groups) {
		Assert.state(this.targetValidator != null, "No target Validator set");
		return this.targetValidator.validate(object, groups);
	}
    
}

SpringValidtorAdapter的结构

image-20240602190311638.png

根据上面结构图和代码,可以看到SpringValidatorAdapter实现了两个不同的validator接口,针对其中核心方法validate()进行了返回值的适配(有点像Runnable Callable之间的适配)

2.2 具体校验器的获取实现

(建议直接跳到异常信息,下面写的不是很清晰)

2.2.1 ValidatorFactory

public interface ValidatorFactory extends AutoCloseable {

	// 显然,这个接口是最为重要的
	Validator getValidator();
	// 定义一个新的ValidatorContext验证器上下文,并且和Validator关联上
	ValidatorContext usingContext();
    
	MessageInterpolator getMessageInterpolator();
	TraversableResolver getTraversableResolver();
	ConstraintValidatorFactory getConstraintValidatorFactory();
	ParameterNameProvider getParameterNameProvider();
	ClockProvider getClockProvider();

	public <T> T unwrap(Class<T> type);
	// 复写AutoCloseable的方法
	@Override
	public void close();

}

ValidatorFactory的实现可以分成两部分,hibernateSpring实现。

LocalValidatorFactoryBean

LocalValidatorFactoryBean不仅是ValidatorFactory实现,还是SpringValidAdapter子类

public class LocalValidatorFactoryBean extends SpringValidatorAdapter
		implements ValidatorFactory, ApplicationContextAware, InitializingBean, DisposableBean {
    
    //重写的InitializingBean中的方法,Bean初始化时会执行
    @Override
	@SuppressWarnings({"rawtypes", "unchecked"})
	public void afterPropertiesSet() {
		Configuration<?> configuration;
        
        ...
      
        //根据配置创建工厂,并从工厂里面拿到Validator
		try {
			this.validatorFactory = configuration.buildValidatorFactory();
			setTargetValidator(this.validatorFactory.getValidator());
		}
		finally {
			closeMappingStreams(mappingStreams);
		}
	}
}

ValidatorFactoryImpl

在最开始我们导入了校验starter,导入的就有org.hibernate.validator相关的类

package org.hibernate.validator.internal.engine;

public class ValidatorFactoryImpl implements HibernateValidatorFactory {
    @Override
	public Validator getValidator() {
		return createValidator(
				constraintValidatorManager.getDefaultConstraintValidatorFactory(),
				valueExtractorManager,
				validatorFactoryScopedContext,
				methodValidationConfiguration
		);
	}
    
    Validator createValidator(ConstraintValidatorFactory constraintValidatorFactory, 
                              ValueExtractorManager valueExtractorManager,
			ValidatorFactoryScopedContext validatorFactoryScopedContext,
                              MethodValidationConfiguration methodValidationConfiguration) {
        
		BeanMetaDataManager beanMetaDataManager = beanMetaDataManagers.computeIfAbsent(
				new BeanMetaDataManagerKey( validatorFactoryScopedContext.getParameterNameProvider(), 
                                           valueExtractorManager, methodValidationConfiguration ),
				key -> new BeanMetaDataManager(
						constraintHelper,
						executableHelper,
						typeResolutionHelper,
						validatorFactoryScopedContext.getParameterNameProvider(),
						valueExtractorManager,
						validationOrderGenerator,
						buildDataProviders(),
						methodValidationConfiguration
				)
		 );

		return new ValidatorImpl(
				constraintValidatorFactory,
				beanMetaDataManager,
				valueExtractorManager,
				constraintValidatorManager,
				validationOrderGenerator,
				validatorFactoryScopedContext
		);
	}
}

2.2.2 ValidatorImpl

ValidatorImplHibernate Validator提供的唯一校验器实现,校验过程很复杂,了解是哪个类就行,感兴趣可以深度剖析

public class ValidatorImpl implements Validator, ExecutableValidator {
    private static final Collection<Class<?>> DEFAULT_GROUPS = Collections.<Class<?>>singletonList( Default.class );

	// 分组Group校验的顺序问题
	// 若依赖于校验顺序,可用使用@GroupSequence注解来控制Group顺序
	private final transient ValidationOrderGenerator validationOrderGenerator;
	private final ConstraintValidatorFactory constraintValidatorFactory;
	...


	@Override
	public final <T> Set<ConstraintViolation<T>> validate(T object, Class<?>... groups) {
		Contracts.assertNotNull( object, MESSAGES.validatedObjectMustNotBeNull() );
        // groups里面的内容不能有null
		sanityCheckGroups( groups );

		@SuppressWarnings("unchecked")
		Class<T> rootBeanClass = (Class<T>) object.getClass();
		BeanMetaData<T> rootBeanMetaData = beanMetaDataManager.getBeanMetaData( rootBeanClass );
        //没有约束直接返回
		if ( !rootBeanMetaData.hasConstraints() ) {
			return Collections.emptySet();
		}
        
		BaseBeanValidationContext<T> validationContext = getValidationContextBuilder().forValidate( rootBeanClass, rootBeanMetaData, object );

		ValidationOrder validationOrder = determineGroupValidationOrder( groups );
         // ValueContext一个实例用于收集所有相关信息,以验证单个类、属性或方法调用。
		BeanValueContext<?, Object> valueContext = ValueContexts.getLocalExecutionContextForBean(
				validatorScopedContext.getParameterNameProvider(),
				object,
				validationContext.getRootBeanMetaData(),
				PathImpl.createRootPath()
		);
        // 返回的是失败的消息对象:ConstraintViolation  它是被存储在ValidationContext里的
		return validateInContext( validationContext, valueContext, validationOrder );
        
}

3. 异常信息

3.1 说明

参数校验失败抛出的异常时MethodArgumentNotValidException,不过建议捕获BindException,因为MethodArgumentNotValidException是对BindResult的封装,只能通过getMessage()获取封装好的信息,不够灵活

//看的出来,确实很简单
public class MethodArgumentNotValidException extends BindException {
    private final MethodParameter parameter;

    public MethodArgumentNotValidException(MethodParameter parameter, BindingResult bindingResult) {
        super(bindingResult);
        this.parameter = parameter;
    }

    public final MethodParameter getParameter() {
        return this.parameter;
    }

    public String getMessage() {
        StringBuilder sb = (new StringBuilder("Validation failed for argument [")).append(this.parameter.getParameterIndex()).append("] in ").append(this.parameter.getExecutable().toGenericString());
        BindingResult bindingResult = this.getBindingResult();
        if (bindingResult.getErrorCount() > 1) {
            sb.append(" with ").append(bindingResult.getErrorCount()).append(" errors");
        }

        sb.append(": ");
        Iterator var3 = bindingResult.getAllErrors().iterator();

        while(var3.hasNext()) {
            ObjectError error = (ObjectError)var3.next();
            sb.append('[').append(error).append("] ");
        }

        return sb.toString();
    }
}

3.2 BindException

//BindException里面主要是封装了一个BindResult
public class BindException extends Exception implements BindingResult {
    private final BindingResult bindingResult;

    public BindException(BindingResult bindingResult) {
        Assert.notNull(bindingResult, "BindingResult must not be null");
        this.bindingResult = bindingResult;
    }

    public BindException(Object target, String objectName) {
        Assert.notNull(target, "Target object must not be null");
        this.bindingResult = new BeanPropertyBindingResult(target, objectName);
    }
}

3.2.1 BindResult

BindResult是一个接口,里面没有什么特别的东西。

public interface BindingResult extends Errors {
    String MODEL_KEY_PREFIX = BindingResult.class.getName() + ".";

    @Nullable
    Object getTarget();

    Map<String, Object> getModel();

    @Nullable
    Object getRawFieldValue(String field);

    @Nullable
    PropertyEditor findEditor(@Nullable String field, @Nullable Class<?> valueType);

    @Nullable
    PropertyEditorRegistry getPropertyEditorRegistry();

    String[] resolveMessageCodes(String errorCode);

    String[] resolveMessageCodes(String errorCode, String field);

    void addError(ObjectError error);

    default void recordFieldValue(String field, Class<?> type, @Nullable Object value) {
    }

    default void recordSuppressedField(String field) {
    }

    default String[] getSuppressedFields() {
        return new String[0];
    }
}

3.2.2 AbstractBindingResult

一般来说抽象类都是接口的基本实现,BindingResult也不例外

public abstract class AbstractBindingResult extends AbstractErrors implements BindingResult, Serializable {
    private final String objectName;
    private MessageCodesResolver messageCodesResolver = new DefaultMessageCodesResolver();
    //储存错误信息
    private final List<ObjectError> errors = new ArrayList();
    private final Map<String, Class<?>> fieldTypes = new HashMap();
    private final Map<String, Object> fieldValues = new HashMap();
    private final Set<String> suppressedFields = new HashSet();
    
    ...
        
}

ObjectError

  1. 本体

    public class ObjectError extends DefaultMessageSourceResolvable {
        //校验失败的对象名,比如shoppingCart
        private final String objectName;
        @Nullable
        private transient Object source;
        
    	...       
    }
    
  2. 子类

    //ObjectError的子类,获取ObjectError可以强转为该类 (具体实现是FieldError的子类ViolationFieldError)
    public class FieldError extends ObjectError {
        //校验失败的地方,比如itemList[1].name
        private final String field;
        //校验失败的值,比如-1
        @Nullable
        private final Object rejectedValue;
        private final boolean bindingFailure;
        ...
    }
    
  3. 父类

    //ObjectError的父类
    public class DefaultMessageSourceResolvable implements MessageSourceResolvable, Serializable {
        //存放详细的校验类型信息
        @Nullable
        private final String[] codes;
        @Nullable
        private final Object[] arguments;
        //注解里面传的Message到这了
        @Nullable
        private final String defaultMessage;
        
     	...   
            
    }
    

Tips:可以自行打印观察值,推测存放信息

 @ExceptionHandler(BindException.class)
    public ResponseResult bindExceptionHandler(BindException e) {
        List<String> list = new ArrayList<>();
        BindingResult bindingResult = e.getBindingResult();
        for (ObjectError objectError : bindingResult.getAllErrors()) {
            FieldError fieldError = (FieldError) objectError;
            System.out.println("--------------");
            System.out.println("codes");
            Arrays.stream(fieldError.getCodes()).forEach(System.out::println);
            System.out.println("--------------");
            System.out.println("code");
            System.out.println(fieldError.getCode());
            System.out.println("--------------");
            System.out.println("ObjectName");
            System.out.println(fieldError.getObjectName());
            logger.error("参数 {} ,{} 校验错误:{}", fieldError.getField(), fieldError.getRejectedValue(), fieldError.getDefaultMessage());
            list.add(fieldError.getDefaultMessage());
        }
        return ResponseResult.error(AppHttpCodeEnum.SYSTEM_ERROR.getCode(),list.toString());
    }