代碼整潔之道-Bean Validation【原創(chuàng)】

前言:

本篇文章不是API參考文檔,所以不會(huì)將用到的所有內(nèi)容詳細(xì)列出來(lái)。本文的目的主要是告訴讀者關(guān)于Java的 Bean Validation在Spring的應(yīng)用,并針對(duì)常見(jiàn)的場(chǎng)景進(jìn)行說(shuō)明,力求讓讀者對(duì)Java的Bean Validation有一個(gè)完整的認(rèn)識(shí)和理解。

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

文章關(guān)鍵字:

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

為了保證代碼的正常運(yùn)行,經(jīng)常會(huì)對(duì)輸入輸出做大量的校驗(yàn),以防止非法參數(shù)導(dǎo)致程序運(yùn)行異常,Java 從2009年開(kāi)始提出了 Bean Validation 1.0(也就是JSR-303)API,力求將輸入輸入的校驗(yàn)標(biāo)準(zhǔn)化和簡(jiǎn)單化,更重要的是將校驗(yàn)通用化。Hibernate Validation 是常用的針對(duì)Bean Validation API的實(shí)現(xiàn)之一(還有Apache BVal),并在Bean Validation 的API基礎(chǔ)上,進(jìn)行了擴(kuò)展,以覆蓋更多的場(chǎng)景。Spring Validation 則在整合了Hibernate Validation 的基礎(chǔ)上,以Spring的方式,支持Spring應(yīng)用的輸入輸出校驗(yàn),比如MVC入?yún)⑿r?yàn),方法級(jí)校驗(yàn)等等。至此,針對(duì)文章關(guān)鍵字已經(jīng)進(jìn)行了大概的說(shuō)明,下面是他們之間的詳細(xì)關(guān)系:

依賴(lài)關(guān)系

到目前為止Java Bean validation一共有三個(gè)版本。

Java Bean Validation版本關(guān)系

概覽

下面的代碼片段是Controller中常見(jiàn)的代碼,這里出現(xiàn)了@Valid@Validated@NotEmpty等等和校驗(yàn)相關(guān)的注解,但是其目的卻很簡(jiǎn)單:對(duì)uuiddtoList兩個(gè)參數(shù)進(jìn)行校驗(yàn),并且對(duì)list中的元素也進(jìn)行遍歷校驗(yàn)。

后續(xù)我們?cè)卺槍?duì)此代碼片段進(jìn)行詳細(xì)說(shuō)明。

@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 中的標(biāo)準(zhǔn)注解,表示對(duì)需要校驗(yàn)的 【字段/方法/入?yún)ⅰ?進(jìn)行校驗(yàn)標(biāo)記

  • @Validated (org.springframework.validation.annotation):是Spring對(duì)@Valid擴(kuò)展后的變體,支持分組校驗(yàn)。

MVC中的校驗(yàn)

Spring中的校驗(yàn)有兩種場(chǎng)景,一種是MVC中的controller層校驗(yàn),一種是添加@Validated的bean的校驗(yàn),上面提到的例子其實(shí)是兩種場(chǎng)景的共用的情況。

MVC中的校驗(yàn)比較簡(jiǎn)單,在Controller的方法入?yún)⒒蛘叱鰠⑻砑?code>@Valid或者@Validated注解,即可對(duì)標(biāo)記的對(duì)象進(jìn)行校驗(yàn)。

假設(shè)需要校驗(yàn)的目標(biāo)對(duì)象為PersonPerson的每個(gè)字段都有一定的業(yè)務(wù)要求:

public class Person {

    @NotBlank //名稱(chēng)不能為空
    private String name;
    
    @Pattern(regexp = "1[0-9]{10}") // 電話號(hào)碼滿足1開(kāi)頭,11位長(zhǎng)的數(shù)字
    private String number;

    @NotEmpty //至少有一個(gè)地址
    private List<String> address;

  //getter/setter
  
}

則以下幾種使用方法都是ok的

