阿里技術專家詳解 DDD 系列- Domain Primitive

nogos發表於2021-01-03

  導讀:對於一個架構師來說,在軟體開發中如何降低系統複雜度是一個永恆的挑戰,無論是 94 年 GoF 的 Design Patterns , 99 年的 Martin Fowler 的 Refactoring , 02 年的 P of EAA ,還是 03 年的 Enterprise Integration Patterns ,都是通過一系列的設計模式或範例來降低一些常見的複雜度。但是問題在於,這些書的理念是通過技術手段解決技術問題,但並沒有從根本上解決業務的問題。所以 03 年 Eric Evans 的 Domain Driven Design 一書,以及後續 Vaughn Vernon 的 Implementing DDD , Uncle Bob 的 Clean Architecture 等書,真正的從業務的角度出發,為全世界絕大部分做純業務的開發提供了一整套的架構思路。

前言

  由於 DDD 不是一套框架,而是一種架構思想,所以在程式碼層面缺乏了足夠的約束,導致 DDD 在實際應用中上手門檻很高,甚至可以說絕大部分人都對 DDD 的理解有所偏差。舉個例子, Martin Fowler 在他個人部落格裡描述的一個 Anti-pattern,Anemic Domain Model ①(貧血域模型)在實際應用當中層出不窮,而一些仍然火熱的 ORM 工具比如 Hibernate,Entity Framework 實際上助長了貧血模型的擴散。同樣的,傳統的基於資料庫技術以及 MVC 的四層應用架構(UI、Business、Data Access、Database),在一定程度上和 DDD 的一些概念混淆,導致絕大部分人在實際應用當中僅僅用到了 DDD 的建模的思想,而其對於整個架構體系的思想無法落地。
  我第一次接觸 DDD 應該是 2012 年,當時除了大型網際網路公司,基本上商業應用都還處於單機的時代,服務化的架構還侷限於單機 +LB 用 MVC 提供 Rest 介面供外部呼叫,或者用 SOAP 或 WebServices 做 RPC 呼叫,但其實更多侷限於對外部依賴的協議。讓我關注到 DDD 思想的是一個叫 Anti-Corruption Layer(防腐層)的概念,特別是其在解決外部依賴頻繁變更的情況下,如何將核心業務邏輯和外部依賴隔離的機制。到了 2014 年, SOA 開始大行其道,微服務的概念開始冒頭,而如何將一個 Monolith 應用合理的拆分為多個微服務成為了各大論壇的熱門話題,而 DDD 裡面的 Bounded Context(限界上下文)的思想為微服務拆分提供了一套合理的框架。而在今天,在一個所有的東西都能被稱之為“服務”的時代(XAAS), DDD 的思想讓我們能冷靜下來,去思考到底哪些東西可以被服務化拆分,哪些邏輯需要聚合,才能帶來最小的維護成本,而不是簡單的去追求開發效率
  所以今天,我開始這個關於 DDD 的一系列文章,希望能繼續在總結前人的基礎上發揚光大 DDD 的思想,但是通過一套我認為合理的程式碼結構、框架和約束,來降低 DDD 的實踐門檻,提升程式碼質量、可測試性、安全性、健壯性。
未來會覆蓋的內容包括:

最佳架構實踐:六邊形應用架構 / Clean 架構的核心思想和落地方案

  • 持續發現和交付:Event Storming > Context Map > Design Heuristics > Modelling
  • 降低架構腐敗速度:通過 Anti-Corruption Layer 整合第三方庫的模組化方案
  • 標準元件的規範和邊界:Entity, Aggregate, Repository, Domain Service, Application Service, Event, DTO Assembler 等
  • 基於 Use Case 重定義應用服務的邊界
  • 基於 DDD 的微服務化改造及顆粒度控制
  • CQRS 架構的改造和挑戰
  • 基於事件驅動的架構的挑戰
  • 等等

  今天先給大家帶來一篇最基礎,但極其有價值的Domain Primitive的概念。

Domain Primitive

  就好像在學任何語言時首先需要了解的是基礎資料型別一樣,在全面瞭解 DDD 之前,首先給大家介紹一個最基礎的概念: Domain Primitive(DP)。

Primitive 的定義是:

