聊一聊領域驅動與貧血模型

糖拌西红柿發表於2024-07-03

寫在前面

前段時間跟領導討論技術債概念時不可避免地提到了程式碼的質量,而影響程式碼質量的因素向來都不是單一的,諸如專案因素、管理因素、技術選型、人員素質等等,因為是技術債務,自然就從技術角度來分析,單純從技術角度來看程式碼質量,其實又細分很多原因,如程式碼設計、程式碼規範、程式設計技巧等等,但我個人覺得這些都是技,並不是程式碼混亂的根,程式碼之所以混亂的根要從最基礎的層面說起,也就是程式碼架構。

但是好像程式碼架構也不像是技術選型的產物,它更像是“潤物細無聲”的環境影響產物,一個Java Web專案,不談一個企業,甚至整個行業都有一個共識,應該是Controller、Service、Dao那一套,從程式碼目錄結構到內部細節編寫,也不知道是MVC架構的宣傳深入人心還是培訓機構或者企業培訓的連帶效應導致的這種渾然天成的共識。就是這種渾然天成的共識導致了一個很奇怪的現象,那就是 :使用標榜物件導向的Java語言所開發的專案卻十分的不物件導向。這裡也不是說物件導向就比程序導向要強,只是各自適用的領域不一樣。超過作業系統到應用層級的應用,不論從需求延申角度、系統規模角度、落地實現角度、擴充套件維護角度,物件導向都要更好一些。

這也是Java這門語言高居榜首的理由。 Java自誕生之初,各自資料、書籍無一不是在講它的物件導向特性和麵向物件設計,隨後的領域驅動設計更是物件導向的整合方法論,在這麼多buff的加持下,為什麼還是會出現使用物件導向去寫程序導向的程式碼呢?答案就在上面的共識裡,好像Java專案的開發和貧血模型一直是強繫結的。

嚴格來講,概念上領域驅動其實只有一種概念,並沒有貧血充血之分,DDD官方概念中並沒有明確定義所謂的貧血模式,貧血模式的誕生其實是對於標準領域驅動的簡化,而與之對應的標準的DDD就成了充血。

貧血模型

貧血模式很多人不陌生, 即上文提到的傳統的Controller、Service、Dao的框架基礎之上,配合以Java的物件實體類概念,但實體類僅有屬性和屬性的get和set方法,在整個系統中,物件幾乎只作傳輸介質的作用,不會影響到層次的劃分,業務邏輯多集中在Service中;隨之後續的資料庫技術、ORM框架等等,都在這一體系上繼續壘加,形成了當下最高複製率的JavaWeb專案結構。

還是轉賬這個經典的例子,使用貧血實現:

/**
 * 賬戶業務物件
 */
public class AccountBO {

    /**
     * 賬戶ID
     */
    private String accountId;

    /**
     * 賬戶餘額
     */
    private Long balance;
    /**
     * 是否凍結
     */
    private boolean isFrozen;

    public String getAccountId() {
        return accountId;
    }

    public void setAccountId(String accountId) {
        this.accountId = accountId;
    }

    public Long getBalance() {
        return balance;
    }

    public void setBalance(Long balance) {
        this.balance = balance;
    }

    public boolean isFrozen() {
        return isFrozen;
    }

    public void setFrozen(boolean isFrozen) {
        this.isFrozen = isFrozen;
    }

}

/**
 * 轉賬業務服務實現
 */
@Service
public class TransferServiceImpl implements TransferService {

    @Autowired
    private AccountMapper accountMapper;

