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

導讀:對於一個架構師來講,在軟件開發中如何下降系統複雜度是一個永恆的挑戰,不管是 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 的建模的思想,而其對於整個架構體系的思想沒法落地。html

我第一次接觸 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);
    }
}

咱們平常絕大部分代碼和模型其實都跟這個是相似的,乍一看貌似沒啥問題,但咱們再深刻一步,從如下四個維度去分析一下:接口的清晰度(可閱讀性)、數據驗證和錯誤處理、業務邏輯代碼的清晰度、和可測試性。

▍問題1 - 接口的清晰度

在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 原則和單一性原則,若是將來須要修改 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> 等,儘可能能把 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 步,就能讓你的代碼變得更加簡潔、優雅、健壯、安全。你還在等什麼?今天就去嘗試吧!



本文做者:殷浩

閱讀原文

本文爲雲棲社區原創內容,未經容許不得轉載。

相關文章
相關標籤/搜索