從MVC到DDD,該如何下手重構?

作者:付政委

博客:bugstack.cn

沉澱、分享、成長,讓自己和他人都能有所收穫!😄

大家好,我是技術UP主小傅哥。多年的 DDD 應用,使我開了技術的眼界!

MVC 舊工程腐化嚴重,迭代成本太高。DDD 新工程全部重構,步子扯的太大。 這是現階段在工程體系化治理中,我們所面臨的最大問題;既想運用 DDD 的思想循序漸進重構現有工程,又想不破壞原有的工程體系結構以保持新需求的承接效率。

經過實踐得知,DDD 架構能解決,現階段 MVC 貧血結構中所遇到的衆多問題。

衆所周知,MVC 分層結構是一種貧血模型設計,它將”狀態“和”行爲“分離到不同的包結構中進行開發使用。domain 裏寫 po、vo、enum 對象,service 裏寫功能邏輯實現。也正因爲 MVC 結構沒有太多的約束,讓前期的交付速度非常快。但隨着系統工程的長期迭代,貧血對象開始被衆多 serivice 交叉使用,而 service 服務也是相互調用。這樣缺少一個上下文關係的開發方式,讓長期迭代的 MVC 工程逐步腐化到嚴重腐化。

MVC 工程的腐化根本,就在於對象、服務、組件的交叉混亂使用。時間越長,腐化的越嚴重。

在 MVC 的分層結構就像家裏所有人的衣服放一個大衣櫃、所有人的褲子放一個大庫櫃。衣服褲子(對象),很少的時候很節省空間,因爲你的褲子別人可能也拿去穿,複用一下開發速度很快。但時間一長,就越來越亂了。🤨 一條褲子被加肥加大,所有人都穿。

而 DDD 架構的模型分層,則是以人爲視角,一個人就是一個領域,一個領域內包括他所需的衣服、褲子、襪子、鞋子。雖然剛開始有點浪費空間,但隨着軟件的長週期發展,後續的維護成本就會降低。

那麼,接下來我們就着重看以下,從 MVC 到 DDD 的輕量化重構應該怎麼做。🍻

文章後面,含有 MVC 到 DDD 重構編碼實踐講解。此文也是 MVC、DDD 的架構編碼指導經驗說明。

一、能學到啥

本文是偏實戰可落地的 DDD 知識分享,也是從 MVC 到 DDD 的可落地方案講解。在本文中會介紹 DDD 架構下的分層結構、調用全景圖以及非常重要的 MVC 到 DDD 應該如何映射和編碼。所以如下這一系列內容都是你能獲得的知識;

  1. DDD 領域驅動設計,對應的分層結構講解。涵蓋調用關係、依賴關係、對象轉換以及各層的功能劃分。—— 簡單且清晰。
  2. DDD 調用全景圖,以一張全方位的結構關係調用視圖,展開 DDD 的血脈流轉關係。有了這一張視圖,你會更加清楚的知道 DDD 的調用鏈路結構和各個代碼都要寫到那一層。
  3. MVC 映射 DDD 後的調整方案,在儘可能低的成本下,讓 MVC 結構具備 DDD 領域驅動設計的實現思想。這樣的調整,可以在一定程度上,阻止舊工程的腐化程度,提高編碼質量。同時也爲後續從 MVC 到 DDD 的遷移,做好基礎。
  4. MVC、DDD 是工程設計骨架,設計原則、設計模式是工程實現血肉。所以設計模式也是本文要展示的重點內容。
  5. 一整套實戰開源課程;講解在 DDD 架構中,各項技術棧;Dubbo、MQ、Redis、Zookeeper - 配置中心等的分層使用。—— 否則你可能都不知道一個 MQ 消息發送要放在哪裏。有了 DDD 分層架構,這些東西會被歸類的特別清晰。

此外,除了這些碎片化的知識學習,還有應用級實戰項目鍛鍊;Lottery DDD 架構設計、ChatGPT 新DDD架構設計、API網關 會話設計 - 學習架構能力和編程思維,以及高端的編碼技巧。

