DDD系列-Domain Primitive

前言

?? 在全面了解DDD之前,首先給大家介紹一個(gè)最基礎(chǔ)的概念: Domain Primitive(DP)

Primitive的定義是:
? 不從任何其他事物發(fā)展而來(lái)
? 初級(jí)的形成或生長(zhǎng)的早期階段

就好像Integer瀑构、String是所有編程語(yǔ)言的Primitive一樣巴刻,在DDD里耗拓,DP可以說(shuō)是一切模型瑞驱、方法捐晶、架構(gòu)的基礎(chǔ)冯键,而就像Integer惹盼、String一樣,DP又是無(wú)所不在的惫确。所以手报,第一講會(huì)對(duì)DP做一個(gè)全面的介紹和分析蚯舱,但我們先不去講概念,而是從案例入手掩蛤,看看為什么DP是一個(gè)強(qiáng)大的概念枉昏。

1. 案例分析

我們先看一個(gè)簡(jiǎn)單的例子,這個(gè)case的業(yè)務(wù)邏輯如下:

一個(gè)新應(yīng)用在全國(guó)通過(guò) 地推業(yè)務(wù)員 做推廣揍鸟,需要做一個(gè)用戶注冊(cè)系統(tǒng)兄裂,同時(shí)>希望在用戶注冊(cè)后能夠通過(guò)用戶電話(先假設(shè)僅限座機(jī))的地域(區(qū)號(hào))對(duì)業(yè)>務(wù)員發(fā)獎(jiǎng)金。

先不要去糾結(jié)這個(gè)根據(jù)用戶電話去發(fā)獎(jiǎng)金的業(yè)務(wù)邏輯是否合理蜈亩,也先不要去管用戶是否應(yīng)該在注冊(cè)時(shí)和業(yè)務(wù)員做綁定懦窘,這里我們看的主要還是如何更加合理的去實(shí)現(xiàn)這個(gè)邏輯。一個(gè)簡(jiǎn)單的用戶和用戶注冊(cè)的代碼實(shí)現(xiàn)如下:

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 {
        // 校驗(yàn)邏輯
        if (name == null || name.length() == 0) {
            throw new ValidationException("name");
        }
        if (phone == null || !isValidPhoneNumber(phone)) {
            throw new ValidationException("phone");
        }
        // 此處省略address的校驗(yàn)邏輯

        // 取電話號(hào)里的區(qū)號(hào)稚配,然后通過(guò)區(qū)號(hào)找到區(qū)域內(nèi)的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);

        // 最后創(chuàng)建用戶畅涂,落盤(pán),然后返回
        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);
    }
}

我們?nèi)粘=^大部分代碼和模型其實(shí)都跟這個(gè)是類似的道川,乍一看貌似沒(méi)啥問(wèn)題午衰,但我們?cè)偕钊胍徊剑瑥囊韵滤膫€(gè)維度去分析一下:接口的清晰度(可閱讀性)冒萄、數(shù)據(jù)驗(yàn)證和錯(cuò)誤處理臊岸、業(yè)務(wù)邏輯代碼的清晰度、和可測(cè)試性尊流。

問(wèn)題1 - 接口的清晰度

在Java代碼中帅戒,對(duì)于一個(gè)方法來(lái)說(shuō)所有的參數(shù)名在編譯時(shí)丟失,留下的僅僅是一個(gè)參數(shù)類型的列表崖技,所以我們重新看一下以上的接口定義逻住,其實(shí)在運(yùn)行時(shí)僅僅是:

User register(String, String, String);

所以以下的代碼是一段編譯器完全不會(huì)報(bào)錯(cuò)的,很難通過(guò)看代碼就能發(fā)現(xiàn)的bug:

service.register("殷浩", "浙江省杭州市余杭區(qū)文三西路969號(hào)", "0571-12345678");

