起因

  • 今天在写一个简单的接口的时候,想对参数进行校验

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @Slf4j
    @RestController
    @RequestMapping("/user")
    public class UserController {
    private final UserService userService;

    @PostMapping("/login")
    public User login(@Validated @NotBlank String username, @Validated @NotBlank String password) {
    return userService.login(username, password);
    }
    }
  • 在调用的过程中,发现校验没有生效

  • 搞不懂为什么会失败??

正确写法

  • 在书写业务逻辑的时候,是否会经常书写大量的判空校验。比如Service层或者Dao层的方法入参、入参对象、出参中,比如有些字段必传,有的非必传;返回值中有些字段必须有值,有的非必须等等~

  • 如上描述的校验逻辑,窥探一下你的代码,估摸里面有大量的if else吧。此部分逻辑简单(因为和业务关系不大)却看起来眼花缭乱。这部分在我们眼中就是垃圾代码

  • Bean Validation校验其实是基于DDD思想设计的,我们虽然可以不完全的遵从这种思考方式编程,但是其优雅的优点还是可取的,本文将介绍Spring为此提供的解决方案~

Spring+JSR相关约束体验

  • 首先就来体验一把吧~

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    @Validated(Default.class)
    public interface UserService {

    User login(@NotBlank String username, @NotBlank String password);
    }


    // 实现类如下
    @Service
    public class UserServiceImpl implements UserService {
    private final UserDao userDao;

    @Override
    public User login(String username, String password) {
    User user = userDao.getOne(new QueryWrapper<User>().lambda().eq(User::getUsername, username));
    if (user != null && password.equals(user.getPassword())) {
    return user;
    }
    return null;
    }
    }
  • 向容器里注册一个处理器:

    1
    2
    3
    4
    5
    6
    7
    @Configuration
    public class RootConfig {
    @Bean
    public MethodValidationPostProcessor methodValidationPostProcessor() {
    return new MethodValidationPostProcessor();
    }
    }
  • 测试:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @SpringBootTest
    class TestSpringBean {
    @Autowired
    private UserService userService;

    @Test
    public void test1() {
    System.out.println(userService.getClass());
    userService.login(null, null);
    }
    }
  • 结果完美的校验住了方法入参。

  • 若需要校验方法返回值,改写如下:

    1
    2
    3
    4
    5
    @Validated(Default.class)
    public interface UserService {
    @NotNull
    User login(@NotBlank String username, @NotBlank String password);
    }
  • 测试

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    @SpringBootTest
    class TestSpringBean {
    @Autowired
    private UserService userService;

    @Test
    public void test1() {
    System.out.println(userService.getClass());
    userService.login("admin", "admin");
    }
    }



    javax.validation.ConstraintViolationException: login.<return value>: must not be null

  • 校验完成。就这样借助Spring+JSR相关约束注解,就非常简单明了,语义清晰的优雅的完成了方法级别(入参校验、返回值校验)的校验。

  • 校验不通过的错误信息,再来个全局统一的异常处理,就能让整个工程都能尽显完美之势。

    • 错误消息可以从异常ConstraintViolationException的getConstraintViolations()方法里获得的~

显示方法参数名称

  • 注意此处的一个小细节: 我们显示的参数名称可能是login.args0等,如果想是形参名。可以使用ava8的编译参数: -parameters,让我们在运行期获取方法参数名称。

  • 普通java工程在 File->Settings->Build,Execution,Deployment->Compiler->Java Compiler-> Additional command line parameters: 后面填上 -parameters,如下图

  • 此处说一点: 若你的逻辑中强依赖于此参数,务必在你的maven中加入编译插件并且配置好此编译参数

    • maven设置编译参数,版本3.6.2之前

      1
      2
      3
      4
      5
      6
      7
      8
      <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-compiler-plugin</artifactId>
      <version>3.6.0</version>
      <configuration>
      <compilerArgs>-parameters</compilerArgs>
      </configuration>
      </plugin>
    • 版本3.6.2及之后

      1
      2
      3
      4
      5
      6
      7
      8
      <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-compiler-plugin</artifactId>
      <version>3.6.0</version>
      <configuration>
      <parameters>true</parameters>
      </configuration>
      </plugin>
  • SpringBoot项目如果继承自spring-boot-starter-parent,会默认开启。

