1. 程式人生 > >Spring方法級別資料校驗:@Validated + MethodValidationPostProcessor

Spring方法級別資料校驗:@Validated + MethodValidationPostProcessor

每篇一句

在《深度工作》中作者提出這麼一個公式:高質量產出=時間*專注度。所以高質量的產出不是靠時間熬出來的,而是效率為王

相關閱讀

【小家Java】深入瞭解資料校驗:Java Bean Validation 2.0(JSR303、JSR349、JSR380)Hibernate-Validation 6.x使用案例
【小家Java】深入瞭解資料校驗(Bean Validation):基礎類打點(ValidationProvider、ConstraintDescriptor、ConstraintValidator)
【小家Spring】詳述Spring對Bean Validation支援的核心API:Validator、SmartValidator、LocalValidatorFactoryBean...


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

前言

你在書寫業務邏輯的時候,是否會經常書寫大量的判空校驗。比如Service層或者Dao層的方法入參、入參物件、出參中你是否都有自己的一套校驗規則?比如有些欄位必傳,有的非必傳;返回值中有些欄位必須有值,有的非必須等等~

如上描述的校驗邏輯,窺探一下你的程式碼,估摸裡面有大量的if else吧。此部分邏輯簡單(因為和業務關係不大)卻看起來眼花繚亂(趕緊偷偷去喵一下你自己的程式碼吧,哈哈)。在攻城主鍵變大的時候,你會發現會有大量的重複程式碼出現,這部分就是你入職一個新公司的吐槽點之一:垃圾程式碼。

若你追求乾淨的程式碼,甚至有程式碼潔癖

,如上眾多if else的重複無意義勞動無疑是你的痛點,那麼本文應該能夠幫到你。
Bean Validation校驗其實是基於DDD思想設計的,我們雖然可以不完全的遵從這種思考方式程式設計,但是其優雅的優點還是可取的,本文將介紹Spring為此提供的解決方案~

效果示例

在講解之前,首先就來體驗一把吧~

@Validated(Default.class)
public interface HelloService {
    Object hello(@NotNull @Min(10) Integer id, @NotNull String name);
}

// 實現類如下
@Slf4j
@Service
public class HelloServiceImpl implements HelloService {
    @Override
    public Object hello(Integer id, String name) {
        return null;
    }
}

向容器裡註冊一個處理器:

@Configuration
public class RootConfig {
    @Bean
    public MethodValidationPostProcessor methodValidationPostProcessor() {
        return new MethodValidationPostProcessor();
    }
}

測試:

@Slf4j
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {RootConfig.class})
public class TestSpringBean {
    @Autowired
    private HelloService helloService;

    @Test
    public void test1() {
        System.out.println(helloService.getClass());
        helloService.hello(1, null);
    }
}

結果如圖:

完美的校驗住了方法入參。

注意此處的一個小細節:若你自己執行這個案例你得到的引數名稱可能是hello.args0等,而我此處是形參名。是因為我使用Java8的編譯引數:-parameters(此處說一點:若你的邏輯中強依賴於此引數,務必在你的maven中加入編譯外掛並且配置好此編譯引數)

若需要校驗方法返回值,改寫如下:

    @NotNull
    Object hello(Integer id);

    // 此種寫法效果同上
    //@NotNull Object hello(Integer id);

執行:

javax.validation.ConstraintViolationException: hello.<return value>: 不能為null
...

校驗完成。就這樣藉助Spring+JSR相關約束註解,就非常簡單明瞭,語義清晰的優雅的完成了方法級別(入參校驗、返回值校驗)的校驗。
校驗不通過的錯誤資訊,再來個全域性統一的異常處理,就能讓整個工程都能盡顯完美之勢。(錯誤訊息可以從異常ConstraintViolationExceptiongetConstraintViolations()方法裡獲得的~)


MethodValidationPostProcessor

它是Spring提供的來實現基於方法MethodJSR校驗的核心處理器~它能讓約束作用在方法入參、返回值上,如:

public @NotNull Object myValidMethod(@NotNull String arg1, @Max(10) int arg2)