當(dāng)然迎献,在真實(shí)代碼中運(yùn)行時(shí)會(huì)報(bào)錯(cuò)淳地,但這種bug是在運(yùn)行時(shí)被發(fā)現(xiàn)的穗椅,而不是在編譯時(shí)艺沼。普通的Code Review也很難發(fā)現(xiàn)這種問(wèn)題昨登,很有可能是代碼上線后才會(huì)被暴露出來(lái)。這里的思考是冀瓦,有沒(méi)有辦法在編碼時(shí)就避免這種可能會(huì)出現(xiàn)的問(wèn)題伴奥?
另外一種常見(jiàn)的,特別是在查詢服務(wù)中容易出現(xiàn)的例子如下:

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

在這個(gè)場(chǎng)景下翼闽,由于入?yún)⒍际荢tring類型拾徙,不得不在方法名上面加上ByXXX來(lái)區(qū)分,而findByNameAndPhone同樣也會(huì)陷入前面的入?yún)㈨樞蝈e(cuò)誤的問(wèn)題肄程,而且和前面的入?yún)⒉煌嗪穑@里參數(shù)順序如果輸錯(cuò)了,方法不會(huì)報(bào)錯(cuò)只會(huì)返回null蓝厌,而這種bug更加難被發(fā)現(xiàn)玄叠。這里的思考是,有沒(méi)有辦法讓方法入?yún)⒁荒苛巳煌靥幔苊馊雲(yún)㈠e(cuò)誤導(dǎo)致的bug读恃?

問(wèn)題2 - 數(shù)據(jù)驗(yàn)證和錯(cuò)誤處理

在前面這段數(shù)據(jù)校驗(yàn)代碼:

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

在日常編碼中經(jīng)常會(huì)出現(xiàn),一般來(lái)說(shuō)這種代碼需要出現(xiàn)在方法的最前端代态,確保能夠fail-fast寺惫。但是假設(shè)你有多個(gè)類似的接口和類似的入?yún)ⅲ诿總€(gè)方法里這段邏輯會(huì)被重復(fù)蹦疑。而更嚴(yán)重的是如果未來(lái)我們要拓展電話號(hào)去包含手機(jī)時(shí)西雀,很可能需要加入以下代碼:

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

如果你有很多個(gè)地方用到了phone這個(gè)入?yún)ⅲ怯袀€(gè)地方忘記修改了歉摧,會(huì)造成bug艇肴。這是一個(gè)DRY原則被違背時(shí)經(jīng)常會(huì)發(fā)生的問(wèn)題。
如果有個(gè)新的需求叁温,需要把入?yún)㈠e(cuò)誤的原因返回再悼,那么這段代碼就變得更加復(fù)雜:

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

可以想像得到,代碼里充斥著大量的類似代碼塊時(shí)膝但,維護(hù)成本要有多高冲九。
最后,在這個(gè)業(yè)務(wù)方法里跟束,會(huì)(隱性或顯性的)拋ValidationException莺奸,所以需要外部調(diào)用方去try/catch,而業(yè)務(wù)邏輯異常和數(shù)據(jù)校驗(yàn)異常被混在了一起泳炉,是否是合理的憾筏?

在傳統(tǒng)Java架構(gòu)里有幾個(gè)辦法能夠去解決一部分問(wèn)題,常見(jiàn)的如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);
    ...
}

但這幾個(gè)傳統(tǒng)的方法同樣有問(wèn)題氧腰,
BeanValidation:

  • 通常只能解決簡(jiǎn)單的校驗(yàn)邏輯,復(fù)雜的校驗(yàn)邏輯一樣要寫(xiě)代碼實(shí)現(xiàn)定制校驗(yàn)器
  • 在添加了新校驗(yàn)邏輯時(shí)刨肃,同樣會(huì)出現(xiàn)在某些地方忘記添加一個(gè)注解的情況古拴,DRY原則還是會(huì)被違背

ValidationUtils類:

  • 當(dāng)大量的校驗(yàn)邏輯集中在一個(gè)類里之后,違背了Single Responsibility單一性原則真友,導(dǎo)致代碼混亂和不可維護(hù)
  • 業(yè)務(wù)異常和校驗(yàn)異常還是會(huì)混雜

??所以黄痪,有沒(méi)有一種方法,能夠一勞永逸的解決所有校驗(yàn)的問(wèn)題以及降低后續(xù)的維護(hù)成本和異常處理成本呢盔然?

問(wèn)題3 - 業(yè)務(wù)代碼的清晰度

