Fluent-Validator 業務校驗器

AskaJohnny發表於2022-05-19

Fluent-Validator 業務校驗器

背景

在網際網路行業中,基於Java開發的業務類系統,不管是服務端還是客戶端,業務邏輯程式碼的更新往往是非常頻繁的,這源於功能的快速迭代特性。在一般公司內部,特別是使用Java web技術構建的平臺中,不管是基於模組化還是服務化的,業務邏輯都會相對複雜。
這些系統之間、系統內部往往存在大量的API介面,這些介面一般都需要對入參(輸入引數的簡稱)做校驗,以保證:
1) 核心業務邏輯能夠順利按照預期執行。
2) 資料能夠正常存取。
3) 資料安全性。包括符合約束以及限制,有訪問許可權控制以及不出現SQL隱碼攻擊等問題。
開發人員在維護核心業務邏輯的同時,還需要為輸入做嚴格的校驗。當輸入不合法時,能夠給caller一個明確的反饋,最常見的反饋就是返回封裝了result的物件或者丟擲exception。
一些常見的驗證程式碼片段如下所示:

public Response execute(Request request) {
    if (request == null) {
        throw BizException();
    }
 
    List cars = request.getCars();
    if (CollectionUtils.isEmpty(cars)) {
        throw BizException();
    }
 
    for (Car car : cars) {
        if (car.getSeatCount() < 2) {
            throw BizException(); 
        }
    }
 
    // do core business logic
}

我們可以發現,它不夠優雅而且違反一些正規化:
1)違反單一職責原則(Single responsibility)。核心業務邏輯(core business logic)和驗證邏輯(validation logic)耦合在一個類中。
2)開閉原則(Open/closed)。我們應該對擴充套件開放,對修改封閉,驗證邏輯不好擴充套件,而且一旦需要修改需要動整體這個類。
3)DRY原則(Don’t repeat yourself)。程式碼冗餘,相同邏輯可能散落多處,長此以往不好收殮。

1.簡介

FluentValidato是一個適用於以Java語言開發的程式,讓開發人員迴歸focus到業務邏輯上,使用流式(Fluent Interface)呼叫風格讓驗證跑起來很優雅,同時驗證器(Validator)可以做到開閉原則,實現最大程度的複用的工具庫。

2.特點

  1. 驗證邏輯與業務邏輯不再耦合
    摒棄原來不規範的驗證邏輯散落的現象。
  2. 校驗器各司其職,好維護,可複用,可擴充套件
    一個校驗器(Validator)只負責某個屬性或者物件的校驗,可以做到職責單一,易於維護,並且可複用。
  3. 流式風格(Fluent Interface)呼叫
  4. 使用註解方式驗證
    可以裝飾在屬性上,減少硬編碼量。
  5. 支援JSR 303 – Bean Validation標準
    或許你已經使用了Hibernate Validator,不用拋棄它,FluentValidator可以站在巨人的肩膀上。
  6. Spring良好整合
    校驗器可以由Spring IoC容器託管。校驗入參可以直接使用註解,配置好攔截器,核心業務邏輯完全沒有驗證邏輯的影子,乾淨利落。
  7. 回撥給予你充分的自由度
    驗證過程中發生的錯誤、異常,驗證結果的返回,開發人員都可以定製。

3.上手

3.1引入maven依賴:

  <dependency>
            <groupId>com.baidu.unbiz</groupId>
            <artifactId>fluent-validator</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>org.slf4j</groupId>
                    <artifactId>slf4j-log4j12</artifactId>
                </exclusion>
            </exclusions>
            <version>1.0.5</version>
        </dependency>

3.2 業務領域模型

從廣義角度來說DTO(Data Transfer Object)、VO(Value Object)、BO(Business Object)、POJO等都可以看做是業務表達模型。
建立一個學生類,包含 name(姓名)、age(年齡)、schoolName(學校名稱)、(area)地區

package com.example.fluentvalidator;

import lombok.AllArgsConstructor;
import lombok.Data;

/**
 * @author :jianyul
 * @date : 2022/5/16 18:00
 */
@Data
@AllArgsConstructor
public class StudentDto {