MethodValidationPostProcessor

  • 它是Spring提供的来实现基于方法Method的JSR校验的核心处理器~它能让约束作用在方法入参、返回值上,如:

    1
    2
    3
    4
    public interface UserService {
    @NotNull
    User login(@NotBlank String username, @NotBlank String password);
    }
  • 官方说明: 方法里写有JSR校验注解要想其生效的话,要求类型级别上必须使用@Validated标注(还能指定验证的Group)@Validated(Default.class)

  • 另外提示一点: 这个处理器同处理@Async的处理器AsyncAnnotationBeanPostProcessor非常相似,都是继承自AbstractBeanFactoryAwareAdvisingPostProcessor的

    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
    // @since 3.1
    public class MethodValidationPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor implements InitializingBean {
    // 备注: 此处你标注@Valid是无用的~~~Spring可不提供识别
    // 当然你也可以自定义注解(下面提供了set方法~~~)
    // 但是注意: 若自定义注解的话,此注解只决定了是否要代理,并不能指定分组哦 so,没啥事别给自己找麻烦吧
    private Class<? extends Annotation> validatedAnnotationType = Validated.class;
    // 这个是javax.validation.Validator
    @Nullable
    private Validator validator;

    // 可以自定义生效的注解
    public void setValidatedAnnotationType(Class<? extends Annotation> validatedAnnotationType) {
    Assert.notNull(validatedAnnotationType, "'validatedAnnotationType' must not be null");
    this.validatedAnnotationType = validatedAnnotationType;
    }

    // 这个方法注意了: 你可以自己传入一个Validator,并且可以是定制化的LocalValidatorFactoryBean哦~(推荐)
    public void setValidator(Validator validator) {
    // 建议传入LocalValidatorFactoryBean功能强大,从它里面生成一个验证器出来靠谱
    if (validator instanceof LocalValidatorFactoryBean) {
    this.validator = ((LocalValidatorFactoryBean) validator).getValidator();
    } else if (validator instanceof SpringValidatorAdapter) {
    this.validator = validator.unwrap(Validator.class);
    } else {
    this.validator = validator;
    }
    }
    // 当然,你也可以简单粗暴的直接提供一个ValidatorFactory即可~
    public void setValidatorFactory(ValidatorFactory validatorFactory) {
    this.validator = validatorFactory.getValidator();
    }


    // 毫无疑问,Pointcut使用AnnotationMatchingPointcut,并且支持内部类哦~
    // 说明@Aysnc使用的也是AnnotationMatchingPointcut,只不过因为它支持标注在类上和方法上,所以最终是组合的ComposablePointcut

    // 至于Advice通知,此处一样的是个`MethodValidationInterceptor`~~~~
    @Override
    public void afterPropertiesSet() {
    Pointcut pointcut = new AnnotationMatchingPointcut(this.validatedAnnotationType, true);
    this.advisor = new DefaultPointcutAdvisor(pointcut, createMethodValidationAdvice(this.validator));
    }

    // 这个advice就是给@Validation的类进行增强的~ 说明: 子类可以覆盖哦~
    // @since 4.2
    protected Advice createMethodValidationAdvice(@Nullable Validator validator) {
    return (validator != null ? new MethodValidationInterceptor(validator) : new MethodValidationInterceptor());
    }
    }
  • 它是个普通的BeanPostProcessor,为Bean创建的代理的时机是postProcessAfterInitialization(),也就是在Bean完成初始化后有必要的话用一个代理对象返回进而交给Spring容器管理~(同@Aysnc)

  • 容易想到,关于校验方面的逻辑不在于它,而在于切面的通知: MethodValidationInterceptor