在這段代碼里:

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);

實(shí)際上出現(xiàn)了另外一種常見(jiàn)的情況桅打,那就是從一些入?yún)⒗锍槿∫徊糠謹(jǐn)?shù)據(jù)是嗜,然后調(diào)用一個(gè)外部依賴獲取更多的數(shù)據(jù),然后通常從新的數(shù)據(jù)中再抽取部分?jǐn)?shù)據(jù)用作其他的作用挺尾。這種代碼通常被稱作“膠水代碼”鹅搪,其本質(zhì)是由于外部依賴的服務(wù)的入?yún)⒉⒉环衔覀冊(cè)嫉娜雲(yún)?dǎo)致的。比如遭铺,如果SalesRepRepository包含一個(gè)findRepByPhone的方法丽柿,則上面大部分的代碼都不必要了。
所以魂挂,一個(gè)常見(jiàn)的辦法是將這段代碼抽離出來(lái)甫题,變成獨(dú)立的一個(gè)或多個(gè)方法:

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);
}

然后原始代碼變?yōu)椋?/p>

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

而為了復(fù)用以上的方法,可能會(huì)抽離出一個(gè)靜態(tài)工具類PhoneUtils 涂召。但是這里要思考的是坠非,靜態(tài)工具類是否是最好的實(shí)現(xiàn)方式呢?當(dāng)你的項(xiàng)目里充斥著大量的靜態(tài)工具類果正,業(yè)務(wù)代碼散在多個(gè)文件當(dāng)中時(shí)麻顶,你是否還能找到核心的業(yè)務(wù)邏輯呢?

問(wèn)題4 - 可測(cè)試性

為了保證代碼質(zhì)量舱卡,每個(gè)方法里的每個(gè)入?yún)⒌拿總€(gè)可能出現(xiàn)的條件都要有TC覆蓋(假設(shè)我們先不去測(cè)試內(nèi)部業(yè)務(wù)邏輯)辅肾,所以在我們這個(gè)方法里需要以下的TC:

條件入?yún)?/th> phone name address
入?yún)閚ull
入?yún)榭?/td>
入?yún)⒉环弦螅赡芏鄠€(gè))

? ? ?
假如一個(gè)方法有N個(gè)參數(shù),每個(gè)參數(shù)有M個(gè)校驗(yàn)邏輯轮锥,至少要有N * M 個(gè)TC 矫钓。
如果這時(shí)候在該方法中加入一個(gè)新的入?yún)⒆侄蝔ax,即使fax和phone的校驗(yàn)邏輯完全一致舍杜,為了保證TC覆蓋率新娜,也一樣需要M個(gè)新的TC。
而假設(shè)有P個(gè)方法中都用到了phone這個(gè)字段既绩,這P個(gè)方法都需要對(duì)該字段進(jìn)行測(cè)試概龄,也就是說(shuō)整體需要:
P*M*N
個(gè)測(cè)試用例才能完全覆蓋所有數(shù)據(jù)驗(yàn)證的問(wèn)題,在日常項(xiàng)目中饲握,這個(gè)測(cè)試的成本非常之高私杜,導(dǎo)致大量的代碼沒(méi)被覆蓋到。而沒(méi)被測(cè)試覆蓋到的代碼才是最有可能出現(xiàn)問(wèn)題的地方救欧。在這個(gè)情況下衰粹,降低測(cè)試成本 == 提升代碼質(zhì)量,如何能夠降低測(cè)試的成本呢笆怠?

2. 解決方案

我們回頭先重新看一下原始的use case铝耻,并且標(biāo)注其中可能重要的概念:

一個(gè)新應(yīng)用在全國(guó)通過(guò) 地推業(yè)務(wù)員 做推廣,需要做一個(gè) 用戶注冊(cè)系統(tǒng)蹬刷,在用戶注冊(cè)后能夠通過(guò)用戶 電話號(hào)的區(qū)號(hào) 對(duì)業(yè)務(wù)員發(fā)獎(jiǎng)金瓢捉。

