本文介紹了Java驗證的幾種機制,包括JPA驗證,Bean驗證,實體監聽器和事務監聽器。通過介紹希望可以在Java項目整體的驗證方面提供一些參考。 ...
原文鏈接:https://www.cuba-platform.com/blog/2018-10-09/945
翻譯:CUBA China
CUBA-Platform 官網 : https://www.cuba-platform.com
CUBA China 官網 : http://cuba-platform.cn
我經常看見很多項目沒有數據驗證的策略和意識。他們的團隊在交付日期的重壓下,面對不清楚的需求,沒有時間去考慮用合適並且統一的方法對數據進行驗證。所以在這樣的項目中,到處能看見數據驗證的代碼:在前端JS中,在後端頁面控制器中,在業務邏輯的bean中,在數據模型實體中,在資料庫的約束和觸發器中。這些代碼都是一些 if-else 的語句,拋出一些不同的未檢查的異常,所以有時會很難找到這些該死的數據到底是在哪裡做的驗證。因此,一段時間之後,當項目成長到足夠大的時候就很難並且需要耗費很多精力來統一這些驗證,並且後面的需求也一樣模糊不清。
那有沒有做數據驗證比較標準、優雅而且還簡潔的方法呢?這個方法不會導致代碼的不可讀,這個方法能幫我們將大部分數據驗證的代碼維護在統一的地方,而且有沒有可能一些流行框架的開發者已經替我們做了大部分的工作呢?
當然有!
作為我們CUBA平臺的開發者來說,讓我們的用戶也遵循最佳實踐非常重要。我們認為,數據驗證的代碼應該是:
1. 可重用但不重覆,遵循DRY原則(Don’t Repeat Yourself)。
2. 用乾凈和自然的方式表達出來。
3. 放在開發人員期望看到的地方。
4. 能對不同數據來源的數據進行檢查:用戶輸入,SOAP或者REST 調用等。
5. 能處理併發。
6. 由應用程式隱式統一調用而不需要手動調用這些檢查代碼。
7. 能用簡潔的彈窗為用戶展示清晰,本地語言的消息。
8. 遵循標準。
這篇文章里,我將使用基於CUBA平臺開發的應用程式來演示所有的例子。由於CUBA是基於Spring和EclipseLink的,所以這些例子對於使用JPA和bean驗證的其他Java框架也適用。
資料庫約束驗證
也許,最常用最直接的數據驗證方法就是使用資料庫級別的約束,比如非空,字元串長度,唯一索引等。對於企業級應用來說,這個方法很自然,因為這種類型的軟體通常都是以數據為中心。但是,即便是這種情況,開發者也經常出錯,在應用程式的各個數據層級分別定義了約束。這個問題主要是由於開發人員的不同責任分工引起的。
我們看一個幾乎大家都會面對的例子,有的人甚至乾過這樣的事 :)。 假設有個規定要求護照號碼欄位需要有10個數字,很可能到處都會做這個規則檢查:資料庫設計者用DDL檢查,後臺開發人員在相應的實體和REST服務中檢查,最後前端工程師在客戶端代碼中檢查。之後這個需求變了,要求護照欄位升到15個數字。技術支持人員可能只修改了資料庫約束,但是這樣對於用戶來說等於什麼都沒改,因為後臺和前臺的檢查還沒修改呢。
大家都知道避免這個問題的方法,驗證需要中心化。在CUBA,這種驗證的中心點在是實體的JPA註解。基於這個元數據信息,CUBA Studio可以生成正確的DDL腳本並且能在客戶端採用相應的驗證器。
此時,如果JPA註解改變的話,CUBA會自動更新DDL腳本以及生成資料庫遷移腳本,所以下次部署項目的時候,新的基於JPA的限制將會在UI和DB生效。
這種方式簡單、也能實施到底層資料庫級別,因此能完全防破解。但是JPA註解的局限性在於,只能使用在最簡單、可以用標準的DDL表述、而不需要引入特定資料庫的觸發器或者存儲過程的情況。所以基於JPA的約束可以用來保證實體欄位是唯一的,或者必須的,抑或也能定義varchar欄位的最大長度。還有,可以使用 @UniqueConstraint 註解來為一組欄位定義唯一性約束。但也就這些了。
如果在需要更加複雜的驗證邏輯的的時候,比如檢查某個欄位的最大最小值或者對一個欄位使用正則表達式進行驗證,此時我們就需要使用眾所周知的叫做 “bean 驗證” 的方法了。
Bean 驗證
我們知道,遵循標準是很好的實踐,通常這種方式有更長的生命周期而且有幾千個項目實戰證明過了。Java 的 Bean驗證是早就寫在石頭上的方案了:在JSR 380, 349 和 303也有些成熟的實現:Hibernate Validator和 Apache BVal。
很多開發者都熟悉這個方法,但是這個方法的好處卻總是被低估。用這個方法甚至可以很容易在遺留項目中添加數據驗證,並且還能以清晰、直接、可靠最貼近業務邏輯的方式表達需要做的驗證。
使用Bean驗證能為項目帶來很多好處:
l 驗證邏輯集中在數據模型附近:使用最自然的方法定義針對值、方法和bean的約束,因此可以將OOP推進到下一個級別(驗證也可以OOP)。
l Bean驗證的標準提供了幾十種 開箱即用的驗證註解比如 @NotNull, @Size, @Min, @Max, @Pattern, @Email, @Past, 不太標準的比如 @URL, @Length,強大的 @ScriptAssert,另外還有很多其他的。
l 不會受限於僅使用預定義的約束,還可以自定義約束註解。可以定義一個註解來將其他幾個註解綁定到一起,或者定義一個全新的註解,然後定義一個相應的Java類作為驗證器。比如,之前那個例子中,可以定義一個類級別的註解 @ValidPassportNumber 用來檢查護照號碼是否符合正確的格式,號碼也許還依賴 country 欄位的值。
l 不止可以在類和欄位上加約束,也可以添加到方法和方法參數上。這個叫做“合同驗證”,後面會介紹。
CUBA平臺(以及一些其他平臺)會在用戶提交數據的時候自動調用這些驗證,所以一旦驗證失敗用戶會馬上看到錯誤消息,不需要考慮手動執行這些bean驗證。
我們一起再看看護照號碼驗證的例子,但是這次我們還需要在實體添加幾個其他的驗證:
l 人物姓名(例子中是用的英文名)至少有2個單詞或者可以更多,必須是格式化很好的姓名。檢查的正則表達式很複雜,比如 Charles Ogier de Batz de Castelmore Comte d'Artagnan 能通過檢查,但是 R2D2 卻不能通過。
l 人物身高的區間:0< height <=300釐米。
l 郵件地址需要是正確的郵件地址格式。
因此,帶有所有這些檢查,Person類看起來是這樣:
那些標準的註解,比如 @NotNull, @DecimalMin, @Length, @Pattern 還有其他幾個都是非常清楚的不需要過多解釋。主要看看自定義的 @ValidPassportNumber 是怎麼實現的。
我們全新的 @ValidPassportNumber 會檢查 Person#passportNumber 是否符合針對每個國家(Person#country)定義的正則表達式。
首先,按照文檔(CUBA或 Hibernate文檔是很好的參考)的描述,我們需要使用新的註解來標記實體類,以及將約束分組傳遞給這個註解。CUBA文檔有說,UiCrossFieldChecks.class 應當在所有單獨的欄位檢查完之後,才執行跨欄位的檢查,Default.class 能將約束添加到預設的驗證組。
註解的定義是這樣的:
@Target(ElementType.TYPE) 定義了註解在運行時生效的對象是一個類,@Constraint(validatedBy = … ) 聲明註解的實現在 ValidPassportNumberValidator 類中,此類需要實現 ConstraintValidator<...> 介面,在isValid(...) 方法中添加驗證代碼,方法也很直接:
好了,足夠了。使用CUBA平臺不需要多寫任何代碼來保證這個驗證的運行,也不需要添加代碼在用戶輸入錯誤的時候給用戶發送消息通知。很簡單吧?
現在,我們看看這些東西都是怎麼工作的,CUBA還做了一些額外的事情:不但給用戶展示錯誤消息,而且還將有問題的表單欄位高亮出來,這些漂亮的描紅欄位沒有通過單一欄位的bean驗證:
是不是很簡潔?在用戶的瀏覽器顯示漂亮的錯誤提醒,只需要在實體中添加幾個簡單的註解就好了。
作為本章節的總結,我們再簡單列舉一下實體的Bean驗證有什麼好處:
- 清晰可讀
- 可以直接在實體模型中定義值的約束
- 可擴展、可定製化
- 跟很多流行的ORM集成,檢查都是在實體保存在資料庫之前自動調用的
- 有些框架也能在用戶從UI提交數據的時候自動運行bean驗證(但是如果不支持的話,很難手動調用 Validator 介面)
- Bean驗證是眾所周知的標準,網上能找到很多相關文檔
但是如果我們需要將驗證放到方法、構造器上或者放到某個REST終端來驗證從外部來的數據呢?或者我們想用聲明式的方法驗證方法參數而不是在每個方法內寫很多if-else這種枯燥的檢查參數的方法?
答案很簡單,bean驗證也可以作用在方法上!
合同驗證
有時候,我們需要前進一步,不只是做到應用的數據模型驗證。如果能做到參數和返回值自動驗證,那麼寫方法的時候就會容易很多。這個需求可能不只是用在檢查REST或者SOAP接入的數據,也會用在針對方法的輸入參數和返回值上。用來做所謂的前置條件和後置條件檢查,確保在方法體執行前對輸入參數的檢查,以及在方法執行後對返回值範圍的檢查,或者只是希望能聲明式的用在參數上限定參數的範圍以達到代碼更好的可讀性。
使用合同驗證,就可以在任何Java類型的方法、構造器的參數和返回值上使用驗證。相對傳統的檢查參數和返回值的辦法,這個方案的優點是:
l 不需要以極端的方式執行檢查(比如,拋出類似 illegalArgumentException 這樣的異常)。我們會更願意使用聲明式的約束,這樣會形成可讀性表達性更強的代碼。
l 約束都是可重用、可配置、可定製化的:不需要每次都寫驗證代碼,更少的代碼意味著更少的bug。
l 如果類、方法的返回值或參數使用了 @Validated 註解,平臺會在每個方法調用的時候自動執行約束檢查。
l 如果一個可執行程式使用了 @Documented 註解,那麼它的前置條件和後置條件會自動包含在生成的JavaDoc中。
因此,使用合同驗證方案,會有清晰、相對少的代碼,更易於維護和理解。
我們看看在CUBA應用的REST控制器中,使用合同驗證的代碼大概是什麼樣的。通過 PersonApiService 介面的 getPerson() 方法可以從資料庫獲取用戶的列表,使用 addNewPerson(…) 方法可以添加新用戶。需要註意的是,bean驗證是可以繼承的!也就是說,如果用驗證的註解標記了某些類,欄位或者方法,那麼這個類的後代或者介面的實現類都會受到這些驗證的影響。
這個代碼片段看起來怎樣,是不是非常清晰,可讀性也不錯?(除了 @RequiredView(“_local”) 註解,這個是CUBA平臺的專有註解,確保返回的Person對象會有 PASSPORTNUMBER_PERSON 表的所有欄位)。
@Valid 註解指定 getPerson() 方法的返回列表中的每個對象需要使用 Person 類的驗證進行檢查。
CUBA會自動生成下列路徑用來執行這些API:
l /app/rest/v2/services/passportnumber_PersonApiService/getPersons
l /app/rest/v2/services/passportnumber_PersonApiService/addNewPerson
我們打開Postman試試這些驗證是否都好用:
你可能會註意到,上面的例子沒有驗證護照號碼。這是因為這個需要在 addNewPerson 做跨參數驗證,passportNumber 的驗證正則表達式依賴 country 的值。這種跨參數的驗證跟實體類級別約束是一樣的。
JSR 349 和 380 支持跨參數驗證, 可以查閱 hibernate 文檔瞭解如何為類/介面方法實施自定義的跨參數驗證。
超越Bean驗證
世上沒有什麼是完美的,bean驗證也有局限性:
l 有時候需要在保存更改之前檢查複雜的對象關係圖的狀態。比如,可能需要檢查客戶在你電商網站的訂單中購買的所有東西是否能裝到一個快遞箱子中。這是個比較繁重的檢查,因此每次客戶訂單的商品變更的時候都做這個檢查不合適。所以這個檢查應該只需要在Order對象和它的OrderItem對象保存到資料庫之前做一次。
l 有些檢查需要在資料庫的事務中做。比如,電商系統需要在訂單保存到資料庫之前檢查是否有足夠的庫存。這些檢查只能在事務級別,因為系統是併發的,庫存的數量是實時變化的。
CUBA平臺提供了兩個在數據提交之前做驗證的機制:實體監聽器和事務監聽器。我們仔細看看。
實體監聽器
CUBA的實體監聽器跟JPA提供的 PreInsertEvent, PreUpdateEvent 和 PredDeleteEvent 監聽器非常相似。這兩種機制都都可以在實體對象持久化到資料庫之前或者之後做檢查。
在CUBA中定義和組織實體監聽器不難,只需要兩步:
- 創建實現了實體監聽器介面的托管bean。作為數據驗證方面的考慮,其中三個介面比較重要:BeforeDeleteEntityListener,BeforeInsertEntityListener以及BeforeUpdateEntityListener。
- 在需要做驗證的實體用 @Listeners 註解標記
可以了。
跟JPA標準(JSR 338 3.5)不一樣,CUBA的監聽器介面是帶數據類型的,所以不需要在方法內做類型轉換,可以直接使用實體。CUBA平臺還提供了跟當前實體關聯的實體以及通過EntityManager去載入或者更改其他任何實體的機制。這些改動也會調用相應的實體監聽器。
另外,CUBA平臺支持“軟刪除(soft deletion)”,實體在資料庫只是標記為刪除,但是不會真正刪除資料庫記錄。所以對於軟刪除,CUBA平臺會調用 BeforeDeleteEntityListener / AfterDeleteEntityListener 而標準的實現則會調用 PreUpdate / PostUpdate。
看看下麵的例子吧。事件監聽器的bean跟實體類連接,只需要一行註解:@Listeners,註解使用的參數是監聽器類的名稱。
實體監聽器的實現是這樣的:
實體監聽器有時候很有用:
l 在實體持久化到資料庫之前需要在事務內做檢查
l 需要在驗證的過程中訪問資料庫信息,比如在保存訂單之前先檢查庫存的數量
l 需要遍歷實體關聯或者組合的實體,比如Order裡面的OrderItem實體
l 需要跟蹤某些實體的增/刪/改操作,比如希望跟蹤Order和OrderItem的變化情況
事務監聽器
CUBA 事務監聽器也在事務的上下文環境中工作,但是跟實體監聽器不一樣的是,事務監聽器是在事務級別被調用的。
因此,事務監聽器是終極大殺器,能監管到所有的資料庫交互,但是這樣也帶來了弱點:
l 不是很好編碼
l 如果做太多檢查會顯著的降低性能
l 編碼需要很小心,一個bug可能會導致整個應用都啟動不了
所以事務監聽器在需要用同一演算法檢查很多不同類型的實體的時候是個好辦法。比如需要給支持所有業務的“欺詐偵探器”填充數據的時候。
我們看看下麵這個例子,檢查是否有實體帶有 @FraudDetectionFlag 註解,如果有的話,調用欺詐偵探器來檢查一下。註意,這個方法會在每次資料庫提交的事務都調用,所以代碼需要儘可能少的檢查數據對象,並且越快越好。
只需要實現 BeforeCommitTransactionListener 介面的 beforeCommit 方法,托管bean就會變成事務監聽器。事務監聽器會在應用啟動的時候自動裝載。CUBA會將所有實現了 BeforeCommitTransactionListener 或者 AfterCompleteTransactionListener 介面的類註冊為事務監聽器。
結論
Bean 驗證(JPA 303 349 980)基本能滿足企業級應用中 95% 的數據驗證的情況。這個方案最大的優點是,大部分驗證的邏輯都集中到了數據模型類中。因此很容易找到代碼,可讀性強還容易維護。Spring,CUBA以及很多類庫都能知道這些標準並且在UI輸入值的時候,調用方法的時候或者做ORM持久化的時候自動調用驗證代碼,從開發者角度來說,這些驗證就像是小魔法。
有些軟體工程師認為,在數據模型層面做的驗證複雜且帶有侵入性,覺得在UI層做驗證就夠了。但是,我個人覺得,在UI或者UI控制器中寫很多驗證點是很容易出問題的。另外,我們這裡討論的驗證方法在跟平臺集成的時候,並不是侵入性的代碼,因為平臺會感知這些驗證器、監聽器然後將它們自動集成到客戶端層。
最後,我們制定一個經驗規則來選擇最佳的驗證方法:
l JPA驗證:功能有限,但是在實體類上做最簡單的約束是最好的選擇。要求這些約束能映射成DDL
l Bean驗證:靈活、簡潔、聲明式、可重用而且易讀。基本上能覆蓋模型中需要的所有驗證,如果不需要在事務中進行驗證的話,這是最好的選擇
l 合同驗證:也是一種bean驗證,不過是應用在方法上。如果需要檢查輸入和輸出參數,比如REST調用,可以使用這個方法
l 實體監聽器:儘管不像bean驗證那樣是使用全部聲明式的方式,但是可以在資料庫事務中對比較複雜的對象關係圖做驗證。比如需要從資料庫載入一些信息來做決定。Hibernate也有類似的監聽器
l 事務監聽器:危險但是這是事務級別的終極武器。如果需要在運行時對實體進行驗證或者需要對很多不同類型的實體使用同一種驗證方法的時候可以選用
希望這篇文章能刷新你對於Java企業級應用中驗證方法的記憶,也希望在提升項目架構方面提供一點點參考。