1. 程式人生 > >Java中的資料檢驗

Java中的資料檢驗

我經常看見很多專案沒有資料驗證的策略和意識。他們的團隊在交付日期的重壓下,面對不清楚的需求,沒有時間去考慮用合適並且統一的方法對資料進行驗證。所以在這樣的專案中,到處能看見資料驗證的程式碼:在前端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 ValidatorApache BVal

很多開發者都熟悉這個方法,但是這個方法的好處卻總是被低估。用這個方法甚至可以很容易在遺留專案中新增資料驗證,並且還能以清晰、直接、可靠最貼近業務邏輯的方式表達需要做的驗證。

 

  使用Bean驗證能為專案帶來很多好處:

  •  驗證邏輯集中在資料模型附近:使用最自然的方法定義針對值、方法和bean的約束,因此可以將OOP推進到下一個級別(驗證也可以OOP)。
  • Bean驗證的標準提供了幾十種 開箱即用的驗證註解 比如 @NotNull, @Size, @Min, @Max, @Pattern, @Email, @Past, 不太標準的比如 @URL, @Length,強大的 @ScriptAssert,另外還有很多其他的。
  • 不會受限於僅使用預定義的約束,還可以自定義約束註解。可以定義一個註解來將其他幾個註解繫結到一起,或者定義一個全新的註解,然後定義一個相應的Java類作為驗證器。比如,之前那個例子中,可以定義一個類級別的註解 @ValidPassportNumber 用來檢查護照號碼是否符合正確的格式,號碼也許還依賴 country 欄位的值。
  • 不止可以在類和欄位上加約束,也可以新增到方法和方法引數上。這個叫做“合同驗證”,後面會介紹。

 

CUBA平臺(以及一些其他平臺)會在使用者提交資料的時候自動呼叫這些驗證,所以一旦驗證失敗使用者會馬上看到錯誤訊息,不需要考慮手動執行這些bean驗證。

我們一起再看看護照號碼驗證的例子,但是這次我們還需要在實體新增幾個其他的驗證:

  • 人物姓名(例子中是用的英文名)至少有2個單詞或者可以更多,必須是格式化很好的姓名。檢查的正則表示式很複雜,比如 Charles Ogier de Batz de Castelmore Comte d'Artagnan 能通過檢查,但是 R2D2 卻不能通過。
  • 人物身高的區間:0< height <=300釐米。
  • 郵件地址需要是正確的郵件地址格式。

因此,帶有所有這些檢查,Person類看起來是這樣:

那些標準的註解,比如 @NotNull, @DecimalMin, @Length, @Pattern 還有其他幾個都是非常清楚的不需要過多解釋。主要看看自定義的 @ValidPassportNumber 是怎麼實現的。

我們全新的 @ValidPassportNumber 會檢查 Person#passportNumber 是否符合針對每個國家(Person#country)定義的正則表示式。

首先,按照文件(CUBAHibernate 文件是很好的參考)的描述,我們需要使用新的註解來標記實體類,以及將約束分組傳遞給這個註解。CUBA文件有說,UiCrossFieldChecks.class 應當在所有單獨的欄位檢查完之後,才執行跨欄位的檢查,Default.class 能將約束新增到預設的驗證組。

註解的定義是這樣的:

@Target(ElementType.TYPE) 定義了註解在執行時生效的物件是一個類,@Constraint(validatedBy = … ) 宣告註解的實現在 ValidPassportNumberValidator 類中,此類需要實現 ConstraintValidator<...> 介面,在isValid(...) 方法中新增驗證程式碼,方法也很直接:

好了,足夠了。使用CUBA平臺不需要多寫任何程式碼來保證這個驗證的執行,也不需要新增程式碼在使用者輸入錯誤的時候給使用者傳送訊息通知。很簡單吧?

現在,我們看看這些東西都是怎麼工作的,CUBA還做了一些額外的事情:不但給使用者展示錯誤訊息,而且還將有問題的表單欄位高亮出來,這些漂亮的描紅欄位沒有通過單一欄位的bean驗證:

是不是很簡潔?在使用者的瀏覽器顯示漂亮的錯誤提醒,只需要在實體中新增幾個簡單的註解就好了。

作為本章節的總結,我們再簡單列舉一下實體的Bean驗證有什麼好處:

  1. 清晰可讀
  2. 可以直接在實體模型中定義值的約束
  3. 可擴充套件、可定製化
  4. 跟很多流行的ORM整合,檢查都是在實體儲存在資料庫之前自動呼叫的
  5. 有些框架也能在使用者從UI提交資料的時候自動執行bean驗證(但是如果不支援的話,很難手動呼叫 Validator 介面)
  6. Bean驗證是眾所周知的標準,網上能找到很多相關文件