在分析了use case后频丘,發(fā)現(xiàn)其中地推業(yè)務(wù)員、用戶本身自帶ID屬性泡态,屬于Entity(實(shí)體)椎镣,而注冊(cè)系統(tǒng)屬于Application Service(應(yīng)用服務(wù)),這幾個(gè)概念已經(jīng)有存在兽赁。但是發(fā)現(xiàn)電話號(hào)這個(gè)概念卻完全被隱藏到了代碼之中。我們可以問(wèn)一下自己冷守,取電話號(hào)的區(qū)號(hào)的邏輯是否屬于用戶(用戶的區(qū)號(hào)刀崖?)?是否屬于注冊(cè)服務(wù)(注冊(cè)的區(qū)號(hào)拍摇?)亮钦?如果都不是很貼切,那就說(shuō)明這個(gè)邏輯應(yīng)該屬于一個(gè)獨(dú)立的概念充活。所以這里引入我們第一個(gè)原則:

Make Implicit Concepts Expecit

將隱性的概念 顯性化

在這里蜂莉,我們可以看到,原來(lái)電話號(hào)僅僅是用戶的一個(gè)參數(shù)混卵,屬于隱形概念映穗,但實(shí)際上電話號(hào)的區(qū)號(hào)才是真正的業(yè)務(wù)邏輯,而我們需要將電話號(hào)的概念顯性化幕随,通過(guò)寫(xiě)一個(gè)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格式錯(cuò)誤");
        }
        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);
    }

}

這里面有幾個(gè)很重要的元素:

  1. 通過(guò)private final String number確保PhoneNumber是一個(gè)(Immutable)Value Object(一般來(lái)說(shuō)VO都是Immutable的蚁滋,這里只是重點(diǎn)強(qiáng)調(diào)一下)
  2. 校驗(yàn)邏輯都放在了constructor里面,確保只要PhoneNumber類被創(chuàng)建出來(lái)后赘淮,一定是校驗(yàn)通過(guò)的辕录。
  3. 之前的findAreaCode方法變成了PhoneNumber類里的getAreaCode,突出了areaCodePhoneNumber的一個(gè)計(jì)算屬性梢卸。

這樣做完之后走诞,我們發(fā)現(xiàn)把PhoneNumber顯性化之后,其實(shí)是生成了一個(gè)Type(數(shù)據(jù)類型)和一個(gè)Class(類)

  • Type指我們?cè)诮窈蟮拇a里可以通過(guò)PhoneNumber去顯性的標(biāo)識(shí)電話號(hào)這個(gè)概念
  • Class指我們可以把所有跟電話號(hào)相關(guān)的邏輯完整的收集到一個(gè)文件里

這兩個(gè)概念加起來(lái)蛤高,構(gòu)造成了本文標(biāo)題的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
) {
    // 找到區(qū)域內(nèi)的SalesRep
    SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());

    // 最后創(chuàng)建用戶蚣旱,落盤(pán),然后返回戴陡,這部分代碼實(shí)際上也能用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之后姻锁,所有的數(shù)據(jù)驗(yàn)證邏輯和非業(yè)務(wù)流程的邏輯都消失了,剩下都是核心業(yè)務(wù)邏輯猜欺,可以一目了然位隶。我們重新用上面的四個(gè)維度評(píng)估一下:

評(píng)估1 - 接口的清晰度

重構(gòu)后的方法簽名變成了很清晰的:

public User register(Name, PhoneNumber, Address)

而之前容易出現(xiàn)的bug,如果按照現(xiàn)在的寫(xiě)法

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

在編譯時(shí)就會(huì)報(bào)錯(cuò)开皿,從而很容易的被及時(shí)發(fā)現(xiàn)
同樣的涧黄,查詢方法可以充分的使用method overloading

User find(Name name);
User find(PhoneNumber phone);
User find(Name name, PhoneNumber phone);

讓接口API變得很干凈篮昧,易拓展。

評(píng)估2 - 數(shù)據(jù)驗(yàn)證和錯(cuò)誤處理

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

