代码整洁之道-Bean Validation【原创】

前言:

本篇文章不是API参考文档,所以不会将用到的所有内容详细列出来。本文的目的主要是告诉读者关于Java的 Bean Validation在Spring的应用,并针对常见的场景进行说明,力求让读者对Java的Bean Validation有一个完整的认识和理解。

最后更新日期:2020-02-17

文章关键字:

  • JSR-303
  • Bean Validation 1.0/1.1/2.0
  • MVC Validation
  • Hibernate Validation
  • Spring Validation

为了保证代码的正常运行,经常会对输入输出做大量的校验,以防止非法参数导致程序运行异常,Java 从2009年开始提出了 Bean Validation 1.0(也就是JSR-303)API,力求将输入输入的校验标准化和简单化,更重要的是将校验通用化。Hibernate Validation 是常用的针对Bean Validation API的实现之一(还有Apache BVal),并在Bean Validation 的API基础上,进行了扩展,以覆盖更多的场景。Spring Validation 则在整合了Hibernate Validation 的基础上,以Spring的方式,支持Spring应用的输入输出校验,比如MVC入参校验,方法级校验等等。至此,针对文章关键字已经进行了大概的说明,下面是他们之间的详细关系:

依赖关系

到目前为止Java Bean validation一共有三个版本。

Java Bean Validation版本关系

概览

下面的代码片段是Controller中常见的代码,这里出现了@Valid@Validated@NotEmpty等等和校验相关的注解,但是其目的却很简单:对uuiddtoList两个参数进行校验,并且对list中的元素也进行遍历校验。

后续我们在针对此代码片段进行详细说明。

@Validated
@RestController
public class DemoController {

    @PutMapping("bean/validation/tips/{uuid}")
    @Validated({Default.class, Update.class})
    public ResponseEntity<List<ValidationDTO>> doSomething(
            @PathVariable("uuid") @Size(min=32, max=32) String uuid,
            @RequestBody @Valid @NotEmpty List<ValidationDTO> dtoList) {
            // do something
    }
}

@Valid和@Validated

  • @Valid (javax.validation): 是Bean Validation 中的标准注解,表示对需要校验的 【字段/方法/入参】 进行校验标记

  • @Validated (org.springframework.validation.annotation):是Spring对@Valid扩展后的变体,支持分组校验。

MVC中的校验

Spring中的校验有两种场景,一种是MVC中的controller层校验,一种是添加@Validated的bean的校验,上面提到的例子其实是两种场景的共用的情况。

MVC中的校验比较简单,在Controller的方法入参或者出参添加@Valid或者@Validated注解,即可对标记的对象进行校验。

假设需要校验的目标对象为PersonPerson的每个字段都有一定的业务要求:

public class Person {

    @NotBlank //名称不能为空
    private String name;
    
    @Pattern(regexp = "1[0-9]{10}") // 电话号码满足1开头,11位长的数字
    private String number;

    @NotEmpty //至少有一个地址
    private List<String> address;

  //getter/setter
  
}

则以下几种使用方法都是ok的

// test1: 使用Valied对Person进行校验
@PostMapping("test1")
public ResponseEntity<?> test1(@RequestBody @Valid Person person) {

    return ResponseEntity.ok("ok");
}
// test2: 使用@Validated对person进行校验,并将错误信息绑定到BindingResult中
@PostMapping("test2")
public ResponseEntity<?> test2(@RequestBody @Validated Person person, BindingResult result) {

    if (result.hasErrors()) {
        for (FieldError fieldError : result.getFieldErrors()) {
            //...
        }
        return ResponseEntity.badRequest().body("fail");
    }
    return ResponseEntity.ok("success");
}
// test3: 如果有多个需要校验的参数需要给到BindingResult中,则每个result需要紧跟着被校验对象
@PostMapping("test3")
public ResponseEntity<?> test3(@Validated Person person, BindingResult result,
                               @Validated Person person2, BindingResult result2) {

    if (result.hasErrors()) {
        for (FieldError fieldError : result.getFieldErrors()) {
            //...
        }
        return ResponseEntity.badRequest().body("fail");
    }
    return ResponseEntity.ok("success");
}

综上代码所述:mvc的校验中@Valid@Validated是可以互换的,行为基本一致。test1中没有将校验的结果放到BindingResult中,则controller校验未通过时,会直接扔出异常,如没有自动捕获,则请求会返回BadRequest:400

校验对象树

