Sivan
Sivan
Published on 2019-01-08 / 150 Visits
0
0

SpringBoot + Validator 参数校验配置[深度学习]

前言

本文Spring版本为 SpringBoot-2.0.7,所有源码相关类、方法、代码行都以此版本为基础。
代码行数: 使用 IDEA 的同学通过Maven Projects -> Donwload Sources and Documentation下载源码及注释文档,保证行数的准确。

非常欢迎您指正在文章中出现的错误,包括但不限于 语句错误、描述错误、示例错误、代码理解错误。

参数校验是代码开发中必不可少的一环,一个方法中参数校验套了一个又一个 if-else,繁琐的操作让广大程序员诟病。

本文我们就讲一下 SpringBoot 结合 Hibernate-Validtor 校验参数、简化工作。

开始

spring-boot-starter-web 已经默认整合、提供了 Hibernate-Validator 的功能,只待我们去使用。

 <dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-web</artifactId>
     <version>2.0.7.RELEASE</version>
 </dependency>

创建一个实体类,并添加校验注解。

本小节只做简单的使用演示
常用注解列表、注解说明、注解用法,以及·自定义校验注解·的教程。JSR 303 - Bean Validation 介绍及最佳实践

public class Student{
    @NotNull
    private String name;
    @NotNull
    private String sex;
    @Min(0)
    @Max(150)
    private int age;
    
    ...get,set...
}

接着编写 Controller 代码。

// @RestController
// DemoController

    @GetMapping("/student")
    public String validator(@Validated Student student, BindingResult result) {
        if (result.hasErrors()) {
            return result.getFieldError().getDefaultMessage();
        }
        return "ok";
    }

启动程序后访问http://{host:prot}/student,将会返回:

must not be null

访问http://{host:prot}/student?name=zhangsan&sex=Male&age=22,将会返回:

ok

到这,本期的教程结束...是不可能的。

进阶

上面的教程还太简单,很多事情都很朦胧。

  1. 书写有没有什么规则?
  2. 我怎么知道‘must not be null’是指哪个参数?跟没提示一样。
  3. 每个 Controller 方法都要判断 BindingResult 还是好麻烦!我懒得写!
  4. 校验规则太少了,能不能自己写规则?
  5. 我想手动校验怎么办?

书写规则

@Validated 和 @Valid 的异同

@Validated 是 Spring 实现的JSR-303的变体 @Valid ,支持验证组的规范。 设计用于方便使用Spring的JSR-303支持,但不支持JSR-303特定。

@Valid JSR-303标准实现的校验注解。

注解范围嵌套校验组
@Validated可以标记类、方法、方法参数,不能用在成员属性(字段)上不支持支持
@Valid可以标记方法、构造函数、方法参数和成员属性(字段)上支持不支持

两者都可以用在方法入参上,但都无法单独提供嵌套验证功能,都能配合嵌套验证注解@Valid进行嵌套验证。

嵌套验证示例:

public class ClassRoom{
    @NotNull
    String name;
    
    @Valid  // 嵌套校验,校验参数内部的属性
    @NotNull
    Student student;
}
    @GetMapping("/room")   // 此处可使用 @Valid 或 @Validated, 将会进行嵌套校验
    public String validator(@Validated ClassRoom classRoom, BindingResult result) {
        if (result.hasErrors()) {
            return result.getFieldError().getDefaultMessage();
        }
        return "ok";
    }

参考:@Validated和@Valid区别---CSDN:花郎徒结

BindingResult 的使用

BindingResult必须跟在被校验参数之后,若被校验参数之后没有BindingResult对象,将会抛出BindException

    @GetMapping("/room")
    public String validator(@Validated ClassRoom classRoom, BindingResult result) {
        if (result.hasErrors()) {
            return result.getFieldError().getDefaultMessage();
        }
        return "ok";
    }

不要使用 BindingResult 接收,String等简单对象的错误信息。简单对象校验失败,会抛出 ConstraintViolationException
主要就是接不着,你要写也算是没关系...

    // ❌ 错误用法,也没有特别的错,只是 result 是接不到值。
    @GetMapping("/room")
    @Validated  // 启用校验
    public String validator(@NotNull String name, BindingResult result) {
        if (result.hasErrors()) {
            return result.getFieldError().getDefaultMessage();
        }
        return "ok";
    }

修改校验失败的提示信息

可以通过各个校验注解的message属性设置更友好的提示信息。

public class ClassRoom{
    @NotNull(message = "Classroom name must not be null")
    String name;
    
    @Valid
    @NotNull
    Student student;
}
    @GetMapping("/room")
    @Validated
    public String validator(ClassRoom classRoom, BindingResult result, @NotNull(message = "姓名不能为空") String name) {
        if (result.hasErrors()) {
            return result.getFieldError().getDefaultMessage();
        }
        return "ok";
    }

