@Validated和@Valid的區別?校驗級聯屬性(內部類)

_YourBatman發表於2019-07-30

每篇一句

NBA裡有兩大笑話:一是科比沒天賦,二是詹姆斯沒技術

相關閱讀

【小家Java】深入瞭解資料校驗:Java Bean Validation 2.0(JSR303、JSR349、JSR380)Hibernate-Validation 6.x使用案例
【小家Spring】讓Controller支援對平鋪引數執行資料校驗(預設Spring MVC使用@Valid只能對JavaBean進行校驗)
【小家Spring】Spring方法級別資料校驗:@Validated + MethodValidationPostProcessor優雅的完成資料校驗動作


對Spring感興趣可掃碼加入wx群:`Java高工、架構師3群`(文末有二維碼)

前言

上篇文章 介紹了Spring環境下實現優雅的方法級別的資料校驗,並且埋下一個伏筆:它在Spring MVCController層)裡怎麼應用呢?本文為此繼續展開講解Spring MVC中的資料校驗~

可能小夥伴能立馬想到:這不一樣嗎?我們使用Controller就是方法級別的,所以它就是直接應用了方法級別的校驗而已嘛~對於此疑問我先不解答,而是順勢再丟擲兩個問題你自己應該就能想明白了:

  1. 上文有說過,基於方法級別的校驗Spring預設是並未開啟的,但是為什麼你在Spring MVC卻可以直接使用@Valid完成校驗呢?
    1. 可能有的小夥伴說他用的是SpringBoot可能預設給開啟了,其實不然。哪怕你用的傳統Spring MVC你會發現也是直接可用的,不信你就試試
  2. 類比一下:Spring MVCHandlerInterceptorAOP思想的實現,但你有沒有發現即使你沒有啟動@EnableAspectJAutoProxy的支援,它依舊好使~

若你能想明白我提出的這兩個問題,下文就非常不難理解了。當然即使你知道了這兩個問題的答案,還是建議你讀下去。畢竟:永遠相信本文能給你帶來意想不到的收穫~

使用示例

關於資料校驗這一塊在Spring MVC中的使用案例,我相信但凡有點經驗的Java程式設計師應該沒有不會使用的,並且還不乏熟練的選手。在此之前我簡單“採訪”過,絕大多數程式設計師甚至一度認為Spring中的資料校驗就是指的在Controller中使用@Validated校驗入參JavaBean這一塊~

因此下面這個例子,你應該一點都不陌生:

@Getter
@Setter
@ToString
public class Person {

    @NotNull
    private String name;
    @NotNull
    @Positive
    private Integer age;

    @Valid // 讓InnerChild的屬性也參與校驗
    @NotNull
    private InnerChild child;

    @Getter
    @Setter
    @ToString
    public static class InnerChild {
        @NotNull
        private String name;
        @NotNull
        @Positive
        private Integer age;
    }

}

@RestController
@RequestMapping
public class HelloController {

    @PostMapping("/hello")
    public Object helloPost(@Valid @RequestBody Person person, BindingResult result) {
        System.out.println(result.getErrorCount());
        System.out.println(result.getAllErrors());
        return person;
    }
}

傳送post請求:/hello Content-Type=application/json,傳入的json串如下:

{
  "name" : "fsx",
  "age" : "-1",
  "child" : {
    "age" : 1
  }
}

控制檯有如下列印:

2
[Field error in object 'person' on field 'child.name': rejected value [null]; codes [NotNull.person.child.name,NotNull.child.name,NotNull.name,NotNull.java.lang.String,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [person.child.name,child.name]; arguments []; default message [child.name]]; default message [不能為null], Field error in object 'person' on field 'age': rejected value [-1]; codes [Positive.person.age,Positive.age,Positive.java.lang.Integer,Positive]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [person.age,age]; arguments []; default message [age]]; default message [必須是正數]]

