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