關於DDD和COLA的一些總結和思考

糖拌西红柿發表於2024-05-10

寫在前面: 其實之前一直想彙總一篇關於自己對於物件導向的思考以及實踐的文章,但是苦於自己的“墨跡”,一延再延,最近機緣巧合下仔細瞭解了一下COLA的內容,這個想法再次被勾起,所以這次一鼓作氣,準備好好梳理一篇。至於標題,因為是被DDD和COLA喚起的,索性就叫這個吧。

思維:物件導向和麵向過程

領域驅動設計本質上是講的物件導向,但是談物件導向,始終無法繞開程序導向,所以我們先好好說一下程序導向和麵向物件這兩個概念。

什麼是程序導向呢,其實就是我們學習程式設計時最初被植入的邏輯,這也是很多人即便學了物件導向後,寫的程式碼卻四不像的原因,因為這個思維是根深蒂固的。我想大多數院校科班生,第一次接觸程式設計都是C語言,從一個hello word開始,然後便是if elsefor迴圈,其實if else 思維便是程序導向的基本邏輯,就是做事的 “步驟”,比如經典的圖書管理系統的課設,基於程序導向去設計編碼,想的是新增圖書、修改圖書名字、描述;是根據輸入的引數,進行if else的選擇,然後進入對應的流程,在流程裡去先做什麼,再做什麼,關注點在圖書的操作上, 是專注於事情本身,專注於做事的流程;所以程序導向更適合去做一些底層的,基於硬邏輯的內容。當需要做的東西規模過大且頻繁變化時,程式碼量和改動成本也會增加。

物件導向相對於程序導向,它更偏向於概念的抽象和建設模型。同樣圖書管理系統,物件導向考慮的重點則變成了圖書(而不是操作圖書資料這件事),一條條的圖書資料,在記憶體裡就是一個個的個體物件,至於圖書的各種操作那是細節的內容,它不會去關心,只要定義了圖書這個物件,那對於圖書的操作那都在物件自身的事情,物件導向專注於事情的主體,是以主體以及它們之間的行為組合去構建程式。當然物件自身內部的行為又是一個個的程序導向組成,這就是在編碼的時候最容易讓人模糊和把握不準的地方。物件導向把程式設計又拔高了一層,把細節忽略,站在更高維度去構建程式。

透過一個簡單的例子來對比一下這兩種思想:資料庫中存在所有學生的資料,比如姓名、學校、專業,下面需要實現一個自我介紹的功能,描述方式為:我是XX,畢業於XXX學校,用程序導向的思維實現是這樣的:

public class test{

   public static void main(String[] args){
       desc("張三");
   }

   public static void desc(String name){
     //查詢資料庫
     connection = DbManager.createConnection(root,XXX,3306);
     //查詢資料
     Map<String,Object> a = connection.query("SELECT name,school FROM tb_student WHERE name = #{name}",name);
     System.out.print("我是"+a.get('name')+",畢業於"+a.get("school");
   }
}

拿到需求,我們關注的自我介紹這件事,只要完成這件事就好了,所以直接定義一個過程(方法、函式)然後過程裡去根據需求把這件事完成;

而使用物件導向的話,面對需求,首先需要確定主體,也就是學生物件,然後學生物件有姓名、學校、專業這些屬性和一個自我描述的能力。

public class Student{
    private String name;
    private String school;
    private String discipline
    public Student(Map map){//省略建構函式內容}
    public void introduce(){
    System.out.print("我是"+this.name+",畢業於"+this.school);
}
}

然後定義另一個物件,資料庫物件,資料庫物件有一個可以查詢學生物件的能力

public class Db{
   private String url;
   private String username;
   //其他屬性…… 
  public Student searchStudent(String name){
    Map a = connection.query("SELECT name,school FROM tb_student WHERE name = #{name}",name);  
    return new Student(a);
  }
}

最後透過使用兩個物件,來完成這件事

public class test{

   public static void main(String[] args){
      Student object = Db.searchStudent("張三")
      object.introduce();
   }
}

透過上面的程式碼,可以發現,物件導向的實現似乎需要更多的程式碼來完成這件事,沒錯,這是事實,雖然在設計上我們忽略細節,可是編碼上是無法忽略的,甚至使用物件導向成本更高,但是注意我這裡說的是針對咱們這個場景需求,當前場景如果戛然而止,確實程序導向方式更精簡,但是如果需求繼續增加,隨著業務增加、需求變大、邊界變寬,程序導向可能就需要追加更多的過程程式碼去完成,而物件導向可能需要的是調整物件的組合方式或者物件本身的擴充套件去完成,所以,物件導向在程式碼層面最大的優勢就是 複用和擴充套件

業務開發物件導向理論:領域驅動

說完程序導向和麵向物件,再說一下關於物件導向的整合方法論—領域驅動,它的本質是統一語言、邊界劃分和麵向物件分析的方法。簡單點來講就是將OOA、OOD和OOP融匯貫通到系統開發中,充分發揮物件導向的優勢和特點,去降低系統開發過程中的熵增。狹義一點解釋就是如何用 java 在業務開發中寫出“真正物件導向”的系統程式碼。

在概念上,領域驅動又分為貧血模式和充血模式。

貧血模式

貧血模式很多人不陌生, 也是大多數Java開發使用的MVC架構,實體類僅有get和set方法,在整個系統中,領域物件幾乎只作傳輸介質的作用,不會影響到層次的劃分,業務邏輯多集中在Service中,也就是絕大多數使用Spring框架進行開發的Web專案的標準形態,即Controller、Service、Dao、POJO;

這裡看一個例子:

/**
 * 賬戶業務物件
 */
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;
    }
}

