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.
本文到此结束。


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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,793评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,567评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,342评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,825评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,814评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,680评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,033评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,687评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,175评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,668评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,775评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,419评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,020评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,206评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,092评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,510评论 2 343

推荐阅读更多精彩内容