如前文代碼展示的笋妥,重構(gòu)后的方法里懊昨,完全沒(méi)有了任何數(shù)據(jù)驗(yàn)證的邏輯,也不會(huì)拋ValidationException春宣。原因是因?yàn)镈P的特性酵颁,只要是能夠帶到入?yún)⒗锏囊欢ㄊ钦_的或null(BeanValidation或lombok的注解能解決null的問(wèn)題)。所以我們把數(shù)據(jù)驗(yàn)證的工作量前置到了調(diào)用方月帝,而調(diào)用方本來(lái)就是應(yīng)該提供合法數(shù)據(jù)的躏惋,所以更加合適。
??再展開(kāi)來(lái)看嚷辅,使用DP的另一個(gè)好處就是代碼遵循了DRY原則和單一性原則簿姨,如果未來(lái)需要修改PhoneNumber的校驗(yàn)邏輯,只需要在一個(gè)文件里修改即可簸搞,所有使用到了PhoneNumber的地方都會(huì)生效扁位。

評(píng)估3 - 業(yè)務(wù)代碼的清晰度

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

除了在業(yè)務(wù)方法里不需要校驗(yàn)數(shù)據(jù)之外,原來(lái)的一段膠水代碼findAreaCode被改為了PhoneNumber類的一個(gè)計(jì)算屬性getAreaCode趁俊,讓代碼清晰度大大提升域仇。而且膠水代碼通常都不可復(fù)用,但是使用了DP后寺擂,變成了可復(fù)用殉簸、可測(cè)試的代碼。我們能看到沽讹,在刨除了數(shù)據(jù)驗(yàn)證代碼般卑、膠水代碼之后,剩下的都是核心業(yè)務(wù)邏輯.

評(píng)估4 - 可測(cè)試性

條件入?yún)?/th> phone name address
入?yún)閚ull
入?yún)榭?/td>
入?yún)⒉环弦螅赡芏鄠€(gè))

當(dāng)我們將PhoneNumber抽取出來(lái)之后爽雄,在來(lái)看測(cè)試的TC:

  • 首先PhoneNumber本身還是需要M個(gè)測(cè)試用例蝠检,但是由于我們只需要測(cè)試單一對(duì)象,每個(gè)用例的代碼量會(huì)大大降低挚瘟,維護(hù)成本降低叹谁。
  • 每個(gè)方法里的每個(gè)參數(shù),現(xiàn)在只需要覆蓋為null的情況就可以了乘盖,其他的case不可能發(fā)生(因?yàn)橹灰皇?code>null就一定是合法的)

所以焰檩,單個(gè)方法的TC從原來(lái)的N * M變成了今天的N + M。同樣的订框,多個(gè)方法的TC數(shù)量變成了
N+M+P
這個(gè)數(shù)量一般來(lái)說(shuō)要遠(yuǎn)低于原來(lái)的數(shù)量N* M * P析苫,讓測(cè)試成本極大的降低。

評(píng)估總結(jié)

維度 傳統(tǒng)代碼 使用Domain Primitive
API接口清晰度 含混不清 接口清晰可讀
數(shù)據(jù)校驗(yàn)、錯(cuò)誤處理 校驗(yàn)邏輯分布多個(gè)地方衩侥,大量重復(fù)代碼 校驗(yàn)邏輯內(nèi)聚国旷,在接口邊界外完成
業(yè)務(wù)代碼的清晰度 校驗(yàn)代碼,膠水代碼茫死,業(yè)務(wù)邏輯混雜 無(wú)膠水代碼跪但,業(yè)務(wù)邏輯清晰可讀
測(cè)試復(fù)雜度 N*M*P N + M + P
其他好處 將隱含的概念顯性化,整體安全性大大提升,Immutability不可變,線程安全

3. 進(jìn)階使用

在上文我介紹了DP的第一個(gè)原則:將隱性的概念顯性化。在這里我將介紹DP的另外兩個(gè)原則峦萎,用一個(gè)新的案例屡久。

案例1 - 轉(zhuǎn)賬

假設(shè)現(xiàn)在要實(shí)現(xiàn)一個(gè)功能,讓A用戶可以支付x元給用戶B爱榔,可能的實(shí)現(xiàn)如下:

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