不從任何其他事物發展而來
初級的形成或生長的早期階段

  就好像 Integer、String 是所有程式語言的Primitive一樣,在 DDD 裡, DP 可以說是一切模型、方法、架構的基礎,而就像 Integer、String 一樣, DP 又是無所不在的。所以,第一講會對 DP 做一個全面的介紹和分析,但我們先不去講概念,而是從案例入手,看看為什麼 DP 是一個強大的概念。

案例

  我們先看一個簡單的例子,這個 case 的業務邏輯如下:

一個新應用在全國通過 地推業務員 做推廣,需要做一個使用者註冊系統,同時希望在使用者註冊後能夠通過使用者電話(先假設僅限座機)的地域(區號)對業務員發獎金。

  先不要去糾結這個根據使用者電話去發獎金的業務邏輯是否合理,也先不要去管使用者是否應該在註冊時和業務員做繫結,這裡我們看的主要還是如何更加合理的去實現這個邏輯。一個簡單的使用者和使用者註冊的程式碼實現如下:

public class User {
    Long userId;
    String name;
    String phone;
    String address;
    Long repId;
}

public class RegistrationServiceImpl implements RegistrationService {

    private SalesRepRepository salesRepRepo;
    private UserRepository userRepo;

    public User register(String name, String phone, String address) 
      throws ValidationException {
        // 校驗邏輯
        if (name == null || name.length() == 0) {
            throw new ValidationException("name");
        }
        if (phone == null || !isValidPhoneNumber(phone)) {
            throw new ValidationException("phone");
        }
        // 此處省略address的校驗邏輯

        // 取電話號裡的區號,然後通過區號找到區域內的SalesRep
        String areaCode = null;
        String[] areas = new String[]{"0571", "021", "010"};
        for (int i = 0; i < phone.length(); i++) {
            String prefix = phone.substring(0, i);
            if (Arrays.asList(areas).contains(prefix)) {
                areaCode = prefix;
                break;
            }
        }
        SalesRep rep = salesRepRepo.findRep(areaCode);

        // 最後建立使用者,落盤,然後返回
        User user = new User();
        user.name = name;
        user.phone = phone;
        user.address = address;
        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);
    }
}

  我們日常絕大部分程式碼和模型其實都跟這個是類似的,乍一看貌似沒啥問題,但我們再深入一步,從以下四個維度去分析一下:介面的清晰度(可閱讀性)、資料驗證和錯誤處理業務邏輯程式碼的清晰度、和可測試性

問題一 - 介面的清晰度

  在Java程式碼中,對於一個方法來說所有的引數名在編譯時丟失,留下的僅僅是一個引數型別的列表,所以我們重新看一下以上的介面定義,其實在執行時僅僅是:

User register(String, String, String);

  所以以下的程式碼是一段編譯器完全不會報錯的,很難通過看程式碼就能發現的 bug :

service.register("殷浩", "浙江省杭州市餘杭區文三西路969號", "0571-12345678");

  當然,在真實程式碼中執行時會報錯,但這種 bug 是在執行時被發現的,而不是在編譯時。普通的 Code Review 也很難發現這種問題,很有可能是程式碼上線後才會被暴露出來。這裡的思考是,有沒有辦法在編碼時就避免這種可能會出現的問題

  另外一種常見的,特別是在查詢服務中容易出現的例子如下:

User findByName(String name);
User findByPhone(String phone);
User findByNameAndPhone(String name, String phone);

  在這個場景下,由於入參都是 String 型別,不得不在方法名上面加上 ByXXX 來區分,而 findByNameAndPhone 同樣也會陷入前面的入參順序錯誤的問題,而且和前面的入參不同,這裡引數順序如果輸錯了,方法不會報錯只會返回 null,而這種 bug 更加難被發現。這裡的思考是,有沒有辦法讓方法入參一目瞭然,避免入參錯誤導致的 bug

問題2 - 資料驗證和錯誤處理

  在前面這段資料校驗程式碼:

if (phone == null || !isValidPhoneNumber(phone)) {
    throw new ValidationException("phone");
}

  在日常編碼中經常會出現,一般來說這種程式碼需要出現在方法的最前端,確保能夠 fail-fast 。但是假設你有多個類似的介面和類似的入參,在每個方法裡這段邏輯會被重複。而更嚴重的是如果未來我們要擴充電話號去包含手機時(業務邏輯變化),很可能需要加入以下程式碼:

if (phone == null || !isValidPhoneNumber(phone) || !isValidCellNumber(phone)) {
    throw new ValidationException("phone");
}

  如果你有很多個地方用到了 phone 這個入參,但是有個地方忘記修改了會造成 bug 。這是一個 DRY 原則被違背時經常會發生的問題。

  如果有個新的需求,需要把入參錯誤的原因返回,那麼這段程式碼就變得更加複雜:

if (phone == null) {
    throw new ValidationException("phone不能為空");
} else if (!isValidPhoneNumber(phone)) {
    throw new ValidationException("phone格式錯誤");
}

  可以想像得到,程式碼裡充斥著大量的類似程式碼塊時,維護成本要有多高。

  最後,在這個業務方法裡,會(隱性或顯性的)拋 ValidationException,所以需要外部呼叫方去try/catch,而業務邏輯異常和資料校驗異常被混在了一起,是否是合理的?

  在傳統Java架構裡有幾個辦法能夠去解決一部分問題,常見的如BeanValidation註解或ValidationUtils類,比如:

// Use Bean Validation
User registerWithBeanValidation(
  @NotNull @NotBlank String name,
  @NotNull @Pattern(regexp = "^0?[1-9]{2,3}-?\\d{8}$") String phone,
  @NotNull String address
);

// Use ValidationUtils:
public User registerWithUtils(String name, String phone, String address) {
    ValidationUtils.validateName(name); // throws ValidationException
    ValidationUtils.validatePhone(phone);
    ValidationUtils.validateAddress(address);
    ...
}

但這幾個傳統的方法同樣有問題,

  • BeanValidation:

通常只能解決簡單的校驗邏輯,複雜的校驗邏輯一樣要寫程式碼實現定製校驗器

在新增了新校驗邏輯時,同樣會出現在某些地方忘記新增一個註解的情況,DRY原則還是會被違背

  • ValidationUtils類:

大量的校驗邏輯集中在一個類裡之後,違背了Single Responsibility單一性原則,導致程式碼混亂和不可維護(這裡不用的業務域定義單獨的校驗工具類可以保證單一性原則)

業務異常和校驗異常還是會混雜

  所以,有沒有一種方法,能夠一勞永逸的解決所有校驗的問題以及降低後續的維護成本和異常處理成本呢

問題3 - 業務程式碼的清晰度

  在這段程式碼裡:

String areaCode = null;
String[] areas = new String[]{"0571", "021", "010"};
for (int i = 0; i < phone.length(); i++) {
    String prefix = phone.substring(0, i);
    if (Arrays.asList(areas).contains(prefix)) {
        areaCode = prefix;
        break;
    }
}
SalesRep rep = salesRepRepo.findRep(areaCode);

  實際上出現了另外一種常見的情況,那就是從一些入參裡抽取一部分資料,然後呼叫一個外部依賴獲取更多的資料,然後通常從新的資料中再抽取部分資料用作其他的作用。這種程式碼通常被稱作“膠水程式碼”,其本質是由於外部依賴的服務的入參並不符合我們原始的入參導致的。比如,如果SalesRepRepository包含一個findRepByPhone的方法,則上面大部分的程式碼都不必要了。

  所以,一個常見的辦法是將這段程式碼抽離出來,變成獨立的一個或多個方法:

private static String findAreaCode(String phone) {
    for (int i = 0; i < phone.length(); i++) {
        String prefix = phone.substring(0, i);
        if (isAreaCode(prefix)) {
            return prefix;
        }
    }
    return null;
}

private static boolean isAreaCode(String prefix) {
    String[] areas = new String[]{"0571", "021"};
    return Arrays.asList(areas).contains(prefix);
}

  然後原始程式碼變為:

String areaCode = findAreaCode(phone);
SalesRep rep = salesRepRepo.findRep(areaCode);

  而為了複用以上的方法,可能會抽離出一個靜態工具類 PhoneUtils 。但是這裡要思考的是,靜態工具類是否是最好的實現方式呢?當你的專案裡充斥著大量的靜態工具類,業務程式碼散在多個檔案當中時,你是否還能找到核心的業務邏輯呢?