message属性配置国际化的消息也可以的,message中填写国际化消息的code,在抛出异常时根据code处理一下就好了。

    @GetMapping("/room")
    @Validated
    public String validator(@NotNull(message = "demo.message.notnull") String name) {
        if (result.hasErrors()) {
            return result.getFieldError().getDefaultMessage();
        }
        return "ok";
    }
// message_zh_CN.properties
demo.message.notnull=xxx消息不能为空

// message_en_US.properties
demo.message.notnull=xxx message must no be null

省略 Controller 中的校验判断

可以利用参数校验失败后抛出异常这点,配置·统一异常拦截·,进行异常统一的处理,合理的将错误信息返回给前端。

抛砖(仅做示例):

// @RestControllerAdvice

    /*  数据校验处理 */
    @ExceptionHandler({BindException.class, ConstraintViolationException.class})
    public String validatorExceptionHandler(Exception e) {
        String msg = e instanceof BindException ? msgConvertor(((BindException) e).getBindingResult())
            : msgConvertor(((ConstraintViolationException) e).getConstraintViolations());

        return msg;
    }

    /**
     * 校验消息转换拼接
     *
     * @param bindingResult
     * @return
     */
    public static String msgConvertor(BindingResult bindingResult) {
        List<FieldError> fieldErrors = bindingResult.getFieldErrors();
        StringBuilder sb = new StringBuilder();
        fieldErrors.forEach(fieldError -> sb.append(fieldError.getDefaultMessage()).append(","));

        return sb.deleteCharAt(sb.length() - 1).toString().toLowerCase();
    }

    private String msgConvertor(Set<ConstraintViolation<?>> constraintViolations) {
        StringBuilder sb = new StringBuilder();
        constraintViolations.forEach(violation -> sb.append(violation.getMessage()).append(","));

        return sb.deleteCharAt(sb.length() - 1).toString().toLowerCase();
    }

注:getMessagegetDefaultMessage 都是直接获取注解上message属性的值,

扩展校验注解、校验规则

常用注解列表、注解说明、注解用法,以及·自定义校验注解·的教程。JSR 303 - Bean Validation 介绍及最佳实践

手动校验

若没有手动配置Validator对象,自然需要从 Spring 容器中获取校验器对象,注入使用。

此处给出一个手动校验的工具类,供大家参考。(lay了...写的自闭,如果对代码有疑问请联系我..持续更新)

代码中提到的与 Spring 集成,主要是对代码返回值的统一。(不支持普通对象...)
若都以注解的message属性来获取提示消息,可以删除 Spring 相关的代码。
若不以message属性作为消息,那么可以从bindingResult中获取字段、类、注解信息,拼装成消息码。

抛砖:

// config
// @Configuration

    @Bean
    public Validator validator() {
        return ValidatorUtils.getValidator();
    }
import org.hibernate.validator.HibernateValidator;
import org.springframework.util.ClassUtils;
import org.springframework.validation.BindException;
import org.springframework.validation.DataBinder;
import org.springframework.validation.SmartValidator;
import org.springframework.validation.beanvalidation.SpringValidatorAdapter;

import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import javax.validation.Validation;
import javax.validation.Validator;
import java.util.Set;

/**
 * hibernate-validator校验工具类
 */
public class ValidatorUtils {
    private static Validator validator;
    private static SmartValidator validatorAdapter;

    static {
        // 快速返回模式
        validator = Validation.byProvider(HibernateValidator.class)
            .configure()
            .failFast(true)
            .buildValidatorFactory()
            .getValidator();
    }

    public static Validator getValidator() {
        return validator;
    }

    private static SmartValidator getValidatorAdapter(Validator validator) {
        if (validatorAdapter == null) {
            validatorAdapter = new SpringValidatorAdapter(validator);
        }
        return validatorAdapter;
    }

    /**
     * 校验参数,用于普通参数校验 [未测试!]
     *
     * @param
     */
    public static void validateParams(Object... params) {
        Set<ConstraintViolation<Object>> constraintViolationSet = validator.validate(params);

        if (!constraintViolationSet.isEmpty()) {
            throw new ConstraintViolationException(constraintViolationSet);
        }
    }

    /**
     * 校验对象
     *
     * @param object
     * @param groups
     * @param <T>
     */
    public static <T> void validate(T object, Class<?>... groups) {
        Set<ConstraintViolation<T>> constraintViolationSet = validator.validate(object, groups);

        if (!constraintViolationSet.isEmpty()) {
            throw new ConstraintViolationException(constraintViolationSet);
        }
    }

    /**
     * 校验对象
     * 使用与 Spring 集成的校验方式。
     * 
     * @param object 待校验对象
     * @param groups 待校验的组
     * @throws BindException
     */
    public static <T> void validateBySpring(T object, Class<?>... groups)
        throws BindException {
        DataBinder dataBinder = getBinder(object);
        dataBinder.validate((Object[]) groups);

        if (dataBinder.getBindingResult().hasErrors()) {
            throw new BindException(dataBinder.getBindingResult());
        }
    }