從列印上看:校驗生效(拿著錯誤訊息就可以返回前端展示,或者定位到錯誤頁面都行)。

此例兩個小細節務必注意:

  1. @RequestBody註解不能省略,否則傳入的json無法完成資料繫結(即使不繫結,校驗也是生效的哦)~
  2. 若方法入參不寫BindingResult result這個引數,請求得到的直接是400錯誤,因為若有校驗失敗的服務端會丟擲異常org.springframework.web.bind.MethodArgumentNotValidException。若寫了,那就呼叫者自己處理嘍~

據我不完全和不成熟的統計,就這個案例就覆蓋了小夥伴們實際使用中的90%以上的真實使用場景,使用起來確實非常的簡單、優雅、高效~

但是作為一個有豐富經驗的程式設計師的你,雖然你使用了@Valid優雅的完成了資料校驗,但回頭是你是否還會發現你的程式碼裡還是存在了大量的if else的基礎的校驗?什麼原因?其實根本原因只有一個:很多case使用@Valid並不能覆蓋,因為它只能校驗JavaBean
我相信你是有這樣那樣的使用痛點的,本文先從原理層面分析,進而給出你所遇到的痛點問題的參考解決參考方案~

原理分析

Controller提供的使用@Valid便捷校驗JavaBean的原理,和Spring方法級別的校驗支援的原理是有很大差異的(可類比Spring MVC攔截器和Spring AOP的差異區別~),那麼現在就看看這塊吧

請不要忽視優雅程式碼的力量,它會成倍提升你的編碼效率、成倍降低後期維護成本,甚至成倍提升你的擴充套件性和成倍降低你寫bug的可能性~

回憶DataBinder/WebDataBinder

若對Spring資料繫結模組不是很熟悉的(有閱讀過我之前文章的可忽略),建議先補:

  1. 【小家Spring】聊聊Spring中的資料繫結 --- DataBinder本尊(原始碼分析)
  2. 【小家Spring】聊聊Spring中的資料繫結 --- WebDataBinder、ServletRequestDataBinder、WebBindingInitializer...

DataBinder類名叫資料繫結,但它在org.springframework.validation這個包,可見Spring它把資料繫結和資料校驗牢牢的放在了一起,並且內部弱化了資料校驗的概念以及邏輯(Spring想讓呼叫者無需關心資料校驗的細節,全由它來自動完成,減少使用的成本)。

我們知道DataBinder它主要對外提供了bind(PropertyValues pvs)validate()方法,當然還有處理繫結/校驗失敗的相關(配置)元件:

public class DataBinder implements PropertyEditorRegistry, TypeConverter {
    ...
    @Nullable
    private AbstractPropertyBindingResult bindingResult; // 它是個BindingResult
    @Nullable
    private MessageCodesResolver messageCodesResolver;
    private BindingErrorProcessor bindingErrorProcessor = new DefaultBindingErrorProcessor();
    // 最重要是它:它是org.springframework.validation.Validator
    // 一個DataBinder 可以持有對個驗證器。也就是說對於一個Bean,是可以交給多個驗證器去驗證的(當然一般都只有一個即可而已~~~)
    private final List<Validator> validators = new ArrayList<>();
    
    public void bind(PropertyValues pvs) {
        MutablePropertyValues mpvs = (pvs instanceof MutablePropertyValues ? (MutablePropertyValues) pvs : new MutablePropertyValues(pvs));
        doBind(mpvs);
    }
    ...
    public void validate() {
        Object target = getTarget();
        Assert.state(target != null, "No target to validate");
        BindingResult bindingResult = getBindingResult();
    
        // 拿到所有的驗證器  一個個的對此target進行驗證~~~
        // Call each validator with the same binding result
        for (Validator validator : getValidators()) {
            validator.validate(target, bindingResult);
        }
    }
}

DataBinder提供了這兩個非常獨立的原子方法:繫結 + 校驗。他倆結合完成了我們的資料繫結+資料校驗,完全的和業務無關~