// test1: 使用Valied對(duì)Person進(jìn)行校驗(yàn)
@PostMapping("test1")
public ResponseEntity<?> test1(@RequestBody @Valid Person person) {

    return ResponseEntity.ok("ok");
}
// test2: 使用@Validated對(duì)person進(jìn)行校驗(yàn),并將錯(cuò)誤信息綁定到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: 如果有多個(gè)需要校驗(yàn)的參數(shù)需要給到BindingResult中,則每個(gè)result需要緊跟著被校驗(yàn)對(duì)象
@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的校驗(yàn)中@Valid@Validated是可以互換的,行為基本一致。test1中沒(méi)有將校驗(yàn)的結(jié)果放到BindingResult中,則controller校驗(yàn)未通過(guò)時(shí),會(huì)直接扔出異常,如沒(méi)有自動(dòng)捕獲,則請(qǐng)求會(huì)返回BadRequest:400

校驗(yàn)對(duì)象樹(shù)

上述例子中Person是一個(gè)較為簡(jiǎn)單的DTO,如果是一個(gè)比較復(fù)雜的嵌套的DTO話,則校驗(yàn)的目標(biāo)就不應(yīng)該是一個(gè)對(duì)象,而是一個(gè)對(duì)象樹(shù)(可以把每一復(fù)雜的對(duì)象屬性看作一個(gè)節(jié)點(diǎn))。這種情況只需要調(diào)整DTO中的校驗(yàn)注解,在需要進(jìn)入到內(nèi)部校驗(yàn)的對(duì)象或者數(shù)據(jù)集合添加@Valid注解即可。Hibernate Validator官方文檔中有較為詳細(xì)的描述【占坑】。

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中每一個(gè)Person對(duì)象都進(jìn)行完整校驗(yàn)
    @NotEmpty
    private List<Person> family;

    @Valid // employee對(duì)象也會(huì)被作為一個(gè)DTO完整校驗(yàn)
    private Employee superior;
}

自定義錯(cuò)誤信息&分組校驗(yàn)

上述Employeename字段上的@NotEmpty注解提供了message,其作用是當(dāng)校驗(yàn)未通過(guò),將會(huì)使用message的值作為錯(cuò)誤消息返回。如果缺省的話,校驗(yàn)框架會(huì)自動(dòng)生成消息如:"Employee.name can not be empty",大多數(shù)情況,校驗(yàn)注解中的message都會(huì)配置為Spring的國(guó)際化消息的code進(jìn)行使用。

上述Employeeuuid主鍵字段上添加了NotNull注解,但是提供了groups,其值為Update.class。其作用是當(dāng)校驗(yàn)組包含Update.class標(biāo)記時(shí),此校驗(yàn)注解才會(huì)生效,其他未提供組的校驗(yàn)注解默認(rèn)為Default.class組,也就是默認(rèn)組。這個(gè)就是按組校驗(yàn),如果要讓Employee中所有的校驗(yàn)注解都生效,則需要使用@Validated({Update.class, Default.class}),當(dāng)然如果只需要默認(rèn)組生效,直接用@Validated或者@Validated(Default.class)都可以。

下面是用法舉例:

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

MVC的入?yún)⑿r?yàn)未生效

ok,到目前都是看起來(lái)一切都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");
}

接口的批量操作是很常見(jiàn)的需求,比如批量新建數(shù)據(jù),這個(gè)時(shí)候Controller的入?yún)⒒旧隙际羌系男问健5瞧婀值氖沁@種寫(xiě)法并不會(huì)生效,無(wú)論是@Valid或者@Validated注解。為什么呢?

原因分析:

直接對(duì)List集合進(jìn)行校驗(yàn)的行為和對(duì)自定的DTO校驗(yàn)的行為其實(shí)是有區(qū)別的,區(qū)別在于自定義的DTO是被作為一個(gè)整體對(duì)象校驗(yàn)(可以理解為一個(gè)入口),對(duì)象里的每一個(gè)字段都會(huì)被按照標(biāo)記的注解進(jìn)行校驗(yàn)。但是將List作為一個(gè)整體對(duì)象的時(shí)候,其內(nèi)部是沒(méi)有任何校驗(yàn)注解的,因?yàn)閖ava源碼中本身就沒(méi)有添加校驗(yàn)相關(guān)的注解。上述的test5test6其本質(zhì)是方法級(jí)別的校驗(yàn),與下面這個(gè)例子test7類(lèi)似。這個(gè)時(shí)候@Valid@NotEmpty都想把personList作為一個(gè)字段來(lái)校驗(yàn),但是MVC不支持這種模式,所以未生效。

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