    @Override
    public boolean transfer(String fromAccountId, String toAccountId, Long amount) {
        AccountBO fromAccount = accountMapper.getAccountById(fromAccountId);
        AccountBO toAccount = accountMapper.getAccountById(toAccountId);

        /** 檢查轉出賬戶 **/
        if (fromAccount.isFrozen()) {
            throw new MyBizException(ErrorCodeBiz.ACCOUNT_FROZEN);
        }
        if (fromAccount.getBalance() < amount) {
            throw new MyBizException(ErrorCodeBiz.INSUFFICIENT_BALANCE);
        }
        fromAccount.setBalance(fromAccount.getBalance() - amount);

        /** 檢查轉入賬戶 **/
        if (toAccount.isFrozen()) {
            throw new MyBizException(ErrorCodeBiz.ACCOUNT_FROZEN);
        }
        toAccount.setBalance(toAccount.getBalance() + amount);

        /** 更新資料庫 **/
        accountMapper.updateAccount(fromAccount);
        accountMapper.updateAccount(toAccount);
        return Boolean.TRUE;
    }
}

貧血模型的本質

貧血模式本質是在架構指導下技術框架趨於成熟的演進產物;換句話說是MVC架構隨著技術的發展而不斷變體而來。MVC的初衷是一個三層架構,即包含資料結構和業務邏輯的模型Model層、應用程式的使用者介面(UI)展示與互動的檢視層View、用於協調Model與View的劇中排程層Controller,旨在將資料、邏輯和互動解耦。但伴隨著ORM框架、Web框架的進步,慢慢形成了Controller、Service、Dao(ORM)的格局,之後前後端分離的浪潮襲來,導致Controller變成了路由,Dao變成了JDBC的簡化,Do變成了資料結構,頭和尾都明確定位的夾擊下,所有的業務功能只能聚焦於中間的Service裡,需求大都集中在了Service,“Service用來承載業務邏輯”的說法更加助長了設計懶惰,形成了 “service=資料+邏輯”的風格,學過C語言的或多或少都聽過一句“程式=資料+演算法”,所以貧血就是在輸入輸出都固定框架模式下的程序導向。

貧血模型下的開發特點

貧血模式的service層的定義導致它的底層思維是 “程式=演算法+資料” ,與之而來的開發流程或者說特點也就十分明瞭了。即 功能與資料密不可分、程式碼與資料密不可分。最後面向資料程式設計。

聚焦功能忽視業務

長期使用貧血模式開發的人員,眼裡看到的多是UI原型或者功能,對於系統的全貌,永遠是站在功能的角度去描述和了解。往往專注於功能的開發,儘可能地去簡化實現,點到實現功能為止,不做過多的設計與業務的擴充套件思考。實現往往是單維度的,僅僅是為了滿足某個功能或某個頁面,當需求分析和系統規劃不夠優秀時,後續功能開發時,不可避免會陷入前後矛盾的情景。

面向資料開發

在接手系統時,程式設計師一般先看兩部分,資料庫裡的表和功能,最後才是程式碼,或者接到需求時,首先做的就是根據UI或者產品的描述去設計儲存用的表結構,然後再基於表結構去進行程式碼設計。也就是說,在實現角度,資料庫的表結構才是根基。

另一方面就是SQL建模,其實也可以說是重SQL輕程式碼,絕大多數做Java Web開發地研發人員從根據需求設計表結構、到實現時進行SQL建模十分嫻熟,甚至我見過很多高手,能用一個SQL完成功能開發,把功能和資料的關係極盡所能地壓縮到一個或者幾個SQL中,程式碼反而只是一個傳輸媒介。

貧血模式的開發者傾向於將注意力集中在資料上,他們直接在資料表上建模。語言框架更多的功能在於資料庫的訪問和UI的繪製,雖然語言可能是Java這種完全物件導向語言,但其實應用裡並沒有什麼客觀物件,除了資料庫的容器和訪問者

貧血模型的合理性

說到這好像顯得貧血模式一無是處,其實也不絕對,畢竟 “存在即合理”,裁剪了繁重體系的領域驅動後,貧血模式就變得十分輕便,本身程序導向的特點又讓它複製性和落地效率十分突出,開發人員只需要UI頁面和資料庫的表結構,隨便一個研發都可以快速接手並完成一個功能的開發 ,降低了軟體設計的複雜度,這恰恰和很多開發團隊快速響應的訴求不謀而合。