貧血模型的問題和難點在於,在面對較為龐大體量的業務系統時,業務邏輯層的膨脹導致程式碼的混亂。因為貧血的特性(POJO僅僅是資料),導致業務程式碼本身就不“物件導向”化,隨著業務的積累和縫縫補補,Service層更像是程序導向,堆滿了if else ,會不斷膨脹和混亂,邊界不易控制,內部的各模組、包之間的依賴會變得不易管理。

充血模式

充血模式更符合領域設計,簡單來講就是OOA和OOP的最佳實踐,將業務邏輯和持久化等內容均內聚到實體類中,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;
    }
}

充血模型的問題和難點在於:

  • Spring框架本身的限制

  • 業務複雜程度是否匹配

  • 如何把握domain層的邊界以及各層間的關係

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

其次是業務的複雜程度是否適配,絕大多數的專案,說難聽點,都是面向資料的概念意淫、CRUD的“建築行業工地式”專案而已,使用Spring+貧血的經典模式足夠滿足,而且貧血模式在開發成本上更適合面向資料開發(需求變更的驅動成因、團隊的管理方式、研發團隊的素質),如果過分追求物件導向反而有些捨近求遠,所以能否根據業務場景決定是否使用充血的領域驅動是挺難的(畢竟很多優秀的物件導向研發是很難捨棄物件導向的誘惑)

最後就是最難的,如何劃分業務邏輯到domain層,即什麼樣的邏輯應該放在Domain Object中,什麼樣的業務邏輯應該放在Service中,這是很含糊的,如果沒有面向領域開發流程以 OO思想的充分沉澱積累,很難做到;即使劃分好了業務邏輯,由於分散在Service和DomainObject層中,不能更好的分模組開發。熟悉業務邏輯的開發人員需要滲透到Domain中去,而在Domian層又包含了持久化,對於開發者(習慣於貧血模型的團隊)來說這十分混亂。

關於COLA框架

COLA 是 Clean Object-Oriented and Layered Architecture的縮寫,代表“整潔物件導向分層架構”。由阿里大佬張建飛所提出的一種基於DDD和程式碼整潔理論所誕生的實踐理論框架,詳細內容可閱讀《程式設計師的底層思維》和相關git程式碼去了解

專案地址:GitHub - alibaba/COLA: 🥤 COLA: Clean Object-oriented & Layered Architecture

下面簡單把COLA框架的主要架構梳理一下,這裡的框架圖採用COLA4.0版本(當前作者認為的最優形態)

COLA整體上由兩部分組成,程式碼架構和通用元件,兩者並非強繫結,程式碼架構是一套基於maven的程式碼模板,通用元件則是可選項,可以理解為Common元件包。

Adapter層(適配層)

負責對前端展示(Web、Wireless、WAP)的路由和適配。對於傳統B/S系統而言,Adapter層就相當於MVC中的Controller。

App層(應用層)

主要負責獲取輸入、組裝上下文、引數校驗、呼叫領域層做業務處理,以及傳送訊息通知(如有需要)等。層次是開放的,應用層也可以繞過領域層,直接訪問基礎設施層。

Domain層(領域層)

用於封裝核心業務邏輯,並利用領域服務(Domain Service)和領域物件(Domain Entity)的方法對App層提供業務實體和業務邏輯計算。領域層是應用的核心,不依賴任何其他層次。

Infrastructure層(基礎設施層)

主要負責技術細節問題的處理,比如資料庫的CRUD、搜尋引擎、檔案系統、分散式服務的RPC等。此外,Infrastructure層還肩負著領域防腐的重任,外部依賴需要透過Gateway的轉義處理,才能被App層和Domain層使用。

Commpont(外掛Cola擴充套件元件)

非業務相關的功能元件包

物件型別概念統一

使用OO理論及COLA,首先就是要明確各類物件的概念,統一認知並且嚴格執行。

DO(Data Object):資料物件

DO應該跟其名字一樣,僅作為資料庫中表的1:1對映,不參與到業務邏輯操作中,僅僅負責Infrastructure層的資料持久化以及讀取的實體物件

Entity:實體物件