MethodValidationInterceptor

  • 它是AOP联盟类型的通知,此处专门用于处理方法级别的数据校验。

  • 注意理解方法级别: 方法级别的入参有可能是各种平铺的参数、也可能是一个或者多个对象

    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
    // @since 3.1  因为它校验Method  所以它使用的是javax.validation.executable.ExecutableValidator
    public class MethodValidationInterceptor implements MethodInterceptor {

    // javax.validation.Validator
    private final Validator validator;

    // 如果没有指定校验器,那使用的就是默认的校验器
    public MethodValidationInterceptor() {
    this(Validation.buildDefaultValidatorFactory());
    }
    public MethodValidationInterceptor(ValidatorFactory validatorFactory) {
    this(validatorFactory.getValidator());
    }
    public MethodValidationInterceptor(Validator validator) {
    this.validator = validator;
    }


    @Override
    @SuppressWarnings("unchecked")
    public Object invoke(MethodInvocation invocation) throws Throwable {
    // Avoid Validator invocation on FactoryBean.getObjectType/isSingleton
    // 如果是FactoryBean.getObject() 方法 就不要去校验了~
    if (isFactoryBeanMetadataMethod(invocation.getMethod())) {
    return invocation.proceed();
    }

    Class<?>[] groups = determineValidationGroups(invocation);

    // Standard Bean Validation 1.1 API ExecutableValidator是1.1提供的
    ExecutableValidator execVal = this.validator.forExecutables();
    Method methodToValidate = invocation.getMethod();
    Set<ConstraintViolation<Object>> result; // 错误消息result 若存在最终都会ConstraintViolationException异常形式抛出

    try {
    // 先校验方法入参
    result = execVal.validateParameters(invocation.getThis(), methodToValidate, invocation.getArguments(), groups);
    } catch (IllegalArgumentException ex) {
    // 此处回退了异步: 找到bridged method方法再来一次
    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;
    }


    // 找到这个方法上面是否有标注@Validated注解 从里面拿到分组信息
    // 备注: 虽然代理只能标注在类上,但是分组可以标注在类上和方法上哦~~~~
    protected Class<?>[] determineValidationGroups(MethodInvocation invocation) {
    Validated validatedAnn = AnnotationUtils.findAnnotation(invocation.getMethod(), Validated.class);
    if (validatedAnn == null) {
    validatedAnn = AnnotationUtils.findAnnotation(invocation.getThis().getClass(), Validated.class);
    }
    return (validatedAnn != null ? validatedAnn.value() : new Class<?>[0]);
    }
    }
  • 这个Advice的实现,简单到不能再简单了,稍微有点基础的应该都能很容易看懂吧(据我不完全估计这个应该是最简单的)。

使用细节(重要)

使用@Validated去校验方法Method,不管从使用上还是原理上,都是非常简单和简约的,建议大家在企业应用中多多使用。

  • 文首虽然已经给了一个使用示例,但是那毕竟只是局部。在实际生产使用中,比如上面理论更重要的是一些使用细节(细节往往是区分你是不是高手的地方),这里从我使用的经验中,总结如下几点供给大家参考(基本算是分享我躺过的坑):

约束注解(如@NotNull)不能放在实体类上

  • 一般情况下,我们对于Service层验证(Controller层一般都不给接口),大都是面向接口编程和使用,那么这种@NotNull放置的位置应该怎么放置呢?

  • 看这个例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public interface HelloService {
    Object hello( Integer id, String name);
    }

    @Validated(Default.class)
    @Slf4j
    @Service
    public class HelloServiceImpl implements HelloService {
    @Override
    public Object hello(@NotNull @Min(10) Integer id,@NotNull String name) {
    return null;
    }
    }
  • 约束条件都写在实现类上,按照我们所谓的经验,应该是不成问题的。但运行:

    1
    2
    3
    javax.validation.ConstraintDeclarationException: HV000151:
    A method overriding another method must not redefine the parameter constraint(约束) configuration,
    but method UserServiceImpl#login(String, String) redefines the configuration of UserService#login(String, String).

请务必注意请务必注意请务必注意这个异常是javax.validation.ConstraintDeclarationException(约束声明异常),而不是错误校验错误异常javax.validation.ConstraintViolationException(约束冲突异常)。请在做全局异常捕获的时候一定要区分开来~

  • 异常信息是说parameter constraint configuration在校验方法入参的约束时,若是@Override父类/接口的方法,那么这个入参约束只能写在父类/接口上面~~~

  • 至于为什么只能写在接口处,这个具体原因其实是和Bean Validation的实现产品有关的,比如使用的Hibernate校验,原因可参考它的此类: OverridingMethodMustNotAlterParameterConstraints

  • 还需注意一点: 若实现类写的约束和接口一模一样,那也是没问题的。比如上面若接口这么写是没有问题能够完成正常校验的:

    @Validated(Default.class)
    public interface UserService {
      @NotNull
      User login(@NotBlank String username, @NotBlank String password);
    }
    
  • 虽然能正常work完成校验,但需要深刻理解一模一样这四个字。简单的说把@min(0)改成@min(1)都会报ConstraintDeclarationException异常,更别谈移除某个注解了(不管多少字段多少注解,但凡只要写了一个就必须保证一模一样)。

  • 关于@Override方法校验返回值方面: 即使写在实现类里也不会抛ConstraintDeclarationException

  • 另外@Validated注解它写在实现类/接口上均可~

  • 最后你应该自己领悟到: 若入参校验失败了,方法体是不会执行的。但倘若是返回值校验执行了(即使是失败了),方法体也肯定被执行了~~

@NotEmpty/@NotBlank只能哪些类型上?

  • 提出这个细节的目的是: 约束注解并不是能用在所有类型上的。比如若你把@NotEmpty让它去验证Object类型,它会报错如下:

    1
    2
    3
    javax.validation.UnexpectedTypeException: HV000030:
    No validator could be found for constraint 'javax.validation.constraints.NotEmpty' validating type 'java.lang.Object'.
    Check configuration for 'hello.<return value>'
  • 需要强调的是: 若标注在方法上是验证返回值的,这个时候方法体是已经执行了的,这个和ConstraintDeclarationException不一样~

  • 对这两个注解依照官方文档做如下简要说明。@NotEmpty只能标注在如下类型

    1
    2
    3
    4
    CharSequence
    Collection
    Map
    Array
  • 注意: "“它是空的,但是” "(空格字符串)就不是了

  • @NotBlank只能使用在CharSequence上,它是Bean Validation 2.0新增的注解~

接口和实现类上都有注解,以谁为准?

  • 这个问题有个隐含条件: 只有校验方法返回值时才有这种可能性。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public interface HelloService {
    @NotEmpty String hello(@NotNull @Min(10) Integer id, @NotNull String name);
    }

    @Slf4j
    @Service
    @Validated(Default.class)
    public class HelloServiceImpl implements HelloService {
    @Override
    public @NotNull String hello(Integer id, String name) {
    return "";
    }
    }
  • 运行返回结果

    1
    javax.validation.ConstraintViolationException: hello.<return value>: must not be empty
  • 到这里,可能有小伙伴就会早早下结论: 当同时存在时,以接口的约束为准

  • 那么,我只把返回值稍稍修改,你再看一下呢???

    1
    2
    3
    4
    @Override
    public @NotNull String hello(Integer id, String name) {
    return null; // 返回值改为null
    }
  • 再运行:

    1
    javax.validation.ConstraintViolationException: hello.<return value>: must not be empty, hello.<return value>: must not be null
  • 透过打印的信息,结论就自然不必我多。但是有个道理此处可说明: 大胆猜测,小心求证

如何校验级联属性?

  • 在实际开发中,其实大多数情况下我们方法入参是个对象(甚至对象里面有对象),而不是单单平铺的参数,因此就介绍一个级联属性校验的例子:

    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
    @Getter
    @Setter
    @ToString
    public class Person {

    @NotNull
    private String name;
    @NotNull
    @Positive
    // @PositiveOrZero // 参数必须是正整数或0
    private Integer age;

    @Valid // 让InnerChild的属性也参与校验
    @NotNull
    private InnerChild child;

    @Getter
    @Setter
    @ToString
    public static class InnerChild {
    @NotNull
    private String name;
    @NotNull
    @Positive
    private Integer age;
    }

    }

    public interface HelloService {
    String cascade(@NotNull @Valid Person father, @NotNull Person mother);
    }

    @Slf4j
    @Service
    @Validated(Default.class)
    public class HelloServiceImpl implements HelloService {
    @Override
    public String cascade(Person father, Person mother) {
    return "hello cascade...";
    }
    }
  • 测试

    1
    2
    3
    4
    @Test
    public void test1() {
    helloService.cascade(null, null);
    }
  • 此处说明一点: 若你father前面没加@NotNull,那打印的消息只有: cascade.mother: 不能为null

  • 我把测试用例改造如下,你继续感受一把:

    @Test
    public void test1() {
        Person father = new Person();
        father.setName("fsx");
        Person.InnerChild innerChild = new Person.InnerChild();
        innerChild.setAge(-1);
        father.setChild(innerChild);
    
        helloService.cascade(father, new Person());
    }
    
  • 错误消息如下(请小伙伴仔细观察和分析缘由):

    1
    cascade.father.age: 不能为null, cascade.father.child.name: 不能为null, cascade.father.child.age: 必须是正数
  • 思考: 为何mother的相关属性以及子属性为何全都没有校验呢?

循环依赖问题

  • 上面说了Spring对@Validated的处理和对@Aysnc的代理逻辑是差不多的,有了之前的经验,很容易想到它也存在着如题的问题:

  • 比如HelloService的A方法想调用本类的B方法,但是很显然我是希望B方法的方法校验是能生效的,因此其中一个做法就是注入自己,使用自己的代理对象来调用:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    public interface HelloService {
    Object hello(@NotNull @Min(10) Integer id, @NotNull String name);
    String cascade(@NotNull @Valid Person father, @NotNull Person mother);
    }

    @Slf4j
    @Service
    @Validated(Default.class)
    public class HelloServiceImpl implements HelloService {
    @Autowired
    private HelloService helloService;

    @Override
    public Object hello(@NotNull @Min(10) Integer id, @NotNull String name) {
    helloService.cascade(null, null); // 调用本类方法
    return null;
    }

    @Override
    public String cascade(Person father, Person mother) {
    return "hello cascade...";
    }
    }
  • 运行测试用例:

    1
    2
    3
    4
    @Test
    public void test1() {
    helloService.hello(18, "fsx"); // 入口方法校验通过,内部调用cascade方法希望继续得到校验
    }
  • 运行报错:

    1
    2
    Caused by: org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'helloServiceImpl': Bean with name 'helloServiceImpl' has been injected into other beans [helloServiceImpl] in its raw version as part of a circular reference, but has eventually been wrapped. This means that said other beans do not use the final version of the bean. This is often the result of over-eager type matching - consider using 'getBeanNamesOfType' with the 'allowEagerInit' flag turned off, for example.
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean
  • 这个报错消息不可为不熟悉。关于此现象,之前做过非常非常详细的说明并且提供了多种解决方案,所以此处略过。

    1
    2
    3
    把allowRawInjectionDespiteWrapping设置为true
    使用@Lazy或者@ComponentScan(lazyInit = true)解决
    不要让@Async的Bean参与循环依赖
  • 提供问题解决后运行的打印输出情况,供给小伙伴调试参考

    1
    javax.validation.ConstraintViolationException: cascade.mother: 不能为null, cascade.father: 不能为null

Spring中校验总结

  • Spring提供给我们方法级别校验的能力,在企业应用中使用此种方式完成绝大部分的基本校验工作,能够让我们的代码更加简洁、可控并且可扩展,因此我是推荐使用和扩散的~

  • 有必要强调一点: 关于上面级联属性的校验时使用的@Valid注解你使用@Validated可替代不了,不会有效果的。

  • 稍稍说一下它的弊端: 因为校验失败它最终采用的是抛异常方式来中断,因此效率上有那么一丢丢的损耗。but,你的应用真的需要考虑这种极致性能问题吗?这才是你该思考的~

SpringMCV校验

它在Spring MVC(Controller层)里怎么应用呢?本文为此继续展开讲解Spring MVC中的数据校验~

  • 可能小伙伴能立马想到: 这不一样吗?我们使用Controller就是方法级别的,所以它就是直接应用了方法级别的校验而已嘛~
  • 对于此疑问我先不解答,而是顺势再抛出两个问题你自己应该就能想明白了:
    • 上文有说过,基于方法级别的校验Spring默认是并未开启的,但是为什么你在Spring MVC却可以直接使用@Valid完成校验呢?
      • 可能有的小伙伴说他用的是SpringBoot可能默认给开启了,其实不然。哪怕你用的传统Spring MVC你会发现也是直接可用的,不信你就试试
    • 类比一下: Spring MVC的HandlerInterceptor是AOP思想的实现,但你有没有发现即使你没有启动@EnableAspectJAutoProxy的支持,它依旧好使~
  • 若你能想明白我提出的这两个问题,下文就非常不难理解了。当然即使你知道了这两个问题的答案,还是建议你读下去。毕竟: 永远相信本文能给你带来意想不到的收获~

使用示例

  • 关于数据校验这一块在Spring MVC中的使用案例,我相信但凡有点经验的Java程序员应该没有不会使用的,并且还不乏熟练的选手。

  • 在此之前我简单“采访”过,绝大多数程序员甚至一度认为Spring中的数据校验就是指的在Controller中使用@Validated校验入参JavaBean这一块~

  • 因此下面这个例子,你应该一点都不陌生:

    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
    @Getter
    @Setter
    @ToString
    public class Person {
    @NotNull
    private String name;
    @NotNull
    @Positive
    private Integer age;

    @Valid // 让InnerChild的属性也参与校验
    @NotNull
    private InnerChild child;

    @Getter
    @Setter
    @ToString
    public static class InnerChild {
    @NotNull
    private String name;
    @NotNull
    @Positive
    private Integer age;
    }
    }
  • controller层

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @RestController
    @RequestMapping
    public class HelloController {
    @PostMapping("/hello")
    public Object helloPost(@Valid @RequestBody Person person, BindingResult result) {
    System.out.println(result.getErrorCount());
    System.out.println(result.getAllErrors());
    return person;
    }
    }
  • 发送post请求: /hello Content-Type=application/json,传入的json串如下:

    1
    2
    3
    4
    5
    6
    7
    {
    "name": "fsx",
    "age": "-1",
    "child": {
    "age": 1
    }
    }
  • 控制台有如下打印:

    1
    2
    2
    [Field error in object 'person' on field 'child.name': rejected value [null]; codes [NotNull.person.child.name,NotNull.child.name,NotNull.name,NotNull.java.lang.String,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [person.child.name,child.name]; arguments []; default message [child.name]]; default message [不能为null], Field error in object 'person' on field 'age': rejected value [-1]; codes [Positive.person.age,Positive.age,Positive.java.lang.Integer,Positive]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [person.age,age]; arguments []; default message [age]]; default message [必须是正数]]
  • 从打印上看: 校验生效(拿着错误消息就可以返回前端展示,或者定位到错误页面都行)。

  • **此例两个小细节务必注意: **

    • @RequestBody注解不能省略,否则传入的json无法完成数据绑定(即使不绑定,校验也是生效的哦)~
    • 若方法入参不写BindingResult result这个参数,请求得到的直接是400错误,因为若有校验失败的服务端会抛出异常org.springframework.web.bind.MethodArgumentNotValidException。
    • 若写了,那就调用者自己处理喽~
    • 据我不完全和不成熟的统计,就这个案例就覆盖了小伙伴们实际使用中的90%以上的真实使用场景,使用起来确实非常的简单、优雅、高效~
  • 但是作为一个有丰富经验的程序员的你,虽然你使用了@Valid优雅的完成了数据校验,但回头是你是否还会发现你的代码里还是存在了大量的if else的基础的校验?什么原因?

    • 其实根本原因只有一个: 很多case使用@Valid并不能覆盖,因为它只能校验JavaBean
    • 我相信你是有这样那样的使用痛点的,本文先从原理层面分析,进而给出你所遇到的痛点问题的参考解决参考方案~