    private static <T> DataBinder getBinder(T object) {
        DataBinder dataBinder = new DataBinder(object, ClassUtils.getShortName(object.getClass()));
        dataBinder.setValidator(getValidatorAdapter(validator));
        return dataBinder;
    }

}
源码经验宝宝[拓展]
为什么 BindingResult 接收不到简单对象的校验信息?

跟进 Spring MVC 源码,发现:SpringMVC 在进行方法参数的注入(将 Http请求参数封装成方法所需的参数)时,不同的对象使用不同的解析器注入对象。

听着好像没什么关系。但其实就是,注入实体对象时使用ModelAttributeMethodProcessor中的校验方法,而注入 String 对象使用AbstractNamedValueMethodArgumentResolver中的校验方法。正是这个差异导致了BindingResult无法接受到简单对象(简单的入参参数类型)的校验信息。

啊?你问我什么是简单对象?emm...
八大基础类型再加上不同解析器支持的类型对象(不同的参数类型),需要看各解析器实现的supportsParameter()方法,文中提到的简单对象,意思是ModelAttributeMethodProcessor不支持的所有对象。

获取参数注入解析器的源码位于HandlerMethodArgumentResolverComposite#resolveArgument():120:

    // HandlerMethodArgumentResolverComposite.class
	public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
			NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
        // 获取 parameter 参数的解析器
		HandlerMethodArgumentResolver resolver = getArgumentResolver(parameter);
		// 调用解析器获取参数
		return resolver.resolveArgument(parameter, mavContainer, webRequest, binderFactory);
	}
	
	// 获取 parameter 参数的解析器
	private HandlerMethodArgumentResolver getArgumentResolver(MethodParameter parameter) {
	    // 从缓存中获取参数对应的解析器
		HandlerMethodArgumentResolver result = this.argumentResolverCache.get(parameter);
		for (HandlerMethodArgumentResolver methodArgumentResolver : this.argumentResolvers) {
		    // 解析器是否支持该参数类型
			if (methodArgumentResolver.supportsParameter(parameter)) {
				result = methodArgumentResolver;
				this.argumentResolverCache.put(parameter, result);
				break;
			}
        }
		return result;
	}

注入 String 参数时,在AbstractNamedValueMethodArgumentResolver#resolveArgument()中,不会抛出BindException/ConstraintViolationException异常、也不会将 BindingResult 传入到方法中。

注入对象时在ModelAttributeMethodProcessor#resolveArgument():154 行的 validateIfApplicable(binder, parameter)语句,进行了参数校验,校验不通过并且实体对象后不存在BindingResult对象,则会在this#resolveArgument():156抛出BindException

public final Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
			NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
			
		// bean 参数绑定和校验
		WebDataBinder binder = binderFactory.createBinder(webRequest, attribute, name);
        
        // 参数校验
		validateIfApplicable(binder, parameter);
		// 校验结果包含错误,并且该对象后不存在 BindingResult 对象,就抛出异常
		if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) {
			throw new BindException(binder.getBindingResult());
		}

		// 在对象后注入 BindingResult 对象
		Map<String, Object> bindingResultModel = bindingResult.getModel();
		mavContainer.removeAttributes(bindingResultModel);
		mavContainer.addAllAttributes(bindingResultModel);
	}
在哪里抛出ConstraintViolationException

可能有同学发现了,简单对象注入后并没有抛出异常,那这个参数在哪里被校验呢?

被方法级的拦截器拦住了。

这里的方法拦截器是 MethodValidationInterceptor:

// MethodValidationInterceptor.class

public Object invoke(MethodInvocation invocation) throws Throwable {
		ExecutableValidator execVal = this.validator.forExecutables();
		// 校验参数
		try {
			result = execVal.validateParameters(
					invocation.getThis(), methodToValidate, invocation.getArguments(), groups);
		}
		catch (IllegalArgumentException ex) {
		    // 解决参数错误异常、再次校验
		    methodToValidate = BridgeMethodResolver.findBridgedMethod(
					ClassUtils.getMostSpecificMethod(invocation.getMethod(), invocation.getThis().getClass()));
			result = execVal.validateParameters(
					invocation.getThis(), methodToValidate, invocation.getArguments(), groups);
		}
		if (!result.isEmpty()) {
			throw new ConstraintViolationException(result);
		}
        
        // 执行结果
		Object returnValue = invocation.proceed();
        
        // 校验返回值
		result = execVal.validateReturnValue(invocation.getThis(), methodToValidate, returnValue, groups);
		if (!result.isEmpty()) {
			throw new ConstraintViolationException(result);
		}

		return returnValue;
	}

over.
本文到此结束。

非常欢迎您指正在文章中出现的错误,包括但不限于 语句错误、描述错误、示例错误、代码理解错误。


Comment