網上有很多文章說DataBinder完成資料繫結後繼續校驗,這種說法是不準確的呀,因為它並不處理這部分的組合邏輯,它只提供原始能力~

Spring MVC處理入參的時機

Spring MVC處理入參的邏輯是非常複雜的,前面花大篇幅講了Spring MVC對返回值的處理器:HandlerMethodReturnValueHandler,詳見:
【小家Spring】Spring MVC容器的web九大元件之---HandlerAdapter原始碼詳解---一篇文章帶你讀懂返回值處理器HandlerMethodReturnValueHandler

同樣的,本文只關注它對@RequestBody這種型別的入參進行講解~
處理入參的處理器:HandlerMethodArgumentResolver,處理@RequestBody最終使用的實現類是:RequestResponseBodyMethodProcessorSpring藉助此處理器完成一系列的訊息轉換器、資料繫結、資料校驗等工作~

RequestResponseBodyMethodProcessor

這個類應該是陌生的,在上面推薦的處理MVC返回值的文章中有提到過它:它能夠處理@ResponseBody註解返回值(請參考它的supportsReturnType()方法~)
它還有另一個能力是:它能夠處理請求引數(當然也是標註了@RequestBody它的~)
所以它既是個處理返回值的HandlerMethodReturnValueHandler,有是一個處理入參的HandlerMethodArgumentResolver。所以它命名為Processor而不是Resolver/Handler嘛,這就是命名的藝術~

// @since 3.1
public class RequestResponseBodyMethodProcessor extends AbstractMessageConverterMethodProcessor {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(RequestBody.class);
    }
    // 類上或者方法上標註了@ResponseBody註解都行
    @Override
    public boolean supportsReturnType(MethodParameter returnType) {
        return (AnnotatedElementUtils.hasAnnotation(returnType.getContainingClass(), ResponseBody.class) || returnType.hasMethodAnnotation(ResponseBody.class));
    }
    
    // 這是處理入參封裝校驗的入口,也是本文關注的焦點
    @Override
    public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
        
        // 它是支援`Optional`容器的
        parameter = parameter.nestedIfOptional();
        // 使用訊息轉換器HttpInputMessage把request請求轉換出來
        // 此處注意:比如本例入參是Person類,所以經過這裡處理會生成一個空的Person物件出來(反射)
        Object arg = readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType());

        // 獲取到入參的名稱
        // 請注意:這裡的名稱是類名首字母小寫,並不是你方法裡寫的名字。比如本利若形參名寫為personAAA,但是name的值還是person
        String name = Conventions.getVariableNameForParameter(parameter);

        // 只有存在binderFactory才會去完成自動的繫結、校驗~
        // 此處web環境為:ServletRequestDataBinderFactory
        if (binderFactory != null) {
            WebDataBinder binder = binderFactory.createBinder(webRequest, arg, name);

            // 顯然傳了引數才需要去繫結校驗嘛
            if (arg != null) {

                // 這裡完成資料繫結+資料校驗~~~~~(繫結的錯誤和校驗的錯誤都會放進Errors裡)
                // Applicable:適合
                validateIfApplicable(binder, parameter);

                // 若有錯誤訊息hasErrors(),並且僅跟著的一個引數不是Errors型別,Spring MVC會主動給你丟擲MethodArgumentNotValidException異常
                // 否則,呼叫者自行處理
                if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) {
                    throw new MethodArgumentNotValidException(parameter, binder.getBindingResult());
                }
            }
        
            // 把錯誤訊息放進去 證明已經校驗出錯誤了~~~
            // 後續邏輯會判斷MODEL_KEY_PREFIX這個key的~~~~
            if (mavContainer != null) {
                mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX + name, binder.getBindingResult());
            }
        }

        return adaptArgumentIfNecessary(arg, parameter);
    }

    // 校驗,如果合適的話。使用WebDataBinder,失敗資訊最終也都是放在它身上~  本方法是本文關注的焦點
    // 入參:MethodParameter parameter
    protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {
        // 拿到標註在此引數上的所有註解們(比如此處有@Valid和@RequestBody兩個註解)
        Annotation[] annotations = parameter.getParameterAnnotations();
        for (Annotation ann : annotations) {
            // 先看看有木有@Validated
            Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class);

            // 這個裡的判斷是關鍵:可以看到標註了@Validated註解 或者註解名是以Valid打頭的 都會有效哦
            //注意:這裡可沒說必須是@Valid註解。實際上你自定義註解,名稱只要一Valid開頭都成~~~~~
            if (validatedAnn != null || ann.annotationType().getSimpleName().startsWith("Valid")) {
                // 拿到分組group後,呼叫binder的validate()進行校驗~~~~
                // 可以看到:拿到一個合適的註解後,立馬就break了~~~
                // 所以若你兩個主機都標註@Validated和@Valid,效果是一樣滴~
                Object hints = (validatedAnn != null ? validatedAnn.value() : AnnotationUtils.getValue(ann));
                Object[] validationHints = (hints instanceof Object[] ? (Object[]) hints : new Object[] {hints});
                binder.validate(validationHints);
                break;
            }
        }
    }
    ...
}