官方說明:方法裡寫有JSR校驗註解要想其生效的話,要求型別級別上必須使用@Validated標註(還能指定驗證的Group)

另外提示一點:這個處理器同處理@Async的處理器AsyncAnnotationBeanPostProcessor非常相似,都是繼承自AbstractBeanFactoryAwareAdvisingPostProcessor的,所以若有興趣再次也推薦@Async的分析博文,可以對比著觀看和記憶:【小家Spring】Spring非同步處理@Async的使用以及原理、原始碼分析(@EnableAsync)

// @since 3.1
public class MethodValidationPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor implements InitializingBean {
    // 備註:此處你標註@Valid是無用的~~~Spring可不提供識別
    // 當然你也可以自定義註解(下面提供了set方法~~~)
    // 但是注意:若自定義註解的話,此註解只決定了是否要代理,並不能指定分組哦  so,沒啥事別給自己找麻煩吧
    private Class<? extends Annotation> validatedAnnotationType = Validated.class;
    // 這個是javax.validation.Validator
    @Nullable
    private Validator validator;

    // 可以自定義生效的註解
    public void setValidatedAnnotationType(Class<? extends Annotation> validatedAnnotationType) {
        Assert.notNull(validatedAnnotationType, "'validatedAnnotationType' must not be null");
        this.validatedAnnotationType = validatedAnnotationType;
    }

    // 這個方法注意了:你可以自己傳入一個Validator,並且可以是定製化的LocalValidatorFactoryBean哦~(推薦)
    public void setValidator(Validator validator) {
        // 建議傳入LocalValidatorFactoryBean功能強大,從它裡面生成一個驗證器出來靠譜
        if (validator instanceof LocalValidatorFactoryBean) {
            this.validator = ((LocalValidatorFactoryBean) validator).getValidator();
        } else if (validator instanceof SpringValidatorAdapter) {
            this.validator = validator.unwrap(Validator.class);
        } else {
            this.validator = validator;
        }
    }
    // 當然,你也可以簡單粗暴的直接提供一個ValidatorFactory即可~
    public void setValidatorFactory(ValidatorFactory validatorFactory) {
        this.validator = validatorFactory.getValidator();
    }


    // 毫無疑問,Pointcut使用AnnotationMatchingPointcut,並且支援內部類哦~
    // 說明@Aysnc使用的也是AnnotationMatchingPointcut,只不過因為它支援標註在類上和方法上,所以最終是組合的ComposablePointcut
    
    // 至於Advice通知,此處一樣的是個`MethodValidationInterceptor`~~~~
    @Override
    public void afterPropertiesSet() {
        Pointcut pointcut = new AnnotationMatchingPointcut(this.validatedAnnotationType, true);
        this.advisor = new DefaultPointcutAdvisor(pointcut, createMethodValidationAdvice(this.validator));
    }
    
    // 這個advice就是給@Validation的類進行增強的~  說明:子類可以覆蓋哦~
    // @since 4.2
    protected Advice createMethodValidationAdvice(@Nullable Validator validator) {
        return (validator != null ? new MethodValidationInterceptor(validator) : new MethodValidationInterceptor());
    }
}