貧血得以盛行的另一個助力就是Spring,Spring家族作為Java系的行業老大哥框架,積累和沉澱非常豐富,各方面已經封裝的很全面,所以留出的“自由度”就比較少,它的宗旨就是讓開發人員減少重複造輪子,僅僅專注功能的開發就好;這些特點使得Spring本身就帶有限制性質,例如Spring的根基Bean管理機制,把物件的管控牢牢把握在框架中,這種將實體類的管理也交由Spring本身也降低了二次開發時物件導向的屬性,導致在Spring中進行bean之間的引用改造會面臨大範圍的bean巢狀構造器的呼叫問題。所以使用Spring也就大機率預設使用貧血。

貧血模型的問題

不可否認,面對大多數簡單而生命週期短暫的專案面向資料開發是一種高效的方式,且當需求發生變動,Service按部就班地追加相應的邏輯也是可以快速響應,可是這些都是建立在系統簡單、專案週期短的前提下,一旦面臨長生命週期且業務複雜的大型系統,貧血模式的問題就是:

  1. 隨著系統體積的增長和需求的蔓延,部分功能設計問題顯露,貧血模式下的程式碼特點難以快速重構來響應這一級別的訴求
  2. 程序導向的形式,讓程式碼陷入 “又臭又長的if else”死衚衕,隨著不斷地開發,維護和改動成本指數級上漲
  3. 長時間的“水多了加面,面多了加水”的開發習慣,團隊人員思維固化,難以提出有效地最佳化或改進,惡性迴圈

充血模型

充血簡單來講就是OO思想的體系方法論,相比貧血的面向資料,它提出領域的概念,即將業務和資料拆開,對業務部分進行領域劃分並進行OOA,程式碼上更重抽象出的領域實體類,將業務邏輯和判定等內容均內聚到實體類中,Service層僅僅充當組合實體物件的畫布,負責簡單封裝部分業務和事務許可權管理等;

為了更直觀感受,使用充血模型,對上面的例子進行修改:

/**
 * 賬戶業務物件
 */
public class AccountBO {

    /**
     * 賬戶ID
     */
    private String accountId;

    /**
     * 賬戶餘額
     */
    private Long balance;

    /**
     * 是否凍結
     */
    private boolean isFrozen;

    /**
     * 出借策略
     */
    private DebitPolicy debitPolicy;

    /**
     * 入賬策略
     */
    private CreditPolicy creditPolicy;

    /**
     * 出借方法
     * 
     * @param amount 金額
     */
    public void debit(Long amount) {
        debitPolicy.preDebit(this, amount);
        this.balance -= amount;
        debitPolicy.afterDebit(this, amount);
    }

    /**
     * 轉入方法
     * 
     * @param amount 金額
     */
    public void credit(Long amount) {
        creditPolicy.preCredit(this, amount);
        this.balance += amount;
        creditPolicy.afterCredit(this, amount);
    }

    public boolean isFrozen() {
        return isFrozen;
    }

    public void setFrozen(boolean isFrozen) {
        this.isFrozen = isFrozen;
    }

    public String getAccountId() {
        return accountId;
    }

    public void setAccountId(String accountId) {
        this.accountId = accountId;
    }

    public Long getBalance() {
        return balance;
    }

    /**
     * BO和DO轉換必須加set方法這是一種權衡
     */
    public void setBalance(Long balance) {
        this.balance = balance;
    }

    public DebitPolicy getDebitPolicy() {
        return debitPolicy;
    }

    public void setDebitPolicy(DebitPolicy debitPolicy) {
        this.debitPolicy = debitPolicy;
    }

    public CreditPolicy getCreditPolicy() {
        return creditPolicy;
    }

    public void setCreditPolicy(CreditPolicy creditPolicy) {
        this.creditPolicy = creditPolicy;
    }
}


/**
 * 入賬策略實現
 */
@Service
public class CreditPolicyImpl implements CreditPolicy {