二、架構分層(DDD)

在 DDD 架構分層中,domain 模塊最重要的,也是最大的那個。所有的其他模塊都要圍着它轉。所有 domian 下的各個領域模塊,都包含着一組完整的;model - 模型對象、service - 服務處理,以及在有需要操作數據庫時,再引入對應的 IRepository - 倉儲服務。這個 domain 的實現,就像是實現了一個火藥包,火藥包的火藥、引線、包布等都是一個個物料被封裝到一起使用。

如下是 DDD 架構所呈現出的一種四層架構分層,可能和一些其他的 DDD 分層略有差異,但核心的重點結構是不變的。尤其是 domain 領域、infrastructure 基礎,是任何一個 DDD 架構分層都需要有的分層模塊。

  • 應用封裝 - app:這是應用啓動和配置的一層,如一些 aop 切面或者 config 配置,以及打包鏡像都是在這一層處理。你可以把它理解爲專門爲了啓動服務而存在的。
  • 接口定義 - api:因爲微服務中引用的 RPC 需要對外提供接口的描述信息,也就是調用方在使用的時候,需要引入 Jar 包,讓調用方好能依賴接口的定義做代理。
  • 領域封裝 - trigger:觸發器層,一般也被叫做 adapter 適配器層。用於提供接口實現、消息接收、任務執行等。所以對於這樣的操作,這裏把它叫做觸發器層。
  • 領域編排【可選】 - case:領域編排層,一般對於較大且複雜的的項目,爲了更好的防腐和提供通用的服務,一般會添加 case/application 層,用於對 domain 領域的邏輯進行封裝組合處理。但對於一些小項目來說,完全可以去掉這一層。少量一層對象轉換,代碼的維護成本會降低很多。
  • 領域封裝 - domain:領域模型服務,是一個非常重要的模塊。無論怎麼做DDD的分層架構,domain 都是肯定存在的。在一層中會有一個個細分的領域服務,在每個服務包中會有【模型、倉庫、服務】這樣3部分。
  • 倉儲服務 - infrastructure:基礎層依賴於 domain 領域層,因爲在 domain 層定義了倉儲接口需要在基礎層實現。這是依賴倒置的一種設計方式。所有的倉儲、接口、事件消息,都可以通過依賴倒置的方式進行調用。
  • 類型定義 - gateway:對於外部接口的調用,也可以從基礎設施層分離一個專門的 gateway 網關層,來封裝外部 RPC/HTTP 等類型接口的調用。
  • 類型定義 - types:通用類型定義層,在我們的系統開發中,會有很多類型的定義,包括;基本的 Response、Constants 和枚舉。它會被其他的層進行引用使用。(這一層沒有畫到圖中)

綜上就是 DDD 架構思想下的工程分層模型結構,DDD 架構的領域驅動設計的重點包括;結構邊界更加清晰、重視上下文調用、分離業務功能與基礎支撐。總之一句話,就是各司其職。那麼鑑於如此清晰工程結構,該如何將舊存工程,MVC 轉向 DDD 呢?接下來就重點介紹下。

三、工程重構(MVC->DDD)

經過實踐驗證,不需要太高成本,MVC 就可以天然的向 DDD 工程分層的模型結構轉變。重點是不改變原有的工程模塊的依賴關係,將貧血的 domain 對象層,設計爲充血的結構。對於 domain 原本在 MVC 分層結構中,就是一個被依賴層,恰好可以與其他層做依賴倒置的設計方案處理。具體如圖所示;

左側是我們常見的 MVC 分層結構,右側是給大家上文講解過的 DDD 分層結構。從 MVC 到 DDD 的映射,使用了相同顏色進行標註。之後我來介紹一些細節;