如果這個(gè)是境內(nèi)轉(zhuǎn)賬被环,并且境內(nèi)的貨幣永遠(yuǎn)不變,該方法貌似沒(méi)啥問(wèn)題搓蚪,但如果有一天貨幣變更了(比如歐元區(qū)曾經(jīng)出現(xiàn)的問(wèn)題),或者我們需要做跨境轉(zhuǎn)賬丁鹉,該方法是明顯的bug妒潭,因?yàn)?code>money對(duì)應(yīng)的貨幣不一定是CNY。

在這個(gè)case里揣钦,當(dāng)我們說(shuō)“支付x元”時(shí)雳灾,除了x本身的數(shù)字之外,實(shí)際上是有一個(gè)隱含的概念那就是貨幣“元”冯凹。但是在原始的入?yún)⒗锘涯叮灾挥昧薆igDecimal的原因是我們認(rèn)為CNY貨幣是默認(rèn)的,是一個(gè)隱含的條件宇姚,但是在我們寫(xiě)代碼時(shí)匈庭,需要把所有隱性的條件顯性化,而這些條件整體組成當(dāng)前的上下文浑劳。所以DP的第二個(gè)原則是:

Make Implicit Context Expecit

將 隱性的 上下文 顯性化

所以當(dāng)我們做這個(gè)支付功能時(shí)阱持,實(shí)際上需要的一個(gè)入?yún)⑹侵Ц督痤~ + 支付貨幣。我們可以把這兩個(gè)概念組合成為一個(gè)獨(dú)立的完整概念:Money魔熏。

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

而原有的代碼則變?yōu)椋?/p>

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

通過(guò)將默認(rèn)貨幣這個(gè)隱性的上下文概念顯性化衷咽,并且和金額合并為Money,我們可以避免很多當(dāng)前看不出來(lái)蒜绽,但未來(lái)可能會(huì)暴雷的bug镶骗。

案例2 - 跨境轉(zhuǎn)賬

前面的案例升級(jí)一下,假設(shè)用戶可能要做跨境轉(zhuǎn)賬從CNY到USD躲雅,并且貨幣匯率隨時(shí)在波動(dòng):

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);
    }
}

在這個(gè)case里鼎姊,由于targetCurrency不一定和moneyCurreny一致,需要調(diào)用一個(gè)服務(wù)去取匯率,然后做計(jì)算此蜈。最后用計(jì)算后的結(jié)果做轉(zhuǎn)賬即横。

這個(gè)case最大的問(wèn)題在于,金額的計(jì)算被包含在了支付的服務(wù)中裆赵,涉及到的對(duì)象也有2個(gè)Currency东囚,2個(gè)Money,1個(gè)BigDecimal战授,總共5個(gè)對(duì)象页藻。這種涉及到多個(gè)對(duì)象的業(yè)務(wù)邏輯,需要用DP包裝掉植兰,所以這里引出DP的第三個(gè)原則:

Encapsulate Multi-Object Behavior

封裝 多對(duì)象 行為

在這個(gè)case 里份帐,可以將轉(zhuǎn)換匯率的功能,封裝到一個(gè)叫做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匯率對(duì)象楣导,通過(guò)封裝金額計(jì)算邏輯以及各種校驗(yàn)邏輯废境,讓原始代碼變得極其簡(jiǎn)單:

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);
}

4. 討論和總結(jié)

Domain Primitive的定義

讓我們重新來(lái)定義一下Domain Primitive:Domain Primitive是一個(gè)在特定領(lǐng)域里,擁有精準(zhǔn)定義的筒繁、可自我驗(yàn)證的噩凹、擁有行為的Value Object

  • DP是一個(gè)傳統(tǒng)意義上的Value Object毡咏,擁有Immutable的特性
  • DP是一個(gè)完整的概念整體驮宴,擁有精準(zhǔn)定義
  • DP使用業(yè)務(wù)域中的原生語(yǔ)言
  • DP可以是業(yè)務(wù)域的最小組成部分、也可以構(gòu)建復(fù)雜組合

注:Domain Primitive的概念和命名來(lái)自于Dan Bergh Johnsson & Daniel Deogun的書(shū) Secure by Design呕缭。

使用Domain Primitive的三原則

  • 讓隱性的概念顯性化
  • 讓隱性的上下文顯性化
  • 封裝多對(duì)象行為

Domain Primitive和DDD里Value Object的區(qū)別

