代碼整潔之道-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)系:

依賴關(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ì)象為Person,Person的每個(gè)字段都有一定的業(yè)務(wù)要求:

public class Person {

    @NotBlank //名稱不能為空
    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)⒒旧隙际羌系男问?。但是奇怪的是這種寫(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類似。這個(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的類上添加@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ì)覆蓋類上的組信息。但是方法上的注釋不會(huì)作為切入點(diǎn),要想方法上的注解生效,類上也必須添加注解。
  4. 支持元注解,可以添加在自定義注解上,組裝為新的注解

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

@Validated加在類上

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

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

比如下例中的代碼,因?yàn)榉椒ㄉ系姆纸M覆蓋了類上的分組信息,因此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
    }
}

本例中,首先類上添加了@Validated注解,沒(méi)有指定分組參數(shù),因?yàn)槟J(rèn)為Default分組。然后doSomething方法添加了@Validated注解并覆蓋了類上的默認(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)⒌挠行?,避免無(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ō),子類重寫(xiě)的方法或者實(shí)現(xiàn)類的方法不能重新定義校驗(yàn)注解,如果校驗(yàn)注解不一致,則扔出HV000151問(wèn)題。

但是以下情況是允許的:

  • 覆蓋父類或者接口的分組信息

public interface A {

    void doSomething(@Valid Object arg);
}

@Component
@Validated
public class B implement A {

    // 可以通過(guò)在子類或者實(shí)現(xiàn)上添加@Validated注解,
    // 覆蓋上層的默認(rèn)分組信息,這樣多個(gè)實(shí)現(xiàn)類就可以客制化校驗(yàn)信息
    @Validated({NewGroup.class})
    public void doSomething(@Valid Object arg) {
        // do something
    }
}
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容