在 MVC 分層結構中,所有的邏輯都集中在 service 層,也是文中提到的腐化最嚴重的層,要治理的也是這一層。所以首先我們要將 service 裏的功能進行拆解。

  1. service 中具備領域特性的服務實現,抽離到原本貧血模型的 domain 中。在 domain 分層中添加 xxx、yyy、zzz 分層領域包,分別實現不同功能。注意每個分層領域包內都具備完整的 DDD 領域服務內所需的模塊
  2. service 中的基礎功能組件,如;緩存Redis、配置中心等,遷移到 dao 層。這裏我們把 dao 層看做爲基礎設施層。它與 domain 領域層的調用關係,爲依賴倒置。也就是 domain 層定義接口,dao 層依賴於 domain 定義的接口,做依賴倒置實現接口。
  3. service 本身最後被當做 application/case 層,來調用 domain 層做服務的編排處理。

因爲恰好,MVC 分層結構中,也是 service 和 dao 依賴於 domain,這和 DDD 分層結構是一致的。所以經過這樣的映射拆分代碼實現調用結構後,並不會讓工程結構發生變化。那麼只要工程結構不發生變化,我們的改造成本就只剩下代碼編寫風格和舊代碼遷移成本。

MVC 分層結構中的 export 層是 RPC 接口定義層,由 web 層實現。web 是對 service 的調用。也就是 DDD 分層結構中調用 application 編排好的服務。這部分無需改動。但如果你原有工程把 domain 也暴漏出去了,則需要把對應的包遷移到 export 因爲 domain 包有太多的核心對象和屬性,還包括數據庫持久化對象。這些都不應該被暴漏。

MVC 分層中,因爲有需要對外部 RPC 接口的調用,所以會單獨有一層 RPC 來封裝其他服務的接口。這一層被 domain 領域使用層,可以定義 adapter 適配器接口,通過依賴倒置,在 rpc 層實現 domain 層定義的調用接口。

此外 dao 層,在 MVC 結構中原本是比較單一的。但經過改造後會需要把基礎的 Redis 使用、配置中使用,都遷移到 dao 層。因爲原本在 service 層的話,domain 層是調用不到的這些基礎服務的,而且也不符合服務功能邊界的劃分。

綜上,就是從 MVC 到 DDD 重構架構的拆解實現方案。這是一種最低成本的最佳實施策略,完全可以保證 MVC 的結構,又可以應用上 DDD 的架構分層優勢。也能運用 DDD 領域驅動設計思想,重構舊代碼,增加可維護性。

到這裏,分層結構問題我們說清楚了。從 MVC 調整結構到 DDD 後,工程模型中的調用鏈路關係是什麼樣呢?接下來我們在展開架構,看細節關係。

四、分層調用鏈路

接下來我們把 DDD 的分層架構平鋪展開,看看從一個接口的實現到各個模塊分層中的調用鏈路關係是什麼樣的。這樣在做自己的代碼開發中也可以參考到應該把什麼的功能分配到哪個模塊中處理。

從APP層、觸發器層、應用層,這三塊主要對領域層的上下文邏輯封裝、觸發式(MQ、HTTP、JOB)使用,並最終在應用層中打包發佈上線。這一部分的都是使用的處理,所以也不會有太複雜的操作。

當進入領域層開始,也是智力集中體現的開始了。所有你對工程的抽象能力,都在這一塊區域體現。

接下來我們着種介紹下領域層和基礎層的模塊職責功能;圖中下方是對象的流轉,可以注意下。

1. 領域服務層

我們可以當 domain 領域層爲一個充血模型結構,在一個 domain 領域層中,可以有多個領域包。當然理想狀態下,如果你的 DDD 拆分的特別乾淨的新工程,那麼可能一個 domain 就一個領域。但大部分時候微服務的拆分鑑於成本考慮不會那麼細,還有一些老工程的重構,都是一個工程內有多個領域,對應的解決方案是在一個工程下建多個同級分層包。比如;賬戶領域包、授信領域包、結算領域包等,每個包內聚合實現不同的功能。

每一個 domain 下的領域包內,都包括;model 模型、倉儲、接口、事件和服務的處理。