它是個普通的BeanPostProcessor,為Bean建立的代理的時機是postProcessAfterInitialization(),也就是在Bean完成初始化後有必要的話用一個代理物件返回進而交給Spring容器管理~(同@Aysnc
容易想到,關於校驗方面的邏輯不在於它,而在於切面的通知:MethodValidationInterceptor

MethodValidationInterceptor

它是AOP聯盟型別的通知,此處專門用於處理方法級別的資料校驗。

注意理解方法級別:方法級別的入參有可能是各種平鋪的引數、也可能是一個或者多個物件

// @since 3.1  因為它校驗Method  所以它使用的是javax.validation.executable.ExecutableValidator
public class MethodValidationInterceptor implements MethodInterceptor {

    // javax.validation.Validator
    private final Validator validator;

    // 如果沒有指定校驗器,那使用的就是預設的校驗器
    public MethodValidationInterceptor() {
        this(Validation.buildDefaultValidatorFactory());
    }
    public MethodValidationInterceptor(ValidatorFactory validatorFactory) {
        this(validatorFactory.getValidator());
    }
    public MethodValidationInterceptor(Validator validator) {
        this.validator = validator;
    }


    @Override
    @SuppressWarnings("unchecked")
    public Object invoke(MethodInvocation invocation) throws Throwable {
        // Avoid Validator invocation on FactoryBean.getObjectType/isSingleton
        // 如果是FactoryBean.getObject() 方法  就不要去校驗了~
        if (isFactoryBeanMetadataMethod(invocation.getMethod())) {
            return invocation.proceed();
        }

        Class<?>[] groups = determineValidationGroups(invocation);

        // Standard Bean Validation 1.1 API  ExecutableValidator是1.1提供的
        ExecutableValidator execVal = this.validator.forExecutables();
        Method methodToValidate = invocation.getMethod();
        Set<ConstraintViolation<Object>> result; // 錯誤訊息result  若存在最終都會ConstraintViolationException異常形式丟擲

        try {
            // 先校驗方法入參
            result = execVal.validateParameters(invocation.getThis(), methodToValidate, invocation.getArguments(), groups);
        } catch (IllegalArgumentException ex) {
            // 此處回退了非同步:找到bridged method方法再來一次
            methodToValidate = BridgeMethodResolver.findBridgedMethod(ClassUtils.getMostSpecificMethod(invocation.getMethod(), invocation.getThis().getClass()));
            result = execVal.validateParameters(invocation.getThis(), methodToValidate, invocation.getArguments(), groups);
        }
        if (!result.isEmpty()) { // 有錯誤就拋異常丟擲去
            throw new ConstraintViolationException(result);
        }
        // 執行目標方法  拿到返回值後  再去校驗這個返回值
        Object returnValue = invocation.proceed();
        result = execVal.validateReturnValue(invocation.getThis(), methodToValidate, returnValue, groups);
        if (!result.isEmpty()) {
            throw new ConstraintViolationException(result);
        }

        return returnValue;
    }


    // 找到這個方法上面是否有標註@Validated註解  從裡面拿到分組資訊
    // 備註:雖然代理只能標註在類上,但是分組可以標註在類上和方法上哦~~~~ 
    protected Class<?>[] determineValidationGroups(MethodInvocation invocation) {
        Validated validatedAnn = AnnotationUtils.findAnnotation(invocation.getMethod(), Validated.class);
        if (validatedAnn == null) {
            validatedAnn = AnnotationUtils.findAnnotation(invocation.getThis().getClass(), Validated.class);
        }
        return (validatedAnn != null ? validatedAnn.value() : new Class<?>[0]);
    }
}

這個Advice的實現,簡單到不能再簡單了,稍微有點基礎的應該都能很容易看懂吧(據我不完全估計這個應該是最簡單的)。


==使用細節==(重要)

文首雖然已經給了一個使用示例,但是那畢竟只是區域性。在實際生產使用中,比如上面理論更重要的是一些使用細節(細節往往是區分你是不是高手的地方),這裡從我使用的經驗中,總結如下幾點供給大家參考(基本算是分享我躺過的坑):

使用@Validated去校驗方法Method,不管從使用上還是原理上,都是非常簡單和簡約的,建議大家在企業應用中多多使用。

1、約束註解(如@NotNull)不能放在實體類上

一般情況下,我們對於Service層驗證(Controller層一般都不給介面),大都是面向介面程式設計和使用,那麼這種@NotNull放置的位置應該怎麼放置呢?

看這個例子:

public interface HelloService {
    Object hello(@NotNull @Min(10) Integer id, @NotNull String name);
}

@Validated(Default.class)
@Slf4j
@Service
public class HelloServiceImpl implements HelloService {
    @Override
    public Object hello(Integer id, String name) {
        return null;
    }
}

約束條件都寫在實現類上,按照我們所謂的經驗,應該是不成問題的。但執行:

javax.validation.ConstraintDeclarationException: HV000151: A method overriding another method must not redefine the parameter constraint configuration, but method HelloServiceImpl#hello(Integer) redefines the configuration of HelloService#hello(Integer).

    at org.hibernate.validator.internal.metadata.aggregated.rule.OverridingMethodMustNotAlterParameterConstraints.apply(OverridingMethodMustNotAlterParameterConstraints.java:24)
...

重說三:請務必注意請務必注意請務必注意這個異常是javax.validation.ConstraintDeclarationException,而不是錯誤校驗錯誤異常javax.validation.ConstraintViolationException。請在做全域性異常捕獲的時候一定要區分開來~

異常資訊是說parameter constraint configuration在校驗方法入參的約束時,若是@Override父類/介面的方法,那麼這個入參約束只能寫在父類/介面上面~~~

至於為什麼只能寫在介面處,這個具體原因其實是和Bean Validation的實現產品有關的,比如使用的Hibernate校驗,原因可參考它的此類:OverridingMethodMustNotAlterParameterConstraints


還需注意一點:若實現類寫的約束和介面一模一樣,那也是沒問題的。比如上面若實現類這麼寫是沒有問題能夠完成正常校驗的:

    @Override
    public Object hello(@NotNull @Min(10) Integer id, @NotNull String name) {
        return null;
    }

雖然能正常work完成校驗,但需要深刻理解一模一樣這四個字。簡單的說把10改成9都會報ConstraintDeclarationException異常,更別談移除某個註解了(不管多少欄位多少註解,但凡只要寫了一個就必須保證一模一樣)。


關於@Override方法校驗返回值方面:即使寫在實現類裡也不會拋ConstraintDeclarationException
另外@Validated註解它寫在實現類/介面上均可~

最後你應該自己領悟到:若入參校驗失敗了,方法體是不會執行的。但倘若是返回值校驗執行了(即使是失敗了),方法體也肯定被執行了~~

2、@NotEmpty/@NotBlank只能哪些型別上?

提出這個細節的目的是:約束註解並不是能用在所有型別上的。比如若你把@NotEmpty讓它去驗證Object型別,它會報錯如下:

javax.validation.UnexpectedTypeException: HV000030: No validator could be found for constraint 'javax.validation.constraints.NotEmpty' validating type 'java.lang.Object'. Check configuration for 'hello.<return value>'

需要強調的是:若標註在方法上是驗證返回值的,這個時候方法體是已經執行了的,這個和ConstraintDeclarationException不一樣~

對這兩個註解依照官方文件做如下簡要說明。@NotEmpty只能標註在如下型別

  1. CharSequence
  2. Collection
  3. Map
  4. Array

    注意:""它是空的,但是" "就不是了

@NotBlank只能使用在CharSequence上,它是Bean Validation 2.0新增的註解~

3、介面和實現類上都有註解,以誰為準?

這個問題有個隱含條件:只有校驗方法返回值時才有這種可能性。

public interface HelloService {
    @NotEmpty String hello(@NotNull @Min(10) Integer id, @NotNull String name);
}

@Slf4j
@Service
@Validated(Default.class)
public class HelloServiceImpl implements HelloService {
    @Override
    public @NotNull String hello(Integer id, String name) {
        return "";
    }
}

執行案例,helloService.hello(18, "fsx");列印如下:

javax.validation.ConstraintViolationException: hello.<return value>: 不能為空
...

到這裡,可能有小夥伴就會早早下結論:當同時存在時,以介面的約束為準。
那麼,我只把返回值稍稍修改,你再看一下呢???

    @Override
    public @NotNull String hello(Integer id, String name) {
        return null; // 返回值改為null
    }

再執行:

javax.validation.ConstraintViolationException: hello.<return value>: 不能為空, hello.<return value>: 不能為null
...

透過列印的資訊,結論就自然不必我多。但是有個道理此處可說明:大膽猜測,小心求證

4、如何校驗級聯屬性

在實際開發中,其實大多數情況下我們方法入參是個物件(甚至物件裡面有物件),而不是單單平鋪的引數,因此就介紹一個級聯屬性校驗的例子:

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

}