    private String name;

    private Integer age;

    private String schoolName;

    private String area;
}

3.3 Validator樣例

針對schoolName(學校名稱)建立一個Validator,程式碼如下:

public class SchoolNameValidator extends ValidatorHandler<String> implements Validator<String> {
    @Override
    public boolean validate(ValidatorContext context, String schoolName) {
        if (!"無錫中學".equals(schoolName)) {
            context.addErrorMsg("學校名稱不正確");
            return false;
        }
        return true;
    }
}

很簡單,實現Validator介面,泛型T規範這個校驗器待驗證的物件的型別,繼承ValidatorHandler可以避免實現一些預設的方法,validate()方法第一個引數是整個校驗過程的上下文,第二個引數是待驗證物件,也就是學校名稱。
驗證邏輯:假設學校名稱必須是無錫中學,否則通過context放入錯誤訊息並且返回false,成功返回true。

3.4 驗證

   StudentDto studentDto = new StudentDto("張三", 18, "蘇州中學", "無錫");
                Result result =
                        FluentValidator.checkAll()
                                .on(studentDto.getSchoolName(), new SchoolNameValidator())
                                .doValidate()
                                .result(toSimple());
                System.out.println(result);
 //列印結果:Result{isSuccess=false, errors=[學校名稱不正確]}

首先我們通過FluentValidator.checkAll()獲取了一個FluentValidator例項,緊接著呼叫了failFast()表示有錯了立即返回,它的反義詞是failOver,然後,、on()操作表示在指定屬性上使用對應校驗器進行校驗,截止到此,真正的校驗還並沒有做,這就是所謂的“惰性求值(Lazy valuation)”,有點像Java8 Stream API中的filter()、map()方法,直到doValidate()驗證才真正執行了,最後我們需要收殮出來一個結果供caller獲取列印,直接使用預設提供的靜態方法toSimple()來做一個回撥函式傳入result()方法,最終返回Result類。

4.深入瞭解

4.1 Validator詳解

Validator介面程式碼如下:

public interface Validator<T> {
 
 /**
 * 判斷在該物件上是否接受或者需要驗證
 * <p/>
 * 如果返回true,那麼則呼叫{@link #validate(ValidatorContext, Object)},否則跳過該驗證器
 *
 * @param context 驗證上下文
 * @param t 待驗證物件
 *
 * @return 是否接受驗證
 */
 boolean accept(ValidatorContext context, T t);
 
 /**
 * 執行驗證
 * <p/>
 * 如果發生錯誤內部需要呼叫{@link ValidatorContext#addErrorMsg(String)}方法,也即<code>context.addErrorMsg(String)
 * </code>來新增錯誤,該錯誤會被新增到結果存根{@link Result}的錯誤訊息列表中。
 *
 * @param context 驗證上下文
 * @param t 待驗證物件
 *
 * @return 是否驗證通過
 */
 boolean validate(ValidatorContext context, T t);
 
 /**
 * 異常回撥
 * <p/>
 * 當執行{@link #accept(ValidatorContext, Object)}或者{@link #validate(ValidatorContext, Object)}發生異常時的如何處理
 *
 * @param e 異常
 * @param context 驗證上下文
 * @param t 待驗證物件
 */
 void onException(Exception e, ValidatorContext context, T t);
 
}

ValidatorHandler是實現Validator介面的一個模板類,如果你自己實現的Validator不想覆蓋上面3個方法,可以繼承這個ValidatorHandler。

public class ValidatorHandler<T> implements Validator<T> {
 
    @Override
    public boolean accept(ValidatorContext context, T t) {
        return true;
    }
 
    @Override
    public boolean validate(ValidatorContext context, T t) {
        return true;
    }
 
    @Override
    public void onException(Exception e, ValidatorContext context, T t) {
 
    }
}

內部校驗邏輯發生錯誤時候,有兩個處理辦法,
第一,簡單處理,如上述3.3中程式碼所示:
context.addErrorMsg("學校名稱不正確");
第二,需要詳細的資訊,包括錯誤訊息,錯誤屬性/欄位,錯誤值,錯誤碼,都可以自己定義,放入錯誤的方法如下,create()方法傳入訊息(必填),setErrorCode()方法設定錯誤碼(選填),setField()設定錯誤欄位(選填),setInvalidValue()設定錯誤值(選填)。當然這些資訊需要result(toComplex())才可以獲取到。