對應業務模型中的業務物件,欄位及方法應該與業務語言抱持一致,原則上Entity和Do應該是包含巢狀的關係,且Entity的生命週期僅存在在系統的記憶體中,不需要序列化以及持久化,業務流程結束,Entity也應該跟隨消亡

DTO(Data Transfer Object): 資料傳輸物件

主要作為和系統外部互動的物件,DTO主要是資料傳輸的載體,期間可以承載一部分領域能力(業務相關的判定職責等);

VO(view Object):展示層物件

性質和DTO相差不多,也是資料傳輸載體,主要是作為對外交付資料的載體,承載部分資料隱藏、資料保護、資料結構化展示的作用。

程式碼組織結構及層次間關係

使用作者推薦的maven Archetypes模板後,能發現COLA整體採用maven module的結構來構建程式碼,劃分原則是結合技術維度和領域維度綜合劃分:

相應的分層則對應到不同的module中(技術維度劃分),由此形成了這樣的依賴關係:

Comm本身作為公共內容被所有module依賴,Adapter作為系統唯一外部輸出僅依賴APP,App本身除了依賴領域層外還因為CQRS相關內容依賴Infras;Domain層巧妙地使用依賴倒置讓Infras層反向依賴它,以保證了Domain的獨立和完整。整體是按照技術維度進行劃分的。

然後就是關於每個module的程式碼目錄劃分原則(基於領域維度進行劃分),即每個module中對應的概念都劃分到具體某一功能領域包中:

框架執行邏輯

場景1(包含業務訴求)

由客戶端發起請求,首先Adapter層接收請求,進行相關的校驗後進行資料封裝(Coverter操作:由請求引數封裝為App層所需要的DTO結構),根據預設的邏輯去呼叫App的內容;DTO資料進入App後, App根據業務訴求,然後呼叫Domain的ability和module進行業務組裝(Converter操作:將DTO轉換為Entity物件);被呼叫的Domian在進行業務處理過程中呼叫Infras層去進行相關持久化和查詢;Infras在被Domain呼叫時,需要把傳入的Entity轉換為內部對應資料的DO物件。最後逐層返回,相應的在Adapter層將DTO轉為VO

場景2(CQRS訴求):

由外部客戶端發起的不攜帶業務處理的查詢操作,例如:某某資料列表檢視、某某內容統計,則由Adapter接收請求後,封裝引數物件後,呼叫相關App內容,App內部進行DTO和DO的相互coverter操作後,直接呼叫Infras層進行資料的查詢操作。

從上面的執行流程不難看出,COLA在程式碼上有兩個特點

  1. Domain層足夠獨立且直至業務核心,透過巧妙的依賴倒置(gateway的介面與實現切割至兩個module中),完成了與基礎資料持久層的耦合,變成了最基礎的被依賴者,內部分的model和ability這兩部分是平等的, model是針對業務場景進行抽象的業務物件Entity、ability是抽象的業務直接的操作,相當於上文充血模式例子中AccountBO類CreditPolicyDebitPolicy的關係;優勢就是可以專注於複雜的業務開發,並且保證業務程式碼的整潔性和可重構性。

  2. 制定了明確地規範,將部分非業務內容(資料轉換、校驗、適配等)均攤至不同的層次裡,降低了原本貧血模式中Service持續積累過程中不可避免地臃腫;但是另一方面,為了實現物件導向與解耦,不可避免地追加了許多轉換操作,存在DTO和Conver操作程式碼膨脹的風險

寫在最後

COLA框架相較於傳統MVC(貧血模式)的三層結構要複雜一些,而複雜出來的內容(convertor、executor、extension、domainservice)的根本目的是在複雜的業務場景下,去踐行物件導向的設計和編碼,充分發揮物件導向的優勢(保證程式碼的整潔、可維護等);但是同時也是要付出DTO物件和資料轉換程式碼存在冗餘激增風險的代價。

所以考慮使用COLA之前先謹慎考慮一下自己專案的特點,實際情況卻是絕大多數業務並沒有很複雜,都是基於資料的CRUD,且團隊整體採用面向資料的開發方式去開發,沒必要 “為了引入而引入”;其次使用COLA框架去開發是有技術成本的,至少對於團隊研發工程師的要求較高(必須精通OOA、OOD、OOP),保證每個功能和需求的設計到落地都是連貫且完整的物件導向設計。

下面是針對COLA框架設計時依據的一些思想的提煉:

  1. 規範的程式碼框架:井井有條的包和目錄分類以及統一的命名規範會像程式碼地圖一樣形成無形的約束

  2. 核心業務邏輯和技術細節分離:區分業務部分和非業務部分,並進行分離,讓“上帝歸上帝,凱撒歸凱撒”

  3. 奧卡姆剃刀:讓事情簡單化,如無必須,勿增實體

  4. 合理使用領域物件模型設計domain和麵向物件設計:讓程式碼更加“物件導向”(個人比較喜歡,程式碼滿足物件導向的一些設計原則),從而充分發揮物件導向的高擴充套件,易維護,實現高內聚、低耦合的究極目的。

相關文章