本節重點?
之前我們已經完成了本項目的功能開發。由于本項目功能豐富、代碼量大,如果是在企業中維護開發的項目,傳統的 MVC 架構可能會讓后續的開發協作越來越困難。所以本節魚皮要從 0 帶大家學習一種新的架構設計模式 —— DDD 領域驅動設計。
大綱:
- 軟件架構模式的演進
- DDD 領域驅動設計概念
- DDD 架構設計
- 項目 DDD 重構
通過本節,你將掌握 DDD 領域驅動架構設計,掌握快速的、標準的、通用的重構傳統 MVC 項目為 DDD 架構項目的方法,學會之后幾乎任何項目都能輕松改造為 DDD 項目。
一、軟件架構模式的演進?
在學習 DDD 之前,我們需要先知曉軟件架構模式的演進之路。
為了應對軟件系統日益復雜化的需求,從最初的簡單傳統單體架構到如今復雜的分布式和微服務架構,軟件架構的演變經歷了多個階段,主要有以下三個典型階段:
傳統單體架構?
所有的應用功能都集成在一個單一的應用程序中,所有模塊和組件都在同一個進程內運行,請求直接操作數據庫,不進行代碼分層,易于開發和部署,尤其適合小型或簡單的應用。
隨著業務增長和需求變更,單體架構變得難以擴展和維護。不同功能的模塊耦合在一起,導致更新某個功能可能影響到整個系統。
分層架構?
應用被劃分為不同的層(如業務接入層、業務邏輯層、數據訪問層等),每一層負責特定的功能,層與層之間通過接口進行交互,促進了模塊化和職責分離,便于管理和維護。
但層與層之間的緊密耦合限制了靈活性,且隨著系統的復雜度增加,可能導致性能下降和維護難度增加,并且它的可擴展性和彈性伸縮性差。
微服務架構?
將系統拆分為多個小而獨立的服務,每個服務負責處理一組特定的功能,每個服務通常由獨立的團隊開發、部署和維護,服務之間通過輕量級協議(如 HTTP、自定義協議或消息隊列)進行通信。
服務之間獨立,易于擴展和維護。每個微服務都可以獨立部署、開發和擴展,且易于使用不同的技術棧。
二、DDD 領域驅動設計概念?
什么是 DDD??
DDD(領域驅動設計,Domain-Driven Design)?是一種軟件開發方法論和設計思想。DDD 通過領域驅動設計方法定義領域模型,從而確定業務和應用的邊界,保證業務模型和代碼模型的一致性。
因為 DDD 主要應用在微服務架構場景,所以想要更好的理解 DDD 的概念,需要結合微服務架構來看:
- DDD 是一種設計思想,確定業務和應用的邊界
- 微服務架構需要?將系統拆分為多個小而獨立的服務
是不是有點感覺了?已經知道 DDD 是用來做什么的了?
微服務的拆分一直是業界的一個難題:微服務拆分的粒度應該多大?服務到底應該如何拆分?服務之間的邊界如何定義?
有人可能認為,微服務不就是拆就完事了?不需要管那么多!實際上微服務的拆分是門 “藝術”:
- 服務拆分的太細,項目復雜度會過高,接口的調用成本、服務運維成本大幅上升。
- 服務拆分的太粗,業務邊界變得模糊,服務的耦合度還是過高,失去了微服務的優勢。
而 DDD 就是一個方法論,指導我們根據領域模型確定業務的邊界,從而劃分出應用的邊界,最終落實成服務的邊界、代碼的邊界。
本課程雖然沒有涉及到微服務,但是不妨礙利用 DDD 思想拆分代碼架構。最終想要變成微服務架構僅需抽離包中的代碼獨立部署即可。
DDD 的目標?
- 通過領域模型實現業務需求:開發者與領域專家共同理解業務需求,形成共享語言并構建模型。
- 提高系統的靈活性與可維護性:通過合理劃分限界上下文,減少系統的耦合度,使得不同模塊或子系統可以獨立演化。
- 支持復雜業務邏輯的表達:通過深入的業務建模,使得復雜的業務邏輯能夠清晰、準確地反映在代碼中。
總結一下,就是讓系統更貼合業務,讓大型系統更利于獨立建設和維護。
DDD 的適用場景?
- 業務復雜的系統:如金融系統、電商平臺等,涉及的業務邏輯復雜且頻繁變化。
- 需要與多個部門或團隊合作的項目:DDD 強調跨部門協作,適用于多方參與的大型項目。
- 長周期、長期維護的項目:DDD 強調可維護性與演化,適合需要長期維護和擴展的系統。
總結一下,大型的、跨部門協作的、長期維護的復雜項目。
DDD 的建設?
DDD 會先建立領域模型,根據業務劃分領域邊界,進而確定微服務的邊界,然后再根據領域分塊編碼實現。
實際上 DDD 的建設包括?戰略設計?和?戰術設計?兩部分。
下面這些內容對沒有參加過企業工作的同學來說會有些難理解,學習時可以跳過。
戰略設計?
從業務出發,建立領域模型,統一限界上下文。
設計時,需要先進行事件風暴(類似于頭腦風暴),邀請領域專家、架構師、開發人員、測試人員、產品經理、項目經理等團隊人員一起參加討論。
描述個場景,大家在會議室里,搞一個大白板,參與者們將自己的想法和意見寫在貼紙里并羅列到白板上,大家?先發散思維?進行討論、記錄。
主要討論的內容是:系統會涉及哪些業務,哪個業務動作會觸發另一個業務的什么動作,其間的輸入是什么?輸出是什么?
通過這類分析把所有的業務、業務行為、業務結果都羅列出來,拆分出領域模型中的事件、命令、實體等領域對象。然后梳理這些領域對象之間的關系,從不同維度進行聚類,形成聚合、聚合根、限界上下文等,這個過程就是?收斂。?限界上下文可以簡單理解為微服務的邊界,將其映射到代碼模型,就完成了微服務的拆分。
💡 事件風暴實際上會利用常見的產品設計和用戶體驗分析方法,比如:
- 用例分析:對系統功能需求進行描述,以確定系統如何與外部參與者(即用戶或其他系統)進行交互
- 場景分析:通過設定具體的情境或情景,來探討用戶如何在不同的環境下使用產品或系統
- 用戶旅程分析:從用戶的角度,描繪用戶在使用產品或服務的過程中,從開始到結束的一系列步驟或行為
戰術設計?
從技術實現出發,將領域模型和代碼模型進行映射
這個階段就是完成代碼落地,包括聚合、聚合根、實體、值對象等代碼邏輯的設計與實現。
DDD 體系名詞解析?
1、領域?
領域指系統關注的業務領域或問題空間,具體的領域與公司或組織的核心業務有關。
實際上在 DDD 中?領域就是用來確定范圍,而范圍就是邊界。
一個領域又可以分為多個子領域,每個子領域代表系統的一部分業務。
而子域根據重要程度和功能特性,可劃分為:
- 通用域:指系統中一些通用的、不特定于某一業務的領域,它們在多個不同領域或系統中都有應用。(例如支付、日志管理)
- 支撐域:指在系統中起到支持作用,但并不是直接驅動業務價值的部分(例如網關)
- 核心域:指系統中最關鍵的部分,是業務的核心競爭力所在,能夠為企業帶來最大的價值
💡 這里需要注意,在不同業務(公司中)三類子域是有區別的,例如在普通公司中需要調用第三方支付,那么支付是通用域,但是對于支付公司(例如支付寶)來說支付是它們的核心域。
2、限界上下文?
是指一個明確的邊界,規定了某個子領域的業務模型和語言,確保在該上下文內的術語、規則、模型不與其他上下文沖突。
在事件風暴討論過程中,我們需要完成通用語言的統一。例如電商場景下,我們統一叫物品為商品、將用戶購買商品的行為叫下單。
我們都知道語言需要有語義環境。不同語義環境下,同一個語言表達的意思是不同的。比如:
- “我吃得很飽,現在不能動了”:這里的“吃得很飽”表示的是 “吃到肚子很滿”,字面意思是 “我已經吃得很飽了,吃不下了”
- “我吃得很飽,今天的演講讓人充實”:這里的 “吃得很飽” 并非字面上的 “吃得飽”,而是比喻 “得到了很大的滿足”,表現出內心的充實感。
而限界上下文實際上就類似于語義環境。通用語言需要業務邊界,限界上下文就是定義了業務的邊界,也就是領域的邊界。
電商語義下稱之為商品的東西,到運輸語義下它就變成了貨物。因此我們需要明確限界上下文,在這個上下文中團隊內部人員對某一領域對象、領域事件的認知是一致的、沒有歧義的。
3、實體?
一般業務對象,且具有唯一標識對象都是實體。在代碼中所謂的唯一標識就是 ID,例如,訂單有訂單 ID,用戶有用戶 ID,它們都是典型的實體。
實體的關鍵點就在于唯一標識,隨著生命周期的變化,實體中的屬性可能會改變,例如訂單可以從未完成變成已完成,但是其 ID 不會改變。
實體映射到代碼中就是實體類。通常采用?充血模型?來實現,即與這個實體相關的所有業務邏輯都寫在實體類中。
如果需要跨多個實體才能完成的業務邏輯,會寫在領域服務中。
4、值對象?
值對象沒有唯一標識,創建后就不允許修改了,只能用另外一個值對象來進行?整體替換。通常用于描述對象的屬性,用于對實體的狀態和特征進行描述。
非常典型的值對象就是地址。比如用戶實體對象有地址這個屬性,那么這個地址就是值對象,它沒有唯一標識,且創建后就不允許修改其本身的值。如果用戶需要修改地址,那么這個屬性是被整體替換的(換新的地址值對象)。
擁有這樣特性的對象,就是值對象。
💡 實體和值對象并不是一成不變的,比如對電腦主機來說,顯卡是一個值對象,顯卡壞了就換一個,而對顯卡廠商來說,顯卡是實體,它們有編號需要追蹤和管理的。
5、聚合?
實體和值對象是基礎的領域對象,聚合將多個實體和值對象組合成一個整體,實現高內聚低耦合。
簡單來說實體和值對象是個體,個體與個體之間的合作需要被“領導”,而聚合就是將它們組織起來協同工作,這樣才能保證聚合內數據的一致性(組織統一口徑)。它可以作為微服務拆分的最小單位。
聚合還是數據修改和持久化的基本單位,實現數據的持久化存儲。
6、聚合根?
聚合根就好比聚合內的帶頭人,聚合內的多個實體不會直接對外提供接口訪問,而是由聚合根統一提供對外接口。
一個聚合內只會有一個聚合根,聚合根通過對象引用的方式組織聚合內的實體和值對象,聚合根之間的合作是通過 ID 關聯的。
這里需要注意:聚合根也是一個實體,也具有業務屬性和業務邏輯和唯一標識。
例如訂單域內只有訂單和訂單子項兩個實體,那個訂單就是這個域中的聚合根。
7、領域服務?
聚合根可以實現跨多個實體的復雜業務行為,但是為了實現高內聚和低耦合,聚合根內部應該更聚焦與自身強關聯的業務行為,復雜的跨多實體的業務可以放在領域服務中實現。
領域服務是指那些?不能歸屬于某個單一實體或值對象,但又屬于領域模型的一部分?的業務邏輯。領域服務封裝了對領域對象進行操作的核心業務規則,通常用于處理跨多個實體的操作,或者當業務邏輯無法直接歸屬于某個特定聚合時。
例如一個訂單系統,需要處理訂單支付功能,而支付涉及訂單、用戶賬戶、支付信息等多個實體,這個支付操作不太好歸屬某個實體,這樣的邏輯就可以放到領域服務中。
public class PaymentService {public void processPayment(Order order, PaymentDetails paymentDetails, Account account) {// 處理支付邏輯// 調用多個實體方法來處理支付過程}
}
那聚合根更適合怎樣的跨實體的業務呢?
例如你有一個“訂單”聚合,其中包含訂單條目、支付信息等,Order 作為聚合根,負責管理訂單條目和確保訂單的完整性。你不能直接訪問訂單條目(如 OrderItem),必須通過 Order 聚合根來進行操作。
public class Order {private List<OrderItem> items;private PaymentDetails paymentDetails;public void addItem(OrderItem item) {// 檢查商品數量、價格等業務規則this.items.add(item);}public List<OrderItem> getOrderItems() {//....}// 其他聚合內部的一致性校驗
}
DDD 建模總結?
結合上面的名詞解析,我們回顧一下 DDD 建模的流程。
首先我們需要領域建模,此時會進行事件風暴,通過用例分析、場景分析等方式列出所有的業務行為與事件,找出產生這些行為的領域對象,包括實體與值對象。梳理這些領域對象之間的關系,從實體中找出聚合根,再根據聚合根的業務,找尋與其業務緊密關聯其它實體與值對象,從而形成聚合。多個聚合之間根據業務相關性又可以劃出限界上下文。
可以通過 “開公司” 的比喻來幫助大家理解 DDD。領域就像公司的行業,決定了公司所從事的核心業務;限界上下文是公司內部的各個部門,每個部門有獨立的職責和規則;實體是公司中的員工,具有唯一標識和生命周期;值對象是員工的地址或電話等屬性,只有值的意義,沒有獨立的身份;聚合是部門,由多個實體和值對象組成,聚合根(如部門經理)是部門的入口,確保部門內部的一致性;領域服務則是跨部門的職能服務,比如 HR 或 IT 服務,為各部門提供支持和協作。
三、DDD 架構設計?
充血模型和貧血模型?
貧血模型和充血模型是兩種面向對象設計模式,用于描述對象的職責劃分和對象是否包含行為邏輯。
我們常見的對象內部的實現非常簡單,僅包含數據屬性和簡單的?getter
/setter
?方法,換句話說,這些對象是一個純粹的“數據容器”,它僅負責保存數據,而不包含任何業務行為。
從領域模型設計角度來說,這樣的設計稱為貧血模型,偏向于傳統分層架構的設計;與之對應的是充血模型,強調面向對象的系統設計。
兩種模型的分類本質是對領域對象中 “數據與行為的職責劃分” 的不同理解。反映了在軟件設計中,如何組織領域對象的數據和行為,以及如何分配業務邏輯的不同設計思路。
充血模型是指領域對象不僅包含數據(屬性),還包含處理這些數據的業務邏輯。換句話說,充血模型的領域對象是“充血”的,它們不僅有狀態(數據),還有行為(業務方法)。
貧血模型則是指領域對象僅包含數據,不包含任何業務邏輯,所有的業務邏輯都放在單獨的服務類中(通常是應用層或領域服務層)。領域對象本身是 “貧血” 的,只有狀態,沒有行為。
總結來看:
- 充血模型?適合復雜業務,業務邏輯和數據緊密結合,符合面向對象設計的原則。
- 貧血模型?適合簡單業務,關注點分離,數據和業務邏輯分開,領域對象僅負責存儲數據,服務類負責業務邏輯。
下面用代碼舉例,大家就知道它們的區別了。
代碼示例?
假設我們有一個訂單系統,Order 是領域對象,包含了訂單的狀態和相關的業務邏輯。
1)充血模型代碼示例
在充血模型中,Order 對象包含了業務邏輯(如 pay 和 cancel 方法),這些方法對訂單的狀態進行操作,直接將數據和行為結合在一起。
public class Order {private String orderId;private double totalAmount;private boolean isPaid;public Order(String orderId, double totalAmount) {this.orderId = orderId;this.totalAmount = totalAmount;this.isPaid = false;}public void pay() {if (this.isPaid) {throw new IllegalStateException("Order is already paid");}this.isPaid = true;}public void cancel() {if (this.isPaid) {throw new IllegalStateException("Cannot cancel a paid order");}// Perform cancellation logic}public boolean isPaid() {return isPaid;}public double getTotalAmount() {return totalAmount;}
}
2)貧血模型代碼示例
在貧血模型中,Order 對象只包含數據(狀態),而所有的業務邏輯(如 payOrder 和 cancelOrder)都被移到了外部的 OrderService 服務類中。
public class Order {private String orderId;private double totalAmount;private boolean isPaid;public Order(String orderId, double totalAmount) {this.orderId = orderId;this.totalAmount = totalAmount;this.isPaid = false;}public String getOrderId() {return orderId;}public double getTotalAmount() {return totalAmount;}public boolean isPaid() {return isPaid;}public void setPaid(boolean paid) {isPaid = paid;}
}public class OrderService {public void payOrder(Order order) {if (order.isPaid()) {throw new IllegalStateException("Order is already paid");}order.setPaid(true);}public void cancelOrder(Order order) {if (order.isPaid()) {throw new IllegalStateException("Cannot cancel a paid order");}// Perform cancellation logic}
}
二者對比?
特點 | 貧血模型 | 充血模型 |
---|---|---|
封裝性 | 數據和邏輯分離 | 數據和邏輯封裝在同一對象內 |
職責分離 | 服務類負責業務邏輯,對象負責數據 | 對象同時負責數據和自身的業務邏輯 |
適用場景 | 簡單的增刪改查、DTO 傳輸對象 | 復雜的領域邏輯和業務建模 |
優點 | 簡單易用,職責清晰 | 高內聚,符合面向對象設計思想 |
缺點 | 服務層臃腫,領域模型弱化 | 復雜度增加,不適合簡單場景 |
面向對象原則 | 違反封裝原則 | 符合封裝原則 |
在實際項目中,貧血模型和充血模型并非互相排斥。通常可以結合兩者的優點:
- 使用充血模型作為領域模型,封裝復雜的業務邏輯。
- 使用貧血模型作為數據傳輸對象(DTO),在系統之間傳輸數據。
擴展知識 - 缺血模型和漲血模型?
1)缺血模型
上面貧血模型的示例可以視為缺血模型的一種表現形式。缺血模型實際上是貧血模型的進一步簡化或極端化版本。
在缺血模型中,不僅對象沒有業務邏輯,甚至服務層也缺乏真正的業務邏輯,系統的整體設計趨向于 CRUD(增刪改查)開發,會將所有邏輯轉移到外部。
需要注意的是,領域對象不包含任何業務邏輯即可稱為貧血模型,無需刻意強調是否屬于缺血模型,除非是在貧血模型與缺血模型對比的語境中。
2)漲血模型
漲血模型則是充血模型的極端化表現,不僅將所有核心業務邏輯集中于領域模型中,甚至連非核心邏輯(如數據庫事務處理、權限校驗等)也全部包含其中。
在實際應用中,缺血模型和漲血模型并不常用,這里僅做擴展了解。我們通常只需關注貧血模型和充血模型的設計取舍即可。
DDD 的分層架構?
在領域驅動設計(DDD)中,分層架構模型是一種常見的設計模式,用于組織和管理系統的復雜性。通過將應用分為不同的層次,每一層都有清晰的責任和角色,從而促進了代碼的高內聚、低耦合和可維護性。
DDD 的分層架構主要有四層:用戶接口層、應用層、領域層、基礎設施層。每層負責不同的職責,協調工作以實現系統的整體功能。
除基礎設施層外,嚴格來說每層只能與?直接下層?產生依賴,即領域層只能被應用層調用,應用層只能被用戶接口層調用。
當然也有?松散分層架構,層與層之間的依賴和交互更加靈活,不嚴格分隔。適用于快速開發,但隨著系統復雜度的增加,可能變得難以維護。
1)用戶接口層
也叫表示層或 Web 層,主要負責與外部(用戶、API 等)的交互。它的主要職責是接收用戶輸入并返回系統的輸出。表示層不包含業務邏輯,而是將用戶的請求轉發到應用層處理,并將處理結果返回給用戶。
2)應用層
應用層主要用來協調領域層的邏輯和基礎設施層的資源。應用層不包含業務規則或業務邏輯,但會調用領域層的服務進行服務編排與組合,來實現特定的業務。
如果有對其他服務的遠程調用,也放在這層實現。除此之外,權限校驗、事務、事件等操作也都可以放在這層進行實現。
3)領域層
領域層是整個架構的核心,包含了應用的業務邏輯、規則和策略。它定義了核心的領域模型,包括聚合根、實體、值對象、領域服務等。
領域層的目的是將業務需求轉化為代碼,并確保業務規則在應用中得以執行。該層的設計強調與業務領域的緊密耦合,是 DDD 中的重點。
4)基礎設施層
基礎設施層提供技術支持和持久化服務,采用依賴倒置設計,封裝基礎資源。負責與外部系統(如數據庫、消息隊列、緩存等)的交互。基礎設施層的主要職責是實現應用層和領域層所需要的技術服務,如數據存儲、郵件發送、日志記錄等等。
依賴倒置設計實際上指的是各層對基礎資源(如數據庫)僅依賴其接口而不是具體的實現,假設后續替換基礎資源(數據庫),僅需替換具體實現,不需要修改各層依賴的代碼。
三層架構到 DDD 四層架構的轉化?
三層架構是傳統的架構模式,結合 SpringMVC 通常由以下三層組成:
- 表示層(Controller 層):處理 HTTP 請求,調用業務層的服務,返回視圖或數據。
- 業務邏輯層(Service 層):封裝核心業務邏輯,執行業務操作。
- 數據訪問層(Repository 層):負責與數據庫交互,執行數據的持久化和查找。
轉化 DDD 四層架構映射關系如下圖所示:
主要改造點就是業務邏輯層的 Service,根據聚合拆分到應用層的應用服務與領域層的領域服務,部分業務邏輯還會以充血模型下沉到 Entity 中。
接著就是數據訪問層的改造,根據依賴倒置原則,數據庫的訪問接口會被放到領域層中(因為屬于行為),具體的訪問實現則是在基礎設施層內(為行為提供支持)。除此之外,第三方工具、Common、Config 等都放在基礎設施層中。
DDD 代碼架構?
首先明確一點,DDD 代碼架構并沒有統一的標準,不同公司的架構都是不一樣的!但是核心的思想都是大差不差的,僅一些細節有調整。
按照四層架構,我們可以建立 interfaces(用戶接口層)、application(應用層)、domain(領域層)、infrastructure(基礎設施層) 這 4 個包。
interface 是 Java 關鍵字,因此包名加了個 s。
1、interface?
該層主要負責與外部系統交互,包括用戶界面(UI)、API接口、請求的接收和響應的返回等。它作為領域層與外部世界的接口,確保領域邏輯的解耦。
存放的代碼:
- 控制器(Controller):處理HTTP請求,負責路由和請求的轉發。
- REST API 接口:定義暴露給外部系統的服務接口。
- 請求和響應對象:用于與外部系統交換數據。
2、application?
該層負責協調多個領域對象的操作,完成應用級的任務。它充當領域層與用戶接口層之間的橋梁,調用領域層中的業務邏輯,并將結果返回給用戶接口層。應用層的職責是實現具體用例,而不包含業務規則。
存放的代碼:
- 應用服務(Application Service):負責組織和協調領域對象,處理跨多個聚合的操作,通常表示應用中的具體功能,如“下訂單”或“注冊用戶”。
3、domain?
該層包含核心業務邏輯,它是系統的核心部分,負責模型的定義和業務規則的實現。領域層中的模型代表著業務概念,通常會包括聚合、實體和值對象。這個層不依賴于任何外部技術或框架,它專注于業務本身。
存放的代碼:
- 聚合:一個聚合由多個實體和值對象構成,它們之間有著一致的業務規則,一般包名就代表一個聚合。
- 實體:具有唯一標識符(ID)的對象。
- 值對象:沒有身份標識且是不可變的對象,通常用于表示某個概念的屬性。
- 領域服務:當某個業務邏輯無法歸屬到某個實體或聚合時,使用領域服務來封裝這些業務邏輯。
- 領域事件:表示領域中發生的某個重要事件,如“訂單已支付”。
- 倉儲接口:定義資源訪問的接口
- 持久化對象:PO(數據庫查詢邏輯不復雜時,可以省略)
4、infrastructure?
該層提供技術支持,是所有其他層的基礎設施。它包含數據庫操作、消息隊列、緩存、文件存儲等第三方依賴。基礎設施層實現了與外部系統的交互,但不包含業務邏輯。
存放的代碼:
- 持久化:如使用 JPA 或 MyBatis 等技術實現數據庫的訪問。
- 外部系統集成:與外部服務或系統的通信,如調用文件存儲。
- 工具類和基礎設施組件:提供諸如日志、定時任務、郵件發送等功能。
項目目錄結構示例?
main/java 包下:
- application(應用層)
- domain(領域層)
- order(訂單聚合)
- entity(實體)
- valueObject(值對象)
- event(事件)
- repository(倉儲)
- service(領域服務)
- user(用戶聚合)
- infrastructure(基礎設施層)
- api(外部接口)
- config(配置)
- mq(消息隊列)
- repository(倉儲實現)
- facade(倉儲接口)
- po(持久化對象)
- util(工具類)
- interfaces(用戶接口層)
- assembler(對象轉化類)
- dto(傳輸對象)
- controller(提供給用戶界面、外部服務的接口)
- shared(共享模塊)
- Application 項目主類(或啟動類)
此外,實現 DDD 的過程中,還可能會用到工廠和倉儲模式。
- 工廠:用于創建聚合和實體,因為聚合根與聚合內的實體、值對象關系比較復雜,為了確保對象創建的一致性和完整性會使用工廠模式來創建領域對象(通常從數據庫獲取 PO 持久化對象后,通過工廠模式創建 DO 領域對象)。
- 倉儲:用于持久化領域對象(如實體和聚合),它封裝了數據庫操作,使得業務邏輯與數據存儲分離。
四、項目 DDD 重構?
下面我們要將項目重構為 DDD 模式,這個過程不僅涉及到目錄結構的改造,還涉及到大量方法的重構、代碼的改造等。
在開始之前明確一點:**DDD 項目的改造沒有一個絕對的標準!**一定要根據實際項目的需求和復雜度綜合評估改造的邏輯。
來看下改造后的項目包結構,有個印象即可,下面帶大家依次實戰:
改造方案?
1、領域劃分?
首先,從系統的功能點出發,并且考慮到要利于拆分,將系統劃分為以下 3 個領域:
- 用戶領域(User Domain),用戶注冊、登錄、獲取個人信息等等用戶相關功能放在這個領域中。
- 圖片領域(Picture Domain),包括圖片上傳、刪除、編輯、URL 上傳、批量管理等功能。
- 空間領域(Space Domain),包括空間創建、空間管理、空間分析、空間成員管理等。
2、改造方案?
一般項目的重構都要有序進行,所以我們要先?淺層改造,也就是將原有代碼移動到不同的目錄中,但是盡量不改變代碼內容本身。有些博主就是這么做的,其實是一種省事兒的方法,不能說這樣改造就錯了,但效果就是“項目看起來像是 DDD 架構設計”,實際上缺少靈魂。
所以在劃分目錄后,還要?深層改造,比如將原有的 Service 層服務進行拆分,將對象轉換類代碼移動到 interfaces 層的 assembler 中、將簡單的業務邏輯移動到 domain.entity 實體類中、將跨領域調用的方法移動到 application.service 應用服務中等等。
大家思考一下,如果讓你來改造 DDD 項目,你具體會怎么執行呢?
是先把 DDD 目錄結構建好,分為 4 個層,然后依次一層一層地完成 infrastructure、domain、application、interface 層的代碼么?
這其實是傳統的正向思維,按照目標的目錄結構來重構。但是這樣重構可能會出現一個問題,比如我在開發 domain 層的時候,有些 service 的方法可能要移動到 application 層或者 interface 層,這就會導致我們開發時經常要在各層的目錄中進行跳轉,增加了復雜度。
所以這里魚皮結合自己的經驗,給大家分享一種又快速、又輕松、又規范的改造方法。讓我們使用?逆向思維,還原我們最初從 0 開發本項目的流程,根據現有代碼進行拆分,而不是按照特定的分層一層一層拆。
舉個例子,拆分原項目 model 包的時候,可以把 entity 放到 domain 層中,把 dto 和 vo 放到 interface 層中。
這樣不僅思路清晰,不容易遺漏代碼,而且按照 model => mapper => service => controller 的順序拆分,每一層都不會缺少對下一層的依賴,不會出現類不存在的情況,能夠大幅提高效率。
此外,建議大家一個領域一個領域地重構,而不是一次性把多個領域的代碼同時改造,這樣出了問題就不好還原了。
💡 DDD 重構的思路都是一致的,完整重構整個項目至少需要好幾個小時,性價比不高,大家只需要重點學習一個領域的重構即可。
下面我們進入項目重構。
新建項目?
首先基于原有的項目復制一個新的項目,然后新建一個根包,而不是改造原有的包。接下來我們可以持續將原有包的代碼移動到新包中,從而提高重構效率。
需要先將 Spring Boot 的啟動類移動到新包中,后續才能啟動項目。
基礎設施層?
infrastructure 層是存放基礎設施的代碼,也就是通用的代碼,所以要優先重構,步驟如下:
1)移動通用代碼:先把 annotation、aop、common、config、exception 包放到?infrastructure
?包下
2)移動數據訪問層 mapper 包。注意,要同步修改 MyBatisPlusConfig 掃描 mapper 的包名!
@MapperScan("com.yupi.yupicture.infrastructure.mapper")
public class MyBatisPlusConfig {}
3)將 CosManager 移動到 api 包中,因為該類主要是負責調用第三方對象存儲 API,和業務無關(可以改名為 CosApi)。
這樣原包的最外層就只有 constant、model、service、controller、manager 包了,重構后的?infrastructure
?包結構如圖:
💡 為什么 Mapper 應該放在 infrastructure 層?
- 職責劃分:domain 層是業務邏輯的核心,應該專注于領域模型和業務規則,避免引入任何技術實現的細節。 infrastructure 層是用來實現技術細節的,包括數據庫訪問、第三方服務集成、緩存實現等。而 MyBatis Plus 的 Mapper 類就是一種數據庫訪問的實現細節。
- 與 DDD 的設計原則保持一致:在 DDD 中,domain 層的職責是獨立于技術實現的,不能直接依賴具體的框架或持久化技術。將 Mapper 放在 infrastructure 層,可以避免技術細節 “污染” 領域層,保持領域模型的純粹性。
用戶領域?
下面我們先拆分項目的核心模塊 —— 用戶領域,這個領域我會拆分地相對細一些,帶大家學習標準的 DDD 重構方法。學會這一個領域之后,其他的領域重構就很簡單了。
1、重構 model 包?
按照下面的規則,將原始 model 包中的代碼移動到對應的新位置:
原始包 | 重構后的包 | 備注 |
---|---|---|
model.entity | domain.user.entity | User 類 |
model.enums | domain.user.valueobject | UserRoleEnum 枚舉類 |
model.dto.user | interfaces.dto.user | 請求封裝類 |
model.vo | interfaces.vo.user | 響應封裝類LoginUserVO、UserVO |
2、重構 constant 包?
原始包 | 重構后的包 | 備注 |
---|---|---|
constant | domain.user.constant | UserConstant 類 |
3、重構數據訪問層?
根據前面講過的依賴倒置原則,在領域包下新建?repository
?包,定義與數據庫交互的接口,然后在?infrastructure.repository
?中寫相應的實現。
由于我們的項目中使用了 MyBatis Plus 框架,可以讓接口直接繼承其提供的 IService 接口,接口的實現繼承 ServiceImpl 類,這樣就直接擁有了一批操作數據庫的方法,簡化開發。
新增 UserRepository 接口:
package com.yupi.yupicture.domain.user.repository;public interface UserRepository extends IService<User> {
}
新增 UserRepositoryImpl 實現類:
package com.yupi.yupicture.infrastructure.repository;@Service
public class UserRepositoryImpl extends ServiceImpl<UserMapper, User> implements UserRepository {
}
UserMapper 之前已經移動到了 infrastructure 包中,作為實現中的一部分。
4、重構 Service?
Service 層的重構是相對最麻煩的,但我們可以利用一些小技巧大幅提高重構效率。
1)首先,直接在 IDE 中移動 Service 接口和實現類到應用服務層。
原始類 | 重構后的類 | 備注 |
---|---|---|
service.UserService | application.service.UserApplicationService | 應用服務接口 |
service.impl.UserServiceImpl | application.service.impl.UserApplicationServiceImpl | 應用服務實現類 |
為什么要這么做呢?因為應用服務層是可供其他領域調用的,而之前的 Service 也是可供其他 Service 調用的。直接移動后,IDE 會?自動重構代碼,將對原始服務接口的調用改為新應用服務接口的調用,減少了手動修改的代碼量。
2)復制 Service 接口和實現類為領域服務層:
原始類 | 重構后的類 | 備注 |
---|---|---|
service.UserService | domain.user.service.UserDomainService | 領域服務接口 |
service.impl.UserServiceImpl | domain.user.service.impl.UserDomainServiceImpl | 領域服務實現類 |
為什么要這么做呢?因為領域服務層是編寫核心業務邏輯的位置,也需要被應用服務層調用,所以先把原來的 Service 接口和實現類復制過來,便于等會兒按需保留代碼或拆分代碼。
3)重構應用服務層
application 層主要做領域服務的編排,事務一般也交由 application 層來控制。
應用服務層遵循的原則:
- 將業務邏輯下沉到?領域服務或實體類?中,應用服務層需要調用領域服務或實體類來完成業務邏輯。
- 如果某個方法需要調用其他應用服務(在單個領域內無法完成),那么該方法不能放到領域服務中,而是保留在應用服務中,因為原則上領域服務不應該調用應用服務。
- 負責為接口層提供調用支持,因為原則上接口層只能調用應用服務層。
比如用戶注冊方法,包含了校驗和執行注冊兩部分業務邏輯。校驗邏輯不涉及調用數據庫,是對實體本身的校驗,所以可以下沉到 User 實體中;執行注冊需要操作數據庫,可以下沉到領域服務 UserDomainService 中。而應用服務層要做的就是組合這些調用,并且?增加事務?等特性,得到完整的應用服務方法。用戶登錄方法同理。
給 User 實體補充方法:
/*** 校驗用戶注冊** @param userAccount* @param userPassword* @param checkPassword*/
public static void validUserRegister(String userAccount, String userPassword, String checkPassword) {// 1. 校驗if (StrUtil.hasBlank(userAccount, userPassword, checkPassword)) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "參數為空");}if (userAccount.length() < 4) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "用戶賬號過短");}if (userPassword.length() < 8 || checkPassword.length() < 8) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "用戶密碼過短");}if (!userPassword.equals(checkPassword)) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "兩次輸入的密碼不一致");}
}/*** 校驗用戶登錄** @param userAccount* @param userPassword*/
public static void validUserLogin(String userAccount, String userPassword) {if (StrUtil.hasBlank(userAccount, userPassword)) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "參數為空");}if (userAccount.length() < 4) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "賬號錯誤");}if (userPassword.length() < 8) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "密碼錯誤");}
}
應用服務層的代碼如下,補充了很多 interfaces 層需要調用的方法(比如 getUserById):
@Service
@Slf4j
public class UserApplicationServiceImpl implements UserApplicationService {@Resourceprivate UserDomainService userDomainService;@Override@Transactionalpublic long userRegister(UserRegisterRequest userRegisterRequest) {ThrowUtils.throwIf(userRegisterRequest == null, ErrorCode.PARAMS_ERROR);String userAccount = userRegisterRequest.getUserAccount();String userPassword = userRegisterRequest.getUserPassword();String checkPassword = userRegisterRequest.getCheckPassword();// 校驗User.validUserRegister(userAccount, userPassword, checkPassword);return userDomainService.userRegister(userAccount, userPassword, checkPassword);}@Overridepublic LoginUserVO userLogin(UserLoginRequest userLoginRequest, HttpServletRequest request) {ThrowUtils.throwIf(userLoginRequest == null, ErrorCode.PARAMS_ERROR);String userAccount = userLoginRequest.getUserAccount();String userPassword = userLoginRequest.getUserPassword();// 校驗User.validUserLogin(userAccount, userPassword);return userDomainService.userLogin(userAccount, userPassword, request);}/*** 獲取當前登錄用戶*/@Overridepublic User getLoginUser(HttpServletRequest request) {return userDomainService.getLoginUser(request);}/*** 用戶注銷*/@Overridepublic boolean userLogout(HttpServletRequest request) {ThrowUtils.throwIf(request == null, ErrorCode.PARAMS_ERROR);return userDomainService.userLogout(request);}@Overridepublic LoginUserVO getLoginUserVO(User user) {return userDomainService.getLoginUserVO(user);}@Overridepublic UserVO getUserVO(User user) {return userDomainService.getUserVO(user);}@Overridepublic List<UserVO> getUserVOList(List<User> userList) {return userDomainService.getUserVOList(userList);}@Overridepublic QueryWrapper<User> getQueryWrapper(UserQueryRequest userQueryRequest) {return userDomainService.getQueryWrapper(userQueryRequest);}@Overridepublic long addUser(User user) {return userDomainService.addUser(user);}@Overridepublic User getUserById(long id) {ThrowUtils.throwIf(id <= 0, ErrorCode.PARAMS_ERROR);User user = userDomainService.getById(id);ThrowUtils.throwIf(user == null, ErrorCode.NOT_FOUND_ERROR);return user;}@Overridepublic UserVO getUserVOById(long id) {return userDomainService.getUserVO(getUserById(id));}@Overridepublic boolean deleteUser(DeleteRequest deleteRequest) {if (deleteRequest == null || deleteRequest.getId() <= 0) {throw new BusinessException(ErrorCode.PARAMS_ERROR);}return userDomainService.removeById(deleteRequest.getId());}@Overridepublic void updateUser(User user) {boolean result = userDomainService.updateById(user);ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);}@Overridepublic Page<UserVO> listUserVOByPage(UserQueryRequest userQueryRequest) {ThrowUtils.throwIf(userQueryRequest == null, ErrorCode.PARAMS_ERROR);long current = userQueryRequest.getCurrent();long size = userQueryRequest.getPageSize();Page<User> userPage = userDomainService.page(new Page<>(current, size),userDomainService.getQueryWrapper(userQueryRequest));Page<UserVO> userVOPage = new Page<>(current, size, userPage.getTotal());List<UserVO> userVO = userDomainService.getUserVOList(userPage.getRecords());userVOPage.setRecords(userVO);return userVOPage;}@Overridepublic List<User> listByIds(Set<Long> userIdSet) {return userDomainService.listByIds(userIdSet);}@Overridepublic String getEncryptPassword(String userPassword) {return userDomainService.getEncryptPassword(userPassword);}
}
💡 小技巧:只要發現不調用其他應用服務的方法、并且不調用 “當前類中依賴其他應用服務” 的方法,就可以改為調用領域服務;否則該方法需要在應用服務中實現。
4)重構領域服務層
領域服務層遵循的原則:
- 需要調用數據庫服務(repository)或基礎設施層(infrastructure)來完成業務邏輯
- 可以根據需要,將和實體強相關的業務邏輯下沉到?實體類?中
比如用戶注冊和用戶登錄方法,無需再包含校驗邏輯(已經下沉到了 User 實體類中),只需要調用 UserRepository 執行數據庫操作即可。
像?isAdmin
?這樣根據 User 對象進行判斷的方法,可以下沉到 User 實體類中:
/*** 是否為管理員** @return*/
public boolean isAdmin() {return UserRoleEnum.ADMIN.getValue().equals(this.getUserRole());
}
領域服務層的代碼如下,補充了很多應用服務層需要調用的方法(比如 getById):
@Service
@Slf4j
public class UserDomainServiceImpl implements UserDomainService {@Resourceprivate UserRepository userRepository;@Overridepublic long userRegister(String userAccount, String userPassword, String checkPassword) {// 檢查是否重復QueryWrapper<User> queryWrapper = new QueryWrapper<>();queryWrapper.eq("userAccount", userAccount);long count = userRepository.getBaseMapper().selectCount(queryWrapper);if (count > 0) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "賬號重復");}// 加密String encryptPassword = getEncryptPassword(userPassword);// 插入數據User user = new User();user.setUserAccount(userAccount);user.setUserPassword(encryptPassword);user.setUserName("無名");user.setUserRole(UserRoleEnum.USER.getValue());boolean saveResult = userRepository.save(user);if (!saveResult) {throw new BusinessException(ErrorCode.SYSTEM_ERROR, "注冊失敗,數據庫錯誤");}return user.getId();}@Overridepublic LoginUserVO userLogin(String userAccount, String userPassword, HttpServletRequest request) {// 2. 加密String encryptPassword = getEncryptPassword(userPassword);// 查詢用戶是否存在QueryWrapper<User> queryWrapper = new QueryWrapper<>();queryWrapper.eq("userAccount", userAccount);queryWrapper.eq("userPassword", encryptPassword);User user = userRepository.getBaseMapper().selectOne(queryWrapper);// 用戶不存在if (user == null) {log.info("user login failed, userAccount cannot match userPassword");throw new BusinessException(ErrorCode.PARAMS_ERROR, "用戶不存在或密碼錯誤");}// 3. 記錄用戶的登錄態request.getSession().setAttribute(USER_LOGIN_STATE, user);// 4. 記錄用戶登錄態到 Sa-token,便于空間鑒權時使用,注意保證該用戶信息與 SpringSession 中的信息過期時間一致StpKit.SPACE.login(user.getId());StpKit.SPACE.getSession().set(USER_LOGIN_STATE, user);return this.getLoginUserVO(user);}/*** 獲取當前登錄用戶*/@Overridepublic User getLoginUser(HttpServletRequest request) {// 先判斷是否已登錄Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE);User currentUser = (User) userObj;if (currentUser == null || currentUser.getId() == null) {throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR);}// 從數據庫查詢(追求性能的話可以注釋,直接返回上述結果)long userId = currentUser.getId();currentUser = userRepository.getById(userId);if (currentUser == null) {throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR);}return currentUser;}/*** 用戶注銷*/@Overridepublic boolean userLogout(HttpServletRequest request) {// 先判斷是否已登錄Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE);if (userObj == null) {throw new BusinessException(ErrorCode.OPERATION_ERROR, "未登錄");}// 移除登錄態request.getSession().removeAttribute(USER_LOGIN_STATE);StpKit.SPACE.logout();return true;}@Overridepublic LoginUserVO getLoginUserVO(User user) {if (user == null) {return null;}LoginUserVO loginUserVO = new LoginUserVO();BeanUtils.copyProperties(user, loginUserVO);return loginUserVO;}@Overridepublic UserVO getUserVO(User user) {if (user == null) {return null;}UserVO userVO = new UserVO();BeanUtils.copyProperties(user, userVO);return userVO;}@Overridepublic List<UserVO> getUserVOList(List<User> userList) {if (CollUtil.isEmpty(userList)) {return new ArrayList<>();}return userList.stream().map(this::getUserVO).collect(Collectors.toList());}@Overridepublic QueryWrapper<User> getQueryWrapper(UserQueryRequest userQueryRequest) {if (userQueryRequest == null) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "請求參數為空");}Long id = userQueryRequest.getId();String userAccount = userQueryRequest.getUserAccount();String userName = userQueryRequest.getUserName();String userProfile = userQueryRequest.getUserProfile();String userRole = userQueryRequest.getUserRole();String sortField = userQueryRequest.getSortField();String sortOrder = userQueryRequest.getSortOrder();QueryWrapper<User> queryWrapper = new QueryWrapper<>();queryWrapper.eq(ObjUtil.isNotNull(id), "id", id);queryWrapper.eq(StrUtil.isNotBlank(userRole), "userRole", userRole);queryWrapper.like(StrUtil.isNotBlank(userAccount), "userAccount", userAccount);queryWrapper.like(StrUtil.isNotBlank(userName), "userName", userName);queryWrapper.like(StrUtil.isNotBlank(userProfile), "userProfile", userProfile);queryWrapper.orderBy(StrUtil.isNotEmpty(sortField), sortOrder.equals("ascend"), sortField);return queryWrapper;}@Overridepublic String getEncryptPassword(String userPassword) {// 鹽值,混淆密碼final String SALT = "yupi";return DigestUtils.md5DigestAsHex((SALT + userPassword).getBytes());}@Overridepublic Long addUser(User user) {// 默認密碼 12345678final String DEFAULT_PASSWORD = "12345678";String encryptPassword = this.getEncryptPassword(DEFAULT_PASSWORD);user.setUserPassword(encryptPassword);boolean result = userRepository.save(user);ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);return user.getId();}@Overridepublic Boolean removeById(Long id) {return userRepository.removeById(id);}@Overridepublic boolean updateById(User user) {return userRepository.updateById(user);}@Overridepublic User getById(long id) {return userRepository.getById(id);}@Overridepublic Page<User> page(Page<User> userPage, QueryWrapper<User> queryWrapper) {return userRepository.page(userPage, queryWrapper);}@Overridepublic List<User> listByIds(Set<Long> userIdSet) {return userRepository.listByIds(userIdSet);}
}
💡 小技巧
- 修改領域服務時,如果發現某個方法沒被 application 調用(IDE 顯示灰色),就可以直接移除掉。
- 如果想節省重復編寫增刪改查等樣板代碼的時間,應用服務或領域服務也可以直接繼承 MyBatis Plus 的接口和實現類,這樣雖然 DDD 目錄結構不是 100% 標準,但是能大幅減少開發成本。
5、重構 Controller?
1)首先將原始 UserController 移動為?interfaces.controller.UserController
?類。
2)為保證接口層的精簡,需要將其中的代碼下沉到?轉換類和應用服務?中。首先編寫轉換類?interfaces.assembler.UserAssembler
,負責將 DTO 轉為實體類:
/*** 用戶對象轉換*/
public class UserAssembler {public static User toUserEntity(UserAddRequest request) {User user = new User();BeanUtils.copyProperties(request, user);return user;}public static User toUserEntity(UserUpdateRequest request) {User user = new User();BeanUtils.copyProperties(request, user);return user;}
}
3)將 Controller 的代碼下沉到應用服務中,調用應用服務和 Assembler 來處理請求。可能會涉及到應用服務方法的參數修改,代碼如下:
/*** 用戶接口*/
@RestController
@RequestMapping("/user")
public class UserController {@Resourceprivate UserApplicationService userApplicationService;// region 登錄相關/*** 用戶注冊*/@PostMapping("/register")public BaseResponse<Long> userRegister(@RequestBody UserRegisterRequest userRegisterRequest) {long result = userApplicationService.userRegister(userRegisterRequest);return ResultUtils.success(result);}/*** 用戶登錄*/@PostMapping("/login")public BaseResponse<LoginUserVO> userLogin(@RequestBody UserLoginRequest userLoginRequest, HttpServletRequest request) {LoginUserVO loginUserVO = userApplicationService.userLogin(userLoginRequest, request);return ResultUtils.success(loginUserVO);}/*** 用戶注銷*/@PostMapping("/logout")public BaseResponse<Boolean> userLogout(HttpServletRequest request) {boolean result = userApplicationService.userLogout(request);return ResultUtils.success(result);}/*** 獲取當前登錄用戶*/@GetMapping("/get/login")public BaseResponse<LoginUserVO> getLoginUser(HttpServletRequest request) {User user = userApplicationService.getLoginUser(request);return ResultUtils.success(userApplicationService.getLoginUserVO(user));}// endregion// region 增刪改查/*** 創建用戶*/@PostMapping("/add")@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)public BaseResponse<Long> addUser(@RequestBody UserAddRequest userAddRequest) {ThrowUtils.throwIf(userAddRequest == null, ErrorCode.PARAMS_ERROR);User userEntity = UserAssembler.toUserEntity(userAddRequest);return ResultUtils.success(userApplicationService.addUser(userEntity));}/*** 根據 id 獲取用戶(僅管理員)*/@GetMapping("/get")@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)public BaseResponse<User> getUserById(long id) {User user = userApplicationService.getUserById(id);return ResultUtils.success(user);}/*** 根據 id 獲取包裝類*/@GetMapping("/get/vo")public BaseResponse<UserVO> getUserVOById(long id) {return ResultUtils.success(userApplicationService.getUserVOById(id));}/*** 刪除用戶*/@PostMapping("/delete")@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)public BaseResponse<Boolean> deleteUser(@RequestBody DeleteRequest deleteRequest) {boolean b = userApplicationService.deleteUser(deleteRequest);return ResultUtils.success(b);}/*** 更新用戶*/@PostMapping("/update")@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)public BaseResponse<Boolean> updateUser(@RequestBody UserUpdateRequest userUpdateRequest) {ThrowUtils.throwIf(userUpdateRequest == null, ErrorCode.PARAMS_ERROR);User userEntity = UserAssembler.toUserEntity(userUpdateRequest);userApplicationService.updateUser(userEntity);return ResultUtils.success(true);}/*** 分頁獲取用戶封裝列表(僅管理員)** @param userQueryRequest 查詢請求參數*/@PostMapping("/list/page/vo")@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)public BaseResponse<Page<UserVO>> listUserVOByPage(@RequestBody UserQueryRequest userQueryRequest) {Page<UserVO> userVOPage = userApplicationService.listUserVOByPage(userQueryRequest);return ResultUtils.success(userVOPage);}// endregion
}
這樣一來,接口的代碼保持了極致的精簡。
💡 前面也提到了,如果覺得一層一層補充調用方法過于麻煩,可以直接給應用服務或領域服務繼承 MyBatis Plus 的 IService 和 ServiceImpl,便于上一層調用。
6、其他代碼兼容?
嘗試啟動項目,應該會出現編譯錯誤,我們根據報錯提示依次解決即可。比如修改下面幾個問題:
1)修改 isAdmin 的調用,改為調用對象的方法:
原始:userApplicationService.isAdmin(loginUser)
改為:loginUser.isAdmin()
2)給用戶應用服務 UserApplicationService 補充其他應用服務需要的方法,比如 listByIds。
除非考慮到開發時間成本的問題,否則其他應用服務盡量調用應用服務層的方法,而不是領域服務層。
最終,嘗試啟動項目,只要不報編譯錯誤,就算是重構完成了,即使項目啟動不起來也不用在意,因為我們有些服務還沒有重構完。
圖片領域?
通過用戶領域,相信大家已經學會領域的拆分方法了,接下來圖片領域和空間領域就不帶大家拆分得那么細節了,我們簡單將項目進行重構即可。
1、重構 model 包?
按照下面的規則,將原始 model 包中的代碼移動到對應的新位置:
原始包 | 重構后的包 | 備注 |
---|---|---|
model.entity | domain.picture.entity | Picture 類 |
model.enums | domain.picture.valueobject | PictureReviewStatusEnum 枚舉類 |
model.dto.picture | interfaces.dto.picture | 請求封裝類 |
model.vo | interfaces.vo.picture | 響應封裝類 PictureVO、PictureTagCategory |
2、重構數據訪問層?
根據前面講過的依賴倒置原則,在領域包下新建?repository
?包,定義與數據庫交互的接口,然后在?infrastructure.repository
?中寫相應的實現。
由于我們的項目中使用了 MyBatis Plus 框架,可以讓接口直接繼承其提供的 IService 接口,接口的實現繼承 ServiceImpl 類,這樣就直接擁有了一批操作數據庫的方法,簡化開發。
新增 PictureRepository 接口:
package com.yupi.yupicture.domain.picture.repository;public interface PictureRepository extends IService<Picture> {
}
新增 PictureRepositoryImpl 實現類:
package com.yupi.yupicture.infrastructure.repository;@Service
public class PictureRepositoryImpl extends ServiceImpl<PictureMapper, Picture> implements PictureRepository {
}
PictureMapper 之前已經移動到了 infrastructure 包中,作為實現中的一部分。
3、重構 Service?
Service 層的重構是相對最麻煩的,但我們可以利用一些小技巧大幅提高重構效率。
1)首先,直接在 IDE 中移動 Service 接口和實現類到應用服務層。
原始類 | 重構后的類 | 備注 |
---|---|---|
service.PictureService | application.service.PictureApplicationService | 應用服務接口 |
service.impl.PictureServiceImpl | application.service.impl.PictureApplicationServiceImpl | 應用服務實現類 |
為什么要這么做呢?因為應用服務層是可供其他領域調用的,而之前的 Service 也是可供其他 Service 調用的。直接移動后,IDE 會?自動重構代碼,將對原始服務接口的調用改為新應用服務接口的調用,減少了手動修改的代碼量。
2)復制 Service 接口和實現類為領域服務層:
原始類 | 重構后的類 | 備注 |
---|---|---|
service.PictureService | domain.user.service.PictureDomainService | 領域服務接口 |
service.impl.PictureServiceImpl | domain.user.service.impl.PictureDomainServiceImpl | 領域服務實現類 |
為什么要這么做呢?因為領域服務層是編寫核心業務邏輯的位置,也需要被應用服務層調用,所以先把原來的 Service 接口和實現類復制過來,便于等會兒按需保留代碼或拆分代碼。
3)重構應用服務層
application 層主要做領域服務的編排,如果,事務一般也交由 application 層來控制。
應用服務層遵循的原則:
- 將業務邏輯下沉到?領域服務或實體類?中,應用服務層需要調用領域服務或實體類來完成業務邏輯。
- 如果某個方法需要調用其他應用服務(在單個領域內無法完成),那么該方法不能放到領域服務中,而是保留在應用服務中,因為原則上領域服務不應該調用應用服務。
- 負責為接口層提供調用支持,因為原則上接口層只能調用應用服務層。
遵循原則,將 getPictureVO、getPictureVOPage 方法的實現保留在 PictureApplicationServiceImpl 中,因為它們都調用了其他應用服務 userApplicationService。其他方法可以下沉到領域服務中,應用服務層的代碼如下:
@Service
@Slf4j
public class PictureApplicationServiceImpl extends ServiceImpl<PictureMapper, Picture> implements PictureApplicationService {@Resourceprivate PictureDomainService pictureDomainService;@Resourceprivate UserApplicationService userApplicationService;@Overridepublic PictureVO uploadPicture(Object inputSource, PictureUploadRequest pictureUploadRequest, User loginUser) {return pictureDomainService.uploadPicture(inputSource, pictureUploadRequest, loginUser);}@Overridepublic void validPicture(Picture picture) {pictureDomainService.validPicture(picture);}@Overridepublic QueryWrapper<Picture> getQueryWrapper(PictureQueryRequest pictureQueryRequest) {return pictureDomainService.getQueryWrapper(pictureQueryRequest);}/*** 獲取圖片 VO*/@Overridepublic PictureVO getPictureVO(Picture picture, HttpServletRequest request) {// 對象轉封裝類PictureVO pictureVO = PictureVO.objToVo(picture);// 關聯查詢用戶信息Long userId = picture.getUserId();if (userId != null && userId > 0) {User user = userApplicationService.getUserById(userId);UserVO userVO = userApplicationService.getUserVO(user);pictureVO.setUser(userVO);}return pictureVO;}/*** 分頁獲取圖片封裝*/@Overridepublic Page<PictureVO> getPictureVOPage(Page<Picture> picturePage, HttpServletRequest request) {List<Picture> pictureList = picturePage.getRecords();Page<PictureVO> pictureVOPage = new Page<>(picturePage.getCurrent(), picturePage.getSize(), picturePage.getTotal());if (CollUtil.isEmpty(pictureList)) {return pictureVOPage;}// 對象列表 => 封裝對象列表List<PictureVO> pictureVOList = pictureList.stream().map(PictureVO::objToVo).collect(Collectors.toList());// 1. 關聯查詢用戶信息Set<Long> userIdSet = pictureList.stream().map(Picture::getUserId).collect(Collectors.toSet());Map<Long, List<User>> userIdUserListMap = userApplicationService.listByIds(userIdSet).stream().collect(Collectors.groupingBy(User::getId));// 2. 填充信息pictureVOList.forEach(pictureVO -> {Long userId = pictureVO.getUserId();User user = null;if (userIdUserListMap.containsKey(userId)) {user = userIdUserListMap.get(userId).get(0);}pictureVO.setUser(userApplicationService.getUserVO(user));});pictureVOPage.setRecords(pictureVOList);return pictureVOPage;}@Overridepublic void doPictureReview(PictureReviewRequest pictureReviewRequest, User loginUser) {pictureDomainService.doPictureReview(pictureReviewRequest, loginUser);}@Overridepublic void fillReviewParams(Picture picture, User loginUser) {pictureDomainService.fillReviewParams(picture, loginUser);}@Overridepublic int uploadPictureByBatch(PictureUploadByBatchRequest pictureUploadByBatchRequest, User loginUser) {return pictureDomainService.uploadPictureByBatch(pictureUploadByBatchRequest, loginUser);}@Overridepublic void clearPictureFile(Picture oldPicture) {pictureDomainService.clearPictureFile(oldPicture);}@Overridepublic void deletePicture(long pictureId, User loginUser) {pictureDomainService.deletePicture(pictureId, loginUser);}@Overridepublic void checkPictureAuth(User loginUser, Picture picture) {pictureDomainService.checkPictureAuth(loginUser, picture);}@Overridepublic void editPicture(Picture picture, User loginUser) {pictureDomainService.editPicture(picture, loginUser);}@Overridepublic List<PictureVO> searchPictureByColor(Long spaceId, String picColor, User loginUser) {return pictureDomainService.searchPictureByColor(spaceId, picColor, loginUser);}@Overridepublic void editPictureByBatch(PictureEditByBatchRequest pictureEditByBatchRequest, User loginUser) {pictureDomainService.editPictureByBatch(pictureEditByBatchRequest, loginUser);}@Overridepublic CreateOutPaintingTaskResponse createPictureOutPaintingTask(CreatePictureOutPaintingTaskRequest createPictureOutPaintingTaskRequest, User loginUser) {return pictureDomainService.createPictureOutPaintingTask(createPictureOutPaintingTaskRequest, loginUser);}
}
由于 interfaces 層要調用應用服務層來實現功能,為了方便,可以直接讓圖片應用服務繼承 MyBatis Plus 的接口和實現類,減少樣板增刪改查方法的編寫(比如 getById)。
💡 小技巧:只要發現不調用其他應用服務的方法、并且不調用 “當前類中依賴其他應用服務” 的方法,就可以改為調用領域服務;否則該方法需要在應用服務中實現。
4)重構領域服務層
領域服務層遵循的原則:
- 需要調用數據庫服務(repository)或基礎設施層(infrastructure)來完成業務邏輯
- 可以根據需要,將和實體強相關的業務邏輯下沉到?實體類?中
遵循原則編寫領域服務層的代碼,由于代碼量較大,下面只列舉關鍵修改:
@Service
@Slf4j
public class PictureDomainServiceImplimplements PictureDomainService {@Resourceprivate PictureRepository pictureRepository;/*** 上傳圖片*/@Overridepublic PictureVO uploadPicture(Object inputSource, PictureUploadRequest pictureUploadRequest, User loginUser) {// ...// 如果是更新圖片,需要校驗圖片是否存在if (pictureId != null) {Picture oldPicture = pictureRepository.getById(pictureId);ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR, "圖片不存在");}transactionTemplate.execute(status -> {boolean result = pictureRepository.saveOrUpdate(picture);ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "圖片上傳失敗");});}/*** 校驗數據** @param picture 圖片*/@Overridepublic void validPicture(Picture picture) {}/*** 獲取查詢條件*/@Overridepublic QueryWrapper<Picture> getQueryWrapper(PictureQueryRequest pictureQueryRequest) { }/*** 圖片審核** @param pictureReviewRequest* @param loginUser*/@Overridepublic void doPictureReview(PictureReviewRequest pictureReviewRequest, User loginUser) {// 判斷是否存在Picture oldPicture = pictureRepository.getById(id);boolean result = pictureRepository.updateById(updatePicture);ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);}@Overridepublic void fillReviewParams(Picture picture, User loginUser) {}@Overridepublic int uploadPictureByBatch(PictureUploadByBatchRequest pictureUploadByBatchRequest, User loginUser) {}@Async@Overridepublic void clearPictureFile(Picture oldPicture) {long count = pictureRepository.lambdaQuery().eq(Picture::getUrl, pictureUrl).count();}@Overridepublic void deletePicture(long pictureId, User loginUser) {// 判斷是否存在Picture oldPicture = pictureRepository.getById(pictureId);ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR);// todo 開啟事務transactionTemplate.execute(status -> {// 操作數據庫boolean result = pictureRepository.removeById(pictureId);return true;});}/*** 空間權限校驗** @param loginUser* @param picture*/@Overridepublic void checkPictureAuth(User loginUser, Picture picture) {}@Overridepublic void editPicture(Picture picture, User loginUser) {Picture oldPicture = pictureRepository.getById(id);// 操作數據庫boolean result = pictureRepository.updateById(picture);ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);}@Overridepublic List<PictureVO> searchPictureByColor(Long spaceId, String picColor, User loginUser) {// 查詢該空間下所有圖片(必須有主色調)List<Picture> pictureList = pictureRepository.lambdaQuery().eq(Picture::getSpaceId, spaceId).isNotNull(Picture::getPicColor).list();}/*** 批量編輯圖片分類和標簽*/@Override@Transactional(rollbackFor = Exception.class)public void editPictureByBatch(PictureEditByBatchRequest pictureEditByBatchRequest, User loginUser) {// 查詢指定圖片,僅選擇需要的字段List<Picture> pictureList = pictureRepository.lambdaQuery().select(Picture::getId, Picture::getSpaceId).eq(Picture::getSpaceId, spaceId).in(Picture::getId, pictureIdList).list();}/*** nameRule 格式:圖片{序號}** @param pictureList* @param nameRule*/private void fillPictureWithNameRule(List<Picture> pictureList, String nameRule) {}@Overridepublic CreateOutPaintingTaskResponse createPictureOutPaintingTask(CreatePictureOutPaintingTaskRequest createPictureOutPaintingTaskRequest, User loginUser) {Picture picture = Optional.ofNullable(pictureRepository.getById(pictureId)).orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND_ERROR));}
}
注意,其實 validPicture 方法是可以移動到 Picture 實體類中的,大家可自行操作。
💡 小技巧
- 修改領域服務時,如果發現某個方法沒被 application 調用(IDE 顯示灰色),就可以直接移除掉。
- 如果想節省重復編寫增刪改查等樣板代碼的時間,應用服務或領域服務也可以直接繼承 MyBatis Plus 的接口和實現類,這樣雖然 DDD 目錄結構不是 100% 標準,但是能大幅減少開發成本。
4、重構 Controller?
1)首先將原始 PictureController 移動為?interfaces.controller.PictureController
?類。
2)為保證接口層的精簡,需要將其中的代碼下沉到?轉換類和應用服務?中。首先編寫轉換類?interfaces.assembler.PictureAssembler
,負責將 DTO 轉為實體類:
public class PictureAssembler {public static Picture toPictureEntity(PictureEditRequest request) {Picture picture = new Picture();BeanUtils.copyProperties(request, picture);// 注意將 list 轉為 stringpicture.setTags(JSONUtil.toJsonStr(request.getTags()));return picture;}public static Picture toPictureEntity(PictureUpdateRequest request) {Picture picture = new Picture();BeanUtils.copyProperties(request, picture);// 注意將 list 轉為 stringpicture.setTags(JSONUtil.toJsonStr(request.getTags()));return picture;}
}
3)將 Controller 的代碼下沉到應用服務中,調用應用服務和 Assembler 來處理請求,可能會涉及到應用服務方法的參數修改。其中 updatePicture、editPicture 是改造的重點,需要調用 Assembler 和應用服務層完成功能,下面只列舉修改的關鍵代碼:
@RestController
@RequestMapping("/picture")
@Slf4j
public class PictureController {@Resourceprivate PictureApplicationService pictureApplicationService;@Resourceprivate UserApplicationService userApplicationService;// region 增刪改查/*** 上傳圖片(可重新上傳)*/@PostMapping("/upload")@SaSpaceCheckPermission(value = SpaceUserPermissionConstant.PICTURE_UPLOAD)public BaseResponse<PictureVO> uploadPicture(@RequestPart("file") MultipartFile multipartFile, PictureUploadRequest pictureUploadRequest, HttpServletRequest request) {User loginUser = userApplicationService.getLoginUser(request);PictureVO pictureVO = pictureApplicationService.uploadPicture(multipartFile, pictureUploadRequest, loginUser);return ResultUtils.success(pictureVO);}/*** 通過 URL 上傳圖片(可重新上傳)*/@PostMapping("/upload/url")@SaSpaceCheckPermission(value = SpaceUserPermissionConstant.PICTURE_UPLOAD)public BaseResponse<PictureVO> uploadPictureByUrl(@RequestBody PictureUploadRequest pictureUploadRequest, HttpServletRequest request) {User loginUser = userApplicationService.getLoginUser(request);String fileUrl = pictureUploadRequest.getFileUrl();PictureVO pictureVO = pictureApplicationService.uploadPicture(fileUrl, pictureUploadRequest, loginUser);return ResultUtils.success(pictureVO);}/*** 刪除圖片*/@PostMapping("/delete")@SaSpaceCheckPermission(value = SpaceUserPermissionConstant.PICTURE_DELETE)public BaseResponse<Boolean> deletePicture(@RequestBody DeleteRequest deleteRequest, HttpServletRequest request) {User loginUser = userApplicationService.getLoginUser(request);pictureApplicationService.deletePicture(deleteRequest.getId(), loginUser);return ResultUtils.success(true);}/*** 更新圖片(僅管理員可用)*/@PostMapping("/update")@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)public BaseResponse<Boolean> updatePicture(@RequestBody PictureUpdateRequest pictureUpdateRequest, HttpServletRequest request) {// 將實體類和 DTO 進行轉換Picture picture = PictureAssembler.toPictureEntity(pictureUpdateRequest);// 數據校驗pictureApplicationService.validPicture(picture);// 判斷是否存在long id = pictureUpdateRequest.getId();Picture oldPicture = pictureApplicationService.getById(id);ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR);// 補充審核參數User loginUser = userApplicationService.getLoginUser(request);pictureApplicationService.fillReviewParams(picture, loginUser);// 操作數據庫boolean result = pictureApplicationService.updateById(picture);ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);return ResultUtils.success(true);}/*** 根據 id 獲取圖片(僅管理員可用)*/@GetMapping("/get")@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)public BaseResponse<Picture> getPictureById(long id, HttpServletRequest request) {// 查詢數據庫Picture picture = pictureApplicationService.getById(id);}/*** 根據 id 獲取圖片(封裝類)*/@GetMapping("/get/vo")public BaseResponse<PictureVO> getPictureVOById(long id, HttpServletRequest request) {// 查詢數據庫Picture picture = pictureApplicationService.getById(id);// 獲取權限列表User loginUser = userApplicationService.getLoginUser(request);List<String> permissionList = spaceUserAuthManager.getPermissionList(space, loginUser);PictureVO pictureVO = pictureApplicationService.getPictureVO(picture, request);pictureVO.setPermissionList(permissionList);// 獲取封裝類return ResultUtils.success(pictureVO);}/*** 分頁獲取圖片列表(僅管理員可用)*/@PostMapping("/list/page")@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)public BaseResponse<Page<Picture>> listPictureByPage(@RequestBody PictureQueryRequest pictureQueryRequest) {// 查詢數據庫Page<Picture> picturePage = pictureApplicationService.page(new Page<>(current, size), pictureApplicationService.getQueryWrapper(pictureQueryRequest));return ResultUtils.success(picturePage);}/*** 分頁獲取圖片列表(封裝類)*/@PostMapping("/list/page/vo")public BaseResponse<Page<PictureVO>> listPictureVOByPage(@RequestBody PictureQueryRequest pictureQueryRequest, HttpServletRequest request) {// 查詢數據庫Page<Picture> picturePage = pictureApplicationService.page(new Page<>(current, size), pictureApplicationService.getQueryWrapper(pictureQueryRequest));// 獲取封裝類return ResultUtils.success(pictureApplicationService.getPictureVOPage(picturePage, request));}/*** 編輯圖片(給用戶使用)*/@PostMapping("/edit")@SaSpaceCheckPermission(value = SpaceUserPermissionConstant.PICTURE_EDIT)public BaseResponse<Boolean> editPicture(@RequestBody PictureEditRequest pictureEditRequest, HttpServletRequest request) {User loginUser = userApplicationService.getLoginUser(request);// 將實體類和 DTO 進行轉換Picture picture = PictureAssembler.toPictureEntity(pictureEditRequest);pictureApplicationService.editPicture(picture, loginUser);return ResultUtils.success(true);}// endregion/*** 返回預置的標簽和分類*/@GetMapping("/tag_category")public BaseResponse<PictureTagCategory> listPictureTagCategory() {}/*** 審核*/@PostMapping("/review")@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)public BaseResponse<Boolean> doPictureReview(@RequestBody PictureReviewRequest pictureReviewRequest, HttpServletRequest request) {User loginUser = userApplicationService.getLoginUser(request);pictureApplicationService.doPictureReview(pictureReviewRequest, loginUser);return ResultUtils.success(true);}/*** 批量抓取和創建圖片*/@PostMapping("/upload/batch")@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)public BaseResponse<Integer> uploadPictureByBatch(@RequestBody PictureUploadByBatchRequest pictureUploadByBatchRequest, HttpServletRequest request) {User loginUser = userApplicationService.getLoginUser(request);Integer uploadCount = pictureApplicationService.uploadPictureByBatch(pictureUploadByBatchRequest, loginUser);return ResultUtils.success(uploadCount);}/*** 以圖搜圖*/@PostMapping("/search/picture")public BaseResponse<List<ImageSearchResult>> searchPictureByPicture(@RequestBody SearchPictureByPictureRequest searchPictureByPictureRequest) {Picture oldPicture = pictureApplicationService.getById(pictureId);ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR);List<ImageSearchResult> resultList = ImageSearchApiFacade.searchImage(oldPicture.getUrl());return ResultUtils.success(resultList);}/*** 根據顏色搜索圖片*/@PostMapping("/search/color")@SaSpaceCheckPermission(value = SpaceUserPermissionConstant.PICTURE_VIEW)public BaseResponse<List<PictureVO>> searchPictureByColor(@RequestBody SearchPictureByColorRequest searchPictureByColorRequest, HttpServletRequest request) {User loginUser = userApplicationService.getLoginUser(request);List<PictureVO> result = pictureApplicationService.searchPictureByColor(spaceId, picColor, loginUser);return ResultUtils.success(result);}/*** 批量編輯圖片*/@PostMapping("/edit/batch")@SaSpaceCheckPermission(value = SpaceUserPermissionConstant.PICTURE_EDIT)public BaseResponse<Boolean> editPictureByBatch(@RequestBody PictureEditByBatchRequest pictureEditByBatchRequest, HttpServletRequest request) {User loginUser = userApplicationService.getLoginUser(request);pictureApplicationService.editPictureByBatch(pictureEditByBatchRequest, loginUser);return ResultUtils.success(true);}/*** 創建 AI 擴圖任務*/@PostMapping("/out_painting/create_task")@SaSpaceCheckPermission(value = SpaceUserPermissionConstant.PICTURE_EDIT)public BaseResponse<CreateOutPaintingTaskResponse> createPictureOutPaintingTask(@RequestBody CreatePictureOutPaintingTaskRequest createPictureOutPaintingTaskRequest,HttpServletRequest request) {User loginUser = userApplicationService.getLoginUser(request);CreateOutPaintingTaskResponse response = pictureApplicationService.createPictureOutPaintingTask(createPictureOutPaintingTaskRequest, loginUser);return ResultUtils.success(response);}/*** 查詢 AI 擴圖任務*/@GetMapping("/out_painting/get_task")public BaseResponse<GetOutPaintingTaskResponse> getPictureOutPaintingTask(String taskId) {}
}
這樣一來,接口的代碼更加精簡。
💡 前面也提到了,如果覺得一層一層補充調用方法過于麻煩,可以直接給應用服務或領域服務繼承 MyBatis Plus 的 IService 和 ServiceImpl,便于上一層調用。
5、其他代碼兼容?
嘗試啟動項目,可能會出現編譯錯誤,我們根據報錯提示依次解決即可。
最終,嘗試啟動項目,只要不報編譯錯誤,就算是重構完成了,即使項目啟動不起來也不用在意,因為我們有些服務還沒有重構完。
空間領域?
包括空間、空間分析、空間成員管理這 3 類核心功能,我們簡單將項目進行重構即可。
1、重構 model 包?
按照下面的規則,將原始 model 包中的代碼移動到對應的新位置:
原始包 | 重構后的包 | 備注 |
---|---|---|
model.entity | domain.space.entity | Space、SpaceUser 類 |
model.enums | domain.space.valueobject | SpaceLevelEnum、SpaceRoleEnum、SpaceTypeEnum 枚舉類 |
model.dto.space | interfaces.dto.space | 請求封裝類 |
model.vo | interfaces.vo.space | 響應封裝類 SpaceVO、SpaceUserVO |
2、重構數據訪問層?
根據前面講過的依賴倒置原則,在領域包下新建?repository
?包,定義與數據庫交互的接口,然后在?infrastructure.repository
?中寫相應的實現。
由于我們的項目中使用了 MyBatis Plus 框架,可以讓接口直接繼承其提供的 IService 接口,接口的實現繼承 ServiceImpl 類,這樣就直接擁有了一批操作數據庫的方法,簡化開發。
新增 SpaceRepository 和 SpaceUserRepository 接口:
package com.yupi.yupicture.domain.space.repository;public interface SpaceRepository extends IService<Space> {
}
package com.yupi.yupicture.domain.space.repository;public interface SpaceUserRepository extends IService<SpaceUser> {
}
新增 SpaceRepositoryImpl 和 SpaceUserRepositoryImpl 實現類:
package com.yupi.yupicture.infrastructure.repository;@Service
public class SpaceRepositoryImpl extends ServiceImpl<SpaceMapper, Space> implements SpaceRepository {
}
package com.yupi.yupicture.infrastructure.repository;@Service
public class SpaceUserRepositoryImpl extends ServiceImpl<SpaceUserMapper, SpaceUser> implements SpaceUserRepository {
}
SpaceMapper 和 SpaceUserMapper 之前已經移動到了 infrastructure 包中,作為實現中的一部分。
3、重構 Service?
Service 層的重構是相對最麻煩的,但我們可以利用一些小技巧大幅提高重構效率。
1)首先,直接在 IDE 中移動 Service 接口和實現類到應用服務層,包括 3 個接口和實現類:
原始類 | 重構后的類 | 備注 |
---|---|---|
service.SpaceService | application.service.SpaceApplicationService | 應用服務接口 |
service.SpaceUserService | application.service.SpaceUserApplicationService | 應用服務接口 |
service.SpaceAnalyzeService | application.service.SpaceAnalyzeApplicationService | 應用服務接口 |
service.impl.SpaceServiceImpl | application.service.impl.SpaceApplicationServiceImpl | 應用服務實現類 |
service.impl.SpaceUserServiceImpl | application.service.impl.SpaceUserApplicationServiceImpl | 應用服務實現類 |
service.impl.SpaceAnalyzeServiceImpl | application.service.impl.SpaceAnalyzeApplicationServiceImpl | 應用服務實現類 |
為什么要這么做呢?因為應用服務層是可供其他領域調用的,而之前的 Service 也是可供其他 Service 調用的。直接移動后,IDE 會?自動重構代碼,將對原始服務接口的調用改為新應用服務接口的調用,減少了手動修改的代碼量。
2)復制 Service 接口和實現類為領域服務層,包括空間服務和空間成員服務。不需要 SpaceAnalayzeDomainService,因為實現分析功能依賴的是 Space 和 Picture 應用服務,而不是依賴 SpaceAnalayzeRepository(根本沒有空間分析表)。
原始類 | 重構后的類 | 備注 |
---|---|---|
service.SpaceService | domain.user.service.SpaceDomainService | 領域服務接口 |
service.SpaceUserService | domain.user.service.SpaceUserDomainService | 領域服務接口 |
service.impl.SpaceServiceImpl | domain.user.service.impl.SpaceDomainServiceImpl | 領域服務實現類 |
service.impl.SpaceUserServiceImpl | domain.user.service.impl.SpaceUserDomainServiceImpl | 領域服務實現類 |
為什么要這么做呢?因為領域服務層是編寫核心業務邏輯的位置,也需要被應用服務層調用,所以先把原來的 Service 接口和實現類復制過來,便于等會兒按需保留代碼或拆分代碼。
3)重構應用服務層
application 層主要做領域服務的編排,如果,事務一般也交由 application 層來控制。
應用服務層遵循的原則:
- 將業務邏輯下沉到?領域服務或實體類?中,應用服務層需要調用領域服務或實體類來完成業務邏輯。
- 如果某個方法需要調用其他應用服務(在單個領域內無法完成),那么該方法不能放到領域服務中,而是保留在應用服務中,因為原則上領域服務不應該調用應用服務。
- 負責為接口層提供調用支持,因為原則上接口層只能調用應用服務層。
遵循原則,將 getSpaceUserVOList、getSpaceUserVO、validSpaceUser、addSpaceUser、getSpaceVOPage、getSpaceVO、addSpace 以及空間分析服務方法的實現保留在 ApplicationServiceImpl 中,因為它們都調用了其他應用服務(比如 userApplicationService)。其他方法可以下沉到領域服務中,以 SpaceApplicationService 為例,應用服務層的代碼如下:
@Service
public class SpaceApplicationServiceImpl extends ServiceImpl<SpaceMapper, Space> implements SpaceApplicationService {@Resourceprivate SpaceDomainService spaceDomainService;@Resourceprivate TransactionTemplate transactionTemplate;@Resourceprivate UserApplicationService userApplicationService;@Resource@Lazyprivate SpaceUserApplicationService spaceUserApplicationService;@Overridepublic long addSpace(SpaceAddRequest spaceAddRequest, User loginUser) {// 保留原本實現}/*** 獲取查詢條件*/@Overridepublic QueryWrapper<Space> getQueryWrapper(SpaceQueryRequest spaceQueryRequest) {return spaceDomainService.getQueryWrapper(spaceQueryRequest);}/*** 獲取空間 VO*/@Overridepublic SpaceVO getSpaceVO(Space space, HttpServletRequest request) {// 保留原本實現}/*** 分頁獲取空間封裝*/@Overridepublic Page<SpaceVO> getSpaceVOPage(Page<Space> spacePage, HttpServletRequest request) {// 保留原本實現}@Overridepublic void fillSpaceBySpaceLevel(Space space) {spaceDomainService.fillSpaceBySpaceLevel(space);}@Overridepublic void checkSpaceAuth(User loginUser, Space space) {spaceDomainService.checkSpaceAuth(loginUser, space);}
}
由于 interfaces 層要調用應用服務層來實現功能,為了方便,可以直接讓空間應用服務繼承 MyBatis Plus 的接口和實現類,減少樣板增刪改查方法的編寫(比如 getById)。
💡 小技巧:只要發現不調用其他應用服務的方法、并且不調用 “當前類中依賴其他應用服務” 的方法,就可以改為調用領域服務;否則該方法需要在應用服務中實現。
4)重構領域服務層
領域服務層遵循的原則:
- 需要調用數據庫服務(repository)或基礎設施層(infrastructure)來完成業務邏輯
- 可以根據需要,將和實體強相關的業務邏輯下沉到?實體類?中
比如 validSpace 方法可以下沉到實體類中,因為校驗邏輯不涉及調用數據庫,是對實體本身的校驗。
遵循原則編寫領域服務層的代碼,以 SpaceDomainServiceImpl 為例:
@Service
public class SpaceDomainServiceImpl extends ServiceImpl<SpaceMapper, Space> implements SpaceDomainService {/*** 獲取查詢條件*/@Overridepublic QueryWrapper<Space> getQueryWrapper(SpaceQueryRequest spaceQueryRequest) {// 保留原有實現}@Overridepublic void fillSpaceBySpaceLevel(Space space) {// 修改級別時,自動填充數據SpaceLevelEnum spaceLevelEnum = SpaceLevelEnum.getEnumByValue(space.getSpaceLevel());if (spaceLevelEnum != null) {long maxSize = spaceLevelEnum.getMaxSize();if (space.getMaxSize() == null) {space.setMaxSize(maxSize);}long maxCount = spaceLevelEnum.getMaxCount();if (space.getMaxCount() == null) {space.setMaxCount(maxCount);}}}/*** 空間權限校驗** @param loginUser* @param space*/@Overridepublic void checkSpaceAuth(User loginUser, Space space) {// 保留原有實現}
}
其實上述代碼中,還可以進一步將方法下沉到實體類中哦,應該下沉哪個方法呢?
💡 小技巧
- 修改領域服務時,如果發現某個方法沒被 application 調用(IDE 顯示灰色),就可以直接移除掉。
- 如果想節省重復編寫增刪改查等樣板代碼的時間,應用服務或領域服務也可以直接繼承 MyBatis Plus 的接口和實現類,這樣雖然 DDD 目錄結構不是 100% 標準,但是能大幅減少開發成本。
4、重構 Controller?
1)首先將原始的空間相關的 3 個 Controller 移動到?interfaces.controller
?包中。
2)為保證接口層的精簡,需要將其中的代碼下沉到?轉換類和應用服務?中。首先編寫轉換類?interfaces.assembler.SpaceAssembler
?和?SpaceUserAssembler
,負責將 DTO 轉為實體類:
public class SpaceAssembler {public static Space toSpaceEntity(SpaceAddRequest request) {Space space = new Space();BeanUtils.copyProperties(request, space);return space;}public static Space toSpaceEntity(SpaceUpdateRequest request) {Space space = new Space();BeanUtils.copyProperties(request, space);return space;}public static Space toSpaceEntity(SpaceEditRequest request) {Space space = new Space();BeanUtils.copyProperties(request, space);return space;}
}
public class SpaceUserAssembler {public static SpaceUser toSpaceUserEntity(SpaceUserAddRequest request) {SpaceUser spaceUser = new SpaceUser();BeanUtils.copyProperties(request, spaceUser);return spaceUser;}public static SpaceUser toSpaceUserEntity(SpaceUserEditRequest request) {SpaceUser spaceUser = new SpaceUser();BeanUtils.copyProperties(request, spaceUser);return spaceUser;}
}
3)將 Controller 的代碼下沉到應用服務中,調用應用服務和 Assembler 來處理請求,可能會涉及到應用服務方法的參數修改。其中 updateSpace、editSpace 是改造的重點,需要調用 Assembler 和應用服務層完成功能,下面只列舉修改的關鍵代碼:
@RestController
@RequestMapping("/space")
@Slf4j
public class SpaceController {@Resourceprivate SpaceApplicationService spaceApplicationService;@Resourceprivate UserApplicationService userApplicationService;@Resourceprivate SpaceUserAuthManager spaceUserAuthManager;// region 增刪改查/*** 創建空間*/@PostMapping("/add")public BaseResponse<Long> addSpace(@RequestBody SpaceAddRequest spaceAddRequest, HttpServletRequest request) {// 填充默認值User loginUser = userApplicationService.getLoginUser(request);// 返回新寫入的數據 idlong newSpaceId = spaceApplicationService.addSpace(spaceAddRequest, loginUser);}/*** 刪除空間*/@PostMapping("/delete")public BaseResponse<Boolean> deleteSpace(@RequestBody DeleteRequest deleteRequest, HttpServletRequest request) {User loginUser = userApplicationService.getLoginUser(request);long id = deleteRequest.getId();// 判斷是否存在Space oldSpace = spaceApplicationService.getById(id);ThrowUtils.throwIf(oldSpace == null, ErrorCode.NOT_FOUND_ERROR);// 僅本人或管理員可刪除spaceApplicationService.checkSpaceAuth(loginUser, oldSpace);// 操作數據庫boolean result = spaceApplicationService.removeById(id);}/*** 更新空間(僅管理員可用)*/@PostMapping("/update")@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)public BaseResponse<Boolean> updateSpace(@RequestBody SpaceUpdateRequest spaceUpdateRequest) {// 將實體類和 DTO 進行轉換Space space = SpaceAssembler.toSpaceEntity(spaceUpdateRequest);// 自動填充數據spaceApplicationService.fillSpaceBySpaceLevel(space);// 數據校驗space.validSpace(false);// 判斷是否存在long id = spaceUpdateRequest.getId();Space oldSpace = spaceApplicationService.getById(id);ThrowUtils.throwIf(oldSpace == null, ErrorCode.NOT_FOUND_ERROR);// 操作數據庫boolean result = spaceApplicationService.updateById(space);}/*** 根據 id 獲取空間(僅管理員可用)*/@GetMapping("/get")@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)public BaseResponse<Space> getSpaceById(long id, HttpServletRequest request) {// 查詢數據庫Space space = spaceApplicationService.getById(id);}/*** 根據 id 獲取空間(封裝類)*/@GetMapping("/get/vo")public BaseResponse<SpaceVO> getSpaceVOById(long id, HttpServletRequest request) {// 查詢數據庫Space space = spaceApplicationService.getById(id);ThrowUtils.throwIf(space == null, ErrorCode.NOT_FOUND_ERROR);SpaceVO spaceVO = spaceApplicationService.getSpaceVO(space, request);User loginUser = userApplicationService.getLoginUser(request);}/*** 分頁獲取空間列表(僅管理員可用)*/@PostMapping("/list/page")@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)public BaseResponse<Page<Space>> listSpaceByPage(@RequestBody SpaceQueryRequest spaceQueryRequest) {// 查詢數據庫Page<Space> spacePage = spaceApplicationService.page(new Page<>(current, size),spaceApplicationService.getQueryWrapper(spaceQueryRequest));}/*** 分頁獲取空間列表(封裝類)*/@PostMapping("/list/page/vo")public BaseResponse<Page<SpaceVO>> listSpaceVOByPage(@RequestBody SpaceQueryRequest spaceQueryRequest,HttpServletRequest request) {// 查詢數據庫Page<Space> spacePage = spaceApplicationService.page(new Page<>(current, size),spaceApplicationService.getQueryWrapper(spaceQueryRequest));// 獲取封裝類return ResultUtils.success(spaceApplicationService.getSpaceVOPage(spacePage, request));}/*** 編輯空間(給用戶使用)*/@PostMapping("/edit")public BaseResponse<Boolean> editSpace(@RequestBody SpaceEditRequest spaceEditRequest, HttpServletRequest request) {// 在此處將實體類和 DTO 進行轉換Space space = SpaceAssembler.toSpaceEntity(spaceEditRequest);// 設置編輯時間space.setEditTime(new Date());// 數據校驗space.validSpace(false);User loginUser = userApplicationService.getLoginUser(request);// 判斷是否存在long id = spaceEditRequest.getId();Space oldSpace = spaceApplicationService.getById(id);ThrowUtils.throwIf(oldSpace == null, ErrorCode.NOT_FOUND_ERROR);// 僅本人或管理員可編輯spaceApplicationService.checkSpaceAuth(loginUser, oldSpace);// 操作數據庫boolean result = spaceApplicationService.updateById(space);}// endregion/*** 查詢空間級別列表*/@GetMapping("/list/level")public BaseResponse<List<SpaceLevel>> listSpaceLevel() {// 保留原有實現}}
這樣一來,接口的代碼更加精簡。其實還可以進一步規范 DDD 的架構,比如上面有的方法的實現還可以進一步下沉,是哪些方法呢?
💡 前面也提到了,如果覺得一層一層補充調用方法過于麻煩,可以直接給應用服務或領域服務繼承 MyBatis Plus 的 IService 和 ServiceImpl,便于上一層調用。
5、其他代碼兼容?
嘗試啟動項目,可能會出現編譯錯誤,我們根據報錯提示依次解決即可。
最終,嘗試啟動項目,只要不報編譯錯誤,就算是重構完成了,即使項目啟動不起來也不用在意,因為我們有些服務還沒有重構完。
公共服務?
現在只剩下公共服務 manager 包的代碼沒有拆分了,接下來的目標就是對 manager 包的代碼進行重構。
重構前,我們要先理解公共服務的本質:
- 跨領域:公共服務通常適用于多個領域,如鑒權、日志、通知等。
- 可復用性:不應該綁定到單一的領域模型或用例。
- 無業務含義:與具體的業務無關,僅提供通用的技術能力。
注意,具體情況具體分析,如果某個服務被各個領域或應用調用,那么它也不能和任何一個領域綁定,處理方式也可以和公共服務類似。
建議根據服務?和業務的結合程度(通用程度)決定將 manager 包的代碼移動到哪個位置。如果公共服務不依賴其他領域或應用服務,可以放到?infrastructure.common
?包中;但如果依賴這些服務,可以放到根包下的?shared
?包中,以供所有層使用。這種方式能更好地支持模塊化管理和解耦。
回歸到本項目,步驟如下:
- 可以先將?
model.dto.file
?和?FileManager
?移動到?manager.upload
?包中,由于該包不依賴任何應用服務,可以直接移動到?infrastructure.manager
?包中,作為基礎設施。 - auth、websocket、sharding 包依賴多個應用服務(或者和多個領域邏輯相關),因此將這些 “公共服務” 作為獨立的?
shared
?包,放到根包下。
剩余代碼?
對其他剩余代碼進行整理,比如將 FileController、MainController 等代碼移動到新的 controller 包中。
**重構完成后,注意將代碼中的原包名全部改為新的包名。**比如 Mapper 掃描路徑、配置文件指定的分庫分表算法路徑、接口文檔路徑等。
五、總結?
通過上述 DDD 理論的學習,以及項目重構實戰,相信大家已經對 DDD 有了一定的理解。建議大家先回顧一下魚皮分享的重構方法(根據現有代碼拆分 + 逐個領域拆分 + 利用好 IDE 重構 + 方法下沉),然后自己跟著教程實操一遍 DDD 重構,并且可以嘗試進一步對圖片領域和空間領域進行拆分。
但其實大家應該也感受到了,其實 DDD 并不是多么“高大上”的知識,有點類似于在傳統分層架構的基礎上多增加了一層 “應用服務層”,對于非大型項目來說,反而增加了額外的編碼。因此雖然大家學會了 DDD,實際的應用場景也并不多,一定要按需使用。