model 模型對象;

  • aggreate:聚合對象,實體對象、值對象的協同組織,就是聚合對象。
  • entity:實體對象,大多數情況下,實體對象(Entity)與數據庫持久化對象(PO)是1v1的關係,但也有爲了封裝一些屬性信息,會出現1vn的關係。
  • valobj:值對象,通過對象屬性值來識別的對象 By 《實現領域驅動設計》

repository 倉儲服務;從數據庫等數據源中獲取數據,傳遞的對象可以是聚合對象、實體對象,返回的結果可以是;實體對象、值對象。因爲倉儲服務是由基礎層(infrastructure) 引用領域層(domain),是一種依賴倒置的結構,但它可以天然的隔離PO數據庫持久化對象被引用。

adapter 接口服務;是依賴於外包的其他 HTTP/RPC 接口的封裝調用,通過在 domain 領域層定義適配器接口,再有依賴於 domain 的基礎層設施層或者一個單獨的專門處理接口的額外分層,來實現 domain 定義的適配器接口,完成對依賴的 HTTP/RPC 進行封裝處理。

event 事件消息;在服務實現中,進行會有業務完成後,對外發送消息的情況。這個時候,可以在領域模型中定義事件消息的接口,再有基礎設施層完成消息的推送。

service 服務設計;這裏要注意,不要以定義了聚合對象,就把超越1個對象以外的邏輯,都封裝到聚合中,這會讓你的代碼後期越來越難維護。聚合更應該注重的是和本對象相關的單一簡單封裝場景,而把一些重核心業務方到 service 裏實現。此外;如果你的設計模式應用不佳,那麼無論是領域驅動設計、測試驅動設計還是換了三層和四層架構,你的工程質量依然會非常差。

2. 基礎設施層

提供數據庫持久化提供Redis和配置中心數據支撐提供事件消息推送提供外部服務接口封裝。總之這一層的核心目的就是更好的輔助 domain 領域層完成領域功能的開發。

而調用方式則爲依賴倒置,也就是領域服務層定義接口,基礎設施層做功能實現。這樣可以有效的避免基礎基礎設施層中的對象被對外暴漏,如數據庫持久化對象,在這樣的分層結構中,天然的被保護在基礎設置層中,外部是沒法引入的,否則就循環依賴了。

有了這一層以後,domain 層不會關係數據的細節處理。傳遞給基礎設施層的方法中,會把聚合對象或實體對象通過接口方法傳遞下來。之後在基礎設施層中完成數據事務的操作。也會含有事務處理後,寫入Redis緩存和發送MQ消息。如果說有誇領域的事務,一般可能就是跨庫表,這個時候要使用 MQ 事件的方式進行驅動。

3. 類型對象層

這一層就比較簡單了,只是一些通用的出入參對象 Response,還有枚舉對象、異常對象等。供給於對外的接口層使用。但如果是 RPC 這樣的接口,建議同 RPC 對外提供的接口描述包中提供,因爲對外只提供1個輕量化的包且不依賴於任何其他包,是最好維護管理的。

五、只是換了別墅

從 MVC 到 DDD,我們有一點是必須清楚的認知的。

從 MVC 到 DDD 我們只是換了一個更大、格局更清晰的房子🏡,但並不能決定你從 MVC 到 DDD 代碼就變得非常乾淨、漂亮、整潔了。因爲從 MVC 到 DDD 只是骨架變了,但骨架之下的血肉並沒有改變。

如果你仍是把原有的爛代碼平移到新的分層架構中,就相當於把老房子裏的破舊傢俱衣物鞋帽搬過來而已。所以依照與軟件設計的原則;分治、抽象和知識,中的知識是設計原則和設計模式的運用。所以要想把代碼寫好,就一定是要把DDD + 設計模式,才能真的把代碼寫好。接下來,小傅哥再給大家舉個使用模式在 DDD 分層結構中重構的案例。

六、重構現有代碼

軟件設計第一原則,康威定律所提到的,分治、抽象和知識,是用於系統設計和實現的指導說明。分治和抽象,我們可以用 DDD 思想映射的分層架構來處理,但知識則是設計原則和設計模式的運用。