public class AreaValidator extends ValidatorHandler<String> implements Validator<String> {
    // 實現Validator介面,泛型T規範這個校驗器待驗證的物件的型別
    @Override
    public boolean validate(ValidatorContext context, String area) {
        if (!"無錫".equals(area)) {
            context.addError(
                    ValidationError.create("地址不正確")
                            .setErrorCode(5000)
                            .setField("area")
                            .setInvalidValue(area));
            // context.addErrorMsg("地址不正確");
            return false;
        }
        return true;
    }
}

如果需要可以使用複雜ComplexResult,內含錯誤訊息,錯誤屬性/欄位,錯誤值,錯誤碼,如下所示:

ComplexResult ret =
        FluentValidator.checkAll()
                .failOver()
                .on(studentDto.getArea(), new AreaValidator())
                .doValidate()
                .result(toComplex());
System.out.println(ret);
//列印結果:Result{isSuccess=false, errors=[ValidationError{errorCode=5000, errorMsg='地址不正確', field='area', invalidValue=蘇州}], timeElapsedInMillis=1}

上述都是針對單個屬性值編寫對應的Validator程式碼,實際開發中,我們需要對整個物件的多個屬性進行業務校驗,這時我們可以針對整個物件編寫對應的Validator,最後用ComplexResult來接收校驗結果,程式碼如下:

public class StudentValidator extends ValidatorHandler<StudentDto>
        implements Validator<StudentDto> {
    @Override
    public boolean validate(ValidatorContext context, StudentDto studentDto) {
        if (!"無錫".equals(studentDto.getArea())) {
            context.addError(
                    ValidationError.create("地址不正確")
                            .setErrorCode(5000)
                            .setField("area")
                            .setInvalidValue(studentDto.getArea()));
        }
        if (!"無錫中學".equals(studentDto.getSchoolName())) {
            context.addError(
                    ValidationError.create("學校名稱不正確")
                            .setErrorCode(5000)
                            .setField("schoolName")
                            .setInvalidValue(studentDto.getSchoolName()));
        }
        //校驗有沒有Error資訊
        if (CollectionUtils.isNotEmpty(context.result.getErrors())) {
            return false;
        }
        return true;
    }
}

on()的一連串呼叫實際就是構建呼叫鏈,因此理所當然可以傳入一個呼叫鏈。

   ValidatorChain chain = new ValidatorChain();
        List<Validator> validators = new ArrayList<Validator>();
        validators.add(new StudentValidator());
        chain.setValidators(validators);
        ComplexResult rets =
                FluentValidator.checkAll().on(studentDto, chain).doValidate().result(toComplex());
        System.out.println(rets);

//列印結果:Result{isSuccess=false, errors=[ValidationError{errorCode=5000, errorMsg='地址不正確', field='area', invalidValue=蘇州}, ValidationError{errorCode=5000, errorMsg='學校名稱不正確', field='schoolName', invalidValue=蘇州中學}], timeElapsedInMillis=7}

擴充

可根據專案自定義返回結果型別,實現ResultCollector即可

public interface ResultCollector<T> {
 
 /**
 * 轉換為對外結果
 *
 * @param result 框架內部驗證結果
 *
 * @return 對外驗證結果物件
 */
 T toResult(ValidationResult result);
}

4.2 onEach

如果要驗證的是一個集合(Collection)或者陣列,那麼可以使用onEach,FluentValidator會自動為你遍歷:

    ComplexResult result =
                FluentValidator.checkAll()
                        .onEach(list, new StudentValidator())
                        .doValidate()
                        .result(toComplex());

4.3 fail fast or fail over

