領域驅動設計入門與實踐[上]

語言: CN / TW / HK

編者按:

軟體工程師所做的事情就是把現實中的事情搬到計算機上,通過資訊化提高生產力。在這個過程中有一個點是不能被忽視的,那就是 [系統的內建質量]

設計良好的系統: 概念清晰,結構合理,即使程式碼庫龐大,依然可理解、可維護;設計糟糕的系統: “屎上雕花”。

其中,領域概念和領域模型的缺失是造成這種差異的罪魁禍首。

— 概念解讀 —


領域驅動設計 - DDD(Domain-Driven Design)是一種基於領域知識來解決複雜業務問題的軟體開發方法論,其本質是將業務上要做的一件大事,經過推演和抽象,拆分成多個內聚的領域。

它有以下三個重點:

  1. 跟領域專家(Domain Expert)密切合作來定義出Domain的範圍及解決方案 
  2. 切分領域出數個子領域,並專注在核心子領域 
  3. 透過一系列設計模式,將領域知識轉換成對應的程式模型(Model)

領域可大可小,對應著大小業務問題的邊界,對邊界的劃分與控制是領域驅動設計強調的核心思想。

— DDD帶來的改變 —


▶ 向Anemic Model 說 "No!"

跟大家介紹一個有名的反模式:貧血模型(Anemic Model)。此模式泛指那些只有 getter與 setter 的 model。這些 model 缺乏行為表述,導致使用者每次都要自己組合出想要的功能。

貧血模型使用起來像在教小孩子一樣,一個指令一個動作還很容易忘掉;具有行為表述能力的模型則像跟大人溝通一樣,一次行動就能完成許多指令。

舉個栗子:以資料為中心的方式是要求客戶程式碼必須知道如何正確地將一個待定項提交到衝刺中。此時,錯誤地修改sprintId或有另外一個屬性需要設值,都要求開發人員認真分析客戶程式碼來完成從客戶資料到BacklogItem屬性的對映。這樣的模型不是領域模型。


public class BacklogItem extends Entity {

    private SprintId sprintId;

    private BacklogItemStatusType status;

    ...

    public void setSprintId(SprintId sprintId) {

        this.sprintId = sprintId;

    }

    public void setStatus(BacklogItemStatusType status) {

        this.status = status;

    }

    ...

}

// 客戶端通過設定sprintId和status將一個BacklogItem提交到Sprint中

backlogItem.setSprintId(sprintId);

backlogItem.setStatus(BacklogItemStatusType.COMMITTED);

通過業務語言封裝程式行為

DDD注重將業務語言引入程式模型之中,對重點業務行為進行封裝。與其隨意封裝程式碼,將程式模型與業務邏輯繫結在一起的行為可以保證程式碼緊隨業務變化做出調整。在建模時,領域專家討論了以下幾個需求:

  • 允許將每一個待定項提交到衝刺中且只有在一個待定項位於釋出計劃(Release)中時才能進行提交

  • 如果一個待定項已提交到了另外一個衝刺中,先將其回收

  • 提交完成時,通知相關客戶方

客戶程式碼並不需要知道提交BacklogItem 的實現細節,因為實現程式碼的邏輯恰好能夠描述業務行為。

public class BacklogItem extends Entity {
   private SprintId sprintId;
   private BacklogItemStatusType status;
   ...
   public void commitTo(Sprint sprint) {
       if (!this.isScheduledForRelease()) {
           throw new IllegalStateException("Must be scheduled for release to commit to sprint.");
       }
       if (this.isComittedToSprint()) {
           if (!sprint.sprintId().equals(this.sprintId())) {
               this.uncommitFromSprint();
           }
       }
       this.elevateStatusWith(BacklogItemStatus.COMMITTED);
       this.setSprintId(sprint.sprintId());
       DomainEventPublisher.instance()
           .publish(new BacklogItemCommitted(
               this.tenantId(),
               this.backlogItemId(),
               this.sprintId()
           ));
   }
}
// 客戶端通過設定特定於領域的行為將BacklogItem提交到Sprint中
backlogItem.commitTo(sprint);

— DDD 詳解 —


舉例說明DDD:

假設我們現在在做一個簡單的資料統計系統,其運算邏輯是這樣的:地推員輸入客戶的姓名和手機號,系統根據客戶手機號的歸屬地和所屬運營商,將客戶群體分組,分配給相應的銷售組,由銷售組跟進後續的業務。