但是如果我們需要將驗證放到方法、構造器上或者放到某個REST終端來驗證從外部來的資料呢?或者我們想用宣告式的方法驗證方法引數而不是在每個方法內寫很多if-else這種枯燥的檢查引數的方法?

答案很簡單,bean驗證也可以作用在方法上!

  

合同驗證

有時候,我們需要前進一步,不只是做到應用的資料模型驗證。如果能做到引數和返回值自動驗證,那麼寫方法的時候就會容易很多。這個需求可能不只是用在檢查REST或者SOAP接入的資料,也會用在針對方法的輸入引數和返回值上。用來做所謂的前置條件和後置條件檢查,確保在方法體執行前對輸入引數的檢查,以及在方法執行後對返回值範圍的檢查,或者只是希望能宣告式的用在引數上限定引數的範圍以達到程式碼更好的可讀性。

使用合同驗證,就可以在任何Java型別的方法、構造器的引數和返回值上使用驗證。相對傳統的檢查引數和返回值的辦法,這個方案的優點是:

  • 不需要以極端的方式執行檢查(比如,丟擲類似 illegalArgumentException 這樣的異常)。我們會更願意使用宣告式的約束,這樣會形成可讀性表達性更強的程式碼。
  • 約束都是可重用、可配置、可定製化的:不需要每次都寫驗證程式碼,更少的程式碼意味著更少的bug。
  • 如果類、方法的返回值或引數使用了 @Validated 註解,平臺會在每個方法呼叫的時候自動執行約束檢查。
  • 如果一個可執行程式使用了 @Documented 註解,那麼它的前置條件和後置條件會自動包含在生成的JavaDoc中。

因此,使用合同驗證方案,會有清晰、相對少的程式碼,更易於維護和理解。

我們看看在CUBA應用的REST控制器中,使用合同驗證的程式碼大概是什麼樣的。通過 PersonApiService 介面的 getPerson() 方法可以從資料庫獲取使用者的列表,使用 addNewPerson(…) 方法可以新增新使用者。需要注意的是,bean驗證是可以繼承的!也就是說,如果用驗證的註解標記了某些類,欄位或者方法,那麼這個類的後代或者介面的實現類都會受到這些驗證的影響。

這個程式碼片段看起來怎樣,是不是非常清晰,可讀性也不錯?(除了 @RequiredView(“_local”)  註解,這個是CUBA平臺的專有註解,確保返回的Person物件會有 PASSPORTNUMBER_PERSON 表的所有欄位)。

@Valid 註解指定 getPerson() 方法的返回列表中的每個物件需要使用 Person 類的驗證進行檢查。

 

CUBA會自動生成下列路徑用來執行這些API:

  • /app/rest/v2/services/passportnumber_PersonApiService/getPersons
  • /app/rest/v2/services/passportnumber_PersonApiService/addNewPerson

 

我們開啟Postman試試這些驗證是否都好用:

你可能會注意到,上面的例子沒有驗證護照號碼。這是因為這個需要在 addNewPerson 做跨引數驗證,passportNumber 的驗證正則表示式依賴 country 的值。這種跨引數的驗證跟實體類級別約束是一樣的。

JSR 349 和 380 支援跨引數驗證, 可以查閱 hibernate 文件 瞭解如何為類/介面方法實施自定義的跨引數驗證。

 

超越Bean驗證

世上沒有什麼是完美的,bean驗證也有侷限性:

  • 有時候需要在儲存更改之前檢查複雜的物件關係圖的狀態。比如,可能需要檢查客戶在你電商網站的訂單中購買的所有東西是否能裝到一個快遞箱子中。這是個比較繁重的檢查,因此每次客戶訂單的商品變更的時候都做這個檢查不合適。所以這個檢查應該只需要在Order物件和它的OrderItem物件儲存到資料庫之前做一次。
  • 有些檢查需要在資料庫的事務中做。比如,電商系統需要在訂單儲存到資料庫之前檢查是否有足夠的庫存。這些檢查只能在事務級別,因為系統是併發的,庫存的數量是實時變化的。

CUBA平臺提供了兩個在資料提交之前做驗證的機制:實體監聽器 事務監聽器我們仔細看看。

 

實體監聽器

CUBA的實體監聽器 跟JPA提供的 PreInsertEvent, PreUpdateEvent 和 PredDeleteEvent 監聽器非常相似。這兩種機制都都可以在實體物件持久化到資料庫之前或者之後做檢查。

在CUBA中定義和組織實體監聽器不難,只需要兩步:

  1. 建立實現了實體監聽器介面的託管bean。作為資料驗證方面的考慮,其中三個介面比較重要:BeforeDeleteEntityListener,BeforeInsertEntityListener以及BeforeUpdateEntityListener。
  2. 在需要做驗證的實體用 @Listeners 註解標記