本文我們只著眼於關注@Valid的資料校驗這塊,有幾個相關的使用小細節,總結如下:

  1. 形參(@RequestBody標註的入參)的name(形參名)和你寫什麼無關,若是實體類名字就是類名首字母小寫。(若是陣列、集合等,都會有自己特定的名稱)
  2. @Validated@Valid都能使校驗生效,但卻不僅僅是它哥倆才能行:任何名稱是"Valid"打頭的註解都能使得資料校驗生效
    1. 自定義註解名稱以Valid開頭,並且給個value屬性同樣能夠指定Group分組
    2. 個人直接建議使用@Validated即可,而去使用@Valid了,更不用自己給自己找麻煩去自定義註解啥的了~
  3. 只有當Errors(BindingResult)入參是是僅跟著@Valid註解的實體,Spring MVC才會把錯誤訊息放權交給呼叫者處理,否則(沒有或者不是緊挨著)它會丟擲MethodArgumentNotValidException異常~

這是使用@RequestBody結合@Valid完成資料校驗的基本原理。其實當Spring MVC在處理@RequestPart註解入引數據時,也會執行繫結、校驗的相關邏輯。對應處理器是RequestPartMethodArgumentResolver,原理大體上和這相似,它主要處理Multipart相關,本文忽略~


==此處提示一個點,此文發出去後有一個好奇的小寶寶問我入參能使用多個物件並且都用@RequestBody標註嗎?==
關於這個問題姑且先不考慮合理與否,我們這樣做試試:

    @PostMapping("/hello")
    public Object helloPost(@Valid @RequestBody Person personAAA, BindingResult result, @Valid @RequestBody Person personBBB) {
        ...
    }

請求卻報錯了:

Resolved [org.springframework.http.converter.HttpMessageNotReadableException: I/O error while reading input message; nested exception is java.io.IOException: Stream closed]

錯誤訊息很好理解:請求域的Body體是隻能被讀取一次的(流只能被讀取一次嘛)。
若你是好奇的,你可能還會問:URL引數呢?請求連結?後面的引數呢,如何封裝???因為本部分內容不是本文的關注點,若有興趣請出門左拐~

說明:關於使用Map、List、陣列等接受@RequestBody引數的情況類似,區別在於繫結器上,對Map、List的校驗前面文章有過講解,此處就不展開了。

希望讀者能掌握這部分內容,因為它和麵向使用者比較重要的@InitBinder強關聯~~~

實際使用中一般使用@Validated分組校驗(若需要),然後結合全域性異常的處理方式來友好的對呼叫者展示錯誤訊息~

全域性異常處理示例