解決方案:

解決辦法有兩種,一種是封裝,將接口需要校驗(yàn)的參數(shù)封裝為一個(gè)DTO,然后再校驗(yàn)。第二個(gè)種是使用Spring的方法級(jí)別的校驗(yàn),在Controller的類(lèi)上添加@Validated注解。注意任何Spring的bean都可以添加@Validated注解來(lái)進(jìn)行方法級(jí)別的校驗(yàn),并不是只能用在Controller上,后續(xù)會(huì)進(jìn)行詳細(xì)說(shuō)明。

詳解@Validated注解

關(guān)于@Validated注解的功能,官方注釋里面已經(jīng)寫(xiě)的很清楚了,我這里簡(jiǎn)單翻譯下:

  1. JSR-303的變種@Valid,支持驗(yàn)證組規(guī)范。支持基于Spring的JSR-303,但不支持JSR-303的特殊擴(kuò)展。
  2. 可以用于例如Spring MVC處理程序方法參數(shù)。通過(guò){@linkorg.springframework.validation.SmartValidator}支持組驗(yàn)證。
  3. 支持方法級(jí)的驗(yàn)證。在方法級(jí)別上添加此注解,會(huì)覆蓋類(lèi)上的組信息。但是方法上的注釋不會(huì)作為切入點(diǎn),要想方法上的注解生效,類(lèi)上也必須添加注解。
  4. 支持元注解,可以添加在自定義注解上,組裝為新的注解

通過(guò)官方的注釋?zhuān)呀?jīng)能夠明白這個(gè)注解的大部分功能了。上文也陸陸續(xù)續(xù)的提到的@Validated注解,那么除了在MVC的校驗(yàn)中可以與@Valid的替換外,其他情況如何來(lái)使用呢?

@Validated加在類(lèi)上

@Validated加在類(lèi)上,Spring會(huì)將標(biāo)注的類(lèi)包裝為切面,從而讓類(lèi)中的方法調(diào)用時(shí),支持Java的校驗(yàn),所以當(dāng)使用@Validated時(shí),不僅可以用于Controller上,其他所有的Spring的bean也都可以使用。

因?yàn)?code>@Validated支持分組校驗(yàn),當(dāng)加在類(lèi)上的@Validated提供了分組參數(shù)時(shí),默認(rèn)會(huì)應(yīng)用到類(lèi)中所有的校驗(yàn)中。比如如下提供的例子,類(lèi)上的@Validated注解提供了DefaultInsert兩個(gè)分組標(biāo)記參數(shù),因此這兩個(gè)組會(huì)默認(rèn)應(yīng)用到類(lèi)中的doSomething方法上。doSomething方法的返回值應(yīng)用了Insert分組,在此類(lèi)中就會(huì)生效。入?yún)⑸咸砑拥?code>@NotEmpty沒(méi)有提供分組參數(shù),默認(rèn)為Default分組,也會(huì)生效。反之,如果此例中類(lèi)上的分組沒(méi)有提供Default分組,則下面doSomething方法入?yún)⑸系?code>@NotEmpty就不會(huì)生效。

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

@Validated加在方法上

當(dāng)@Validated注解單獨(dú)加在方法上時(shí),并不會(huì)按照預(yù)期的效果工作。因此,@Validated注解加在類(lèi)上是必要條件。方法上的@Validated注解作用一般是覆蓋類(lèi)上提供的分組。

比如下例中的代碼,因?yàn)榉椒ㄉ系姆纸M覆蓋了類(lèi)上的分組信息,因此doSomething方法上的@NotNull因?yàn)榉纸M不匹配的原因,并不會(huì)生效。

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

實(shí)戰(zhàn)

實(shí)際使用較為復(fù)雜的情況,會(huì)用到上文中提到的一個(gè)或者多個(gè)特性組合使用。繼續(xù)使用文章開(kāi)頭的例子進(jìn)行講解。

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

本例中,首先類(lèi)上添加了@Validated注解,沒(méi)有指定分組參數(shù),因?yàn)槟J(rèn)為Default分組。然后doSomething方法添加了@Validated注解并覆蓋了類(lèi)上的默認(rèn)分組信息,額外添加了Update分組。因此,此方法的校驗(yàn)會(huì)在DefaultUpdate上生效。