當出現校驗失敗時,也就是Validator的validate()方法返回了false,那麼是繼續還是直接退出呢?預設為使用failFast()方法,直接退出,如果你想繼續完成所有校驗,使用failOver()來skip掉。

  ComplexResult result1 =
                FluentValidator.checkAll()
                        .failFast()
                        .on(liS.getArea(), new AreaValidator())
                        .on(liS.getSchoolName(), new SchoolNameValidator())
                        .doValidate()
                        .result(toComplex());

  ComplexResult result2 =
                FluentValidator.checkAll()
                        .failOver()
                        .on(liS.getArea(), new AreaValidator())
                        .on(liS.getSchoolName(), new SchoolNameValidator())
                        .doValidate()
                        .result(toComplex());

4.4 when

on()後面可以緊跟一個when(),當when滿足expression表示式on才啟用驗證,否則skip呼叫。

  ComplexResult result =
                FluentValidator.checkAll()
                        .failOver()
                        .on(liS.getArea(), new AreaValidator())
                        .when("20".equals(liS.getAge()))
                        .on(liS.getSchoolName(), new SchoolNameValidator())
                        .doValidate()
                        .result(toComplex());
        System.out.println(result);

4.5 驗證回撥callBack

doValidate()方法接受一個ValidateCallback介面

public interface ValidateCallback {
 
 /**
 * 所有驗證完成並且成功後
 *
 * @param validatorElementList 驗證器list
 */
 void onSuccess(ValidatorElementList validatorElementList);
 
 /**
 * 所有驗證步驟結束,發現驗證存在失敗後
 *
 * @param validatorElementList 驗證器list
 * @param errors 驗證過程中發生的錯誤
 */
 void onFail(ValidatorElementList validatorElementList, List<ValidationError> errors);
 
 /**
 * 執行驗證過程中發生了異常後
 *
 * @param validator 驗證器
 * @param e 異常
 * @param target 正在驗證的物件
 *
 * @throws Exception
 */
 void onUncaughtException(Validator validator, Exception e, Object target) throws Exception;
 
}

我們可以根據業務需求,在校驗回撥介面中做其他邏輯處理,如下所示:

 FluentValidator.checkAll()
                        .on(liS.getSchoolName(), new SchoolNameValidator())
                        .doValidate(
                                new DefaultValidateCallback() {
                                    @Override
                                    public void onSuccess(
                                            ValidatorElementList validatorElementList) {
                                        
                                        System.out.println("校驗成功");
                                    }

                                    @Override
                                    public void onFail(
                                            ValidatorElementList validatorElementList,
                                            List<ValidationError> errors) {
                                        System.out.println("校驗失敗");
                                    }
                                })
                        .result(toComplex());

4.6 RuntimeValidateException

如果驗證中發生了一些不可控異常,例如資料庫呼叫失敗,RPC連線失效等,會丟擲一些異常,如果Validator沒有try-catch處理,FluentValidator會將這些異常封裝在RuntimeValidateException,然後再re-throw出去。

4.7 上下文傳遞

通過putAttribute2Context()方法,可以往FluentValidator注入一些鍵值對,在所有Validator中共享,有時候這相當有用。

 Result result =
                FluentValidator.checkAll()
                        .putAttribute2Context("school", "常州中學")
                        .on(liS.getSchoolName(), new SchoolNameValidator())
                        .doValidate()
                        .result(toSimple());

可在Validator中通過context.getAttribute拿到這個值

   String name = context.getAttribute("school", String.class);
        if (!name.equals(schoolName)) {
            context.addErrorMsg("學校名稱不正確");
            return false;
        }
        return true;

4.8 閉包

通過putClosure2Context()方法,可以往FluentValidator注入一個閉包,這個閉包的作用是在Validator內部可以呼叫,並且快取結果到Closure中,這樣caller在上層可以獲取這個結果。
典型的應用場景是,當需要頻繁呼叫一個RPC的時候,往往該執行執行緒內部一次呼叫就夠了,多次呼叫會影響效能,我們就可以快取住這個結果,在所有Validator間和caller中共享。
下面展示了在caller處存在一個getAreas()方法,它假如需要RPC才能獲取所有地區資訊,顯然是很耗時的,可以在validator中呼叫,然後validator內部共享的同時,caller可以利用閉包拿到結果,用於後續的業務邏輯。

  StudentDto liS = new StudentDto("李四", 18, "常州中學", "常州");
        Closure<List<String>> closure =
                new ClosureHandler<List<String>>() {

                    private List<String> allManufacturers;

                    @Override
                    public List<String> getResult() {
                        return allManufacturers;
                    }

                    @SneakyThrows
                    @Override
                    public void doExecute(Object... input) {
                        // getAreas()模擬RPC遠端介面呼叫
                        allManufacturers = getAreas();
                    }
                };
        FluentValidator.checkAll()
                .putClosure2Context("area", closure)
                .on(liS.getArea(), new AreaValidator())
                .doValidate()
                .result(toSimple());