問題4 - 可測試性

  為了保證程式碼質量,每個方法裡的每個入參每個可能出現的條件都要有 TC 覆蓋(假設我們先不去測試內部業務邏輯),所以在我們這個方法裡需要以下的 TC :
在這裡插入圖片描述
  假如一個方法有 N 個引數,每個引數有 M 個校驗邏輯,至少要有 N * M 個 TC 。

  如果這時候在該方法中加入一個新的入參欄位 fax ,即使 fax 和 phone 的校驗邏輯完全一致,為了保證 TC 覆蓋率,也一樣需要 M 個新的 TC 。

  而假設有 P 個方法中都用到了 phone 這個欄位,這 P 個方法都需要對該欄位進行測試,也就是說整體需要:

P * N * M

  個測試用例才能完全覆蓋所有資料驗證的問題,在日常專案中,這個測試的成本非常之高,導致大量的程式碼沒被覆蓋到。而沒被測試覆蓋到的程式碼才是最有可能出現問題的地方。

  在這個情況下,降低測試成本 == 提升程式碼質量,如何能夠降低測試的成本呢

解決方案

  我們回頭先重新看一下原始的 use case,並且標註其中可能重要的概念:

一個新應用在全國通過 地推業務員 做推廣,需要做一個使用者的註冊系統,在使用者註冊後能夠通過使用者電話號的區號對業務員發獎金。

  在分析了 use case 後,發現其中地推業務員、使用者本身自帶 ID 屬性,屬於 Entity(實體),而註冊系統屬於 Application Service(應用服務),這幾個概念已經有存在。但是發現電話號這個概念卻完全被隱藏到了程式碼之中。我們可以問一下自己,取電話號的區號的邏輯是否屬於使用者(使用者的區號?)?是否屬於註冊服務(註冊的區號?)?如果都不是很貼切,那就說明這個邏輯應該屬於一個獨立的概念。所以這裡引入我們第一個原則:

Make Implicit Concepts Explicit
將隱性的概念顯性化

  在這裡,我們可以看到,原來電話號僅僅是使用者的一個引數,屬於隱形概念,但實際上電話號的區號才是真正的業務邏輯,而我們需要將電話號的概念顯性化,通過寫一個Value Object:

public class PhoneNumber {
  
    private final String number;
    public String getNumber() {
        return number;
    }

    public PhoneNumber(String number) {
        if (number == null) {
            throw new ValidationException("number不能為空");
        } else if (isValid(number)) {
            throw new ValidationException("number格式錯誤");
        }
        this.number = number;
    }

    public String getAreaCode() {
        for (int i = 0; i < number.length(); i++) {
            String prefix = number.substring(0, i);
            if (isAreaCode(prefix)) {
                return prefix;
            }
        }
        return null;
    }

    private static boolean isAreaCode(String prefix) {
        String[] areas = new String[]{"0571", "021", "010"};
        return Arrays.asList(areas).contains(prefix);
    }

    public static boolean isValid(String number) {
        String pattern = "^0?[1-9]{2,3}-?\\d{8}$";
        return number.matches(pattern);
    }

}

  這裡面有幾個很重要的元素:

  • 通過 private final String number 確保 PhoneNumber 是一個(Immutable)Value Object。(一般來說 VO 都是 Immutable 的,這裡只是重點強調一下)

  • 校驗邏輯都放在了 constructor 裡面,確保只要 PhoneNumber 類被建立出來後,一定是校驗通過的

  • 之前的 findAreaCode 方法變成了 PhoneNumber 類裡的 getAreaCode ,突出了 areaCode 是 PhoneNumber 的一個計算屬性

  這樣做完之後,我們發現把 PhoneNumber 顯性化之後,其實是生成了一個 Type(資料型別)和一個 Class(類):

Type 指我們在今後的程式碼裡可以通過 PhoneNumber 去顯性的標識電話號這個概念

Class 指我們可以把所有跟電話號相關的邏輯完整的聚集到一個檔案裡

  這兩個概念加起來,構造成了本文標題的 Domain Primitive(DP)。
  我們看一下全面使用了 DP 之後效果:

public class User {
    UserId userId;
    Name name;
    PhoneNumber phone;
    Address address;
    RepId repId;
}

