聊聊領域驅動設計與編碼思想

zyz123456發表於2022-11-23

在開始之前,讓我們回顧一下萬惡之源:

把大象裝進冰箱需要幾步?

應該有很多計算機系的朋友對這個問題印象深刻吧,它是大部分大學在教授物件導向這門課程時用來拋磚引玉的第一問。

而我們通常會得到兩個答案:

  • 需要三步,先開啟冰箱門,然後把大象放進冰箱,然後關上冰箱門。
  • 需要三步,冰箱開啟門,大象走進冰箱,冰箱關上門。

上述兩種答案,本質上是思想的不同,第一種回答是站在第一人稱的視角來審視問題,這種思考方式我們稱其為 過程事務指令碼

而第二種回答則是分別站在不同 事物 的視角上看待問題,這種思考方式我們稱其為 物件導向思想

過程事務指令碼,其實就是對問題解決流程的羅列,好處是有的,例如不需要額外的思考成本,寫起來簡單,入門門檻低等等等等。但從複雜度和事務發展的客觀規律來看,它不是最合適的。

注:‘事物發展的客觀規律’ 就是指事物往復雜、熵增的方向發展。

為什麼這麼說,讓我們來看一個實際的問題。

從問題出發

假設我們要開發一個商城系統,在設計初期,產品給出了下面需求:

使用者提交訂單後,後臺計算訂單商品總金額,儲存訂單商品條目快照,鎖定庫存然後生成訂單並回顯。

於是研發部根據需求寫出了第一版程式:

class OrderService is
    method createOrder(createOrderInputDto): Order is
        // 從訂單建立物件中解構所需要的資料

        // 計算訂單總金額
        // 儲存訂單商品條目快照
        // 鎖定庫存

        // 建立訂單並返回

程式上線後,由於使用者激增,單體式應用很快便滿足不了龐大的使用者量的需求,於是產品部要求研發部進行服務拆分,進一步提升系統併發請求量,然後第二版程式就出來了:

class OrderService is
    method createOrder(createOrderInputDto): Order is
        // 從訂單建立物件中解構所需要的資料

        // + 呼叫遠端服務獲取商品資料
        // 計算訂單總金額
        // 儲存訂單商品條目快照
        // - 鎖定庫存
        // + 呼叫倉儲服務鎖定庫存

        // 建立訂單並返回

突然有一天,運營一拍大腿,決定搞一個優惠活動:

使用者消費時,根據使用者會員等級和單筆消費金額進行返利,返利直接補貼進單筆消費訂單總金額中,並且允許使用者可以使用優惠券疊加計算優惠金額。

忙碌的程式猿們再次扛起鍵盤準備戰鬥,於是最新的程式又出來了:

class OrderService is
    method createOrder(createOrderInputDto): Order is
        // 從訂單建立物件中解構所需要的資料

        // 呼叫遠端服務獲取商品資料
        // + 呼叫遠端服務獲取使用者會員等級權益資訊
        // 計算訂單總金額
        // + 計算會員等級優惠及返利
        // + 再次計算訂單總金額
        // + 計算疊加優惠券後的金額

        // 儲存訂單商品條目快照
        // 鎖定庫存
        // 呼叫倉儲服務鎖定庫存

        // 建立訂單並返回

在這之後,腦洞大開的運營時不時會想到一些新奇的創意,研發部門充滿了快樂的空氣...

那這麼做有什麼問題呢?

從整體來看,在Service層堆積的程式碼,不只是業務程式碼,還包括了應用服務排程,資料庫操作,框架的許可權認證等一系列跟業務不相關,而是跟技術層面強相關的邏輯。資料庫操作跟應用服務的排程高度耦合於本服務,這樣從長遠來看明顯是不好的。

接下來讓我們深入到createOrder這個方法,來看看其中的部分程式碼:

此處假設我們已經開始建立訂單Model了
Order order = new Order();
order.setUserId(xxx);
order.setItems(xxx);
order.setPrice(xxx);
...
...