上述例子中Person是一个较为简单的DTO,如果是一个比较复杂的嵌套的DTO话,则校验的目标就不应该是一个对象,而是一个对象树(可以把每一复杂的对象属性看作一个节点)。这种情况只需要调整DTO中的校验注解,在需要进入到内部校验的对象或者数据集合添加@Valid注解即可。Hibernate Validator官方文档中有较为详细的描述【占坑】。

public static class Employee {

    @NotNull(groups = {Update.class})
     private String uuid;

    @NotBlank(message = "员工姓名不能为空")
    private String name;

    @Pattern(regexp = "1[0-9]{10}")
    private String number;

    @NotEmpty
    private List<String> address;

    @Valid // family中每一个Person对象都进行完整校验
    @NotEmpty
    private List<Person> family;

    @Valid // employee对象也会被作为一个DTO完整校验
    private Employee superior;
}

自定义错误信息&分组校验

上述Employeename字段上的@NotEmpty注解提供了message,其作用是当校验未通过,将会使用message的值作为错误消息返回。如果缺省的话,校验框架会自动生成消息如:"Employee.name can not be empty",大多数情况,校验注解中的message都会配置为Spring的国际化消息的code进行使用。

上述Employeeuuid主键字段上添加了NotNull注解,但是提供了groups,其值为Update.class。其作用是当校验组包含Update.class标记时,此校验注解才会生效,其他未提供组的校验注解默认为Default.class组,也就是默认组。这个就是按组校验,如果要让Employee中所有的校验注解都生效,则需要使用@Validated({Update.class, Default.class}),当然如果只需要默认组生效,直接用@Validated或者@Validated(Default.class)都可以。

下面是用法举例:

// 分组校验
@PostMapping("test1")
public ResponseEntity<?> test4(@RequestBody @Validated({Update.class, Default.class}) Employee employee) {
    return ResponseEntity.ok("ok");
}

MVC的入参校验未生效

ok,到目前都是看起来一切都OK,但是注意下面例子中 test5/test6的情况。

@PostMapping("test5")
public ResponseEntity<?> test5(@Valid @RequestBody List<Person> personList) {
    return ResponseEntity.ok("ok");
}

@PostMapping("test6")
public ResponseEntity<?> test6(@Validated @RequestBody List<Person> personList) {
    return ResponseEntity.ok("ok");
}

接口的批量操作是很常见的需求,比如批量新建数据,这个时候Controller的入参基本上都是集合的形式。但是奇怪的是这种写法并不会生效,无论是@Valid或者@Validated注解。为什么呢?

原因分析:

直接对List集合进行校验的行为和对自定的DTO校验的行为其实是有区别的,区别在于自定义的DTO是被作为一个整体对象校验(可以理解为一个入口),对象里的每一个字段都会被按照标记的注解进行校验。但是将List作为一个整体对象的时候,其内部是没有任何校验注解的,因为java源码中本身就没有添加校验相关的注解。上述的test5test6其本质是方法级别的校验,与下面这个例子test7类似。这个时候@Valid@NotEmpty都想把personList作为一个字段来校验,但是MVC不支持这种模式,所以未生效。

@PostMapping("test7")
public ResponseEntity<?> test7(@Valid @NotEmpty @RequestBody List<Person> personList) {
    return ResponseEntity.ok("ok");
}

解决方案:

解决办法有两种,一种是封装,将接口需要校验的参数封装为一个DTO,然后再校验。第二个种是使用Spring的方法级别的校验,在Controller的类上添加@Validated注解。注意任何Spring的bean都可以添加@Validated注解来进行方法级别的校验,并不是只能用在Controller上,后续会进行详细说明。

详解@Validated注解

关于@Validated注解的功能,官方注释里面已经写的很清楚了,我这里简单翻译下:

  1. JSR-303的变种@Valid,支持验证组规范。支持基于Spring的JSR-303,但不支持JSR-303的特殊扩展。
  2. 可以用于例如Spring MVC处理程序方法参数。通过{@linkorg.springframework.validation.SmartValidator}支持组验证。
  3. 支持方法级的验证。在方法级别上添加此注解,会覆盖类上的组信息。但是方法上的注释不会作为切入点,要想方法上的注解生效,类上也必须添加注解。
  4. 支持元注解,可以添加在自定义注解上,组装为新的注解

通过官方的注释,已经能够明白这个注解的大部分功能了。上文也陆陆续续的提到的@Validated注解,那么除了在MVC的校验中可以与@Valid的替换外,其他情况如何来使用呢?

@Validated加在类上

