每篇一句

NBA里有两大笑话:一是科比没天赋,二是詹姆斯没技术

相关阅读

【小家Java】深入了解数据校验:Java Bean Validation 2.0(JSR303、JSR349、JSR380)Hibernate-Validation 6.x使用案例
【小家Spring】让Controller支持对平铺参数执行数据校验(默认Spring MVC使用@Valid只能对JavaBean进行校验)
【小家Spring】Spring方法级别数据校验:@Validated + MethodValidationPostProcessor优雅的完成数据校验动作


<center>对Spring感兴趣可扫码加入wx群:Java高工、架构师3群(文末有二维码)</center>


前言

上篇文章 介绍了Spring环境下实现优雅的方法级别的数据校验,并且埋下一个伏笔:它在Spring MVCController层)里怎么应用呢?本文为此继续展开讲解Spring MVC中的数据校验~

可能小伙伴能立马想到:这不一样吗?我们使用Controller就是方法级别的,所以它就是直接应用了方法级别的校验而已嘛~对于此疑问我先不解答,而是顺势再抛出两个问题你自己应该就能想明白了:

  1. 上文有说过,基于方法级别的校验Spring默认是并未开启的,但是为什么你在Spring MVC却可以直接使用@Valid完成校验呢?

    1. 可能有的小伙伴说他用的是`SpringBoot`可能默认给开启了,其实不然。哪怕你用的传统`Spring MVC`你会发现也是直接可用的,不信你就试试
  2. 类比一下:Spring MVCHandlerInterceptorAOP思想的实现,但你有没有发现即使你没有启动@EnableAspectJAutoProxy的支持,它依旧好使~

若你能想明白我提出的这两个问题,下文就非常不难理解了。当然即使你知道了这两个问题的答案,还是建议你读下去。毕竟:永远相信本文能给你带来意想不到的收获~

使用示例

关于数据校验这一块在Spring MVC中的使用案例,我相信但凡有点经验的Java程序员应该没有不会使用的,并且还不乏熟练的选手。在此之前我简单“采访”过,绝大多数程序员甚至一度认为Spring中的数据校验就是指的在Controller中使用@Validated校验入参JavaBean这一块~

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

@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;
    }

}

@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串如下:

{
  "name" : "fsx",
  "age" : "-1",
  "child" : {
    "age" : 1
  }
}

控制台有如下打印:

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 [必须是正数]]

从打印上看:校验生效(拿着错误消息就可以返回前端展示,或者定位到错误页面都行)。

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

  1. @RequestBody注解不能省略,否则传入的json无法完成数据绑定(即使不绑定,校验也是生效的哦)~
  2. 若方法入参不写BindingResult result这个参数,请求得到的直接是400错误,因为若有校验失败的服务端会抛出异常org.springframework.web.bind.MethodArgumentNotValidException。若写了,那就调用者自己处理喽~

据我不完全和不成熟的统计,就这个案例就覆盖了小伙伴们实际使用中的90%以上的真实使用场景,使用起来确实非常的简单、优雅、高效~

但是作为一个有丰富经验的程序员的你,虽然你使用了@Valid优雅的完成了数据校验,但回头是你是否还会发现你的代码里还是存在了大量的if else的基础的校验?什么原因?其实根本原因只有一个:很多case使用@Valid并不能覆盖,因为它只能校验JavaBean
我相信你是有这样那样的使用痛点的,本文先从原理层面分析,进而给出你所遇到的痛点问题的参考解决参考方案~

原理分析

Controller提供的使用@Valid便捷校验JavaBean的原理,和Spring方法级别的校验支持的原理是有很大差异的(可类比Spring MVC拦截器和Spring AOP的差异区别~),那么现在就看看这块吧

请不要忽视优雅代码的力量,它会成倍提升你的编码效率、成倍降低后期维护成本,甚至成倍提升你的扩展性和成倍降低你写bug的可能性~

回忆DataBinder/WebDataBinder

若对Spring数据绑定模块不是很熟悉的(有阅读过我之前文章的可忽略),建议先补:

  1. 【小家Spring】聊聊Spring中的数据绑定 --- DataBinder本尊(源码分析)
  2. 【小家Spring】聊聊Spring中的数据绑定 --- WebDataBinder、ServletRequestDataBinder、WebBindingInitializer...