上面程式碼是對業務需求的描述,在商城系統中是由對應一個建立訂單的語義化行為來表述的,但在此處轉為了對Model的賦值操作,而且這些賦值操作並沒有處理值的限定,那這就很可能產生一個錯誤的結果,例如我們將Price這個價值單位給予了一個負數值。

可能有的人會說setPrice是對類中變數的封裝,我們可以在這個函式中做賦值的校驗處理。但是社群給出的大多數實踐,還是在service層面對業務做校驗的比較多。

從另外一個層面來考慮,這樣的程式碼依賴於開發人員對業務的理解,但我們不能保證每個開發人員對業務的理解都是正確的,因此很容易出現開發人員不知道是否應該對某個欄位賦值,或賦錯值的情況。

這本質上是因為 對業務的表述在轉化為 <u>過程事務指令碼</u> 的這個過程中丟失了 ,我們在業務程式碼編寫的過程中,自然而然的將業務中帶有語義化的行為描述,轉化成了對Model中某些變數的賦值,這導致了語義的丟失。

過程事務指令碼與物件導向思想

正如前文所說,過程事務指令碼容易丟失語義,那麼有沒有什麼方法能解決這個問題呢?答案是有,就是物件導向思想。

傳統物件導向思想認為:

程式的本質是對現實的建模與抽象

讓我們帶入現代社群的常規做法來看看當下是否符合 物件導向 這個概念。

在傳統軟體開發領域中,面對業務時常規的做法是 根據產品經理給出的業務需求以及互動原型,劃分出系統功能模組並設計出各功能模組對應的資料庫表。在這個設計流程中我們主要考慮的是隱含在業務中的屬性,具體的業務互動流程和其中物件的行為則被分離到了MVC三層中的控制器中。

而在遊戲開發的領域,常規的做法是基於遊戲中被設計的主體物件進行建模,最終形成的是帶有屬性以及行為的 物件模型 ,一個十分生動的例子如下:

在這個例子中,我們準備設計一個仿照英雄聯盟的遊戲,其中針對英雄的需求描述如下:

  • 英雄有血量以及魔法值
  • 英雄的血量和魔法值會隨時間慢慢恢復
  • 英雄每釋放一次技能就會損失一定程度的魔法值,魔法值為0則不能釋放技能
  • 英雄可以使用普通攻擊來重創對手
  • 當血量下降為0時,英雄死亡

我們可以針對上面的需求來完成對於“英雄”這個物件的建模:

class Hero is
    // 英雄的血量
    property Blood: Float
    // 英雄的魔法值
    property Magic: Float
    
    // timer用於控制英雄血量以及魔法值的恢復
    var _timer: Timer
    
    /**
     * 英雄被攻擊事件
     */
    method UNDER_ATTACK(who, how) is
        // 計算新血量
    
    /**
     * 英雄死亡事件
     */
    method HERO_DIED() is
        // 呼叫解構函式,銷燬某個英雄物件
    
    /**
     * 構造一個新英雄
     */
    method constructor is
        // 初始化自動回覆timer
        
    /**
     * 英雄的攻擊方法
     */
    method attack(target) is
        // 釋出攻擊英雄的事件
        
    /**
     * 英雄釋放技能的方法
     */
    method release(skill) is
        // 產生技能釋放效果
        // 碰撞檢測
        // 觸發攻擊效果
        
    /**
     * 初始化自動回覆timer
     */
    private method initialTimer()

從上面程式碼我們不難發現,英雄 這個模型內部不止有屬性,還有動作行為以及事件,這種將物件的業務行為一併封裝進模型的做法明顯更為自然,後續業務的迭代與變遷顯然也更好維護。

那麼如果我們使用處理傳統軟體的設計辦法來設計上面那個遊戲,會怎麼樣呢?

首先我們需要遍歷整個地圖中的所有英雄,然後挨個處理它們的血量跟魔法值恢復的邏輯。在某個英雄產生攻擊狀態時,我們需要再次遍歷整個地圖中的所有英雄,挨個進行碰撞檢測,然後處理扣血以及死亡判定的邏輯。剩下的不用我多說我想你們也能想象的出來吧...