@Validated和@Valid的区别

  • 如题的问题,我相信是很多小伙伴都很关心的一个对比,若你把这个系列都有喵过,那么这个问题的答案就浮出水面了:

  • @Valid: 标准JSR-303规范的标记型注解,用来标记验证属性和方法返回值,进行级联和递归校验

  • @Validated: Spring的注解,是标准JSR-303的一个变种(补充),提供了一个分组功能,可以在入参验证时,根据不同的分组采用不同的验证机制

    • 在Controller中校验方法参数时,使用@Valid和@Validated并无特殊差异(若不需要分组校验的话)
  • @Validated注解可以用于类级别,用于支持Spring进行方法级别的参数校验。@Valid可以用在属性级别约束,用来表示级联校验。

  • @Validated只能用在类、方法和参数上,而@Valid可用于方法、字段构造器和参数上

对平铺参数执行数据校验

  • 我们知道Spring MVC层是默认可以支持Bean Validation
  • 但是我在实际使用起来有很多不便之处(相信我的使用痛点也是小伙伴的痛点),就感觉它是个半拉子: 只支持对JavaBean的验证,而并不支持对Controller处理方法的平铺参数的校验。
  • 我们Controller控制器方法中入参,其实大部分情况下都是平铺参数而非JavaBean的。然而对于平铺参数我们并不能使用@Validated像校验JavaBean一样去做,并且Spring MVC也并没有提供源生的解决方案(其实提供了,哈哈)。
  • 那怎么办?难道真的只能自己书写重复的if else去完成吗?当然不是,那么本文将对此常见的痛点问题(现象)提供两种思路,供给使用者参考~