DataBinder类名叫数据绑定,但它在org.springframework.validation这个包,可见Spring它把数据绑定和数据校验牢牢的放在了一起,并且内部弱化了数据校验的概念以及逻辑(Spring想让调用者无需关心数据校验的细节,全由它来自动完成,减少使用的成本)。

我们知道DataBinder它主要对外提供了bind(PropertyValues pvs)validate()方法,当然还有处理绑定/校验失败的相关(配置)组件:

public class DataBinder implements PropertyEditorRegistry, TypeConverter {
    ...
    @Nullable
    private AbstractPropertyBindingResult bindingResult; // 它是个BindingResult
    @Nullable
    private MessageCodesResolver messageCodesResolver;
    private BindingErrorProcessor bindingErrorProcessor = new DefaultBindingErrorProcessor();
    // 最重要是它:它是org.springframework.validation.Validator
    // 一个DataBinder 可以持有对个验证器。也就是说对于一个Bean,是可以交给多个验证器去验证的(当然一般都只有一个即可而已~~~)
    private final List<Validator> validators = new ArrayList<>();
    
    public void bind(PropertyValues pvs) {
        MutablePropertyValues mpvs = (pvs instanceof MutablePropertyValues ? (MutablePropertyValues) pvs : new MutablePropertyValues(pvs));
        doBind(mpvs);
    }
    ...
    public void validate() {
        Object target = getTarget();
        Assert.state(target != null, "No target to validate");
        BindingResult bindingResult = getBindingResult();
    
        // 拿到所有的验证器  一个个的对此target进行验证~~~
        // Call each validator with the same binding result
        for (Validator validator : getValidators()) {
            validator.validate(target, bindingResult);
        }
    }
}

DataBinder提供了这两个非常独立的原子方法:绑定 + 校验。他俩结合完成了我们的数据绑定+数据校验,完全的和业务无关~

网上有很多文章说DataBinder完成数据绑定后继续校验,这种说法是不准确的呀,因为它并不处理这部分的组合逻辑,它只提供原始能力~

Spring MVC处理入参的时机

Spring MVC处理入参的逻辑是非常复杂的,前面花大篇幅讲了Spring MVC对返回值的处理器:HandlerMethodReturnValueHandler,详见:
【小家Spring】Spring MVC容器的web九大组件之---HandlerAdapter源码详解---一篇文章带你读懂返回值处理器HandlerMethodReturnValueHandler

同样的,本文只关注它对@RequestBody这种类型的入参进行讲解~
处理入参的处理器:HandlerMethodArgumentResolver,处理@RequestBody最终使用的实现类是:RequestResponseBodyMethodProcessorSpring借助此处理器完成一系列的消息转换器、数据绑定、数据校验等工作~

RequestResponseBodyMethodProcessor

这个类应该是陌生的,在上面推荐的处理MVC返回值的文章中有提到过它:它能够处理@ResponseBody注解返回值(请参考它的supportsReturnType()方法~)
它还有另一个能力是:它能够处理请求参数(当然也是标注了@RequestBody它的~)
所以它既是个处理返回值的HandlerMethodReturnValueHandler,有是一个处理入参的HandlerMethodArgumentResolver。所以它命名为Processor而不是Resolver/Handler嘛,这就是命名的艺术~