當校驗失敗時,Spring會丟擲MethodArgumentNotValidException異常,該異常會持有校驗結果物件BindingResult,從而獲得校驗失敗資訊。本處只給示例,僅供參考:

@RestControllerAdvice
public class MethodArgumentNotValidExceptionHandler {


    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result handleMethodArgumentNotValid(MethodArgumentNotValidException ex) {
        BindingResult bindingResult = ex.getBindingResult();

        StringBuilder stringBuilder = new StringBuilder();
        for (FieldError error : bindingResult.getFieldErrors()) {
            String field = error.getField();
            Object value = error.getRejectedValue();
            String msg = error.getDefaultMessage();
            String message = String.format("錯誤欄位:%s,錯誤值:%s,原因:%s;", field, value, msg);
            stringBuilder.append(message).append("\r\n");
        }
        return Result.error(MsgDefinition.ILLEGAL_ARGUMENTS.codeOf(), stringBuilder.toString());
    }
}

遺留痛點

你是否發現,雖然Spring MVC給我們提供了極其方便的資料校驗方式,但是它還是有比較大的侷限性的:它要求待校驗的入參是JavaBean

請注意:並不一樣要求是請求Body體哦,比如get請求的入參若用JavaBean接收的話,依舊能啟用校驗

但在實際應用中,其實我們非常多的Controller方法的方法入參是平鋪的,也就是所謂的平鋪引數,形如這樣:

    @PutMapping("/hello/id/{id}/status/{status}")
    public Object helloGet(@PathVariable Integer id, @PathVariable Integer status) {
        ...
        return "hello world";
    }

其實,特別是get請求的case,@RequestParam入參一般是非常多的(比如分頁查詢),難道對於這種平鋪引數的case,我們真的是能通過人肉if else的去校驗嗎?
興許你對此問題有興趣,那就參閱本文吧,它能給你提供解決方案:【小家Spring】讓Controller支援對平鋪引數執行資料校驗(預設Spring MVC使用@Valid只能對JavaBean進行校驗)

==@Validated和@Valid的區別==

如題的問題,我相信是很多小夥伴都很關心的一個對比,若你把這個系列都有喵過,那麼這個問題的答案就浮出水面了:

  1. @Valid:標準JSR-303規範的標記型註解,用來標記驗證屬性和方法返回值,進行級聯和遞迴校驗
  2. @ValidatedSpring的註解,是標準JSR-303的一個變種(補充),提供了一個分組功能,可以在入參驗證時,根據不同的分組採用不同的驗證機制
  3. Controller中校驗方法引數時,使用@Valid和@Validated並無特殊差異(若不需要分組校驗的話)
  4. @Validated註解可以用於類級別,用於支援Spring進行方法級別的引數校驗。@Valid可以用在屬性級別約束,用來表示級聯校驗
  5. @Validated只能用在類、方法和引數上,而@Valid可用於方法、欄位、構造器和引數上

最後提示一點:Spring BootWeb Starter已經加入了Bean Validation以及實現的依賴,可以直接使用。但若是純Spring MVC環境,請自行匯入~

總結

本文介紹的是我們平時使用得最多的資料校驗場景:使用@Validated完成Controller的入參校驗,實現優雅的處理資料校驗。同時希望通過本文能讓你徹底弄懂@Validated和@Valid使用上的區別以及聯絡,在實際生產使用中能夠做到更加的得心應手~

知識交流

若文章格式混亂,可點選原文連結-原文連結-原文連結-原文連結-原文連結

==The last:如果覺得本文對你有幫助,不妨點個讚唄。當然分享到你的朋友圈讓更多小夥伴看到也是被作者本人許可的~==

若對技術內容感興趣可以加入wx群交流:Java高工、架構師3群
若群二維碼失效,請加wx號:fsx641385712(或者掃描下方wx二維碼)。並且備註:"java入群" 字樣,會手動邀請入群

相關文章