Controller层平铺参数的校验

  • 因为Spring MVC并不天然支持对控制器方法平铺参数的数据校验,但是这种case的却有非常的常见,因此针对这种常见现象提供一些可靠的解决方案,对你的项目的收益是非常高的。

方案一: 借助Spring对方法级别数据校验的能力

  • 首先必须明确一点: 此能力属于Spring框架的,而不是web框架Spring MVC。

  • Spring对方法级别数据校验的能力非常重要(它能对Service层、Dao层的校验等),前面也重点分析过

  • Spring方法级别数据校验: @Validated + MethodValidationPostProcessor优雅的完成数据校验动作

  • 使用此种方案来解决问题的步骤比较简单,使用起来也非常方便。下面我写个简单示例作为参考:

    1
    2
    3
    4
    5
    6
    7
    8
    @Configuration
    @EnableWebMvc
    public class WebMvcConfig extends WebMvcConfigurerAdapter {
    @Bean
    public MethodValidationPostProcessor mvcMethodValidationPostProcessor() {
    return new MethodValidationPostProcessor();
    }
    }
  • Controller 上使用@Validated标注,然后方法上正常使用约束注解标注平铺的属性:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @RestController
    @RequestMapping
    @Validated
    public class HelloController {
    @PutMapping("/hello/id/{id}/status/{status}")
    public Object helloGet(@Max(5) @PathVariable Integer id, @Min(5) @PathVariable Integer status) {
    return "hello world";
    }
    }
  • 请求: /hello/id/6/status/4 可看见抛异常

  • 由此可见,校验生效了。抛出了javax.validation.ConstraintViolationException异常,这样我们再结合一个全局异常的处理程序,也就能达到我们预定的效果了~

