01.spring-mvc 结合 Java Validation Api 提升web开发效能

一、痛点?

在应用程序的业务逻辑中,经常会碰到参数校验的情况,通常我们使用spring-mvc来接收用户请求数据一般会封装成一个Bean,需要校验字段值是否空,长度,枚举格式等情况下,如果使用SringUtils或者if判断来解决,代码会阅读不友好,维护成本大,代码冗余。 因此有了JSR 303.

Bean Validation为JavaBean提供了相应的API来给我们做参数的验证。通过Bean Validation比如@NotNull @Pattern等方法来对我们字段的值做进一步的教研。

Bean Validation 是一个运行时框架,在验证之后错误信息会直接返回。

二、使用

1. 添加maven依赖

<!--添加依赖-->
<dependency>
 <groupId>javax.validation</groupId>
 <artifactId>validation-api</artifactId>
 <version>2.0.1.Final</version>
</dependency>

注: hibernate-validator 扩展了些自定义的validator可供参考。

<dependency>
    <groupId>org.hibernate.validator</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>6.2.0.Final</version>
</dependency>

2. 常用注解说明

  • constraints 包下边即定义好的注解,可直接使用
注解 用途
AssertFalse 用于boolean字段,该字段的值只能为false
AssertTrue 用于boolean字段,该字段只能为true
DecimalMax(value) 被注释的元素必须是一个数字,只能大于或等于该值
DecimalMin(value) 被注释的元素必须是一个数字,只能小于或等于该值
Digits(integer,fraction) 检查是否是一种数字的(整数,小数)的位数
Email 被注释的元素必须是电子邮箱地址
Future 检查该字段的日期是否是属于将来的日期
FutureOrPresent 判断日期是否是将来或现在日期
Max(value) 该字段的值只能小于或等于该值
Min(value) 该字段的值只能大于或等于该值
Negative 判断负数
NegativeOrZero 判断负数或0
NotBlank 只能用于字符串不为null,并且字符串trim()以后length要大于0
NotEmpty 集合对象的元素不为0,即集合不为空,也可以用于字符串不为null
NotNull 不能为null
Null 必须为 null
Past 检查该字段的日期是在过去
PastOrPresent 判断日期是否是过去或现在日期
Pattern(value) 被注释的元素必须符合指定的正则表达式
Positive 判断正数
PositiveOrZero 判断正数或0
Size(max, min) 检查该字段的size是否在min和max之间,可以是字符串、数组、集合、Map等
Length(max, min) 判断字符串长度
CreditCardNumber 被注释的字符串必须通过Luhn校验算法,银行卡,信用卡等号码一般都用Luhn计算合法性

三、自定义注解

1. 自定义Mobile注解

//注解作用范围
@Target({
        ElementType.METHOD,
        ElementType.FIELD,
        ElementType.ANNOTATION_TYPE,
        ElementType.CONSTRUCTOR,
        ElementType.PARAMETER,
        ElementType.TYPE_USE
})
//注解保留阶段
@Retention(RetentionPolicy.RUNTIME)
@Documented
//指定验证器
@Constraint(
        validatedBy = MobileValidator.class
)
public @interface Mobile {

    String message() default "手机号格式不正确";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

2. 验证器 MobileValidator

实现ConstraintValidator 的isValid方法即可

public class MobileValidator implements ConstraintValidator<Mobile, String> {
    @Override
    public void initialize(Mobile annotation) {
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        // 如果手机号为空,默认不校验,即校验通过
        if (!StringUtils.hasText(value)) {
            return true;
        }
        // 校验手机
        return ValidationUtil.isMobile(value);
    }
}

3. 编写UserVo

public class UserVo {

    @NotEmpty(message = "用户名不能为空")
    @Length(min = 4, max = 6, message = "手机验证码长度为 4-6 位")
    @Pattern(regexp = "^[0-9]+$", message = "手机验证码必须都是数字")
    private String name;

    @Mobile()
    private String mobile;

    public String getName() {
        return name;
    }

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

    public String getMobile() {
        return mobile;
    }

    public void setMobile(String mobile) {
        this.mobile = mobile;
    }

    @Override
    public String toString() {
        return new org.apache.commons.lang3.builder.ToStringBuilder(this)
                .append("name", name)
                .append("mobile", mobile)
                .toString();
    }
}

4. 测试Controller

    @RequestMapping("/validTest")
    @ResponseBody
    public String validTest(@Valid @RequestBody UserVo userVo) {
        String print = userVo.toString();
        log.info(print);
        return print;
    }

注意:如果輸入的参数不正确,会抛出MethodArgumentNotValidException 异常,并会返回400错误给前端, 我们进一步美化输出统一格式的错误。

如果controller签名方法 参数使用

@GetMapping("/label/list")
@ResponseBody
@Validated //加上才能被spring处理
public RdfaResult<List<LabelVo>> label_list_parent( @NotEmpty(message = "缺少参数") @RequestParam("kpi_level") String kpi_level) throws Exception {

    return RdfaResult.success(ResponseErrorEnum.SUCESS.getCode(), ResponseErrorEnum.SUCESS.getVal(), resp);
}

5. 定义全局异常处理器

/**
 * 全局异常处理器,将 Exception 翻译成 CommonResult + 对应的异常编号
 */
@RestControllerAdvice
public class GlobalExceptionHandler {