public User register(
  @NotNull Name name,
  @NotNull PhoneNumber phone,
  @NotNull Address address
) {
    // 找到區域內的SalesRep
    SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());

    // 最後建立使用者,落盤,然後返回,這部分程式碼實際上也能用Builder解決
    User user = new User();
    user.name = name;
    user.phone = phone;
    user.address = address;
    if (rep != null) {
        user.repId = rep.repId;
    }

    return userRepo.saveUser(user);
}

  我們可以看到在使用了 DP 之後,所有的資料驗證邏輯和非業務流程的邏輯都消失了,剩下都是核心業務邏輯,可以一目瞭然。我們重新用上面的四個維度評估一下:

評估1 - 介面的清晰度

  重構後的方法簽名變成了很清晰的:

public User register(Name, PhoneNumber, Address)

  而之前容易出現的bug,如果按照現在的寫法

service.register(new Name("殷浩"), 
	new Address("浙江省杭州市餘杭區文三西路969號"), 
	new PhoneNumber("0571-12345678"));

讓介面 API 變得很乾淨,易擴充。

評估2 - 資料驗證和錯誤處理

public User register(
  @NotNull Name name,
  @NotNull PhoneNumber phone,
  @NotNull Address address
) // no throws

  如前文程式碼展示的,重構後的方法裡,完全沒有了任何資料驗證的邏輯,也不會拋 ValidationException 。原因是因為 DP 的特性,只要是能夠帶到入參裡的一定是正確的或 null(Bean Validation 或 lombok 的註解能解決 null 的問題)。所以我們把資料驗證的工作量前置到了呼叫方,而呼叫方本來就是應該提供合法資料的,所以更加合適。

  再展開來看,使用DP的另一個好處就是程式碼遵循了 DRY(Don’t Repeat Yourself ) 原則和單一性原則,如果未來需要修改 PhoneNumber 的校驗邏輯,只需要在一個檔案裡修改即可,所有使用到了 PhoneNumber 的地方都會生效。

評估3 - 業務程式碼的清晰度

SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());
User user = xxx;
return userRepo.save(user);

  除了在業務方法裡不需要校驗資料之外,原來的一段膠水程式碼 findAreaCode 被改為了 PhoneNumber 類的一個計算屬性 getAreaCode ,讓程式碼清晰度大大提升。而且膠水程式碼通常都不可複用,但是使用了 DP 後,變成了可複用、可測試的程式碼。我們能看到,在刨除了資料驗證程式碼、膠水程式碼之後,剩下的都是核心業務邏輯。( Entity 相關的重構在後面文章會談到,這次先忽略)

評估4 - 可測試性

在這裡插入圖片描述
  當我們將 PhoneNumber 抽取出來之後,在來看測試的 TC :

首先 PhoneNumber 本身還是需要 M 個測試用例,但是由於我們只需要測試單一物件,每個用例的程式碼量會大大降低,維護成本降低。

每個方法裡的每個引數,現在只需要覆蓋為 null 的情況就可以了,其他的 case 不可能發生(因為只要不是 null 就一定是合法的)

  所以,單個方法的 TC 從原來的 N * M 變成了今天的 N + M 。同樣的,多個方法的 TC 數量變成了

N + M + P

  這個數量一般來說要遠低於原來的數量 N* M * P ,讓測試成本極大的降低。

評估結論

在這裡插入圖片描述

進階使用

  在上文我介紹了 DP 的第一個原則:將隱性的概念顯性化。在這裡我將介紹 DP 的另外兩個原則,用一個新的案例。

案例1 - 轉賬

  假設現在要實現一個功能,讓A使用者可以支付 x 元給使用者 B ,可能的實現如下:

public void pay(BigDecimal money, Long recipientId) {
    BankService.transfer(money, "CNY", recipientId);
}

  如果這個是境內轉賬,並且境內的貨幣永遠不變,該方法貌似沒啥問題,但如果有一天貨幣變更了(比如歐元區曾經出現的問題),或者我們需要做跨境轉賬,該方法是明顯的 bug ,因為 money 對應的貨幣不一定是 CNY

  在這個 case 裡,當我們說“支付 x 元”時,除了 x 本身的數字之外,實際上是有一個隱含的概念那就是貨幣“元”。但是在原始的入參裡,之所以只用了 BigDecimal 的原因是我們認為 CNY 貨幣是預設的,是一個隱含的條件,但是在我們寫程式碼時,需要把所有隱性的條件顯性化,而這些條件整體組成當前的上下文。所以 DP 的第二個原則是:

Make Implicit Context Explicit
將 隱性的 上下文 顯性化

  所以當我們做這個支付功能時,實際上需要的一個入參是支付金額 + 支付貨幣。我們可以把這兩個概念組合成為一個獨立的完整概念:Money

@Value
public class Money {
    private BigDecimal amount;
    private Currency currency;
    public Money(BigDecimal amount, Currency currency) {
        this.amount = amount;
        this.currency = currency;
    }
}

  而原有的程式碼則變為:

public void pay(Money money, Long recipientId) {
    BankService.transfer(money, recipientId);
}

  通過將預設貨幣這個隱性的上下文概念顯性化,並且和金額合併為 Money ,我們可以避免很多當前看不出來,但未來可能會暴雷的bug。

案例2 - 跨境轉賬

  前面的案例升級一下,假設使用者可能要做跨境轉賬從 CNY 到 USD ,並且貨幣匯率隨時在波動:

public void pay(Money money, Currency targetCurrency, Long recipientId) {
    if (money.getCurrency().equals(targetCurrency)) {
        BankService.transfer(money, recipientId);
    } else {
        BigDecimal rate = ExchangeService.getRate(money.getCurrency(), targetCurrency);
        BigDecimal targetAmount = money.getAmount().multiply(new BigDecimal(rate));
        Money targetMoney = new Money(targetAmount, targetCurrency);
        BankService.transfer(targetMoney, recipientId);
    }
}

  在這個case裡,由於 targetCurrency 不一定和 money 的 Curreny 一致,需要呼叫一個服務去取匯率,然後做計算。最後用計算後的結果做轉賬。

  這個case最大的問題在於,金額的計算被包含在了支付的服務中,涉及到的物件也有2個 Currency ,2 個 Money ,1 個 BigDecimal ,總共 5 個物件。這種涉及到多個物件的業務邏輯,需要用 DP 包裝掉,所以這裡引出 DP 的第三個原則:

Encapsulate Multi-Object Behavior
封裝 多物件 行為

  在這個 case 裡,可以將轉換匯率的功能,封裝到一個叫做 ExchangeRate 的 DP 裡:

@Value
public class ExchangeRate {
    private BigDecimal rate;
    private Currency from;
    private Currency to;

    public ExchangeRate(BigDecimal rate, Currency from, Currency to) {
        this.rate = rate;
        this.from = from;
        this.to = to;
    }

    public Money exchange(Money fromMoney) {
        notNull(fromMoney);
        isTrue(this.from.equals(fromMoney.getCurrency()));
        BigDecimal targetAmount = fromMoney.getAmount().multiply(rate);
        return new Money(targetAmount, to);
    }
}

  ExchangeRate 匯率物件,通過封裝金額計算邏輯以及各種校驗邏輯,讓原始程式碼變得極其簡單:

public void pay(Money money, Currency targetCurrency, Long recipientId) {
    ExchangeRate rate = ExchangeService.getRate(money.getCurrency(), targetCurrency);
    Money targetMoney = rate.exchange(money);
    BankService.transfer(targetMoney, recipientId);
}

討論和總結

Domain Primitive 的定義

  讓我們重新來定義一下 Domain Primitive :Domain Primitive 是一個在特定領域裡擁有精準定義的可自我驗證的擁有行為Value Object

DP是一個傳統意義上的Value Object,擁有Immutable的特性

DP是一個完整的概念整體,擁有精準定義

DP使用業務域中的原生語言

DP可以是業務域的最小組成部分、也可以構建複雜組合

  注:Domain Primitive的概念和命名來自於Dan Bergh Johnsson & Daniel Deogun的書 Secure by Design。

使用 Domain Primitive 的三原則

讓隱性的概念顯性化

讓隱性的上下文顯性化

封裝多物件行為

Domain Primitive 和 DDD 裡 Value Object 的區別

  在 DDD 中, Value Object 這個概念其實已經存在:

在 Evans 的 DDD 藍皮書中,Value Object 更多的是一個非 Entity 的值物件