@Validated加在类上,Spring会将标注的类包装为切面,从而让类中的方法调用时,支持Java的校验,所以当使用@Validated时,不仅可以用于Controller上,其他所有的Spring的bean也都可以使用。

因为@Validated支持分组校验,当加在类上的@Validated提供了分组参数时,默认会应用到类中所有的校验中。比如如下提供的例子,类上的@Validated注解提供了DefaultInsert两个分组标记参数,因此这两个组会默认应用到类中的doSomething方法上。doSomething方法的返回值应用了Insert分组,在此类中就会生效。入参上添加的@NotEmpty没有提供分组参数,默认为Default分组,也会生效。反之,如果此例中类上的分组没有提供Default分组,则下面doSomething方法入参上的@NotEmpty就不会生效。

@Validated({Insert.class, Default.class})
@Component
public class ValidatedTest {
    
    public @NotNull(groups = Insert.class) Object doSomething(@NotEmpty Object[] arg) {
        // do something
        return null;
    }
}

@Validated加在方法上

@Validated注解单独加在方法上时,并不会按照预期的效果工作。因此,@Validated注解加在类上是必要条件。方法上的@Validated注解作用一般是覆盖类上提供的分组。

比如下例中的代码,因为方法上的分组覆盖了类上的分组信息,因此doSomething方法上的@NotNull因为分组不匹配的原因,并不会生效。

@Validated({Insert.class, Default.class})
@Component
public class ValidatedTest {
    @Validated({Default.class})
    public Object doSomething(@NotNull(groups = {Insert.class}) Object arg) {
        // do something
        return null;
    }
}

实战

实际使用较为复杂的情况,会用到上文中提到的一个或者多个特性组合使用。继续使用文章开头的例子进行讲解。

@Validated
@RestController
public class DemoController {

    @PutMapping("bean/validation/tips/{uuid}")
    @Validated({Default.class, Update.class})
    public ResponseEntity<List<ValidationDTO>> doSomething(
            @PathVariable("uuid") @Size(min=32, max=32) String uuid,
            @RequestBody @Valid @NotEmpty List<ValidationDTO> dtoList) {
            // do something
    }
}

本例中,首先类上添加了@Validated注解,没有指定分组参数,因为默认为Default分组。然后doSomething方法添加了@Validated注解并覆盖了类上的默认分组信息,额外添加了Update分组。因此,此方法的校验会在DefaultUpdate上生效。

uuid参数上有一个@Size注解,指定了字符串的长度只能为32,默认分组,因此会生效。

指定长度为32有什么意义,除了对生产环境的入参严格校验之外,对开发也是有帮助的。比如我经常会遇到对接的前端的代码有bug,传递了undefineduuid参数中,如果此时添加了长度校验,就可以一眼看出来问题,而不用再去debug代码。

dtoList参数就有意思了,为了遍历校验到list中的所有元素,需要添加@Valid注解,除此之外,为了保证入参的有效性,避免无效的请求,添加了@NotEmpty注解,保证集合中至少有一个元素。而方法上标注的分组信息DefultUpdate会应用于集合中的每一个元素的校验上。

如果ValidationDTO如下,则在DefaultUpdate分组有效时只有contentversionNumber字段上的注解会生效。

class ValidationDTO {
    
    @NotEmpty(groups = Insert.class)
    private String id;
    
    @NotBlank
    private String content;
    
    @NotNull(groups = Update.class)
    private Long versionNumber;
    
    @Valid
    @NotEmpty(groups = Insert.class)
    private List<ValidationDTO> children;
    
}

分组校验有什么意义:

实际的业务场景往往比较复杂,单个DTO可能会用于新建和更新等多个方法入参上,因为更新和新建的时候,业务需求的参数不一样,因此校验的要求也就不一样,这个时候如果没有分组校验的支持,我们可能需要建立两个DTO来分别满足新建和更新两种操作场景。而如果有了分组校验,就可以针对业务要求,只开启需要校验的分组,保证的代码的简洁和通用。

常见错误

  1. HV000151问题

javax.validation.ConstraintDeclarationException: HV000151: A method overriding another method must not redefine the parameter constraint configuration, but method XxxxImpl.

翻译过来就是说,子类重写的方法或者实现类的方法不能重新定义校验注解,如果校验注解不一致,则扔出HV000151问题。

但是以下情况是允许的:

  • 覆盖父类或者接口的分组信息

public interface A {

    void doSomething(@Valid Object arg);
}

@Component
@Validated
public class B implement A {

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

推荐阅读更多精彩内容