springboot Hibernate Validate 校验

前言
在做web相关的应用时,经常需要提供接口与用户交互(获取数据、上传数据等),由于这个过程需要用户进行相关的操作,为了避免出现一些错误的数据等,一般需要对数据进行校验,随着接口的增多,校验逻辑的冗余度也越来越大,虽然可以通过抽象出校验的方法来处理,但还是需要每次手动调用校验逻辑,相对来说还是不方便。

为了解决这个问题,Java中提供了Bean Validation的标准,该标准规定了校验的具体内容,通过简单的注解就能完成必要的校验逻辑了,相对来说就方便了很多,而该规范其实只是规范,并没有具体的实现,Hibernate提供了具体的实现,也即Hibernate Validator,这个也是目前使用得比较多的验证器了。

在SpringBoot中使用Hibernate Validate
首先新建一个spring boot项目,引入web依赖

pom.xml

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

在web依赖中,已经引入了hibernate-validator的支持,所以只需要引入web依赖即可。

mvn dependency:tree命令可以查看依赖情况

...
[INFO] +- org.springframework.boot:spring-boot-starter-web:jar:2.1.0.RELEASE:compile
[INFO] |  +- org.hibernate.validator:hibernate-validator:jar:6.0.13.Final:compile
[INFO] |  |  +- javax.validation:validation-api:jar:2.0.1.Final:compile
[INFO] |  |  +- org.jboss.logging:jboss-logging:jar:3.3.2.Final:compile
[INFO] |  |  \- com.fasterxml:classmate:jar:1.4.0:compile
...

如果你所使用的版本没有支持,或者不是使用SpringBoot项目,具体的请参考文档解决。

然后配置一下validator,由于默认情况下,Hibernate-validator使用的校验策略是依次校验,并且将不通过的结果保存,最后再统一抛出异常信息,但实际上,当校验出现第一个不满足情况的时候,就可以停止了(当然,如果选择全部验证完也是可以的),所以我们手动配置一下ValidatorConfig

@Configuration
public class ValidatorConfig {

    @Bean
    public Validator validator() {
        ValidatorFactory factory = Validation.byProvider(HibernateValidator.class)
                .configure()
                // 将fail_fast设置为true即可,如果想验证全部,则设置为false或者取消配置即可
                .addProperty("hibernate.validator.fail_fast", "true"                .buildValidatorFactory();
        return factory.getValidator();
    }
java
}

接下来编写需要进行验证的Bean

User.java

public class User {
    @NotBlank(message = "用户名不能为空")
    private String name;

    @Max(value = 120, message = "年龄不能超过120岁")
    private int age;

    @NotNull
    @Size(min = 8, max = 20, message = "密码必须大于8位并且小于20位")
    private String password;

    @Email(message = "请输入符合格式的邮箱")
    private String email;

    // 省略 set、get方法
}

上面的注解已经很能够见名知意了,所以这里就先不讲解,后面再补充常用的验证注解及作用总结。

定义一个简单的测试接口

UserController.java

@RestController
@RequestMapping("/users")
public class UserController {
   @PostMapping
    public User addUser(@Valid @RequestBody User user) {
        // 仅测试验证过程,省略其他的逻辑
        return user;
    }
}  

注意上面所使用的@Valid注解,通过该注解能够使得验证生效,如果去除的话,可以看到验证逻辑并没有生效。

通过上面的一个简单注解之后,验证的逻辑已经能够生效,然而,在测试的时候,可能会出现下面的情况

{
    "timestamp": "2018-11-09T01:47:56.985+0000",
    "status": 400,
    "error": "Bad Request",
    "errors": [
        {
            "codes": [
                "Size.user.password",
                "Size.password",
                "Size.java.lang.String",
                "Size"
            ],
            "arguments": [
                {
                    "codes": [
                        "user.password",
                        "password"
                    ],
                    "arguments": null,
                    "defaultMessage": "password",
                    "code": "password"
                },
                20,
                8
            ],
            "defaultMessage": "密码必须大于8位并且小于20位",
            "objectName": "user",
            "field": "password",
            "rejectedValue": "huanfe",
            "bindingFailure": false,
            "code": "Size"
        }
    ]

//省略trace信息

}

这是因为,默认情况下,SpringBoot配置了默认异常处理器DefaultHandlerExceptionResolver,而该处理器仅仅是将异常信息打印出来,显然,我们并不需要返回如此多的信息,只需要将对应属性中的message信息给调用者即可,解决的方法有两种。

在需要验证的方法中加入BindingResult参数,SpringBoot会自动将异常错误信息绑定到该参数上,然后处理对应的逻辑,如下

UserController.java

@PostMapping
public User addUser(@Valid @RequestBody User user, BindingResult bindingResult) {
    if (bindingResult.hasErrors()) {
        // 具体的处理逻辑,如封装错误信息等
    }
    return user;
}

但是这种方式不是很优雅,因为对于每一个需要验证的方法,都需要进行这样的逻辑(虽然封装处理可以解决,但依旧每次需要手动调用以及加入BindingResult参数)

由于在验证失败的时候,会抛出异常,所以可以使用全局异常处理器来捕获该异常,然后进行统一处理即可,具体的异常类型是MethodArgumentNotValidException,具体实现如下所示

GlobalExceptionHandler.java

@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResultInfo<?> validationErrorHandler(MethodArgumentNotValidException ex) {
        // 同样是获取BindingResult对象,然后获取其中的错误信息
        // 如果前面开启了fail_fast,事实上这里只会有一个信息
        //如果没有,则可能又多个
        List<String> errorInformation = ex.getBindingResult().getAllErrors()
                .stream()
                .map(ObjectError::getDefaultMessage)
                .collect(Collectors.toList());
        return new ResultInfo<>(400, errorInformation.toString(), null);
    }
}

