1。 概覽
在實際開發過程中,資料校驗是最為重要的一環,問題資料一旦進入系統,將對系統造成不可估量的損失。輕者,查詢時觸發空指標異常,導致整個頁面不可用;重者,業務邏輯錯誤,造成流量甚至金錢上的損失。
1。1。 背景
資料校驗,天天都在做,但因此而引起的bug也一直沒有中斷。沒有經驗的同學精力只定在正常流程,對於邊界條件視而不見;有經驗的同學,編寫大量的程式碼,對資料進行驗證,確實大幅提升了系統的健壯性,但也耗費了大量精力。
對此,我們需要:
一套完整方法論和工具,對系統進行立體式防護;
簡單快捷,快速接入,降低開發負擔;
1。2。 目標
首先,先看下應用程式架構,其中的每一個層次都需不同的驗證機制進行保障。
常用應用架構
構建完整的驗證體系,從各個層次對應用服務提供保護,需考慮:
應用層引數驗證,包括:
入參驗證
嵌入物件驗證
自定義邏輯驗證
領域層業務驗證。
業務規則外掛化
儲存層規則驗證;
入庫前規則校驗
2。 快速入門
2。1。 Spring Validator 入門
Spring 對 Validator 提供了支援,可以對簡單屬性進行驗證,大大降低編碼量。
新增 vlidator starter 依賴,具體如下:
Starter 會自動引入 hibernate-validator,並完成與 Spring MVC 和 Spring AOP 的整合。此時,便可以使用驗證註解對入參或屬性進行標註,Bean Validation 內建的註解如下:
註解
含義
@Valid
標記的元素為一個物件,對其所有欄位進行檢測
@Null
被標註的元素必須為 null
@NotNull
被標註的元素必須不為 null
@AssertTrue
被標記的元素必須為 true
@AssertFalse
被標記的元素必須為 false
@Min(value)
被標記的元素為數值,並且大於等於最小值
@Max(value)
被標記的元素為數值,並且小於等於最大值
@DecimalMin(value)
被標記的元素為數值,並且大於等於最小值
@DecimalMax(value)
被標記的元素為數值,並且小於等於最大值
@Size(max, min)
被標記的元素必須指定範圍內
@Digits (integer, fraction)
被註釋的元素必須是一個數字,其值必須在可接受的範圍內
@Past
被註釋的元素必須是一個過去的日期
@Future
被註釋的元素必須是一個將來的日期
@Pattern(value)
被註釋的元素必須符合指定的正則表示式
Hibernat Validator 擴充套件註解如下:
註解
含義
被標註的元素必須是郵箱
@Length(min=, max=)
被標註的字串必須在指定範圍內
@NotEmpty
被標註的字串不能為空串
@Range(min=, max=)
被標註的元素必須在指定範圍內
@NotBlank
被標註的字串不能為空串
@URL(protocol=,host=, port=, regexp=, flags=)
被標記的元素必須為有效的 url
@CreditCardNumber
被註釋的字串必須透過Luhn校驗演算法,銀行卡,信用卡等號碼一般都用Luhn計算合法性
@ScriptAssert(lang=, script=, alias=)
要有Java Scripting API 即JSR 223 的實現
@SafeHtml(whitelistType=, additionalTags=)
classpath中要有jsoup包
2。2。 基礎引數驗證
基礎引數驗證是最簡單的驗證,直接使用 validator 提供的註解便可完成驗證。
2。2。1。 開啟驗證 AOP
在介面或實現類上新增 @Validated 註解,將啟動 MethodValidationInterceptor 對方法進行驗證攔截。
具體程式碼如下:
@Validatedpublic interface ApplicationValidateService {}
建議將 @Validated 註解新增到介面上,其所有實現類都會開啟方法驗證。
2。2。2。 簡單型別入參驗證
簡單型別是最常見的入參,如需對其進行驗證,只需在入參上新增對應註解即可,示例如下:
void singleValidate(@NotNull(message = “id 不能為null”) Long id);
執行測試用例:
applicationValidateService。singleValidate((Long) null);
丟擲如下異常:
javax。validation。ConstraintViolationException: singleValidate。id: id 不能為null at org。springframework。validation。beanvalidation。MethodValidationInterceptor。invoke(MethodValidationInterceptor。java:120) at org。springframework。aop。framework。ReflectiveMethodInvocation。proceed(ReflectiveMethodInvocation。java:186) at org。springframework。aop。framework。CglibAopProxy$CglibMethodInvocation。proceed(CglibAopProxy。java:763)
2。2。3。 物件型別入參驗證
為了方便,經常將多個屬性封裝到一個物件中,並使用該物件作為入參,如果想對物件型別的入參進行驗證需要:
在物件的屬性上根據需求增加驗證註解,示例如下:
@Datapublic class SingleForm { @NotNull(message = “id不能為null”) private Long id; @NotEmpty(message = “name不能為空”) private String name;}
在方法入參處使用 @Valid 註解,示例如下:
void singleValidate(@Valid @NotNull(message = “form 不能為 null”) SingleForm singleForm);
此時,singleValidate 便擁有:
singleForm 入參不能為空驗證
singleForm 示例屬性驗證
id 不能為null
name 不能為空
執行單元測試:
this。applicationValidateService。singleValidate((SingleForm) null);
丟擲如下異常:
javax。validation。ConstraintViolationException: singleValidate。singleForm: form 不能為 null at org。springframework。validation。beanvalidation。MethodValidationInterceptor。invoke(MethodValidationInterceptor。java:120) at org。springframework。aop。framework。ReflectiveMethodInvocation。proceed(ReflectiveMethodInvocation。java:186) at org。springframework。aop。framework。CglibAopProxy$CglibMethodInvocation。proceed(CglibAopProxy。java:763)
執行單元測試:
SingleForm singleForm = new SingleForm();this。applicationValidateService。singleValidate(singleForm);
丟擲如下異常:
javax。validation。ConstraintViolationException: singleValidate。singleForm。name: name不能為空, singleValidate。singleForm。id: id不能為null at org。springframework。validation。beanvalidation。MethodValidationInterceptor。invoke(MethodValidationInterceptor。java:120) at org。springframework。aop。framework。ReflectiveMethodInvocation。proceed(ReflectiveMethodInvocation。java:186) at org。springframework。aop。framework。CglibAopProxy$CglibMethodInvocation。proceed(CglibAopProxy。java:763)
2。3。 擴充套件 Validation 框架
有時僅僅驗證單個屬性無法滿足業務需求,比如在修改密碼時,需要使用者輸入兩次密碼,用以保障輸入密碼的準確性。
在這種情況下,可以對 Validation 框架進行擴充套件,具體如下:
建立一個驗證物件 Password,用於儲存兩次輸入的值,示例如下:
@Datapublic class Password { @NotEmpty(message = “密碼不能為空”) private String input1; @NotEmpty(message = “確認密碼不能為空”) private String input2;}
其中,Password 中的兩個屬性全部添加了驗證註解。
建立一個驗證元件 PasswordValidator,用於對“兩次密碼是否一致”進行驗證,示例如下:
public class PasswordValidator implements ConstraintValidator
驗證元件實現 ConstraintValidator 介面,僅當兩次密碼一致時透過驗證。
建立驗證註解 PasswordConsistency,程式碼如下:
@Target({ElementType。METHOD, ElementType。FIELD, ElementType。ANNOTATION_TYPE, ElementType。CONSTRUCTOR, ElementType。PARAMETER, ElementType。TYPE_USE})@Retention(RetentionPolicy。RUNTIME)@Documented@Constraint( validatedBy = PasswordValidator。class)public @interface PasswordConsistency { String message() default “{javax。validation。constraints。password。consistency。message}”; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {};}
其中 @Constraint 用於說明該註解使用的驗證器為 PasswordValidator。
一切準備好之後,並可以使用自定義驗證元件,具體如下:
void customSingleValidate(@NotNull @Valid @PasswordConsistency(message = “兩次密碼不相同”) Password password);
其中
@NotNull 表明入參 password 不能為 null
@Valid 表明對Password 的屬性進行校驗
@PasswordConsistency 表明使用 PasswordValidator 進行驗證
執行單元測試:
this。applicationValidateService。customSingleValidate(null);
執行結果如下:
javax。validation。ConstraintViolationException: customSingleValidate。password: 不能為null at org。springframework。validation。beanvalidation。MethodValidationInterceptor。invoke(MethodValidationInterceptor。java:120) at org。springframework。aop。framework。ReflectiveMethodInvocation。proceed(ReflectiveMethodInvocation。java:186) at org。springframework。aop。framework。CglibAopProxy$CglibMethodInvocation。proceed(CglibAopProxy。java:763)
執行單元測試:
Password password = new Password();this。applicationValidateService。customSingleValidate(password);
執行結果如下:
javax。validation。ConstraintViolationException: customSingleValidate。password。input1: 密碼不能為空, customSingleValidate。password。input2: 確認密碼不能為空 at org。springframework。validation。beanvalidation。MethodValidationInterceptor。invoke(MethodValidationInterceptor。java:120) at org。springframework。aop。framework。ReflectiveMethodInvocation。proceed(ReflectiveMethodInvocation。java:186) at org。springframework。aop。framework。CglibAopProxy$CglibMethodInvocation。proceed(CglibAopProxy。java:763)
執行單元測試:
Password password = new Password();password。setInput1(“123”);password。setInput2(“456”);this。applicationValidateService。customSingleValidate(password);
執行結果如下:
javax。validation。ConstraintViolationException: customSingleValidate。password: 兩次密碼不相同 at org。springframework。validation。beanvalidation。MethodValidationInterceptor。invoke(MethodValidationInterceptor。java:120) at org。springframework。aop。framework。ReflectiveMethodInvocation。proceed(ReflectiveMethodInvocation。java:186) at org。springframework。aop。framework。CglibAopProxy$CglibMethodInvocation。proceed(CglibAopProxy。java:763)
2。4。 新增 Validateable 驗證
擴充套件驗證規則非常繁瑣,一個驗證需要新建註解和驗證類,並完成兩者的配置,在實際開發中使用的頻次極低。
相反,在開發中更習慣呼叫物件上的驗證方法進行資料驗證,示例如下:
if(!createUserCommand。validate()){ throw new XXXXException();}
對於這種非常通用的解決方案,lego 提供了框架支援。
2。4。1。 引入 lego starter
在配置檔案中新增 lego starter,示例如下:
基於 Spring Boot 的自動配置機制,ValidatorAutoConfiguration 將自動新增 ValidateableMethodValidationInterceptor,對方法進行攔截,進行資料校驗。
2。4。2。 應用 Validateable
比如,使用者註冊時,系統要求密碼與使用者名稱不能相同。使用 Validateable 進行驗證具體如下:
讓物件繼承自 Validateable,並實現 validate 介面,示例程式碼如下:
@Datapublic class UserValidateForm implements Validateable { @NotEmpty private String name; @NotEmpty private String password; @Override public void validate(ValidateErrorHandler validateErrorHandler) { if (getName()。equals(getPassword())){ validateErrorHandler。handleError(“user”, “1”, “使用者名稱密碼不能相同”); } }}
驗證方法如下:
void validateForm(@NotNull @Valid UserValidateForm userValidateForm);
執行單元測試:
this。applicationValidateService。validateForm(null);
執行結果如下:
javax。validation。ConstraintViolationException: validateForm。userValidateForm: 不能為null at org。springframework。validation。beanvalidation。MethodValidationInterceptor。invoke(MethodValidationInterceptor。java:120) at org。springframework。aop。framework。ReflectiveMethodInvocation。proceed(ReflectiveMethodInvocation。java:186) at org。springframework。aop。framework。CglibAopProxy$CglibMethodInvocation。proceed(CglibAopProxy。java:763)
執行單元測試:
UserValidateForm userValidateForm = new UserValidateForm();this。applicationValidateService。validateForm(userValidateForm);
執行結果如下:
javax。validation。ConstraintViolationException: validateForm。userValidateForm。name: 不能為空, validateForm。userValidateForm。password: 不能為空 at org。springframework。validation。beanvalidation。MethodValidationInterceptor。invoke(MethodValidationInterceptor。java:120) at org。springframework。aop。framework。ReflectiveMethodInvocation。proceed(ReflectiveMethodInvocation。java:186) at org。springframework。aop。framework。CglibAopProxy$CglibMethodInvocation。proceed(CglibAopProxy。java:763)
執行單元測試:
UserValidateForm userValidateForm = new UserValidateForm();userValidateForm。setName(“name”);userValidateForm。setPassword(“name”);this。applicationValidateService。validateForm(userValidateForm);
執行結果如下:
javax。validation。ConstraintViolationException: null: 使用者名稱密碼不能相同 at com。geekhalo。lego。starter。validator。ValidatorAutoConfiguration。lambda$validateErrorReporter$1(ValidatorAutoConfiguration。java:61) at com。geekhalo。lego。starter。validator。ValidatorAutoConfiguration$$Lambda$749/562345204。handleErrors(Unknown Source) at com。geekhalo。lego。core。validator。ValidateableMethodValidationInterceptor。invoke(ValidateableMethodValidationInterceptor。java:39)
2。5。 業務規則外掛化
在一些複雜流程中,業務規則校驗邏輯佔比非常重,大量的 if-else 充斥在主流程中非常不便於維護。
在這種場景下,建議將驗證元件外掛化,使得每個驗證邏輯全部封裝在一個類中,將邏輯進行拆分,最終實現“開閉原則”。
2。5。1。 初識 ValidateService
ValidateService 整體架構如下:
image
其中,包括兩個核心元件:
1。BeanValidator。業務驗證介面,由開發人員實現,用於承載驗證邏輯,包括:
support 方法(繼承自SmartComponent)用於定義元件應用場景
validate 方法,實現業務邏輯
2。ValidateService。驗證服務的入口,主要職責包括:
管理所有的 BeanValidator 實現,由 Spring 完成所有的 BeanValidator 例項注入,並對其進行統一管理;
對外提供 validate 方法,從 BeanValidator 例項中選擇對應的元件,並呼叫 BeanValidator 的 validate 方法;
整體介紹完成後,讓我們看一個真實案例。比如,在一個生單流程中,我們需要保障:
使用者必須存在,並且為可用狀態;
商品必須存在,並且為售賣狀態;
庫存餘量必須大於購買數量;
這三個規則相互獨立,沒有太多關聯關係,如果在一個方法中編寫,便會產生強耦合,不利於應對未來的變更。這種情況下,最佳方案是將其封裝到不同的元件中。示例如下:
UserStatusValidator
@Order(1)@Componentpublic class UserStatusValidator extends FixTypeBeanValidator
ProductStatusValidator
@Component@Order(2)public class ProductStatusValidator extends FixTypeBeanValidator
StockCapacityValidator
@Component@Order(3)public class StockCapacityValidator extends FixTypeBeanValidator
三個驗證元件具有以下特徵:
繼承自 FixTypeBeanValidator,僅對 CreateOrderContext 進行處理
使用 @Component 將其宣告為 Spring 的託管bean,從而被框架所感知;
使用 @Order(n) 標記執行順序
其中,FixTypeBeanValidator 會根據泛型進行型別判斷,自動完成元件的篩選。程式碼如下:
public abstract class FixTypeBeanValidator implements BeanValidator{ private final Class type; protected FixTypeBeanValidator(){ Class type = (Class)((ParameterizedType)getClass() 。getGenericSuperclass()) 。getActualTypeArguments()[0]; this。type = type; } protected FixTypeBeanValidator(Class type) { this。type = type; } @Override public final boolean support(Object a) { return this。type。isInstance(a); }}
有了驗證元件後,可以直接使用 ValidateService 進行驗證,具體示例程式碼如下:
@Overridepublic void createOrder(CreateOrderContext context) { validateService。validate(context);}
執行測試用例:
CreateOrderContext context = new CreateOrderContext();context。setUser(User。builder() 。build());context。setProduct(Product。builder() 。build());context。setStock(Stock。builder() 。count(0) 。build());context。setCount(1);this。domainValidateService。createOrder(context);
執行結果如下:
ValidateException(name=stock, code=4, msg=庫存不足) at com。geekhalo。lego。core。validator。BeanValidator。lambda$validate$0(BeanValidator。java:17) at com。geekhalo。lego。core。validator。BeanValidator$$Lambda$1383/1570024586。handleError(Unknown Source) at com。geekhalo。lego。validator。StockValidator。validate(StockValidator。java:24) at com。geekhalo。lego。validator。StockValidator。validate(StockValidator。java:13)
該設計符合開閉原則:
新增驗證規則時,只需編寫新的驗證元件;
修改驗證規則時,只需修改對應的驗證元件,其他邏輯不受影響;
2。5。2。 與 LazyLoad 整合
有了靈活的驗證體系,最麻煩的就是對 Context 的維護,主要矛盾為:
如果一次性載入 Context 的全部資料,可能在第一個驗證元件就中斷流程,白白載入了過多資料;
可以在獲取的時候進行判斷,只有為 null 的時候才進行載入。但,如果多個元件依賴同一組資料,將會:
每個元件都需要寫一遍載入邏輯
為了避免多次載入,需要將資料寫回到 Context 例項
載入邏輯和驗證邏輯放在一起,職責混亂
對於這種情況,最好的方式便是讓 Context 具有延時載入的能力,其特徵如下:
只有在呼叫 getter 方法時,才觸發載入,避免全部載入產生的浪費
成功載入後,將資料透過 setter 寫回到 Context,由其他元件進行共享
這正是 LazyLoad 的設計初衷,示例如下:
定義一個具有延時載入能力的 Context,程式碼如下:
@Datapublic class CreateOrderContextV2 implements CreateOrderContext{ private CreateOrderCmd cmd; @LazyLoadBy(“#{@userRepository。getById(cmd。userId)}”) private User user; @LazyLoadBy(“#{@productRepository。getById(cmd。productId)}”) private Product product; @LazyLoadBy(“#{@addressRepository。getDefaultAddressByUserId(user。id)}”) private Address defAddress; @LazyLoadBy(“#{@stockRepository。getByProductId(product。id)}”) private Stock stock; @LazyLoadBy(“#{@priceService。getByUserAndProduct(user。id, product。id)}”) private Price price;}
基於 CreateOrderContextV2 編寫驗證元件,程式碼如下:
@Component@Order(3)public class StockCapacityV2Validator extends FixTypeBeanValidator
編寫驗證服務,程式碼如下:
@Overridepublic void createOrder(CreateOrderCmd cmd) { CreateOrderContextV2 context = new CreateOrderContextV2(); context。setCmd(cmd); CreateOrderContextV2 contextProxy = this。lazyLoadProxyFactory。createProxyFor(context); this。validateService。validate(contextProxy);}
lazyLoadProxyFactory 生成具有延遲載入能力的 Context 物件。
執行單元測試,核心程式碼如下:
CreateOrderCmd cmd = new CreateOrderCmd();cmd。setCount(10000);cmd。setProductId(100L);cmd。setUserId(100L);this。domainValidateService。createOrder(cmd);
執行結果如下:
ValidateException(name=stock, code=4, msg=庫存不足) at com。geekhalo。lego。core。validator。BeanValidator。lambda$validate$0(BeanValidator。java:17) at com。geekhalo。lego。core。validator。BeanValidator$$Lambda$1388/1691696909。handleError(Unknown Source) at com。geekhalo。lego。validator。StockCapacityV2Validator。validate(StockCapacityV2Validator。java:25) at com。geekhalo。lego。validator。StockCapacityV2Validator。validate(StockCapacityV2Validator。java:14) at com。geekhalo。lego。core。validator。BeanValidator。validate(BeanValidator。java:16) at com。geekhalo。lego。core。validator。ValidateService。lambda$validate$5(ValidateService。java:34)
2。6。 持久化前規則驗證
將問題資料寫入到資料庫是一個高危操作,輕則出現展示問題,比如 空指標異常;重則出現邏輯問題,比如金額對不上等。
一個最常見的例子便是 訂單系統的金額計算。隨著業務的發展,金額計算變得越來越複雜,比如優惠券、滿贈、滿減、VIP 使用者折扣等,這些業務都會對 訂單上的金額進行操作,一旦出現bug將導致嚴重的問題。
由於上層的更新入口太多,很難有一套行之有效的機制保障其不出問題。不如換個視角,在將變更同步到資料庫前,有沒有一種比較通用的檢測機制能發現金額問題?
其實是有的,無論上層業務怎麼變化,金額恆等式是不變的,及:
使用者支付金額 = 商品總售賣金額(售價 * 數量) - 優惠總金額 - 手工改價金額
只需在變更寫回資料庫前執行校驗邏輯,如果不符合公式,則直接丟擲異常。
很多框架都提供了對實體生命週期的擴充套件,比如 JPA 就提供了大量註解,以便在實體生命週期中嵌入回撥方法。
以標準的Order設計為例,具體如下:
@Entity@Table(name = “validate_order”)@Datapublic class ValidateableOrder { @Id @GeneratedValue(strategy = GenerationType。IDENTITY) private Long id; /** * 支付金額 */ private Integer payPrice; /** * 售價 */ private Integer sellPrice; /** * 購買數量 */ private Integer amount; /** * 折扣價 */ private Integer discountPrice; /** * 手工改價 */ private Integer manualPrice; @PrePersist @PreUpdate void checkPrice(){ Integer realPayPrice = sellPrice * amount - discountPrice - manualPrice; if (realPayPrice != payPrice){ throw new ValidateException(“order”, “570”, “金額計算錯誤”); } }}
其中,@PrePersist 和 @PreUpdate 註解表明,checkPrice 方法在儲存前和更新前進行回撥,用以驗證是否破壞了金額計算邏輯。
使用 JpaRepository 對資料進行儲存,具體如下:
public void createOrder(ValidateableOrder order){ this。repository。save(order);}
執行單元測試,程式碼如下:
ValidateableOrder order = new ValidateableOrder();order。setSellPrice(20);order。setAmount(2);order。setDiscountPrice(5);order。setManualPrice(1);order。setPayPrice(35);this。applicationService。createOrder(order);
執行結果如下:
ValidateException(name=order, code=570, msg=金額計算錯誤) at com。geekhalo。lego。validator。ValidateableOrder。checkPrice(ValidateableOrder。java:53) at sun。reflect。NativeMethodAccessorImpl。invoke0(Native Method) at sun。reflect。NativeMethodAccessorImpl。invoke(NativeMethodAccessorImpl。java:62) at sun。reflect。DelegatingMethodAccessorImpl。invoke(DelegatingMethodAccessorImpl。java:43) at java。lang。reflect。Method。invoke(Method。java:483) at org。hibernate。jpa。event。internal。EntityCallback。performCallback(EntityCallback。java:50)
不僅如此,Spring 對事務進行回滾,避免髒資料進入到資料庫。
3。 小結
對應用程式提供一套立體式的驗證保障機制,包括:
應用層的基礎資料校驗
業務層的業務邏輯校驗
儲存層的持久化前校驗
這些措施共同發力,徹底將問題資料拒絕於系統之外。
4。 專案資訊
專案倉庫地址:https://gitee。com/litao851025/lego
專案文件地址:https://gitee。com/litao851025/lego/wikis/support/validator
猜你喜歡
- 2023-01-05Java->JDK內建的SPI實現JDBC後門
- 2021-12-11鵝肉怎麼做才好吃?大廚教你5種鵝肉的家常做法,好吃又下飯
- 2021-06-26心情日記:相信以前先驗證,雖然麻煩卻有益,驗證之後知真假
- 2021-06-22Java JDK是什麼?JDK安裝目錄介紹
- 2021-04-29玩家直呼“人間失格”你見過哪些反人類的驗證?