    private Logger logger = LoggerFactory.getLogger(getClass());
 
    /**
     * 处理 SpringMVC  参数校验异常 Validator 校验
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public CommonResult MethodArgumentNotValidException(MethodArgumentNotValidException ex) {
        logger.warn("[MethodArgumentNotValidException]", ex);
        FieldError fieldError = ex.getBindingResult().getFieldError();
        
        //断言,避免告警
        assert fieldError != null; 
        
        String format = String.format("请求参数不正确:%s", fieldError.getDefaultMessage());
        CommonResult<String> commonResult =CommonResult.fail(msg); 
        return commonResult;
    }
}
  • spring-web 组件, org.springframework.web.bind即常见的参数绑定异常


    spring-mvc-bind异常

四、自定义枚举校验器

检验器实现方式有好多种,基本原理就是从自定义枚举注解上边获取原数据,然后使用当前值与枚举所有值进行比较,存在则返回true, 否则返回fase.

  1. 枚举校验注解
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = EnumCheckValidator.class)
public @interface EnumCheck {
    /**
     * 是否必填 默认是必填的
     * @return
     */
    boolean required() default true;
    /**
     * 验证失败的消息
     * @return
     */
    String message() default "枚举的验证失败";
    /**
     * 分组的内容
     * @return
     */
    Class<?>[] groups() default {};

    /**
     * 错误验证的级别
     * @return
     */
    Class<? extends Payload>[] payload() default {};

    /**
     * 枚举的Class
     * @return
     */
    Class<? extends Enum<?>> enumClass();

    /**
     * 枚举中的验证方法
     * @return
     */
    String enumMethod() default "validation";
}
  1. 枚举校验器
public class EnumCheckValidator implements ConstraintValidator<EnumCheck, Object> {

    private static final Logger logger = LoggerFactory.getLogger(EnumCheckValidator.class);

    private EnumCheck enumCheck;

    @Override
    public void initialize(EnumCheck enumCheck) {
        this.enumCheck =enumCheck;
    }

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext constraintValidatorContext) {
        // 注解表明为必选项 则不允许为空,否则可以为空
        if (value == null) {
            return !this.enumCheck.required();
        }

        Boolean result = Boolean.FALSE;
        Class<?> valueClass = value.getClass();
        try {
            //通过反射执行枚举类中validation方法
            Method method = this.enumCheck.enumClass().getMethod(this.enumCheck.enumMethod(), valueClass);
            result = (Boolean)method.invoke(null, value);
            if(result == null){
                return false;
            }
        } catch (Exception e) {
            logger.error("custom EnumCheckValidator error", e);
        }
        return result;
    }
}

六、原理

  1. 在配置spring-mvc 时有个核心类 WebMvcConfigurationSupport.java 会配置 Validator 的一个实例。
@Bean
public Validator mvcValidator() {
    Validator validator = getValidator();
    if (validator == null) {
        if (ClassUtils.isPresent("javax.validation.Validator", getClass().getClassLoader())) {
            Class<?> clazz;
            try {
                String className = "org.springframework.validation.beanvalidation.OptionalValidatorFactoryBean";
                clazz = ClassUtils.forName(className, WebMvcConfigurationSupport.class.getClassLoader());
            }
            catch (ClassNotFoundException | LinkageError ex) {
                throw new BeanInitializationException("Failed to resolve default validator class", ex);
            }
            validator = (Validator) BeanUtils.instantiateClass(clazz);
        }
        else {
            validator = new NoOpValidator();
        }
    }
    return validator;
}

这个实例的作用是:用于验证标注了@ModelAttribute和@RequestBody注解的方法参数。实例化时,首先委托getValidator(),如果返回null,则检查类路径上是否存在 JSR-303实现(javax.validation.Validator), 存在则实例化OptionalValidatorFactoryBean,否则返回一个无操作的Validator。

  1. 在 实例化 OptionalValidatorFactoryBean 的时候,初始化了SpringConstraintValidatorFactory 用来代理ConstraintValidatorFactory(JSR-303)。

  2. HandlerMethodArgumentResolver 的实现类 RequestPartMethodArgumentResolver 里边作注解参数的解析及验证。


    image.png

默认检测 @javax.validation.Valid、Spring's Validated、自定义以 "Valid" 开头的注解


protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {
    Annotation[] annotations = parameter.getParameterAnnotations();
    for (Annotation ann : annotations) {
        Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class);
        if (validatedAnn != null || ann.annotationType().getSimpleName().startsWith("Valid")) {
            Object hints = (validatedAnn != null ? validatedAnn.value() : AnnotationUtils.getValue(ann));
            Object[] validationHints = (hints instanceof Object[] ? (Object[]) hints : new Object[] {hints});
            binder.validate(validationHints);
            break;
        }
    }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
禁止转载,如需转载请通过简信或评论联系作者。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,142评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,298评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,068评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,081评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,099评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,071评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,990评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,832评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,274评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,488评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,649评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,378评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,979评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,625评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,796评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,643评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,545评论 2 352

推荐阅读更多精彩内容