这种方案一样有一个非常值得注意但是很多人都会忽略的地方: 因为我们希望能够代理Controller这个Bean,所以仅仅只在父容器中配置MethodValidationPostProcessor是无效的,必须在子容器(web容器)的配置文件中再配置一个MethodValidationPostProcessor,请务必注意~

  • 有小伙伴问我了,为什么它的项目里只配置了一个MethodValidationPostProcessor也生效了呢? 我的回答是: 检查一下你是否是用的SpringBoot。

借助HandlerInterceptor做拦截处理(轻量)

  • 方案一的使用已经很简单了,但我个人总还觉得怪怪的,因为我一直不喜欢Controller层被代理(可能是洁癖吧)。
  • 因此针对这个现象,我自己接下来提供一个自定义拦截器HandlerInterceptor的处理方案来实现,大家不一定要使用,也是供以参考嘛~
  • 设计思路: Controller拦截器 + @Validated注解 + 自定义校验器(当然这里面涉及到不少细节的: 比如入参解析、绑定等等内置的API)
  1. 准备一个拦截器ValidationInterceptor用于处理校验逻辑:

    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
    // 注意: 此处只支持@RequesrMapping方式~~~~
    public class ValidationInterceptor implements HandlerInterceptor, InitializingBean {

    @Autowired
    private LocalValidatorFactoryBean validatorFactoryBean;
    @Autowired
    private RequestMappingHandlerAdapter adapter;
    private List<HandlerMethodArgumentResolver> argumentResolvers;

    @Override
    public void afterPropertiesSet() throws Exception {
    argumentResolvers = adapter.getArgumentResolvers();
    }

    // 缓存
    private final Map<MethodParameter, HandlerMethodArgumentResolver> argumentResolverCache = new ConcurrentHashMap<>(256);
    private final Map<Class<?>, Set<Method>> initBinderCache = new ConcurrentHashMap<>(64);

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    // 只处理HandlerMethod方式
    if (handler instanceof HandlerMethod) {
    HandlerMethod method = (HandlerMethod) handler;
    Validated valid = method.getMethodAnnotation(Validated.class); //
    if (valid != null) {
    // 根据工厂,拿到一个校验器
    ValidatorImpl validatorImpl = (ValidatorImpl) validatorFactoryBean.getValidator();

    // 拿到该方法所有的参数们~~~ org.springframework.core.MethodParameter
    MethodParameter[] parameters = method.getMethodParameters();
    Object[] parameterValues = new Object[parameters.length];

    //遍历所有的入参: 给每个参数做赋值和数据绑定
    for (int i = 0; i < parameters.length; i++) {
    MethodParameter parameter = parameters[i];
    // 找到适合解析这个参数的处理器~
    HandlerMethodArgumentResolver resolver = getArgumentResolver(parameter);
    Assert.notNull(resolver, "Unknown parameter type [" + parameter.getParameterType().getName() + "]");

    ModelAndViewContainer mavContainer = new ModelAndViewContainer();
    mavContainer.addAllAttributes(RequestContextUtils.getInputFlashMap(request));

    WebDataBinderFactory webDataBinderFactory = getDataBinderFactory(method);
    Object value = resolver.resolveArgument(parameter, mavContainer, new ServletWebRequest(request, response), webDataBinderFactory);
    parameterValues[i] = value; // 赋值
    }

    // 对入参进行统一校验
    Set<ConstraintViolation<Object>> violations = validatorImpl.validateParameters(method.getBean(), method.getMethod(), parameterValues, valid.value());
    // 若存在错误消息,此处也做抛出异常处理 javax.validation.ConstraintViolationException
    if (!violations.isEmpty()) {
    System.err.println("方法入参校验失败~~~~~~~");
    throw new ConstraintViolationException(violations);
    }
    }

    }

    return true;
    }

    private WebDataBinderFactory getDataBinderFactory(HandlerMethod handlerMethod) {
    Class<?> handlerType = handlerMethod.getBeanType();
    Set<Method> methods = this.initBinderCache.get(handlerType);
    if (methods == null) {
    // 支持到@InitBinder注解
    methods = MethodIntrospector.selectMethods(handlerType, RequestMappingHandlerAdapter.INIT_BINDER_METHODS);
    this.initBinderCache.put(handlerType, methods);
    }
    List<InvocableHandlerMethod> initBinderMethods = new ArrayList<>();
    for (Method method : methods) {
    Object bean = handlerMethod.getBean();
    initBinderMethods.add(new InvocableHandlerMethod(bean, method));
    }
    return new ServletRequestDataBinderFactory(initBinderMethods, adapter.getWebBindingInitializer());
    }

    private HandlerMethodArgumentResolver getArgumentResolver(MethodParameter parameter) {
    HandlerMethodArgumentResolver result = this.argumentResolverCache.get(parameter);
    if (result == null) {
    for (HandlerMethodArgumentResolver methodArgumentResolver : this.argumentResolvers) {
    if (methodArgumentResolver.supportsParameter(parameter)) {
    result = methodArgumentResolver;
    this.argumentResolverCache.put(parameter, result);
    break;
    }
    }
    }
    return result;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
    }
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    }

    }

  2. 配置拦截器到Web容器里(拦截所有请求),并且自己配置一个LocalValidatorFactoryBean:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    @Configuration
    @EnableWebMvc
    public class WebMvcConfig extends WebMvcConfigurerAdapter {

    // 自己配置校验器的工厂 自己随意定制化哦~
    @Bean
    public LocalValidatorFactoryBean localValidatorFactoryBean() {
    return new LocalValidatorFactoryBean();
    }

    // 配置用于校验的拦截器
    @Bean
    public ValidationInterceptor validationInterceptor() {
    return new ValidationInterceptor();
    }
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(validationInterceptor()).addPathPatterns("/**");
    }
    }
  3. Controller方法(只需要在方法上标注即可)上标注@Validated注解:

    1
    2
    3
    4
    5
    6
    @Validated // 只需要方法处标注注解即可 非常简便
    @GetMapping("/hello/id/{id}/status/{status}")
    public Object helloGet(@Max(5) @PathVariable("id") Integer id, @Min(5) @PathVariable("status") Integer status) {
    return "hello world";
    }

  • 同样的完美完成了我们的校验需求。针对我自己书写的这一套,这里继续有必要再说说两个小细节:

    • 本例的@PathVariable(“id”)是指定的value值的,因为在处理@PathVariable过程中我并没有去分析字节码来得到形参名,所以为了简便此处写上value值,当然这里是可以优化的,- 有兴趣的小伙伴可自行定制
    • 因为制定了value值,错误信息中也能正确识别出字段名了~
    • 在Spring MVC的自动数据封装体系中,value值不是必须的,只要字段名对应上了也是ok的(这里面运用了字节码技术,后文有讲解)。但是在数据校验中,它可并没有用到字节码结束,请注意做出区分~~~

总结

  • 本文介绍了两种方案来处理我们平时遇到Controller中对处理方法平铺类型的数据校验问题,至于具体你选择哪种方案当然是仁者见仁了。(方案一简便,方案二需要你对Spring MVC的处理流程API很熟练,可炫技)

  • 数据校验相关知识介绍至此,不管是Java上的数据校验,还是Spring上的数据校验,都可以统一使用优雅的Bean Validation来完成了。希望这么长时间来讲的内容能对你的项目有实地的作用,真的能让你的工程变得更加的简介,甚至高能。毕竟真正做技术的人都是追求一定的极致性,甚至是存在代码洁癖,甚至是偏执的~