    @Override
    public void preCredit(AccountBO account, Long amount) {
        if (account.isFrozen()) {
            throw new MyBizException(ErrorCodeBiz.ACCOUNT_FROZEN);
        }        
    }

    @Override
    public void afterCredit(AccountBO account, Long amount) {
        System.out.println("afterCredit");
    }
}

/**
 * 出借策略實現
 */
@Service
public class DebitPolicyImpl implements DebitPolicy {

    @Override
    public void preDebit(AccountBO account, Long amount) {
        if (account.isFrozen()) {
            throw new MyBizException(ErrorCodeBiz.ACCOUNT_FROZEN);
        }
        if (account.getBalance() < amount) {
            throw new MyBizException(ErrorCodeBiz.INSUFFICIENT_BALANCE);
        }
    }

    @Override
    public void afterDebit(AccountBO account, Long amount) {
        System.out.println("afterDebit");
    }
}

/**
 * 轉賬業務服務實現
 */
@Service
public class TransferServiceImpl implements TransferService {

    @Resource
    private AccountMapper accountMapper;
    @Resource
    private CreditPolicy creditPolicy;
    @Resource
    private DebitPolicy debitPolicy;

    @Override
    public boolean transfer(String fromAccountId, String toAccountId, Long amount) {
        AccountBO fromAccount = accountMapper.getAccountById(fromAccountId);
        AccountBO toAccount = accountMapper.getAccountById(toAccountId);
        //此處採用輕量化地方式解決了自身物件導向和Spring的bean管理權矛盾
        fromAccount.setDebitPolicy(debitPolicy);
        toAccount.setCreditPolicy(creditPolicy);

        fromAccount.debit(amount);
        toAccount.credit(amount);
        accountMapper.updateAccount(fromAccount);
        accountMapper.updateAccount(toAccount);
        return Boolean.TRUE;
    }
}

充血模型的開發特點

抽象業務

相比於貧血聚焦於功能、UI頁面,充血模式在面對設計時,需要更上升一層,聚焦於業務。將業務內容抽象出業務物件、物件關係、業務流程、物件依賴,以排列組合物件來滿足業務需求。面對需求,需要層層分析,設計,模型場景推演,做到重業務物件,輕資料,從而實現業務、功能、程式碼、資料的清晰劃分,避免直譯式的粘連耦合實現。

透過領域概念將業務與技術分離

領域驅動中突出領域的概念,包含:通用域、支撐域、核心域三部分,其中通用域和支撐域則是純技術流和通用內容,包括持久化、通訊技、安全、效能、通用元件、規則等,核心域則是具體業務分析得出的業務塊,其中包含通用的業務語言、領域內的上下文、複雜變化的業務邏輯等。通用域和支撐域負責資料管理、包裝介面、繪製介面、收接訊息等系統的基本技術能力,以此來支撐核心域的業務流轉、運作。 通用域、支撐域不與核心域耦合,讓技術歸技術、業務歸業務,做到技術和業務的分離。

充血模型的問題

很多書籍和資料都是在說充血模型是解決貧血模型的良藥,其實也過於吹捧了,之前也提到了,貧血模型有其存在的必要性,充血模型和貧血模型只是適用範圍不同,如果貧血模型的缺點都是由於應用場景導致,那充血確是良藥。但如果單純是管理問題、團隊人員技術問題導致的問題,那強行套用充血才是災難,至於貧血換充血的理由,會在後面提到。這裡單純以批評視角說一說充血的問題。

單純看充血模型存在的問題,就兩點:

  • 理論過於理想,實踐較為困難
  • 邊界難以把控