在Vernon的IDDD紅皮書中,作者更多的關注了Value Object的Immutability、Equals方法、Factory方法等

  Domain Primitive 是 Value Object 的進階版,在原始 VO 的基礎上要求每個 DP 擁有概念的整體,而不僅僅是值物件。在 VO 的 Immutable 基礎上增加了 Validity 和行為。當然同樣的要求無副作用(side-effect free)。

Domain Primitive 和 Data Transfer Object (DTO) 的區別

  在日常開發中經常會碰到的另一個資料結構是 DTO ,比如方法的入參和出參。DP 和 DTO 的區別如下:

在這裡插入圖片描述

什麼情況下應該用 Domain Primitive

  常見的 DP 的使用場景包括:

有格式限制的 String:比如Name,PhoneNumber,OrderNumber,ZipCode,Address等

有限制的Integer:比如OrderId(>0),Percentage(0-100%),Quantity(>=0)等

可列舉的 int :比如 Status(一般不用Enum因為反序列化問題)

Double 或 BigDecimal:一般用到的 Double 或 BigDecimal 都是有業務含義的,比如
Temperature、Money、Amount、ExchangeRate、Rating 等

複雜的資料結構:比如 Map<String, List> 等,儘量能把 Map 的所有操作包裝掉,僅暴露必要行為

實戰 - 老應用重構的流程

  在新應用中使用 DP 是比較簡單的,但在老應用中使用 DP 是可以遵循以下流程按部就班的升級。在此用本文的第一個 case 為例。

第一步 - 建立 Domain Primitive,收集所有 DP 行為

  在前文中,我們發現取電話號的區號這個是一個可以獨立出來的、可以放入 PhoneNumber 這個 Class 的邏輯。類似的,在真實的專案中,以前散落在各個服務或工具類裡面的程式碼,可以都抽出來放在 DP 裡,成為 DP 自己的行為或屬性。這裡面的原則是:所有抽離出來的方法要做到無狀態,比如原來是 static 的方法。如果原來的方法有狀態變更,需要將改變狀態的部分和不改狀態的部分分離,然後將無狀態的部分融入 DP 。因為 DP 本身不能帶狀態,所以一切需要改變狀態的程式碼都不屬於 DP 的範疇。

(程式碼參考 PhoneNumber 的程式碼,這裡不再重複)

第二步 - 替換資料校驗和無狀態邏輯

  為了保障現有方法的相容性,在第二步不會去修改介面的簽名,而是通過程式碼替換原有的校驗邏輯和根 DP 相關的業務邏輯。比如:

public User register(String name, String phone, String address)
        throws ValidationException {
    if (name == null || name.length() == 0) {
        throw new ValidationException("name");
    }
    if (phone == null || !isValidPhoneNumber(phone)) {
        throw new ValidationException("phone");
    }
    
    String areaCode = null;
    String[] areas = new String[]{"0571", "021", "010"};
    for (int i = 0; i < phone.length(); i++) {
        String prefix = phone.substring(0, i);
        if (Arrays.asList(areas).contains(prefix)) {
            areaCode = prefix;
            break;
        }
    }
    SalesRep rep = salesRepRepo.findRep(areaCode);
    // 其他程式碼...
}

  通過 DP 替換程式碼後:

public User register(String name, String phone, String address)
        throws ValidationException {
    
    Name _name = new Name(name);
    PhoneNumber _phone = new PhoneNumber(phone);
    Address _address = new Address(address);
    
    SalesRep rep = salesRepRepo.findRep(_phone.getAreaCode());
    // 其他程式碼...
}

  通過 new PhoneNumber(phone) 這種程式碼,替代了原有的校驗程式碼。

  通過 _phone.getAreaCode() 替換了原有的無狀態的業務邏輯。

第三步 - 建立新介面

   建立新介面,將DP的程式碼提升到介面引數層:

public User register(Name name, PhoneNumber phone, Address address) {
    SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());
}

第四步 - 修改外部呼叫

   外部呼叫方需要修改呼叫鏈路,比如:

service.register("殷浩", "0571-12345678", "浙江省杭州市餘杭區文三西路969號");

   改為:

service.register(new Name("殷浩"), new PhoneNumber("0571-12345678"), 
	new Address("浙江省杭州市餘杭區文三西路969號"));

   通過以上 4 步,就能讓你的程式碼變得更加簡潔、優雅、健壯、安全。你還在等什麼?今天就去嘗試吧!

相關文章