可以了。

跟JPA標準(JSR 338 3.5)不一樣,CUBA的監聽器介面是帶資料型別的,所以不需要在方法內做型別轉換,可以直接使用實體。CUBA平臺還提供了跟當前實體關聯的實體以及通過EntityManager去載入或者更改其他任何實體的機制。這些改動也會呼叫相應的實體監聽器。

另外,CUBA平臺支援“軟刪除(soft deletion)”,實體在資料庫只是標記為刪除,但是不會真正刪除資料庫記錄。所以對於軟刪除,CUBA平臺會呼叫 BeforeDeleteEntityListener / AfterDeleteEntityListener 而標準的實現則會呼叫 PreUpdate / PostUpdate。

看看下面的例子吧。事件監聽器的bean跟實體類連線,只需要一行註解:@Listeners,註解使用的引數是監聽器類的名稱。

實體監聽器的實現是這樣的:

實體監聽器有時候很有用:

  • 在實體持久化到資料庫之前需要在事務內做檢查
  • 需要在驗證的過程中訪問資料庫資訊,比如在儲存訂單之前先檢查庫存的數量
  • 需要遍歷實體關聯或者組合的實體,比如Order裡面的OrderItem實體
  • 需要跟蹤某些實體的增/刪/改操作,比如希望跟蹤Order和OrderItem的變化情況

 

事務監聽器

CUBA 事務監聽器 也在事務的上下文環境中工作,但是跟實體監聽器不一樣的是,事務監聽器是在事務級別被呼叫的。

因此,事務監聽器是終極大殺器,能監管到所有的資料庫互動,但是這樣也帶來了弱點:

  • 不是很好編碼
  • 如果做太多檢查會顯著的降低效能
  • 編碼需要很小心,一個bug可能會導致整個應用都啟動不了

所以事務監聽器在需要用同一演算法檢查很多不同型別的實體的時候是個好辦法。比如需要給支援所有業務的“欺詐偵探器”填充資料的時候。

我們看看下面這個例子,檢查是否有實體帶有 @FraudDetectionFlag 註解,如果有的話,呼叫欺詐偵探器來檢查一下。注意,這個方法會在每次資料庫提交的事務都呼叫,所以程式碼需要儘可能少的檢查資料物件,並且越快越好。

只需要實現 BeforeCommitTransactionListener 介面的 beforeCommit 方法,託管bean就會變成事務監聽器。事務監聽器會在應用啟動的時候自動裝載。CUBA會將所有實現了 BeforeCommitTransactionListener 或者 AfterCompleteTransactionListener 介面的類註冊為事務監聽器。

 

結論

Bean 驗證(JPA 303 349 980)基本能滿足企業級應用中 95% 的資料驗證的情況。這個方案最大的優點是,大部分驗證的邏輯都集中到了資料模型類中。因此很容易找到程式碼,可讀性強還容易維護。Spring,CUBA以及很多類庫都能知道這些標準並且在UI輸入值的時候,呼叫方法的時候或者做ORM持久化的時候自動呼叫驗證程式碼,從開發者角度來說,這些驗證就像是小魔法。

有些軟體工程師認為,在資料模型層面做的驗證複雜且帶有侵入性,覺得在UI層做驗證就夠了。但是,我個人覺得,在UI或者UI控制器中寫很多驗證點是很容易出問題的。另外,我們這裡討論的驗證方法在跟平臺整合的時候,並不是侵入性的程式碼,因為平臺會感知這些驗證器、監聽器然後將它們自動整合到客戶端層。

最後,我們制定一個經驗規則來選擇最佳的驗證方法:

  • JPA驗證:功能有限,但是在實體類上做最簡單的約束是最好的選擇。要求這些約束能對映成DDL
  • Bean驗證:靈活、簡潔、宣告式、可重用而且易讀。基本上能覆蓋模型中需要的所有驗證,如果不需要在事務中進行驗證的話,這是最好的選擇
  • 合同驗證:也是一種bean驗證,不過是應用在方法上。如果需要檢查輸入和輸出引數,比如REST呼叫,可以使用這個方法
  • 實體監聽器:儘管不像bean驗證那樣是使用全部宣告式的方式,但是可以在資料庫事務中對比較複雜的物件關係圖做驗證。比如需要從資料庫載入一些資訊來做決定。Hibernate也有類似的監聽器
  • 事務監聽器:危險但是這是事務級別的終極武器。如果需要在執行時對實體進行驗證或者需要對很多不同型別的實體使用同一種驗證方法的時候可以選用

我希望這篇文章能重新整理你對於Java企業級應用中驗證方法的記憶,也希望在提升專案架構方面提供一點點參考。