uuid參數(shù)上有一個(gè)@Size注解,指定了字符串的長(zhǎng)度只能為32,默認(rèn)分組,因此會(huì)生效。

指定長(zhǎng)度為32有什么意義,除了對(duì)生產(chǎn)環(huán)境的入?yún)?yán)格校驗(yàn)之外,對(duì)開(kāi)發(fā)也是有幫助的。比如我經(jīng)常會(huì)遇到對(duì)接的前端的代碼有bug,傳遞了undefineduuid參數(shù)中,如果此時(shí)添加了長(zhǎng)度校驗(yàn),就可以一眼看出來(lái)問(wèn)題,而不用再去debug代碼。

dtoList參數(shù)就有意思了,為了遍歷校驗(yàn)到list中的所有元素,需要添加@Valid注解,除此之外,為了保證入?yún)⒌挠行裕苊鉄o(wú)效的請(qǐng)求,添加了@NotEmpty注解,保證集合中至少有一個(gè)元素。而方法上標(biāo)注的分組信息DefultUpdate會(huì)應(yīng)用于集合中的每一個(gè)元素的校驗(yàn)上。

如果ValidationDTO如下,則在DefaultUpdate分組有效時(shí)只有contentversionNumber字段上的注解會(huì)生效。

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

分組校驗(yàn)有什么意義:

實(shí)際的業(yè)務(wù)場(chǎng)景往往比較復(fù)雜,單個(gè)DTO可能會(huì)用于新建和更新等多個(gè)方法入?yún)⑸希驗(yàn)楦潞托陆ǖ臅r(shí)候,業(yè)務(wù)需求的參數(shù)不一樣,因此校驗(yàn)的要求也就不一樣,這個(gè)時(shí)候如果沒(méi)有分組校驗(yàn)的支持,我們可能需要建立兩個(gè)DTO來(lái)分別滿足新建和更新兩種操作場(chǎng)景。而如果有了分組校驗(yàn),就可以針對(duì)業(yè)務(wù)要求,只開(kāi)啟需要校驗(yàn)的分組,保證的代碼的簡(jiǎn)潔和通用。

常見(jiàn)錯(cuò)誤

  1. HV000151問(wèn)題

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

翻譯過(guò)來(lái)就是說(shuō),子類(lèi)重寫(xiě)的方法或者實(shí)現(xiàn)類(lèi)的方法不能重新定義校驗(yàn)注解,如果校驗(yàn)注解不一致,則扔出HV000151問(wèn)題。

但是以下情況是允許的:

  • 覆蓋父類(lèi)或者接口的分組信息

public interface A {

    void doSomething(@Valid Object arg);
}

@Component
@Validated
public class B implement A {

    // 可以通過(guò)在子類(lèi)或者實(shí)現(xiàn)上添加@Validated注解,
    // 覆蓋上層的默認(rèn)分組信息,這樣多個(gè)實(shí)現(xiàn)類(lèi)就可以客制化校驗(yàn)信息
    @Validated({NewGroup.class})
    public void doSomething(@Valid Object arg) {
        // do something
    }
}
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,460評(píng)論 6 538
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 99,067評(píng)論 3 423
  • 文/潘曉璐 我一進(jìn)店門(mén),熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人,你說(shuō)我怎么就攤上這事。” “怎么了?”我有些...
    開(kāi)封第一講書(shū)人閱讀 177,467評(píng)論 0 382
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)。 經(jīng)常有香客問(wèn)我,道長(zhǎng),這世上最難降的妖魔是什么? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 63,468評(píng)論 1 316
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 72,184評(píng)論 6 410
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書(shū)人閱讀 55,582評(píng)論 1 325
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,616評(píng)論 3 444
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起,我...
    開(kāi)封第一講書(shū)人閱讀 42,794評(píng)論 0 289
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,343評(píng)論 1 335
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 41,096評(píng)論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 43,291評(píng)論 1 371
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,863評(píng)論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 44,513評(píng)論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 34,941評(píng)論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 36,190評(píng)論 1 291
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 52,026評(píng)論 3 396
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 48,253評(píng)論 2 375

推薦閱讀更多精彩內(nèi)容