其實這兩點歸於一點也不為過,那就是充血模型的實踐土壤過於苛刻,因為其脫身於物件導向,著重以物件導向思想解決系統開發中熵增的問題,由此誕生了非常繁重的體系理論,而每一處體系理論都透露著對於團隊人員素質以及業務場景理解的高要求;對於團隊人員素質,這一點是最難的,因為在一個群體中很難把每個研發對於物件導向的理解拉到同一個維度,只能是不要有太大偏差。其次就是設計者很容易陷入到 “設計師的錘子”理論中,即 “手裡有錘子,處處是釘子”,很容易過度設計,導致無意義地加重複雜度,畢竟把握“精準平衡” 這四個字對於 架構師、設計師、研發工程師的要求不是一紙證書那麼簡單。

從貧血到充血的前制條件

聊怎麼做之前,先談一談必要性,即貧血一定要切換為充血麼,其實從貧血過度到充血也不是必須品,至於要不要使用充血模式,取決於兩個方面:

  • 領域特性
  • 團隊成熟度

領域特性

衡量是否必要的第一點就是領域邏輯的複雜度,充血模式最適合具有複雜領域邏輯的應用。如果業務比較複雜,隨著我們對領域邏輯的瞭解,就能很快感受到基於資料的做法帶來的限制。僅從資料出發,CRUD系統無法建立出好的業務模型。業務的複雜性可以體現在精巧的商業模式、複雜的製造過程、精益的管理方法等方面。

第二點就是領域的穩定性,這裡的穩定性是指內在商業邏輯是穩定的,未來的需求主要集中在支撐功能和業務量的擴充套件上。當然,穩定性指標並不是絕對的,要看它所處的階段。有的軟體功能在時間長度上,幾年內將不斷變化,但一旦改變就不是簡單的變更,例如金融、政策領域的一些法規。或者,雖然業務現階段處於變化之中,但它的商業模式一旦形成,將作為企業的核心資產,例如新興的移動網際網路應用中的各種商業模式。它們都適合採用DDD,即便業務邏輯處在變化之中,我們也可以從DDD的另一個特性——快速測試和驗證領域邏輯中收益,它可以為高頻釋出類應用提供很好的支援。

團隊成熟度

另一個說必要性有點不夠準確,應該說是前置條件,那就是團隊的成熟度。

團隊的成熟度首先表現在團隊的技術素養上,如果團隊大多數人還需要花費大量時間去學習和體會物件導向技術(這些技術包括UML語言、物件導向設計、理解面向介面程式設計、基於服務的架構、設計模式、開發流程、需求分析等),那麼在構建通用語言、模型設計和架構解耦上投入的精力就會受到限制,冒然轉為充血模式的領域驅動,將是災難性質的。

其次就是團隊中一定要有 “領域專家”角色,這個領域專家嚴格來說只是一種角色,它可以泛指那些對業務領域的政策、工作流程、關鍵節點和特性都有深刻理解的所有人。一個判斷標準是,他們對領域的論述是有體系的,而不是散亂的,而且十分清楚規則的應用範圍。沒有領域專家,就不會有通用語言和與語言一致的模型和程式碼。誰來保證我們是“領域驅動”,還是過度設計?即便是最簡的設計,誰來驗證呢?這個“最簡”只能是個偽概念,領域專家的重要性是不可替代的。

說起前置條件不得不提一下專案的週期。相對來說,領域驅動更適合週期長的專案。交付時間過於緊張的專案,團隊成員的注意力都會集中在功能的開發上,這時候強調領域知識的學習和領域模型的精煉,顯然會和各利益相關者產生工作安排重點的衝突。相反,週期越長的專案,比如核心產品的研發,隨著時間的推移,提煉出的領域模型就會逐步釋放出它的威力。因此,專案生命週期越長,收益越大。

如何從貧血到充血

主要是思考角度的轉換,角度轉換主要指兩方面:需求視角, 不要過分糾結具體的功能,而是上升一層,去理解業務,抽象出業務物件和它們之間的關係;實現視角不要拘泥於具體程式碼或實現框架,要站在架構一層上,遮蔽掉系統對於資料的依賴細節,以物件導向的思維去構建系統。

設計視角:將業務轉換為物件和物件關係