Validator中獲取介面查詢的資料:

  Closure<List<String>> closure = context.getClosure("area");
        List<String> areas = closure.executeAndGetResult();
        if (!areas.contains(area)) {
            context.addError(
                    ValidationError.create("地址不正確")
                            .setErrorCode(5000)
                            .setField("area")
                            .setInvalidValue(area));
            return false;
        }
        return true;
    }

5.高階玩法

Hibernate Validator整合

Hibernate ValidatorJSR 303 – Bean Validation規範的一個最佳的實現類庫,他僅僅是jboss家族的一員,和大名鼎鼎的Hibernate ORM是系出同門,屬於遠房親戚關係。很多框架都會天然整合這個優秀類庫,例如Spring MVC的@Valid註解可以為Controller方法上的引數做校驗。
FluentValidator當然不會重複早輪子,這麼好的類庫,一定要使用站在巨人肩膀上的策略,將它整合進來。
想要了解更多Hibernate Validator用法,參考這個連結
fluent-validator 整合 hibernate-validator 需要新增依賴

<dependency>
            <groupId>com.baidu.unbiz</groupId>
            <artifactId>fluent-validator-jsr303</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>org.slf4j</groupId>
                    <artifactId>slf4j-log4j12</artifactId>
                </exclusion>
            </exclusions>
            <version>1.0.5</version>
        </dependency>

5.1 註解驗證

上述都是通過顯示的API呼叫來進行驗證,FluentValidator同樣提供簡潔的基於註解配置的方式來達到同樣的效果。
@FluentValidate可以裝飾在屬性上,內部接收一個Class[]陣列引數,這些個classes必須是Validator的子類,這叫表明在某個屬性上依次用這些Validator做驗證。如下,我們改造下StudentDto這個類:

@Data
@AllArgsConstructor
public class StudentDto {

    @NotNull private String name;

    private Integer age;

    @Length(max = 5)
    @FluentValidate({SchoolNameValidator.class})
    private String schoolName;

    @FluentValidate({AreaValidator.class})
    private String area;
}

然後還是利用on()或者onEach()方法來校驗,這裡只不過不用傳入Validator或者ValidatorChain了。

       ComplexResult ret =
                FluentValidator.checkAll()
                        .failOver()
                        .configure(new SimpleRegistry())
                        .on(liS)
                        .doValidate()
                        .result(toComplex());

預設的,FluentValidator使用SimpleRegistry,它會嘗試從當前的class loader中呼叫Class.newInstance()方法來新建一個Validator。

5.2 分組驗證

當使用註解驗證時候,會遇到這樣的情況,某些時候例如新增操作,我們會驗證A/B/C三個屬性,而修改操作,我們需要驗證B/C/D/E 4個屬性
@FluentValidate註解另外一個接受的引數是groups,裡面也是Class[]陣列,只不過這個Class可以是開發人員隨意寫的一個簡單的類,不含有任何屬性方法都可以,例如:

@Data
@AllArgsConstructor
public class StudentDto {

    @NotNull private String name;

    private Integer age;

    @Length(max = 5)
    @FluentValidate(
            value = {SchoolNameValidator.class},
            groups = {Add.class})
    private String schoolName;

    @FluentValidate(
            value = {AreaValidator.class},
            groups = Update.class)
    private String area;
}

那麼驗證的時候,只需要在checkAll()方法中傳入想要驗證的group,就只會做選擇性的分組驗證,例如下面例子,只有area(地區)會被驗證。

  ComplexResult result =
                FluentValidator.checkAll(new Class<?>[] {Update.class})
                        .on(liS)
                        .doValidate()
                        .result(toComplex());