所以,如果沒有合理的運用設計知識來對代碼進行細化處理,那麼即使拆分出流程邊界在清晰的架構,也很難做出好維護的代碼。而通常最常用的設計模式,無外乎;工廠、策略、模板的組合使用,少部分會用到責任鏈、建造者、組合模式。那麼接下來,在分享一個帶有流程的設計模式使用,讓大家可以有一份可參考的工程代碼設計。

1. 場景設定

這裏我們做一個提額場景的設定。估計大家都用過信用卡💳,它有一個初始的額度,在後續的使用中會隨着信用的積累和消費的增加,進行提高額度。而額度的提高則需要一系列的校驗判斷並最終做出提額處理。流程如下;

這樣的流程圖,是我們做業務開發的小夥伴,經常看到的。做一系列的流程判斷處理,之後完成一個具體的功能。簡單來說,就是 if···else 寫代碼,一條條的校驗。但寫着寫着,時間一長就會發現代碼變得特別混亂。最主要的原因就是,那些爲了支撐完成業務的各類判斷是不穩定因素,會隨着業務的變化不斷的調整。甚至有時候就直接下掉了。但你的代碼就中多就了一條 // 業務說暫時不使用,你也不敢刪!就像有首歌唱的🎤:“需求依舊停在曠野上,你的代碼被越拉越長。直到遠去的馬蹄聲響,呼喚你的Bug傳四方。”

所以對於這樣的功能流程設計,怎麼辦呢?總不能讓曠野的馬蹄,一直拉着你的bug在奔襲。

2. 代碼現狀

一個接口一個實現,一個實現代碼一片。
一片一片,又一片,代碼行數,兩三千。

大部分我們在 MVC 工程分層結構下,參與開發的代碼,基本都是定義一個接口,就寫一片功能實現。功能實現中,如果看到有現成的接口,直接拿來複用。所有的實現並不會基於接口、抽象、模板等進行,所以最終這樣的代碼腐化的非常嚴重。

3. 重新分層

重構前,先說明下新的分層處理;如圖

  • 首先,在原有的 domain 貧血模型中,添加一個對應的領域包。credit 你可以是自己的其他的領域包。之後的 domain 則爲充血模型設計。
  • 之後,在領域包內實現自己的業務邏輯,注意這裏需要用到設計模式來實現。代碼實現中需要用到的數據查詢、緩存使用、接口調用,全部採用依賴倒置的方式讓基礎層/接口層,來提供具體的實現邏輯。而 domain 層只是定義接口和使用 Spring 的注入進行使用。

4. 重構代碼

抽象類,是一個非常好用的類。一種是可以定義出流程結構,讓代碼變得清晰乾淨。再有一種是定義共用方法,讓其他實現類可複用。

那麼這裏,我們就使用抽象類定義模板 + 策略和工廠實現的規則引擎處理頻繁變動的校驗類流程,完成代碼開發。如圖我們先設計下代碼的實現結構。

  • 首先,定義一個受理調額的接口。因爲額度的調整,包括;提額、降額。所以不要把名字寫的太死。
  • 之後,由抽象類實現接口。在抽象類中定義出整個調用鏈路關係,並把一些公用的數據類支撐邏輯,提到支撐類裏。這和 Spring 的設計很像。
  • 之後,因爲規則校驗這東西是爲了支撐核心流程走下去的,而且還是隨着業務頻繁變動的。那就沒必要在主線業務流程中,用 if···else 貼膏藥的寫代碼,而是應該拆解出來。所以這裏設計一個策略模式實現的規則校驗,並通過工廠對外提供服務。
  • 最後,這些東西零件類的東西都處理好後。就可以在抽象類的子類實現中進行調用處理了。

5. 代碼呈現

經過設計模式的重構處理,現在的代碼就以如下形式體現了。—— 拆解出來的僞代碼,具體可以參考過往的一些設計模式運用。