貧血模式所對應的開發方式更為“簡單粗暴”,針對需求和功能頁面,1:1的進行技術實現,直接針對功能復刻儲存結構、參照MVC,逐層進行編碼開發,中間幾乎沒有分析與設計可言,輸出的成果本質上是對功能UI頁面負責;這個流程其實是百分百信任或者說依賴產品經理的能力,而產品經理是否在產出產品說明書之前就進行了高質量的需求分析、是否進行了業務到功能的有效對映和轉換、是否進行了有效的功能設計又都是研發不可知的。這也是諸多研發人員即便參與工作或者某個專案很久,仍舊無法說清楚所謂“業務”的原因,因為在貧血模式下的開發都是公式化的功能程式碼轉化,沒有業務理解和設計。

切入DDD的開發方式,對研發和產品都是挑戰,都要有成為領域專家的意識。意味著產品經理角色自己要明白業務、領域的概念,而不是執著於幾個功能或者互動;研發人員則需要同產品經理一起參與需求的分析,對於功能的設計和規劃不是拘泥於一點,而是根據業務,透過繪製用例圖等手段理清整個業務線,搞清楚其中牽扯的物件,並抽象出通用語言和具有業務意義的業務物件,再進一步根據業務流程 去梳理出物件間的關係、物件間的通訊,從而得到完整的一套業務模型,最後結合資料情況,設計資料對接部分。

實現視角:切斷資料與程式碼強耦合

所有的模型、被二級制化的領域邏輯和資料都不可能一直活躍在記憶體中,而需要被儲存在資料庫或檔案中。此時,程式碼就要與具體的持久化技術框架產生耦合。如果不能將這部分程式碼分離出去,領域層的獨立性就無從談起了。我們也不可能脫離技術複雜度而獨立開發領域邏輯,所以領域模型要想保持自己的獨立性,離不開儲存庫將其與持久化機制解耦。

貧血模式下很多情況是資料庫表、SQL模型、ORM配置、實體類到Service邏輯集於一人開發,這就導致了一個問題,當個人擁有“穿透許可權”時是很容易忽視邊界感的,久而久之就會懈怠去維護層與層之間的邊界,業務模型與底層資料通道耦合,難以重構和複用。

解決這種情況技術上就要建立DDD中支撐域的概念,在業務之下,建立真正的資料層,資料層需要反向依賴業務層中定義的需求介面,即資料層是純技術實現層,實現參照就是各個領域中列出的interface類;對應的開發習慣也需要進行改變,即業務開發人員不要參與資料層的邏輯設計與開發,只需要在Domain中定義資料訴求介面即可,由專門的開發團隊去完成資料層或者說資料底座的開發,這樣能夠保證邊界感,同時也能防止程式碼氾濫。透過支撐域資料層和核心域業務層的依賴倒置,實現程式碼上的領域模型與資料的解耦。保證業務模型能夠在不受底層技術影響的情況下進行演化,讓我們可以獨立開發領域模型,無須關注架構的技術細節。

架構視角補充:利用領域概念進行技術整合

使用DDD概念後,類似於包裝介面、描繪介面、引數校驗、資料封裝、各類Util工具包, 包括上文中提到的支撐域中的資料管理則都可以劃分到純技術中,對於體量較大的系統,核心領域則可以拆分為單獨的module,配合整個平臺統一的資料支撐域module,形成模組化組裝形態,各個領域不依賴資料形態、資料獲取方式,僅提出資料介面需求,由支撐域完成核心域的需求,技術角度上,支撐域是一個大而全的資料底座,依賴於業務領域繪製的需求進行開發。

對於微服務架構,DDD更多的是利用其領域劃分提供服務劃分依據,技術上為保證各服務的獨立性,需要弱化支撐域的概念,轉而技術上將許可權下放給各個服務,獨立的各個微服務內部可以有自身獨立的資料互動、資料持久化通道,保證各微服務的獨立管理和演進,將支撐域中的內容抽離為通用域的公共元件,進行技術上的統一化管理。

相關文章