5.3 級聯驗證

級聯驗證(cascade validation),也叫做物件圖(object graphs),指一個類巢狀另外一個類的時候做的驗證。
如下例所示,我們在車庫(Garage)類中含有一個汽車列表(carList),可以在這個汽車列表屬性上使用@FluentValid註解,表示需要級聯到內部Car做onEach驗證。

public class Garage {
 
    @FluentValidate({CarNotExceedLimitValidator.class})
    @FluentValid
    private List<Car> carList;
}

注意,@FluentValid和@FluentValidate兩個註解不互相沖突,如下所示,呼叫鏈會先驗證carList上的CarNotExceedLimitValidator,然後再遍歷carList,對每個car做內部的生產商、座椅數、牌照驗證。

6.SpringBoot實戰

6.1 新增依賴

fluent-validator 整合 spring 需要新增依賴

<dependency>
    <groupId>com.baidu.unbiz</groupId>
    <artifactId>fluent-validator-spring</artifactId>
    <version>1.0.9</version>
</dependency>

6.2 註冊 Fluent-validator

fluent-validate 與 spring 結合使用 annotation 方式進行引數校驗,需要藉助於 spring 的 AOP,fluent-validate 提供了處理類 FluentValidateInterceptor,但是 fluent-validate 提供的預設驗證回撥類 DefaultValidateCallback 對校驗失敗的情況並沒有處理,所以需要自行實現一個

@Slf4j
public class MyValidateCallBack extends DefaultValidateCallback implements ValidateCallback {
    @Override
    public void onSuccess(ValidatorElementList validatorElementList) {
        log.info("校驗成功");
        super.onSuccess(validatorElementList);
    }

    @Override
    public void onFail(ValidatorElementList validatorElementList, List<ValidationError> errors) {
        log.info("校驗失敗");
        throw new RuntimeException(errors.get(0).getErrorMsg());
    }

    @Override
    public void onUncaughtException(Validator validator, Exception e, Object target)
            throws Exception {
        log.info("校驗異常");
        throw new RuntimeException(e);
    }

6.3 註冊IOC

註冊 FluentValidateInterceptor攔截器及MyValidateCallBack回撥方法,最後配置一個 AOP 規則

@Configuration
public class ValidateCallbackConfig {

    @Bean
    public FluentValidateInterceptor fluentValidateInterceptor() {
        FluentValidateInterceptor fluentValidateInterceptor = new FluentValidateInterceptor();
        fluentValidateInterceptor.setCallback(validateCallback());
        return fluentValidateInterceptor;
    }

    public MyValidateCallBack validateCallback() {
        return new MyValidateCallBack();
    }

    @Bean
    public BeanNameAutoProxyCreator beanNameAutoProxyCreator() {
        // 使用BeanNameAutoProxyCreator來建立代理
        BeanNameAutoProxyCreator proxyCreator = new BeanNameAutoProxyCreator();
        // 設定要建立代理的那些Bean的名字
        proxyCreator.setBeanNames("*ServiceImpl");
        proxyCreator.setInterceptorNames("fluentValidateInterceptor");
        return proxyCreator;
    }
}

6.4 使用校驗

為了方便,在StudentServiceImpl實現類上增加引數校驗

@Service
public class StudentServiceImpl implements StudentService {
    @Override
    public Integer getAge(@FluentValid StudentDto studentDto) {
        return studentDto.getAge();
    }
}

總結

fluent-validate 可以全方位相容 hibernate-validate,基於 spring 的 AOP 可以提供基於註解的方法入參校驗,同時也可以提供流式程式設計的工具類業務校驗,替代 hibernate-validate 的同時提供了更多擴充套件性

參考文件:http://neoremind.com/2016/02/java%E7%9A%84%E4%B8%9A%E5%8A%A1%E9%80%BB%E8%BE%91%E9%AA%8C%E8%AF%81%E6%A1%86%E6%9E%B6fluent-validator/

歡迎大家訪問 個人部落格 Johnny小屋

相關文章