public AdjustAssetOrderEntity acceptAdjustAssetApply(AdjustAssetApplyEntity adjustAssetApplyEntity) {
    // 1. 參數校驗
    this.parameterVerification(adjustAssetApplyEntity);
  
    // 2. 查詢申請單數據,如已經存在則直接返回
    AdjustAssetOrderEntity orderEntity = queryAssetLog(adjustAssetApplyEntity.getPin(), adjustAssetApplyEntity.getAccountType(), adjustAssetApplyEntity.getTaskNo(), adjustAssetApplyEntity.getAdjustType());
    if (null != orderEntity) {
        log.info("pin={} taskNo={} 受理申請,檢索到任務存在進行中的申請單。", adjustAssetApplyEntity.getUserId(), adjustAssetApplyEntity.getTaskNo());
        return orderEntity;
    }
  
    // 3. 以下流程放到分佈式鎖內處理【避免相同請求二次進入】
    String lockId = genLockId(adjustAssetApplyEntity.getAdjustType(), adjustAssetApplyEntity.getUserId());
    try {
        // 3.1 分佈式鎖:加鎖
        long state = lock(lockId);
        if (0 == state) {
            throw new AccountRuntimeException(BizResultCodeEm.DISTRIBUTED_LOCK_EXCEPTION.getCode(), "分佈式鎖異常,當前用戶行爲處理中。");
        }
      
        // 3.2 賬戶查詢
        UserAccountInfoDTO userAccountInfoDTO = queryJtAccount(adjustAssetApplyEntity.getUserId(), adjustAssetApplyEntity.getAccountType());
      
        // 3.3 基礎校驗;(1)賬戶類型、(2)狀態狀態、(3)額度類型、(4)賬戶逾期、(5)費率類型【暫無】
        LogicCheckResultEntity logicCheckResultEntity = doCheckLogic(adjustAssetApplyEntity, userAccountInfoDTO,
                DefaultLogicFactory.LogicModel.ACCOUNT_TYPE_FILTER.getCode(),
                DefaultLogicFactory.LogicModel.ACCOUNT_STATUS_FILTER.getCode(),
                DefaultLogicFactory.LogicModel.ACCOUNT_QUOTA_FILTER.getCode(),
                DefaultLogicFactory.LogicModel.ACCOUNT_OVERDUE_FILTER.getCode()
        );
      
        if (!AssetCycleQuotaAlterCodeEnum.E0000.getCode().equals(logicCheckResultEntity.getCode())) {
            log.info("userId={} taskNo={} 規則校驗過濾攔截。code:{} info:{}", adjustAssetApplyEntity.getUserId(), adjustAssetApplyEntity.getTaskNo(), logicCheckResultEntity.getCode(), logicCheckResultEntity.getInfo());
            throw new AccountRuntimeException(logicCheckResultEntity.getCode(), logicCheckResultEntity.getInfo());
        }
      
        // 3.4 受理調額
        return this.acceptAsset(adjustAssetApplyEntity, userAccountInfoDTO);
    } finally {
        // 3.1 分佈式鎖:解鎖
        this.unlock(lockId);
    }
}

這樣的處理後,代碼就變得非常清晰了。

  1. 先是做基礎的校驗和數據的查詢判斷,之後加鎖避免一個人超時申請。而後,進行規則引擎的調用和處理,根據不同的訴求,開發不同的規則,並配置的方式進行使用。
  2. 最後所有的這些東西處理完成後,就是做最終的調額處理了。

七、實戰學習

  • 重構,是一直都在發生的事情,不能積累到最後才重構。那只有重做的可能。
  • 工廠、模板、策略,這3個設計模式,就可以解決80%的場景問題。
  • 小傅哥的編碼標準也會成爲夥伴參考的案例,所以小傅哥會更嚴格要求自己的標準。

注意📢,很多學不會 DDD,也學不會設計模式的。憑良心說,不就是沒看見好的代碼,沒跟着有價值的項目走一遍嗎!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章