这里的ResultInfo是自定义的一个结果对象,用于作为统一的返回对象,内容如下

ResultInfo.java

public class ResultInfo<T> {
    private int code;
    private String message;
    private T body;
   
   public ResultInfo(int code, String message, T body) {
        this.code = code;
        this.message = message;
        this.body = body;
    }

    public ResultInfo(int code, String message) {
        this(code, message, null);
    }

    public ResultInfo(String message) {
        this(200, message, null);
    }
    //省略get、set方法
}

通过上面的处理之后,现在如果验证不通过,则可以以比较优雅的方式返回给调用者了。

{
    "code": 400,
    "message": "[密码必须大于8位并且小于20位]",
    "body": null
}

这里需要注意,如果上面两种方式都开启的话,是以第一种方式优先的,所以,第二种方式不会生效。

上面的方式能够解决@RequestBody标注的参数的验证及错误处理,然而,并不能处理@PathVariable以及@RequestParam标注的入参(不生效),而事实上,这两种类型的操作也是非常常用的(也是需要对这两种类型进行验证,除了手动验证外,还有一种通用的解决方案,也是通过注解来实现),对于这两种类型同样可以使用验证注解进行标注,如下所示

UserController.java

@RestController
@RequestMapping("/users")
public class UserController {
    // .....

    @GetMapping("/{name}")
    public User getUserByName(
                    @NotNull 
                    @Size(min = 1, max = 20, message = "用户名格式有误")
                    @PathVariable String name) {
        User user = new User();
        user.setName(name);
        return user;
    }

    @GetMapping
    public User getUserByNameParam(
                    @NotNull 
                    @Size(min = 1, max = 20, message = "用户名格式有误") 
                    @RequestParam("name") String name) {
        User user = new User();
        user.setName(name);
        return user;
    }
}

为了让对应的注解生效,可以在类的上方使用@Validated进行标注,注意是标注在类上方,即

@RestController
@RequestMapping("/users")
@Validated
public class UserController {
    // ...
}

但此时如果验证失败,会抛出异常信息,而且,异常类型不是MethodArgumentNotValidException,而是ConstraintViolationException,巨坑!!!,所以还需要捕获该类型并且进行处理,如下所示

GlobalExceptionHandler.java

@ExceptionHandler(ConstraintViolationException.class)
public ResultInfo<?> validationErrorHandler(ConstraintViolationException ex) {
    List<String> errorInformation = ex.getConstraintViolations()
            .stream()
            .map(ConstraintViolation::getMessage)
            .collect(Collectors.toList());
    return new ResultInfo<>(400, errorInformation.toString(), null);
}

到此,基本上的参数验证就能完成了。

此外,在搜索解决方案的过程中,也发现了两个有用的小技巧,顺便记录在这里(跟上面的内容无关哈)。

对于@PathVariable还有另外一种解决方案(严格来说作为验证并不完善),通过正则表达式来进行匹配(这种方式不支持长度限制,但可以进行类型限制,如只包含字符,只包含数字等),如下所示

UserController.java

@GetMapping("/{name:[a-zA-Z]+")
public User getUserByNameRegex(@PathVariable String name) {
    User user = new User();
    user.setName(name);
    return user;
}

对于@RequestParam来说,可以使用默认值来实现可变化的参数列表,如下所示

UserController.java

@GetMapping
public User getUser(
    @RequestParam(required = true, value = "name") String name,
    @RequestParam(value = "age", defaultValue = "22") int age) {
    return new User();
}

更复杂的如分页,排序等等,可以通过默认参数的形式,来实现,而不再需要强制调用者输入对应的参数(毕竟这些参数是可选的嘛)。

常用验证注解
常用的注解主要有以下几个,作用及内容如下所示

@Null,标注的属性值必须为空
@NotNull,标注的属性值不能为空
@AssertTrue,标注的属性值必须为true
@AssertFalse,标注的属性值必须为false
@Min,标注的属性值不能小于min中指定的值
@Max,标注的属性值不能大于max中指定的值
@DecimalMin,小数值,同上
@DecimalMax,小数值,同上
@Negative,负数
@NegativeOrZero,0或者负数
@Positive,整数
@PositiveOrZero,0或者整数
@Size,指定字符串长度,注意是长度,有两个值,min以及max,用于指定最小以及最大长度
@Digits,内容必须是数字
@Past,时间必须是过去的时间
@PastOrPresent,过去或者现在的时间
@Future,将来的时间
@FutureOrPresent,将来或者现在的时间
@Pattern,用于指定一个正则表达式
@NotEmpty,字符串内容非空
@NotBlank,字符串内容非空且长度大于0
@Email,邮箱
@Range,用于指定数字,注意是数字的范围,有两个值,min以及max
总结

本小节主要学习了如何在SpringBoot中使用Hibernate-Validator,验证器的作用在于验证参数是否符合规定,通过配置验证器以及对应的异常处理器,可以使我们从繁琐的验证流程中解脱出来,当然,对于复杂的验证,其实还是要手动验证的,验证器能提供的是一些通用的,常规的验证操作,当然,大部分情况下已经足够了

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

推荐阅读更多精彩内容