在DDD中堵泽,Value Object這個(gè)概念其實(shí)已經(jīng)存在:

  • 在Evans的DDD藍(lán)皮書(shū)中,Value Object更多的是一個(gè)非Entity的值對(duì)象
  • 在Vernon的IDDD紅皮書(shū)中恢总,作者更多的關(guān)注了Value Object的Immutability迎罗、Equals方法、Factory方法等

Domain Primitive是Value Object的進(jìn)階版片仿,在原始VO的基礎(chǔ)上要求每個(gè)DP擁有概念的整體佳谦,而不僅僅是值對(duì)象。在VO的Immutable基礎(chǔ)上增加了Validity和行為滋戳。當(dāng)然同樣的要求無(wú)副作用(side-effect free)钻蔑。

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

在日常開(kāi)發(fā)中經(jīng)常會(huì)碰到的另一個(gè)數(shù)據(jù)結(jié)構(gòu)是DTO,比如方法的入?yún)⒑统鰠ⅰ?##DP和DTO的區(qū)別如下:

DTO DP
功能 數(shù)據(jù)傳輸屬于技術(shù)細(xì)節(jié) 代表業(yè)務(wù)域中的概念
數(shù)據(jù)的關(guān)聯(lián) 只是一堆數(shù)據(jù)放在一起不一定有關(guān)聯(lián)度 數(shù)據(jù)之間的高相關(guān)性
行為 無(wú)行為 豐富的行為和業(yè)務(wù)邏輯

什么情況下應(yīng)該用Domain Primitive

常見(jiàn)的DP的使用場(chǎng)景包括:

  • 有格式限制的String:比如Name奸鸯,PhoneNumber咪笑,OrderNumber,ZipCode娄涩,Address等
  • 有限制的Integer:比如OrderId(>0)窗怒,Percentage(0-100%)映跟,Quantity(>=0)等
  • 可枚舉的int:比如Status(一般不用Enum因?yàn)榉葱蛄谢瘑?wèn)題)
    Double或BigDecimal:一般用到的Double或BigDecimal都是有業(yè)務(wù)含義的,比如Temperature扬虚、Money努隙、Amount、ExchangeRate辜昵、Rating等
  • 復(fù)雜的數(shù)據(jù)結(jié)構(gòu):比如Map<String, List<Integer>>等荸镊,盡量能把Map的所有操作包裝掉,僅暴露必要行為

5. 實(shí)戰(zhàn) - 老應(yīng)用重構(gòu)的流程

在新應(yīng)用中使用DP是比較簡(jiǎn)單的堪置,但在老應(yīng)用中使用DP是可以遵循以下流程按部就班的升級(jí)躬存。在此用本文的第一個(gè)case為例。

第一步 - 創(chuàng)建Domain Primitive舀锨,收集所有DP行為

在前文中岭洲,我們發(fā)現(xiàn)取電話號(hào)的區(qū)號(hào)這個(gè)是一個(gè)可以獨(dú)立出來(lái)的、可以放入PhoneNumber這個(gè)Class的邏輯坎匿。類似的盾剩,在真實(shí)的項(xiàng)目中,以前散落在各個(gè)服務(wù)或工具類里面的代碼替蔬,可以都抽出來(lái)放在DP里告私,成為DP自己的行為或?qū)傩浴_@里面的原則是:所有抽離出來(lái)的方法要做到無(wú)狀態(tài)进栽,比如原來(lái)是static的方法德挣。如果原來(lái)的方法有狀態(tài)變更恭垦,需要將改變狀態(tài)的部分和不改狀態(tài)的部分分離快毛,然后將無(wú)狀態(tài)的部分融入DP。因?yàn)镈P本身不能帶狀態(tài)番挺,所以一切需要改變狀態(tài)的代碼都不屬于DP的范疇唠帝。
(代碼參考PhoneNumber的代碼,這里不再重復(fù))

第二步 - 替換數(shù)據(jù)校驗(yàn)和無(wú)狀態(tài)邏輯

為了保障現(xiàn)有方法的兼容性玄柏,在第二步不會(huì)去修改接口的簽名襟衰,而是通過(guò)代碼替換原有的校驗(yàn)邏輯和根DP相關(guān)的業(yè)務(wù)邏輯。比如:

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);
    // 其他代碼...
}

