DDD 落地的思考 -- 映射偏移模式
一、背景
DDD 落地的思考系列已經寫了兩篇,接下來搞點乾貨,來總結一些新的模式。"映射" 這個詞在 eric 的書中出現了很多次,主要表達了各種模型之間的映射問題。由於面向不同層次導致對應的模型對象本身擔負的職責出現了一些微妙的變化,因此在構建模型的過程中不得不對模型進行轉換,本文將深度揭露不同模型之間映射的問題。另外也將引出一個新的模式 -- 映射偏移模式。
二、模型映射的場景
2.1 模型映射的疑惑
大家在構建業務模型的時候常常會發現,數據庫字段名或者數據庫表名與代碼裏定義的 entity 名稱多少會有一些出入,除了類型不太一樣,名字也命名的不一樣。同時在同一個統一語言下的業務模型命名也不太一樣,所以在構建模型的時候不得不處理這部分偏差,同時還要忍受無法重構的狀況。那爲什麼會這樣呢,僅僅是把問題歸咎於命名的問題?程序員的水平問題?我想還有另外的答案。就是在構建模型的時候有時候你覺得這個字段在這一層這麼命名是好的,但是到另外一層可能做了簡寫或者發現了更合適的其他名字,或者說命名的時候根本沒有保持一致。
當代碼寫的差不多了,注意力就會分散,不再專注於某一層。如果一旦要修改命名,則對所有層次的模型都會產生影響,這可能讓人更屈服於讓映射偏移繼續存在。深入到業務邏輯中才會發現處理這些映射問題會讓程序變得臃腫,代碼量增多,壞味道也瀰漫開來。
在單體應用或者分佈式應用中都會出現,但是分佈式應用下因爲領域劃分導致的模型引用變得複雜,這樣的情況會變得更糟糕。比如在電商領域商品,優惠券的命名可能在不同的微服務邊界下變得模糊,可能 A 團隊在代碼裏定義爲 a,B 團隊在定義引用 A 團隊裏負責的 a 則成了 a1。
2.2 ER 模型與 JavaEntity 模型映射
一般情況下我們的 ER 模型(這裏指表字段結構)與 JavaEntity 模型的映射是非常簡單的,但是仍然存在一些問題,比如類型,由於數據庫底層的數據類型與開發語言定義的數據類型不同則需要處理這部分映射 (0,1 對應 false,true;但是類型是數字和布爾類型的轉換)。另外的命名則是駝峯式命名轉爲下劃線命名。
像 Hibernate,JPA 等基本上從框架層面自己處理了模型數據類型和模型名稱轉換的問題,但是在 Mybatis 中則需要自己處理這兩個映射的問題。當然從自研的角度來看,能比較好地處理這個問題算是走好了一小步了。
2.3 JavaEntity 模型與業務模型映射
由於 ER 模型與 JavaEntity 模型之間的映射更偏向於數據結構方面的描述,同時描述的內容也基本是一致的,但是業務模型可能就會複雜一點,有些業務模型是與 JavaEntity 模型是一一對應的,但是另外一些則是業務模型衍生出的業務對象,本身可能只是作爲上下文參數傳遞或者作爲 JSON 字段對應數據庫字段,到業務模型裏是要反序列化的。
通常來說如果業務不復雜的話,業務模型與 JavaEntity 模型則可以作爲一個模型來對待。這樣一來,在 ORM 下則需要更靈活的處理哪些需要持久化。但是大多數場景下還是需要將這二者區分開。一般來說這兩個之間的映射則需要一些工具類幫助,不然則會更加複雜,也就是說需要通過 MapStruct 或者 BeanUtils 來做對象之間的轉換。在工具類沒有普及之前,這倆之間的映射則需要手動處理,那麼一個保存單據的業務代碼就會膨脹很多。
2.4 業務模型與接口參數模型映射
很早之前做 web 管理系統已經將接口參數與業務模型參數隔離開來,但是業務參數還跟 JavaEntity 模型是一致的,所以在業務模型與接口參數模型之間的轉換則顯得更加乾脆,因爲業務邏輯處理的過程中就是直接的參數模型映射,直到數據庫層面。不過由於接口層的特殊性,在模型映射之前還需要保證數據的正確性,這樣的前置校驗可以避免後續邏輯產生更多的問題。
2.5 業務模型之間的映射
上面的映射過程都沒有提到 DDD,在分層架構下或者強調多層多模塊的架構風格中,不同的層都有不同的業務模型,比如 DTO,BO,DO,Context,Bean,MsgBody,Event 等等。
有時候相同的層次之間存在多個業務模型,比如領域層可能會包括 BO,Bean,ContextBO, 或者 Event。當模型之間需要轉換傳遞數據的時候就存在映射關係,那麼處理這些映射可能用 BeanUtils 也不一定好使了,就我當前的使用經驗來看這些模型之間的轉換會更復雜,場景也比較多,屬性類型,命名都不一樣,同時也牽扯着一些數據處理。當然,使用 MapStruct 可能稍微好點,不過,依然搞不定這些情況。
一般的做法就是顯示轉換,另外的方案就是單獨構建一個 factory 或者 helper 類來輔助完成模型之間的映射,當然也可以叫做對象的構建。
2.6 屬性映射
上面整體講述了不同模型和不同模塊層次之間的映射,現在從微觀的角度來看一下映射的過程。在屬性上的映射無非就下面兩種情況。
- 屬性名之間的映射
屬性名之間的映射的潛在條件是屬性名相同,屬性類型相同,這樣的話在不同的映射轉換工具下都是講得通的,但是複雜情況下的映射也要看映射轉換工具是否支持,比如類型是複合數據類型 (List) 的情況下還麻煩一點。
- 屬性類型之間的映射
如果屬性名相同,但是屬性類型不同的話,做映射則需要藉助 SDK 轉換方法來轉換,更多的是不同類型之間的轉換可能會報錯,比如 string 轉換到 integer。通常情況下不建議屬性是枚舉類型,否則轉換則需要更多的代碼支持。
2.7 複雜場景映射
映射轉換工具 (也叫 bean 對象轉換複製工具) 如 BeanUtils 對不同的場景支持度不一樣,這裏不再深入敘述,現在我們看一下複雜場景下的映射問題。
- 列表對象映射
比較常見的一類映射就是列表對象,Listor Map<key,XXX> 都是比較常見的,這一類一般來說都需要基礎對象的支持才能構建,由於複雜數據結構,在構建之前需要聲明下對象數據容器。如果對象中有這一類數據需要轉換,那麼就會麻煩一點,不過另外一點也說明了存在這種情況的話,其對象本身可能就是一個複雜對象,同時與聚合或者快照有關。
- 換值映射
現在我們看一下什麼是換值映射,之前做報表導入的時候深有體會,因爲報表中很多列都與枚舉有關,有時候是漢字 --> 數字, 有時候是數字 --> 漢字。這樣的話就需要在枚舉類中親自構建映射方法。在頁面查詢的時候依然如此,用戶不希望看到一個數字或者一個字符串。當然,在涉及統計計算的時候最好是數字或者簡單的字符串。
- 邏輯映射
這裏的邏輯映射場景可能比較少見,當然也是最複雜的一種。舉個例子,參數傳遞進來的 dto 是 (a,b,c,d),但是在業務層的業務模型 bo 就變成了 (a,c,d)。中間的邏輯映射變成了 a =a+b。那麼到了數據庫層面就是 a+b。當然更復雜的情況比如存在不同條件下進行不同的屬性拼接,截取,合併,拆分等都可能會讓模型之間的映射變得不再直觀。有時候一旦出問題,或者傳參出錯都必須要看代碼才能解決,同時這種情況下的映射條件也變得嚴格,需要在映射過程中保持一定的校驗。
三、映射偏移模式
3.1 概念說明
不同模型之間出現數據流動,模型相似度較高,在領域和上下文之間進行傳遞,因而出現模型之間的相互轉換。轉換的過程可以視爲映射偏移。不同的轉換過程其實現方式也不一樣,對於這種場景可以稱爲一種現象或者模式。
3.2 映射偏移產生的問題 (副作用)
-
對業務產生理解偏差
-
實現複雜,容易出錯
-
可能存在性能問題
-
干擾業務核心流程,導致代碼可讀性比較差
-
數據轉換複雜,增加重構難度
3.3 映射偏移在架構工程下的體現
映射偏移在工程架構中的體現 - 分層架構 (1).png
3.4 映射偏移的作用
-
輔助業務數據流轉
-
模型和上下文之間解耦合
-
讓模型職責更加單一
-
輔助工程架構進行合理分層
3.5 實現映射偏移的方法
現在我們看一下從代碼上來看不同的映射場景下的實現映射偏移的方法。
- mybatis-ResultMap
- Hibernate(自研 ORM 框架)
這一類的 ORM 映射則將 JavaEntity 與框架或者自定義註解結合在一起,通常來說不需要寫 sql, 查詢結果直接按照一定的約定 (下劃線<--> 駝峯)進行相互轉換,具體代碼不再演示。
- Spring BeanUtils
- MapStruct
- 手動 get/set
- 模型內部轉換
四、最佳實踐
4.1 選擇主轉換工具
在大型複雜工程下,建議選擇一個合適的主轉換工具來幫助解決大量模型轉換的問題,當然不建議貪快或者省事,不同的轉換工具使用都是有成本的,所以選擇了一個合適的主轉換工具,代碼量會少很多。
4.2 建議使用 lombok
這裏個人建議使用 lombok 來自動生成 get/set 方法,就個人的使用經驗看 lombok+mapstruct 已經相對完美了,但是有以下幾點需要說明,避免踩坑。
-
存在模型類繼承的場景下 lombok+mapStruct 可能出現轉換失效的問題
-
慎用 lombok 的 builder,toString 註解
-
mapStruct 在編譯後,如修改了或者新增屬性之後,可能需要清空字節碼重新編譯生效
4.3 在主流程中少用手動 get/set 轉換
通常情況下,主流程中包括各種調用,數據計算,數據轉換,持久化等等。但是由於主流程的業務性比較強,在不同的條件語句,循環語句下建議將手動 get/set 的模型映射邏輯單獨弄出來。當然少量的 1-3 個的手動 get/set 也沒有太多問題。
4.4 單個服務模型層次之間轉換不超過 2 次
這裏從工程架構的角度來看模型層次之間的轉換,通常來說如果不用 DDD 或者業務不復雜的情況下,兩層模型即可,也就是說 DTO/VO 可以直接轉換到 JavaEntity 然後進行持久化。但是用 DDD 或者存在分層架構下,模型就有了三層,中間則需要轉換兩次,即 DTO/VO<--->BO/DomainEntity<---->JavaEntity/POJO。由於在分佈式和微服務的場景下可能存在更多的模型,比如如下轉換鏈路:
DTO/VO<--->BO/DomainEntity<---->MSGBODY/EVENT
DTO/VO<--->CMD<--->BO/DomainEntity<---->DTO/VO(adapterLayer) 所以個人建議在構建服務的時候需要考慮讓服務內模型層次之間的轉換不要超過 2 次,尤其是主轉換流程。
4.5 使用建造者模式和工廠模式
上面的主轉換流程中已經有了一些應對方法,這裏看一下複雜場景下的轉換如何應對,比如一個聚合對象的保存操作,首先構建聚合對象的 DTO/VO 在協議層需要進行轉換,到應用層反序列化成爲 Java 對象,然後往下層傳遞的時候可能需要根據參數構建一個對應複雜的 BO/DomainEntity, 一般而言可以使用工廠模式,來構建一個複雜對象,同 DDD 書裏寫讀聚合構建方法。
另外一種場景就是我保存的數據對象是聚合對象的一部分,那麼在領域層構建的聚合保存則是以聚合對象爲入參的,那這樣的話就需要構建一個聚合對象,然後把被聚合的對象給裝載到聚合對象裏,如上圖 3.5 節的 mapstruct 對應的代碼,ProjectBO 是聚合對象,ApiBO 是有獨立 Controller 對應的,但是 ApiBO 在領域層卻沒有獨立的保存接口,而是通過 ProjectBO 來輔助 ApiBO 進行保存。
通常情況下如果一個數據對象沒有父類的話,使用建造者模式進行一定的轉換也是可以的,這種情況比較適用於模型內部的轉換,如果需要參數可以單獨再傳,也就是模型本身可以提供靜態方法的能力來讓自己轉換成別的對象或者別的對象轉換成自己。這麼做的一個代價就是模型之間耦合嚴重。
4.6 MapStruct 使用專場
在處理映射偏移模型轉換的過程中對 MapStruct 的應用也有了一些經驗,這裏專門用一小節來闡述下 MapStruct 的使用場景。通常情況下我們會爲每個識別出的領域實體構建一個轉換接口,如下:
- 常規場景
- 帶有枚舉類的使用場景
- 聚合對象轉換的使用場景
- 轉換過程中存在其他數據處理邏輯
在轉換過程中也存在一些其他的數據處理邏輯,比如 a=a+b 的情況,這樣的話可以參考上面的方式在 expression 中提供靜態轉換方法或者處理方法,但是個人建議不要過多使用,因爲轉換方法如果涉及到一些業務性比較強的處理語意,很可能會讓代碼變得複雜。
五、總結
本文深度討論了模型映射相關的場景和問題,同時給出映射偏移的基本概念,討論映射偏移下的代碼模型轉換的一些工具和可能存在的坑,最後總結如何應對映射偏移的方法。針對映射偏移衍生出的應用場景在數據工廠 2.0 中也有所體現,相關技術細節會在公衆號持續發佈,敬請期待。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/YfTWca6mCkIQJcsyJr2X-g