ublic class RegistrationServiceImpl implements RegistrationService {
   private final SalesRepRepository salesRepRepo;
   private final UserRepository userRepo;

   public User register(String name, String phone) throws ValidationException {
       // 引數校驗
       if (name == null "" name.length() == 0) {
           throw new ValidationException("name");
       }
       if (phone == null "" !isValidPhoneNumber(phone)) {
           throw new ValidationException("phone");
       }
       // 獲取手機號歸屬地編號和運營商編號,然後通過編號找到區域內的SalesRep
       String areaCode = getAreaCode(phone);
       String operatorCode = getOperatorCode(phone);
       SalesRep rep = salesRepRepo.findRep(areaCode, operatorCode);
       // 最後建立使用者,落盤,然後返回
       User user = new User();
       user.name = name;
       user.phone = phone;
       if (rep != null) {
           user.repId = rep.repId;
       }
       return userRepo.save(user);
   }

   private boolean isValidPhoneNumber(String phone) {
       String pattern = "^0[1-9]{2,3}-?\\d{8}$";
       return phone.matches(pattern);
   }
   private String getOperatorCode(String phone) {
       // TODO
   }
   private String getAreaCode(String phone) {
       // TODO
   }
}

程式碼如上,大部分人都是這麼寫的,看起來也沒什麼問題,對一個小工程或短期下線的系統來說,這樣寫可以稱得上是又快又好;但把其放在一起迭代頻繁的大工程內,還留有一些隱患:

隱患1:介面語義不明確

Register 方法的bug在於它支援一種型別、兩組引數(使用者名稱、手機號)。當用戶註冊系統的引數變更時,比如改用身份證註冊,Register方法就要被改造為

RegisterByPhone和RegisterByIdCard。

由於內部校驗只會保留引數型別不會保留引數名,因此變更引數意味著新的介面和再來一遍的校驗,這不是我們預期的目標。我們期望的是:語義介面足夠明確無歧義、可擴充套件性強且帶有一定的自檢性,這才是最優解。

介面語義修改目標:

語義明確無歧義、擴充套件性強、帶有一定的自檢性

隱患2:引數校驗邏輯複雜

如果存在多個類似的方法,每個方法都要在開頭校驗,一定會存在大量重複程式碼。一旦某個型別的引數校驗邏輯需要修改,那麼每個地方都要一一修改,這顯然不符合“開閉原則"。即使將其封裝進某個工具進行復用,還存留兩個bug:

1、在業務方法中把引數異常和業務邏輯異常混合起來,不太合理: 業務方法內還需要主動呼叫工具類來進行校驗,如果校驗失敗,需要丟擲異常;

2、隨著引數型別越來越多,工具類中的校驗邏輯會隨之不斷膨脹,後續維護起來是不小的工作量。

引數校驗修改目標:

提高校驗邏輯複用性

引數校驗異常與業務邏輯異常解耦

隱患3:核心業務邏輯清晰度不夠

經過改造後的程式碼,雖然多了些優雅但不“純粹”。

RegistrationService 是用於對使用者進行註冊的服務,它的職責應僅限定為「註冊」。而註冊最本質的行為就是「拿到使用者的資訊並存儲起來」。在這段程式碼中存在的兩個行為「獲取手機號的歸屬地編碼」、「獲取運營商編碼」顯然並不適用於「註冊」這個業務邏輯。

問題來了:那我們為什麼要在Register方法裡邊寫這些邏輯?為了適配findRep這個介面來對原始的引數進行處理拼接,就像拿膠水來進行縫縫補補的“膠水邏輯"?

如何改造這些膠水邏輯才算合理?

兩個思路:

1、改造 findRep 這個介面的入參

這在抽象上就是合理的,不必在register方法內進行膠水操作了

2、把「獲取手機號的歸屬地編碼」&「獲取運營商編碼」內聚到手機號這個型別中

這兩個行為都是獲取手機號相關的屬性,內聚在手機號這個型別中在抽象上也是合理的。

由此看來,採用內聚、搭建核心領域編輯的方法能使註冊方法邏輯最為清晰。

什麼邏輯應該歸屬於哪個業務域,這是對“領域"的理解,就像如何對微服務進行邊界限定一樣,不同的理解角度會產生不同的領域模型劃分。

需要說明一點:

很多同學對寫單元測試感到頭疼:寫的話,要做到高覆蓋很麻煩;不寫的話,不僅跑不過CI,心裡還有點慌......不怕!

通過對PhoneNumber邏輯的內聚、業務邏輯的簡化,童鞋們寫單元測試的效率能夠得到極大的提升。PhoneNumber 這型別的改動頻率比較小,一旦寫了完善的測試用例,複用程度會很高~這樣,後邊的業務邏輯儘管會變複雜,但單元測試邏輯的維護成本也不會提高~


領域驅動設計入門與實踐[上]

領域驅動設計不是某一種具體的流程方法,它是由核心概念、語義方法,經過實戰演練後被證明可以舉一反三,事半功倍的軟體開發方法論。經它驗證過的案例不勝列舉,後續我們將就一則新的案例討論其背後的語言邏輯和行為方法。— by LigaAI智慧研發協作平臺