通過(guò)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());
    // 其他代碼...
}

通過(guò)new PhoneNumber(phone)這種代碼粪摘,替代了原有的校驗(yàn)代碼瀑晒。
通過(guò)phone.getAreaCode()替換了原有的無(wú)狀態(tài)的業(yè)務(wù)邏輯。

第三步 - 創(chuàng)建新接口

創(chuàng)建新接口徘意,將DP的代碼提升到接口參數(shù)層:

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

第四步 - 修改外部調(diào)用

外部調(diào)用方需要修改調(diào)用鏈路苔悦,比如:

service.register("殷浩", "0571-12345678", "浙江省杭州市余杭區(qū)文三西路969號(hào)");

改為:

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

通過(guò)以上4步,就能讓你的代碼變得更加簡(jiǎn)潔椎咧、優(yōu)雅玖详、健壯、安全。你還在等什么蟋座?今天就去嘗試吧拗踢!

原文鏈接:https://developer.aliyun.com/article/713097

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市向臀,隨后出現(xiàn)的幾起案子巢墅,更是在濱河造成了極大的恐慌,老刑警劉巖飒硅,帶你破解...
    沈念sama閱讀 216,372評(píng)論 6 498
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件砂缩,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡三娩,警方通過(guò)查閱死者的電腦和手機(jī)庵芭,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,368評(píng)論 3 392
  • 文/潘曉璐 我一進(jìn)店門(mén),熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)雀监,“玉大人双吆,你說(shuō)我怎么就攤上這事』崆埃” “怎么了好乐?”我有些...
    開(kāi)封第一講書(shū)人閱讀 162,415評(píng)論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)瓦宜。 經(jīng)常有香客問(wèn)我蔚万,道長(zhǎng),這世上最難降的妖魔是什么临庇? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 58,157評(píng)論 1 292
  • 正文 為了忘掉前任反璃,我火速辦了婚禮,結(jié)果婚禮上假夺,老公的妹妹穿的比我還像新娘淮蜈。我一直安慰自己,他們只是感情好已卷,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,171評(píng)論 6 388
  • 文/花漫 我一把揭開(kāi)白布梧田。 她就那樣靜靜地躺著,像睡著了一般侧蘸。 火紅的嫁衣襯著肌膚如雪裁眯。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書(shū)人閱讀 51,125評(píng)論 1 297
  • 那天讳癌,我揣著相機(jī)與錄音穿稳,去河邊找鬼。 笑死析桥,一個(gè)胖子當(dāng)著我的面吹牛司草,可吹牛的內(nèi)容都是我干的艰垂。 我是一名探鬼主播,決...
    沈念sama閱讀 40,028評(píng)論 3 417
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼埋虹,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼猜憎!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起搔课,我...
    開(kāi)封第一講書(shū)人閱讀 38,887評(píng)論 0 274
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤胰柑,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后爬泥,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體柬讨,經(jīng)...
    沈念sama閱讀 45,310評(píng)論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,533評(píng)論 2 332
  • 正文 我和宋清朗相戀三年袍啡,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了踩官。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 39,690評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡境输,死狀恐怖蔗牡,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情嗅剖,我是刑警寧澤辩越,帶...
    沈念sama閱讀 35,411評(píng)論 5 343
  • 正文 年R本政府宣布,位于F島的核電站信粮,受9級(jí)特大地震影響黔攒,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜强缘,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,004評(píng)論 3 325
  • 文/蒙蒙 一督惰、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧欺旧,春花似錦姑丑、人聲如沸蛤签。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 31,659評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)震肮。三九已至称龙,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間戳晌,已是汗流浹背鲫尊。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 32,812評(píng)論 1 268
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留沦偎,地道東北人疫向。 一個(gè)月前我還...
    沈念sama閱讀 47,693評(píng)論 2 368
  • 正文 我出身青樓咳蔚,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親搔驼。 傳聞我的和親對(duì)象是個(gè)殘疾皇子谈火,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,577評(píng)論 2 353

推薦閱讀更多精彩內(nèi)容