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