// @since 3.1
public class RequestResponseBodyMethodProcessor extends AbstractMessageConverterMethodProcessor {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(RequestBody.class);
    }
    // 类上或者方法上标注了@ResponseBody注解都行
    @Override
    public boolean supportsReturnType(MethodParameter returnType) {
        return (AnnotatedElementUtils.hasAnnotation(returnType.getContainingClass(), ResponseBody.class) || returnType.hasMethodAnnotation(ResponseBody.class));
    }
    
    // 这是处理入参封装校验的入口,也是本文关注的焦点
    @Override
    public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
        
        // 它是支持`Optional`容器的
        parameter = parameter.nestedIfOptional();
        // 使用消息转换器HttpInputMessage把request请求转换出来
        // 此处注意:比如本例入参是Person类,所以经过这里处理会生成一个空的Person对象出来(反射)
        Object arg = readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType());

        // 获取到入参的名称
        // 请注意:这里的名称是类名首字母小写,并不是你方法里写的名字。比如本利若形参名写为personAAA,但是name的值还是person
        String name = Conventions.getVariableNameForParameter(parameter);

        // 只有存在binderFactory才会去完成自动的绑定、校验~
        // 此处web环境为:ServletRequestDataBinderFactory
        if (binderFactory != null) {
            WebDataBinder binder = binderFactory.createBinder(webRequest, arg, name);

            // 显然传了参数才需要去绑定校验嘛
            if (arg != null) {

                // 这里完成数据绑定+数据校验~~~~~(绑定的错误和校验的错误都会放进Errors里)
                // Applicable:适合
                validateIfApplicable(binder, parameter);

                // 若有错误消息hasErrors(),并且仅跟着的一个参数不是Errors类型,Spring MVC会主动给你抛出MethodArgumentNotValidException异常
                // 否则,调用者自行处理
                if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) {
                    throw new MethodArgumentNotValidException(parameter, binder.getBindingResult());
                }
            }
        
            // 把错误消息放进去 证明已经校验出错误了~~~
            // 后续逻辑会判断MODEL_KEY_PREFIX这个key的~~~~
            if (mavContainer != null) {
                mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX + name, binder.getBindingResult());
            }
        }

        return adaptArgumentIfNecessary(arg, parameter);
    }

    // 校验,如果合适的话。使用WebDataBinder,失败信息最终也都是放在它身上~  本方法是本文关注的焦点
    // 入参:MethodParameter parameter
    protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {
        // 拿到标注在此参数上的所有注解们(比如此处有@Valid和@RequestBody两个注解)
        Annotation[] annotations = parameter.getParameterAnnotations();
        for (Annotation ann : annotations) {
            // 先看看有木有@Validated
            Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class);

            // 这个里的判断是关键:可以看到标注了@Validated注解 或者注解名是以Valid打头的 都会有效哦
            //注意:这里可没说必须是@Valid注解。实际上你自定义注解,名称只要一Valid开头都成~~~~~
            if (validatedAnn != null || ann.annotationType().getSimpleName().startsWith("Valid")) {
                // 拿到分组group后,调用binder的validate()进行校验~~~~
                // 可以看到:拿到一个合适的注解后,立马就break了~~~
                // 所以若你两个主机都标注@Validated和@Valid,效果是一样滴~
                Object hints = (validatedAnn != null ? validatedAnn.value() : AnnotationUtils.getValue(ann));
                Object[] validationHints = (hints instanceof Object[] ? (Object[]) hints : new Object[] {hints});
                binder.validate(validationHints);
                break;
            }
        }
    }
    ...
}

本文我们只着眼于关注@Valid的数据校验这块,有几个相关的使用小细节,总结如下:

  1. 形参(@RequestBody标注的入参)的name(形参名)和你写什么无关,若是实体类名字就是类名首字母小写。(若是数组、集合等,都会有自己特定的名称)
  2. @Validated@Valid都能使校验生效,但却不仅仅是它哥俩才能行:任何名称是"Valid"打头的注解都能使得数据校验生效

        1. 自定义注解名称以`Valid`开头,并且给个`value`属性同样能够指定`Group`分组
        2. 个人直接建议使用`@Validated`即可,而去使用`@Valid`了,更不用自己给自己找麻烦去自定义注解啥的了~
  3. 只有当Errors(BindingResult)入参是是仅跟着@Valid注解的实体,Spring MVC才会把错误消息放权交给调用者处理,否则(没有或者不是紧挨着)它会抛出MethodArgumentNotValidException异常~

这是使用@RequestBody结合@Valid完成数据校验的基本原理。其实当Spring MVC在处理@RequestPart注解入参数据时,也会执行绑定、校验的相关逻辑。对应处理器是RequestPartMethodArgumentResolver,原理大体上和这相似,它主要处理Multipart相关,本文忽略~


==此处提示一个点,此文发出去后有一个好奇的小宝宝问我入参能使用多个对象并且都用@RequestBody标注吗?==
关于这个问题姑且先不考虑合理与否,我们这样做试试:

    @PostMapping("/hello")
    public Object helloPost(@Valid @RequestBody Person personAAA, BindingResult result, @Valid @RequestBody Person personBBB) {
        ...
    }

请求却报错了:

Resolved [org.springframework.http.converter.HttpMessageNotReadableException: I/O error while reading input message; nested exception is java.io.IOException: Stream closed]

