Java應用中的資料校驗
翻譯:吳嘉俊 ,叩丁狼高階講師。
[譯者注:這篇文章是開源專案CUBA Platform的作者,在這篇文章中,作者闡述了CUBA平臺中關於資料校驗的設計思想和使用方式,可以作為大家在設計資料校驗方面一個比較好的參考。]
我接觸到的很多專案中,對資料校驗這方面內容都沒有一個很明確的策略。這些團隊常常面對即將臨近的交付期壓力,不明確的專案續期,所以根本沒有太多時間來規劃和實現專案中的校驗策略。所以,你可以看到,資料校驗的程式碼零散的分佈在整個應用中:Javascript中有,Java控制器中有,業務邏輯程式碼中有,實體模型中有,資料庫中還有約束和觸發器。用於資料校驗的程式碼,充斥著各種if..else..,在不同位置丟擲完全混亂的異常,甚至想找到一個數據究竟在哪裡驗證的,心裡面都想罵一句FUCK。長此以往,當專案越來越複雜,驗證會越來越難控制,陷入極為難堪的維護境地。
那麼,是否有一種優雅的,標準的,簡單的方法來處理應用中的資料校驗呢?這個方法既讓我們的程式碼可讀性較高,又能將大部分的資料校驗程式碼集中管理,還能很好的整合進入目前主流的Java框架呢?
是的,有這種方法。
我們開發了CUBA Platform(https://www.cuba-platform.com/),能讓我們按照最佳實踐的方式來完成。我們歸納除了關於校驗程式碼的一些要求:
- 能重複使用,遵循DRY原則;
- 能自然清晰的表達驗證規則;
- 放在程式設計師願意放置的位置;
- 能夠支援從不同的資料來源中獲取資料,比如使用者輸入,SOAP或者REST請求等;
- 支援併發處理;
- 應用隱式的去呼叫,而不需要處處都通過手動呼叫;
- 展示清晰,本地化的提示資訊供開發者使用;
- 遵循業界已有標準;
在這篇文章中,我們會使用一個基於CUBA平臺的應用來作為示例。CUBA是基於Spring和EclipseLink平臺的,所以,文中大部分的例子也能在其他支援JPA和Java bean校驗的標準平臺上面執行。
資料庫約束校驗
最普遍,最直接的資料校驗可能就是使用資料庫級別的約束,比如Require(或者NOT NULL),字串長度,唯一索引等等,特別在企業應用中,大部分以資料庫為中心,這是非常常見的方法。但是,因為開發人員分工職責不同,常常在不同的應用層中,重複定義資料約束,這就非常容易出錯。
我們來舉一個例子,我們大多數開發都見過或者參與過。如果在需求中提出,護照這個欄位需要10位數字,那麼,最可能的情況回事這樣:DB工程師,會在DDL中限制,後臺開發會在實體和REST服務中檢查,最後,UI層開發會在前端(客戶端)限制。好了,過了一段時間,需求修改了,護照欄位改成15位了,技術修改了資料庫中的定義,但客戶端仍然只能輸入10位資料。
所有人都知道如何避免出現這個問題,那就是資料校驗必須集中!在CUBA平臺中,這個集中點就是在實體類中使用JPA註解。基於這些元資料資訊,CUBA平臺能夠生成正確的DDL,並且在客戶端生成正確的校驗器,如下圖所示。
如果JPA註解發生了變化,CUBA會及時生成修改補丁指令碼,下一次再部署應用的時候,基於新的JPA限制會在DB和UI端更新。
為了隔離資料庫的複雜性,讓生成的DDL指令碼保持標準,避免引入資料庫相關的獨特的觸發器或者儲存過程,JPA註解僅僅只能做一些最基本的校驗,比如,保證實體欄位的唯一性,必要性,或者定義字串長度,此外,還可以使用@UniqueConstraint註解來完成複合唯一約束等,但這些仍遠遠不夠。
在需要更加複雜的驗證邏輯,比如檢查欄位最大最小值,或者正則表示式驗證,或者完成一個特殊的資料校驗,我們就需要使用Bean Validation。
Bean校驗
我們知道,遵循規範是一個最佳實踐。規範是根據成千上萬的應用歸納和驗證的,在Java Bean驗證這塊,現有的規範主要是JSR 380,JSR 349和JSR 303(https://beanvalidation.org/specification/),最出名的實現就是Hibernate Validator(https://docs.jboss.org/hibernate/stable/validator/reference/en-US/html_single/?v=5.3)和Apache BVal(http://bval.apache.org/)。
儘管不少開發都熟悉這套工具,但是它的帶來的好處卻經常被誤解。這是一種為遺留專案新增資料驗證的有效的方法,允許你以清晰、直接,可靠的方式,以儘可能接近業務邏輯的方式表達你的驗證規則。使用Bean驗證能為你的應用帶來很多好處:
- 驗證邏輯儘可能的靠近領域模型,在模型中定義值,方法,bean約束是很符合OOP方法的。
- Bean驗證標準提供了多種直接可以使用的驗證規則(@NotNull"">https://docs.jboss.org/hibernate/stable/validator/reference/en-US/html_single/#validator-defineconstraints-spec),比如:@NotNull, @Size, @Min, @Max,@Pattern, @Email, @Past,@URL, @Length,@ScriptAssert等等;
- 允許擴充套件已有的約束,或者定義你自己的約束註解。你把多個其他驗證約束組合起來變成一個新的校驗註解,或者通過開發一個校驗器,建立一個全新的校驗註解。
- 舉個例子,回到我們之前的護照的例子,我們完全可以建立一個類級別的註解:@ValidPassportNumber,用這個註解根據我們的國家(country)欄位來校驗我們的護照號碼欄位。
- 你不僅僅能夠在類或者欄位上面增加約束,還能夠在方法和方法引數上新增約束,這種方法叫做“合約約束(validation by contract)”,這是下一節介紹的重點。
CUBA平臺(或者一些其他平臺)在使用者提交資料的時候,會自動的執行這些驗證規則,如果驗證失敗,使用者立刻會得到錯誤的提示,這一些都是自動執行的,不需要手動干預或者呼叫。
讓我們再來看看護照號碼的例子,我們這次會增加一些額外的約束:
- 使用者名稱至少2位及以上,並且是合法的名字。這個正則式比較複雜,因為R2D2這個名字無效,但是Charles Ogier de Batz de Castelmore Comte d’Artagnan卻是一個有效的名字。
- 使用者的身高應該在0到300釐米之間;
- Email必須是一個正確的email地址;
那麼,現在的Person類應該類似這樣:
我想@NotNull, @DecimalMin, @Length, @Pattern這些標準的註解的用法,大家應該很熟悉,就不用做過多說明,下面來看看@ValidPassportNumber註解是如何實現的。
我們的@ValidPassportNumber註解使用Person#country配合正則表示式來檢查Person#passportNumber。
首先,根據文件(CUBA或者Hibernate文件),我們使用@ValidPassportNumber註解標記我們的類,並制定分組引數(groups),UiCrossFieldChecks.class引數表示passportNumber的檢查應該在每一個獨立的欄位檢查完成之後再執行檢查(所以UiCrossFieldChecks.class放在Default.class之後);
該註解的定義如下:
@Target指定該註解的標記位置;@Constraint(validatedBy = … )就是真正用於執行這個檢查的檢查類。這個ValidPassportNumberValidator類需要實現ConstraintValidator<…>介面,並且實現介面中定義的isValid(…)方法來完成真正的驗證。
在CUBA平臺中,除了需要建立我們自定義的校驗器,給出錯誤的提示,除此之外,不需要寫其他額外的程式碼,保證了程式碼的清晰和簡潔。
來看看最後的工作效果:CUBA平臺生成的前端指令碼,能夠展示錯誤提示,還能夠對錯誤欄位進行樣式標記:
整個過程非常乾淨。我們只需要在業務模型上新增一些註解,就能得到一個漂亮的UI檢查介面。
總結這個小節,使用bean 驗證的主要好處有:
- 清晰,可讀性高;
- 允許直接在業務類中定義值驗證約束;
- 易擴充套件和自定義;
- 很多流行的ORM框架支援自動檢查,並支援DDL同步更新;
- 一些框架支援自動生成驗證UI指令碼,自動呼叫驗證(如果不支援和,通過Validator介面手動驗證,也是很容易的)
- bean校驗遵循業界的規範,有非常多的相關文件和社群支援。
那麼,如果需要在方法,構造器,甚至一個被外部系統呼叫傳入資料的REST端點上進行驗證,該怎麼做呢?又或者我們想檢查方法的引數值,但是又不想寫一堆無聊的if-else程式碼,又應該怎麼做呢?
答案很簡單,可以在方法上也使用bean驗證;
合約約束
有時候,我們不僅僅只是驗證應用資料模型的值,我們希望更進一步,如果使方法也能夠受益於傳入引數和返回值的自動驗證。這代表,不僅僅能夠檢查從REST或者SOAP端傳入的資料,還能表達方法執行的先決條件或者後決條件,或者要求返回引數在我們期望的範圍之內,並且提供一定的可讀性。
有了Bean驗證,約束同樣也可以施加在方法引數,或者方法的返回值上,或者構造方法,或者方法的先決條件或者後決條件判定上。相對於傳統的引數和返回值校驗,這種方式的好處有:
- 不需要手動執行驗證(比如手動丟擲IllegalArgumentException異常)。我們只需要宣告我們的驗證規則,這樣我們程式碼會非常清晰和易讀。
- 約束是可以重用的,支援靈活配置和擴充套件的:我們不需要每次要驗證的地方都去寫同樣的程式碼:越少的程式碼,越少的bug。
- 如果一個類或者一個方法的返回值,或者一個方法的引數被標記上@Validated註解,那麼每次方法的呼叫都會自動觸發驗證規則的執行。
- 如果使用@Documented標籤,那麼一個方法的先決或者後決條件會自動的包含在生成的JavaDoc中。
使用合約約束的結果就是,我們能得到乾淨,易於閱讀和理解的程式碼。
下面我們來看看在CUBA應用中的一個REST控制器如何使用合約約束。PersonApiService介面允許使用getPersons()方法從資料庫中獲取使用者列表,同時也提供了addNewPerson(…)介面新增一個使用者。記住:bean驗證是可以繼承的,意味著,如果一個類或者欄位使用了約束註解,那麼所有子類或者實現了該介面的類都能得到同樣的驗證約束。
這個程式碼片段是不是看著非常清晰和易讀?(@RequiredView(“_local”)可能比較礙眼,這是CUBA提供的註解,用於檢查所有返回的Person物件的欄位完全從PASSPORTNUMBER_PERSON表載入完成)。@Valid註解表明,通過getPersons()方法返回的每一個Person物件都需要被Person類上定義的約束檢查。
CUBA將這些方法暴露成以下的服務:
- /app/rest/v2/services/passportnumber_PersonApiService/getPersons
- /app/rest/v2/services/passportnumber_PersonApiService/addNewPerson
我們使用POSTMAN來驗證一下校驗約束是否起作用:
你可能會注意到,上面的程式碼並沒有驗證護照號碼。這是因為這個驗證需要cross-parameter驗證,passport的驗證需要依賴於country值,所以這個驗證會在實體類上進行驗證(具體執行儲存實體物件的時候驗證)
Cross-parameter驗證在JSR349和JSR380中已經支援。可以參考相關文件去看看如何在類/介面中的方法上實現Cross-parameter驗證。
補充一些
沒有什麼東西是完美的,bean validation仍然有它的侷限性:
- 可能有這樣的需求,在每次物件狀態發生變化時,都需要執行一個非常複雜驗證。比如,你希望在你的電子商務系統中,檢查客戶的訂單明細和你的集裝箱一一匹配。這是一個非常“重”的操作,在每次使用者新增一個訂單項之前都要做這樣一次檢查,絕對不是一個好的想法。實際上,更好的做法是當所有的訂單項都準備好之後,在儲存到資料庫之前,統一檢查一次就可以了。
- 有的檢查需要執行在事務中。比如,在電子商務系統中,需要檢查訂單數量在倉庫中是否充足。這種檢查必須要在一個事務中執行,因為庫存中的數量可能在任何時間被其他事務同步修改。
CUBA平臺提供了兩種機制來對應這兩種情況,一個叫做實體監聽器(entity listener),一個叫做事務監聽器(transcation listener)。我們來看看這兩種機制的作用。
CUBA提供的實體監聽器類似JPA提供給開發的PreInsertEvent,PreUpdateEvent和PreDeleteEvent三種監聽器。兩種機制都允許在實體物件持久化到資料庫之前或者之後進行驗證。
在CUBA中定義並註冊一個實體監聽器也比較簡單,我們只需要做如下兩個事情:
- 按照需要實現一個實體監聽器介面。提供了3種不同目標的介面:BeforeDeleteEntityListener,BeforeInsertEntityListenerand和BeforeUpdateEntityListener
- 使用@Listeners註解將監聽器繫結在需要檢查的實體物件上。
CUBA平臺和JPA標準(JSR338)有點區別的地方在於,CUBA提供的實體監聽器介面是泛型的,所以你不需要再強制把Object型別引數強制轉化成你需要檢查的實體型別。CUBA會將和當前物件有關聯的其他物件加載出來,或者可以使用EntityManager去載入或修改其他物件。所有這些物件的變化都可能會觸發實體監聽器的執行。
同時,CUBA平臺提供了邏輯刪除(soft deletion),即在資料庫中並不真實刪除物件,僅僅只是標記為刪除。針對邏輯刪除,CUBA平臺會呼叫BeforeDeleteEntityListener/AfterDeleteEntityListener代替標準的修改觸發PreUpdate/PostUpdate監聽器。
我們來看一個例子。實體監聽器使用@Listeners註解和一個實體型別繫結在一起,只需要在@Listeners中提供監聽器的名字:
一個實體監聽器可能的實現如下:
實體監聽器非常適合:
- 在一個事務中,當實體物件持久化到資料庫前檢查資料;
- 在檢查的過程中,需要從資料庫中提取一些資料輔助檢查;
- 檢查資料需要依賴當前物件的關聯物件,比如檢查Order物件的時候,需要參考OrderItems;
- 追蹤某一些實體物件的insert/update/delete操作。比如僅僅只是想追蹤Order和OrderItem兩個實體物件的資料庫操作。
CUBA事務監聽器執行在事務上下文中,與實體監聽器不一樣,事務監聽器在每一次資料庫事務呼叫的時候被啟動。因為伴隨著事務,所以你需要注意:
- 更加難以編碼,
- 如果施加了過多無效的檢查,效能會顯著降低,
- 編碼必須更加小心:在事務監聽器中的一個bug可能會導致應用崩潰
事務監聽器適用於需要用同樣的演算法檢查多種型別的實體類的情況,比如用於檢查所有業務資料的防欺詐檢查。
我們來看一個事務監聽器,該監聽器檢查所有標記了@FraudDetectionFlag註解的實體類,並使用自定義的防欺詐檢查器檢查。再次提醒,因為事務檢查器是在每一次資料庫事務提交之前進行檢查,所以我們應該儘量減少用於檢查的物件。
一個事務監聽器必須實現BeforeCommitTransactionListener介面,並重寫beforeCommit方法。事務監聽器會在應用啟動的時候啟動。CUBA會自動將所有型別為BeforeCommitTransactionListener和AfterCompleteTransactionListener的類作為事務監聽器。
小結
在一個企業專案中,Bean validation(JPA 303, 349 and 980)足以處理95%以上的資料驗證情況。這種方式帶來的最大的好處是基本上可以將所有驗證集中在實體類上。所以驗證規則集中,易讀並且符合標準。Spring,CUBA以及其他很多程式碼庫會在UI輸入,驗證方法呼叫或者ORM持久化處理過程中自動的呼叫驗證檢查,而不需要開發人員過度的關注。
在最後,我們在總結一下不同的驗證方式針對的最佳的使用場景:
- JPA validation:功能有限,適合簡單的實體型別約束,並且易於將這些約束同步到DDL。
- Bean Validation:在領域模型類中,是最靈活,集中,複用性高,易讀的驗證方法。如果在事務之外執行驗證,這是最該值得考慮的方法。
- Validation by Contract :基於bean驗證,可以施加於方法的呼叫。當你想檢查方法的輸入引數或者返回值的時候,是非常值得考慮的方法,比如在一個REST請求處理中。
- Entity listeners: 雖然比不上Bean Validation的宣告式校驗,但是在一個數據庫事務中需要檢查物件以及關聯物件的時候,是非常好的一個辦法。比如需要從資料庫中查詢一些資料來支援驗證規則。
- Transaction listeners :非常危險,但威力強大。執行在事務上下文中,當你需要在執行時確定需要檢查哪些物件,或者當你需要使用相同規則驗證多種型別物件的時候,這是一種值得考慮的驗證方法。
我希望這篇文章能夠重新整理你對企業應用中如何使用不同的驗證方法的看法,並給你一些關於如何改進正在進行的專案的驗證相關架構的參考。
原文地址:https://www.javacodegeeks.com/2018/10/validation-java-applications.html