public interface HelloService {
    String cascade(@NotNull @Valid Person father, @NotNull Person mother);
}

@Slf4j
@Service
@Validated(Default.class)
public class HelloServiceImpl implements HelloService {
    @Override
    public String cascade(Person father, Person mother) {
        return "hello cascade...";
    }
}

執行測試用例:

    @Test
    public void test1() {
        helloService.cascade(null, null);
    }

輸出如下:

cascade.father: 不能為null, cascade.mother: 不能為null

此處說明一點:若你father前面沒加@NotNull,那列印的訊息只有:cascade.mother: 不能為null

我把測試用例改造如下,你繼續感受一把:

    @Test
    public void test1() {
        Person father = new Person();
        father.setName("fsx");
        Person.InnerChild innerChild = new Person.InnerChild();
        innerChild.setAge(-1);
        father.setChild(innerChild);

        helloService.cascade(father, new Person());
    }

錯誤訊息如下(請小夥伴仔細觀察和分析緣由):

cascade.father.age: 不能為null, cascade.father.child.name: 不能為null, cascade.father.child.age: 必須是正數

思考:為何mother的相關屬性以及子屬性為何全都沒有校驗呢?

5、迴圈依賴問題

上面說了Spring對@Validated的處理和對@Aysnc的代理邏輯是差不多的,有了之前的經驗,很容易想到它也存在著如題的問題:比如HelloService的A方法想呼叫本類的B方法,但是很顯然我是希望B方法的方法校驗是能生效的,因此其中一個做法就是注入自己,使用自己的代理物件來呼叫:

public interface HelloService {
    Object hello(@NotNull @Min(10) Integer id, @NotNull String name);
    String cascade(@NotNull @Valid Person father, @NotNull Person mother);
}

@Slf4j
@Service
@Validated(Default.class)
public class HelloServiceImpl implements HelloService {

    @Autowired
    private HelloService helloService;

    @Override
    public Object hello(@NotNull @Min(10) Integer id, @NotNull String name) {
        helloService.cascade(null, null); // 呼叫本類方法
        return null;
    }

    @Override
    public String cascade(Person father, Person mother) {
        return "hello cascade...";
    }
}

執行測試用例:

    @Test
    public void test1() {
        helloService.hello(18, "fsx"); // 入口方法校驗通過,內部呼叫cascade方法希望繼續得到校驗
    }

執行報錯:

Caused by: org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'helloServiceImpl': Bean with name 'helloServiceImpl' has been injected into other beans [helloServiceImpl] in its raw version as part of a circular reference, but has eventually been wrapped. This means that said other beans do not use the final version of the bean. This is often the result of over-eager type matching - consider using 'getBeanNamesOfType' with the 'allowEagerInit' flag turned off, for example.
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean
...

這個報錯訊息不可為不熟悉。關於此現象,之前做過非常非常詳細的說明並且提供了多種解決方案,所以此處略過。

若關於此問的原因和解決方案不明白的,請移步此處:【小家Spring】使用@Async非同步註解導致該Bean在迴圈依賴時啟動報BeanCurrentlyInCreationException異常的根本原因分析,以及提供解決方案

雖然我此處不說解決方案,但我提供問題解決後執行的列印輸出情況,供給小夥伴除錯參考,此舉很暖心有木有:

javax.validation.ConstraintViolationException: cascade.mother: 不能為null, cascade.father: 不能為null
...

總結

本文介紹了Spring提供給我們方法級別校驗的能力,在企業應用中使用此種方式完成絕大部分的基本校驗工作,能夠讓我們的程式碼更加簡潔、可控並且可擴充套件,因此我是推薦使用和擴散的~

在文末有必要強調一點:關於上面級聯屬性的校驗時使用的@Valid註解你使用@Validated可替代不了,不會有效果的。
至於有小夥伴私信我疑問的問題:為何他Controller方法中使用@Valid@Validated均可,並且網上同意給的答案都是都可用,差不多???還是那句話:這是下篇文章的重點,請持續關注~

稍稍說一下它的弊端:因為校驗失敗它最終採用的是拋異常方式來中斷,因此效率上有那麼一丟丟的損耗。but,你的應用真的需要考慮這種極致效能問題嗎?這才是你該思考的~

知識交流

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

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

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