错误消息很好理解:请求域的Body体是只能被读取一次的(流只能被读取一次嘛)。
若你是好奇的,你可能还会问:URL参数呢?请求链接?后面的参数呢,如何封装???因为本部分内容不是本文的关注点,若有兴趣请出门左拐~

说明:关于使用Map、List、数组等接受@RequestBody参数的情况类似,区别在于绑定器上,对Map、List的校验前面文章有过讲解,此处就不展开了。

希望读者能掌握这部分内容,因为它和面向使用者比较重要的@InitBinder强关联~~~

实际使用中一般使用@Validated分组校验(若需要),然后结合全局异常的处理方式来友好的对调用者展示错误消息~

全局异常处理示例

当校验失败时,Spring会抛出MethodArgumentNotValidException异常,该异常会持有校验结果对象BindingResult,从而获得校验失败信息。本处只给示例,仅供参考:

@RestControllerAdvice
public class MethodArgumentNotValidExceptionHandler {


    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result handleMethodArgumentNotValid(MethodArgumentNotValidException ex) {
        BindingResult bindingResult = ex.getBindingResult();

        StringBuilder stringBuilder = new StringBuilder();
        for (FieldError error : bindingResult.getFieldErrors()) {
            String field = error.getField();
            Object value = error.getRejectedValue();
            String msg = error.getDefaultMessage();
            String message = String.format("错误字段:%s,错误值:%s,原因:%s;", field, value, msg);
            stringBuilder.append(message).append("\r\n");
        }
        return Result.error(MsgDefinition.ILLEGAL_ARGUMENTS.codeOf(), stringBuilder.toString());
    }
}

遗留痛点

你是否发现,虽然Spring MVC给我们提供了极其方便的数据校验方式,但是它还是有比较大的局限性的:它要求待校验的入参是JavaBean

请注意:并不一样要求是请求Body体哦,比如get请求的入参若用JavaBean接收的话,依旧能启用校验

但在实际应用中,其实我们非常多的Controller方法的方法入参是平铺的,也就是所谓的平铺参数,形如这样:

    @PutMapping("/hello/id/{id}/status/{status}")
    public Object helloGet(@PathVariable Integer id, @PathVariable Integer status) {
        ...
        return "hello world";
    }

其实,特别是get请求的case,@RequestParam入参一般是非常多的(比如分页查询),难道对于这种平铺参数的case,我们真的是能通过人肉if else的去校验吗?
兴许你对此问题有兴趣,那就参阅本文吧,它能给你提供解决方案:【小家Spring】让Controller支持对平铺参数执行数据校验(默认Spring MVC使用@Valid只能对JavaBean进行校验)

==@Validated和@Valid的区别==

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

  1. @Valid:标准JSR-303规范的标记型注解,用来标记验证属性和方法返回值,进行级联和递归校验
  2. @ValidatedSpring的注解,是标准JSR-303的一个变种(补充),提供了一个分组功能,可以在入参验证时,根据不同的分组采用不同的验证机制
  3. Controller中校验方法参数时,使用@Valid和@Validated并无特殊差异(若不需要分组校验的话)
  4. @Validated注解可以用于类级别,用于支持Spring进行方法级别的参数校验。@Valid可以用在属性级别约束,用来表示级联校验
  5. @Validated只能用在类、方法和参数上,而@Valid可用于方法、字段、构造器和参数上
最后提示一点:Spring BootWeb Starter已经加入了Bean Validation以及实现的依赖,可以直接使用。但若是纯Spring MVC环境,请自行导入~

总结

本文介绍的是我们平时使用得最多的数据校验场景:使用@Validated完成Controller的入参校验,实现优雅的处理数据校验。同时希望通过本文能让你彻底弄懂@Validated和@Valid使用上的区别以及联系,在实际生产使用中能够做到更加的得心应手~

知识交流

若文章格式混乱,可点击原文链接-原文链接-原文链接-原文链接-原文链接

==The last:如果觉得本文对你有帮助,不妨点个赞呗。当然分享到你的朋友圈让更多小伙伴看到也是被作者本人许可的~==

**若对技术内容感兴趣可以加入wx群交流:Java高工、架构师3群
若群二维码失效,请加wx号:fsx641385712(或者扫描下方wx二维码)。并且备注:"java入群" 字样,会手动邀请入群**


YourBatman
277 声望66 粉丝