並且,當我們將動作行為使用過程事務指令碼構建以後,業務明顯變的複雜了,也產生了很多說不通,不好維護的點。

所以,當我們回過頭來反思,傳統軟體行業是否做到了 傳統物件導向 這一概念,我們也已經有答案了。

貧血模型與充血模型

說了這麼多,實際上導致設計向著過程事務指令碼或物件導向思想發展的根本原因只有一點,那就是模型

前面說過,程式是對現實世界的抽象與建模,計算機最初也是為了解決生活中的基本需求而被創造出來。傳統開發模式下,我們一直堅持著資料庫為主的原則,所有的編碼思路都圍繞既定的資料庫表進行實現,也因此我們將資料與處理邏輯進行分離,資料被控制在Model層內,邏輯則被分離到了Controller層中,這樣的Model我們稱其為 貧血模型 ,因為它只有屬性而沒有行為。

基於物件導向思想思考而得到的模型,其內部既包含屬性,又包含目標物件的行為和事件,因此也被稱為 充血模型 ,充血模型具有以下優點:

  • 獨立,可移植
  • 完整
  • 自包含

所以充血模型在研發時可以單獨進行單元測試,以此確定領域業務邏輯處理的正確性,同傳統的甩鍋原則一樣。

傳統的甩鍋原則:前端基於Mock資料完成頁面需求,在沒有跨域問題的前提下,如果後端接入之後產生了問題,鍋一定是後端的。

這也就是說,在單元測試能夠保證領域物件的充血模型沒有問題的前提下,如果最終介面實現有問題,問題一定出在除Model層以外的其他層面上,這很好的隔離了領域業務和技術邏輯的關注點。

現代軟體架構之父 Martin Fowler 認為貧血模型是一種反模式,因為軟體開發流程中的建模需要對應於特定領域的業務,而這種分離領域邏輯與資料表達的做法,有悖於自然衍生的設計法則。

對於持續演進,頻繁迭代的業務來說,充血模型是比較好的選擇,但它也有以下缺點:

  • 設計難度高
  • 需要設計人員對業務高度熟悉
  • 不穩定的充血模型會帶來其他層面程式碼的頻繁變動

相對的,如果業務需求比較簡單,顯然貧血模型是更好的選擇。

領域驅動設計

領域驅動設計【Domain Driven Design】(下文簡稱DDD),是:

  • 一套完整的模型驅動軟體設計工具,用於簡化大型軟體專案的複雜度。
  • 一種設計思路,可以應用在複雜業務的軟體設計中,加快交付進度。
  • 一組提煉出來的原則和模式,有助於提高團隊成員的軟體核心業務設計能力。

為什麼我們要學習DDD

有助於劃分微服務

DDD透過劃分領域,並將劃分後的領域限定在上下文中,以此來達到隔離關注點的目的。這裡的上下文,在DDD中就被稱為 限界上下文

就好比生物學中的細胞,細胞之所以能存在,是因為細胞膜定義了什麼在細胞內,什麼在細胞外,並且確認了什麼物質可以透過細胞膜。

子域內的每個領域模型都有它對應的限界上下文,領域內所有限界上下文(包含內部的子·領域模型)共同構成了整個領域的領域模型。我們將限界上下文對應微服務進行對映,就完成了整個微服務的劃分。

降低複雜系統迭代的難度

複雜系統之所以難以迭代,是因為傳統基於資料庫進行設計的方式無法限定子系統中的“變數”,這些變數在系統迭代的任何一個階段都可能成為領域崩塌的關鍵。

軟體存在的意義就是它能夠解決現實中存在的問題,DDD中一個主要的步驟就是對業務表述的問題進行梳理與劃分,將大的問題劃分成若干個小問題,然後逐一解決。

這樣的方式可以最大程度的限制子問題中的變數,從而達到降低迭代複雜度的目的。

提高研發團隊協作的效率

傳統設計思想跟DDD相比最大的差別在於:DDD重視業務語義,提倡針對業務建立統一的描述語言,系統的設計建立在團隊成員對業務的一致認知上,這有利於團隊的溝通和交流。

書籍&文章推薦

書籍

文章

相關文章