前言
?? 在全面了解DDD之前,首先給大家介紹一個最基礎(chǔ)的概念: Domain Primitive(DP)
Primitive的定義是:
? 不從任何其他事物發(fā)展而來
? 初級的形成或生長的早期階段
就好像Integer、String是所有編程語言的Primitive一樣,在DDD里,DP可以說是一切模型、方法、架構(gòu)的基礎(chǔ),而就像Integer、String一樣,DP又是無所不在的。所以,第一講會對DP做一個全面的介紹和分析,但我們先不去講概念,而是從案例入手,看看為什么DP是一個強(qiáng)大的概念。
1. 案例分析
我們先看一個簡單的例子,這個case的業(yè)務(wù)邏輯如下:
一個新應(yīng)用在全國通過 地推業(yè)務(wù)員 做推廣,需要做一個用戶注冊系統(tǒng),同時>希望在用戶注冊后能夠通過用戶電話(先假設(shè)僅限座機(jī))的地域(區(qū)號)對業(yè)>務(wù)員發(fā)獎金。
先不要去糾結(jié)這個根據(jù)用戶電話去發(fā)獎金的業(yè)務(wù)邏輯是否合理,也先不要去管用戶是否應(yīng)該在注冊時和業(yè)務(wù)員做綁定,這里我們看的主要還是如何更加合理的去實現(xiàn)這個邏輯。一個簡單的用戶和用戶注冊的代碼實現(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 {
// 校驗邏輯
if (name == null || name.length() == 0) {
throw new ValidationException("name");
}
if (phone == null || !isValidPhoneNumber(phone)) {
throw new ValidationException("phone");
}
// 此處省略address的校驗邏輯
// 取電話號里的區(qū)號,然后通過區(qū)號找到區(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)建用戶,落盤,然后返回
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ù)據(jù)驗證和錯誤處理、業(yè)務(wù)邏輯代碼的清晰度、和可測試性。
問題1 - 接口的清晰度
在Java代碼中,對于一個方法來說所有的參數(shù)名在編譯時丟失,留下的僅僅是一個參數(shù)類型的列表,所以我們重新看一下以上的接口定義,其實在運行時僅僅是:
User register(String, String, String);
所以以下的代碼是一段編譯器完全不會報錯的,很難通過看代碼就能發(fā)現(xiàn)的bug:
service.register("殷浩", "浙江省杭州市余杭區(qū)文三西路969號", "0571-12345678");
當(dāng)然,在真實代碼中運行時會報錯,但這種bug是在運行時被發(fā)現(xiàn)的,而不是在編譯時。普通的Code Review也很難發(fā)現(xiàn)這種問題,很有可能是代碼上線后才會被暴露出來。這里的思考是,有沒有辦法在編碼時就避免這種可能會出現(xiàn)的問題?
另外一種常見的,特別是在查詢服務(wù)中容易出現(xiàn)的例子如下:
User findByName(String name);
User findByPhone(String phone);
User findByNameAndPhone(String name, String phone);
在這個場景下,由于入?yún)⒍际荢tring類型,不得不在方法名上面加上ByXXX來區(qū)分,而findByNameAndPhone同樣也會陷入前面的入?yún)㈨樞蝈e誤的問題,而且和前面的入?yún)⒉煌?,這里參數(shù)順序如果輸錯了,方法不會報錯只會返回null,而這種bug更加難被發(fā)現(xiàn)。這里的思考是,有沒有辦法讓方法入?yún)⒁荒苛巳?,避免入?yún)㈠e誤導(dǎo)致的bug?
問題2 - 數(shù)據(jù)驗證和錯誤處理
在前面這段數(shù)據(jù)校驗代碼:
if (phone == null || !isValidPhoneNumber(phone)) {
throw new ValidationException("phone");
}
在日常編碼中經(jīng)常會出現(xiàn),一般來說這種代碼需要出現(xiàn)在方法的最前端,確保能夠fail-fast。但是假設(shè)你有多個類似的接口和類似的入?yún)?,在每個方法里這段邏輯會被重復(fù)。而更嚴(yán)重的是如果未來我們要拓展電話號去包含手機(jī)時,很可能需要加入以下代碼:
if (phone == null || !isValidPhoneNumber(phone) || !isValidCellNumber(phone)) {
throw new ValidationException("phone");
}
如果你有很多個地方用到了phone這個入?yún)?,但是有個地方忘記修改了,會造成bug。這是一個DRY原則被違背時經(jīng)常會發(fā)生的問題。
如果有個新的需求,需要把入?yún)㈠e誤的原因返回,那么這段代碼就變得更加復(fù)雜:
if (phone == null) {
throw new ValidationException("phone不能為空");
} else if (!isValidPhoneNumber(phone)) {
throw new ValidationException("phone格式錯誤");
}
可以想像得到,代碼里充斥著大量的類似代碼塊時,維護(hù)成本要有多高。
最后,在這個業(yè)務(wù)方法里,會(隱性或顯性的)拋ValidationException,所以需要外部調(diào)用方去try/catch,而業(yè)務(wù)邏輯異常和數(shù)據(jù)校驗異常被混在了一起,是否是合理的?
在傳統(tǒng)Java架構(gòu)里有幾個辦法能夠去解決一部分問題,常見的如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);
...
}
但這幾個傳統(tǒng)的方法同樣有問題,
BeanValidation:
- 通常只能解決簡單的校驗邏輯,復(fù)雜的校驗邏輯一樣要寫代碼實現(xiàn)定制校驗器
- 在添加了新校驗邏輯時,同樣會出現(xiàn)在某些地方忘記添加一個注解的情況,
DRY原則還是會被違背
ValidationUtils類:
- 當(dāng)大量的校驗邏輯集中在一個類里之后,違背了Single Responsibility單一性原則,導(dǎo)致代碼混亂和不可維護(hù)
- 業(yè)務(wù)異常和校驗異常還是會混雜
??所以,有沒有一種方法,能夠一勞永逸的解決所有校驗的問題以及降低后續(xù)的維護(hù)成本和異常處理成本呢?
問題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);
實際上出現(xiàn)了另外一種常見的情況,那就是從一些入?yún)⒗锍槿∫徊糠謹(jǐn)?shù)據(jù),然后調(diào)用一個外部依賴獲取更多的數(shù)據(jù),然后通常從新的數(shù)據(jù)中再抽取部分?jǐn)?shù)據(jù)用作其他的作用。這種代碼通常被稱作“膠水代碼”,其本質(zhì)是由于外部依賴的服務(wù)的入?yún)⒉⒉环衔覀冊嫉娜雲(yún)?dǎo)致的。比如,如果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);
}
然后原始代碼變?yōu)椋?/p>
String areaCode = findAreaCode(phone);
SalesRep rep = salesRepRepo.findRep(areaCode);
而為了復(fù)用以上的方法,可能會抽離出一個靜態(tài)工具類PhoneUtils 。但是這里要思考的是,靜態(tài)工具類是否是最好的實現(xiàn)方式呢?當(dāng)你的項目里充斥著大量的靜態(tài)工具類,業(yè)務(wù)代碼散在多個文件當(dāng)中時,你是否還能找到核心的業(yè)務(wù)邏輯呢?
問題4 - 可測試性
為了保證代碼質(zhì)量,每個方法里的每個入?yún)⒌拿總€可能出現(xiàn)的條件都要有TC覆蓋(假設(shè)我們先不去測試內(nèi)部業(yè)務(wù)邏輯),所以在我們這個方法里需要以下的TC:
| 條件入?yún)?/th> | phone | name | address |
|---|---|---|---|
| 入?yún)閚ull | |||
| 入?yún)榭?/td> | |||
| 入?yún)⒉环弦螅赡芏鄠€) |
? ? ?
假如一個方法有個參數(shù),每個參數(shù)有
個校驗邏輯,至少要有
個TC 。
如果這時候在該方法中加入一個新的入?yún)⒆侄蝔ax,即使fax和phone的校驗邏輯完全一致,為了保證TC覆蓋率,也一樣需要個新的TC。
而假設(shè)有個方法中都用到了phone這個字段,這
個方法都需要對該字段進(jìn)行測試,也就是說整體需要:
個測試用例才能完全覆蓋所有數(shù)據(jù)驗證的問題,在日常項目中,這個測試的成本非常之高,導(dǎo)致大量的代碼沒被覆蓋到。而沒被測試覆蓋到的代碼才是最有可能出現(xiàn)問題的地方。在這個情況下,降低測試成本 == 提升代碼質(zhì)量,如何能夠降低測試的成本呢?
2. 解決方案
我們回頭先重新看一下原始的use case,并且標(biāo)注其中可能重要的概念:
一個新應(yīng)用在全國通過 地推業(yè)務(wù)員 做推廣,需要做一個 用戶 的 注冊系統(tǒng),在用戶注冊后能夠通過用戶 電話號的區(qū)號 對業(yè)務(wù)員發(fā)獎金。
在分析了use case后,發(fā)現(xiàn)其中地推業(yè)務(wù)員、用戶本身自帶ID屬性,屬于Entity(實體),而注冊系統(tǒng)屬于Application Service(應(yīng)用服務(wù)),這幾個概念已經(jīng)有存在。但是發(fā)現(xiàn)電話號這個概念卻完全被隱藏到了代碼之中。我們可以問一下自己,取電話號的區(qū)號的邏輯是否屬于用戶(用戶的區(qū)號?)?是否屬于注冊服務(wù)(注冊的區(qū)號?)?如果都不是很貼切,那就說明這個邏輯應(yīng)該屬于一個獨立的概念。所以這里引入我們第一個原則:
在這里,我們可以看到,原來電話號僅僅是用戶的一個參數(shù),屬于隱形概念,但實際上電話號的區(qū)號才是真正的業(yè)務(wù)邏輯,而我們需要將電話號的概念顯性化,通過寫一個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的,這里只是重點強(qiáng)調(diào)一下) - 校驗邏輯都放在了
constructor里面,確保只要PhoneNumber類被創(chuàng)建出來后,一定是校驗通過的。 - 之前的
findAreaCode方法變成了PhoneNumber類里的getAreaCode,突出了areaCode是PhoneNumber的一個計算屬性。
這樣做完之后,我們發(fā)現(xiàn)把PhoneNumber顯性化之后,其實是生成了一個Type(數(shù)據(jù)類型)和一個Class(類):
- Type指我們在今后的代碼里可以通過PhoneNumber去顯性的標(biāo)識電話號這個概念
- Class指我們可以把所有跟電話號相關(guān)的邏輯完整的收集到一個文件里
這兩個概念加起來,構(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)建用戶,落盤,然后返回,這部分代碼實際上也能用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è)務(wù)流程的邏輯都消失了,剩下都是核心業(yè)務(wù)邏輯,可以一目了然。我們重新用上面的四個維度評估一下:
評估1 - 接口的清晰度
重構(gòu)后的方法簽名變成了很清晰的:
public User register(Name, PhoneNumber, Address)
而之前容易出現(xiàn)的bug,如果按照現(xiàn)在的寫法
service.register(new Name("殷浩"), new Address("浙江省杭州市余杭區(qū)文三西路969號"), new PhoneNumber("0571-12345678"));
在編譯時就會報錯,從而很容易的被及時發(fā)現(xiàn)
同樣的,查詢方法可以充分的使用method overloading:
User find(Name name);
User find(PhoneNumber phone);
User find(Name name, PhoneNumber phone);
讓接口API變得很干凈,易拓展。
評估2 - 數(shù)據(jù)驗證和錯誤處理
public User register(
@NotNull Name name,
@NotNull PhoneNumber phone,
@NotNull Address address
) // no throws
如前文代碼展示的,重構(gòu)后的方法里,完全沒有了任何數(shù)據(jù)驗證的邏輯,也不會拋ValidationException。原因是因為DP的特性,只要是能夠帶到入?yún)⒗锏囊欢ㄊ钦_的或null(BeanValidation或lombok的注解能解決null的問題)。所以我們把數(shù)據(jù)驗證的工作量前置到了調(diào)用方,而調(diào)用方本來就是應(yīng)該提供合法數(shù)據(jù)的,所以更加合適。
??再展開來看,使用DP的另一個好處就是代碼遵循了DRY原則和單一性原則,如果未來需要修改PhoneNumber的校驗邏輯,只需要在一個文件里修改即可,所有使用到了PhoneNumber的地方都會生效。
評估3 - 業(yè)務(wù)代碼的清晰度
SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());
User user = xxx;
return userRepo.save(user);
除了在業(yè)務(wù)方法里不需要校驗數(shù)據(jù)之外,原來的一段膠水代碼findAreaCode被改為了PhoneNumber類的一個計算屬性getAreaCode,讓代碼清晰度大大提升。而且膠水代碼通常都不可復(fù)用,但是使用了DP后,變成了可復(fù)用、可測試的代碼。我們能看到,在刨除了數(shù)據(jù)驗證代碼、膠水代碼之后,剩下的都是核心業(yè)務(wù)邏輯.
評估4 - 可測試性
| 條件入?yún)?/th> | phone | name | address |
|---|---|---|---|
| 入?yún)閚ull | |||
| 入?yún)榭?/td> | |||
| 入?yún)⒉环弦螅赡芏鄠€) |
當(dāng)我們將PhoneNumber抽取出來之后,在來看測試的TC:
- 首先
PhoneNumber本身還是需要個測試用例,但是由于我們只需要測試單一對象,每個用例的代碼量會大大降低,維護(hù)成本降低。
- 每個方法里的每個參數(shù),現(xiàn)在只需要覆蓋為
null的情況就可以了,其他的case不可能發(fā)生(因為只要不是null就一定是合法的)
所以,單個方法的TC從原來的變成了今天的
。同樣的,多個方法的TC數(shù)量變成了
這個數(shù)量一般來說要遠(yuǎn)低于原來的數(shù)量,讓測試成本極大的降低。
評估總結(jié)
| 維度 | 傳統(tǒng)代碼 | 使用Domain Primitive |
|---|---|---|
| API接口清晰度 | 含混不清 | 接口清晰可讀 |
| 數(shù)據(jù)校驗、錯誤處理 | 校驗邏輯分布多個地方,大量重復(fù)代碼 | 校驗邏輯內(nèi)聚,在接口邊界外完成 |
| 業(yè)務(wù)代碼的清晰度 | 校驗代碼,膠水代碼,業(yè)務(wù)邏輯混雜 | 無膠水代碼,業(yè)務(wù)邏輯清晰可讀 |
| 測試復(fù)雜度 | ||
| 其他好處 | 將隱含的概念顯性化,整體安全性大大提升,Immutability不可變,線程安全 |
3. 進(jìn)階使用
在上文我介紹了DP的第一個原則:將隱性的概念顯性化。在這里我將介紹DP的另外兩個原則,用一個新的案例。
案例1 - 轉(zhuǎn)賬
假設(shè)現(xiàn)在要實現(xiàn)一個功能,讓A用戶可以支付x元給用戶B,可能的實現(xiàn)如下:
public void pay(BigDecimal money, Long recipientId) {
BankService.transfer(money, "CNY", recipientId);
}
如果這個是境內(nèi)轉(zhuǎn)賬,并且境內(nèi)的貨幣永遠(yuǎn)不變,該方法貌似沒啥問題,但如果有一天貨幣變更了(比如歐元區(qū)曾經(jīng)出現(xiàn)的問題),或者我們需要做跨境轉(zhuǎn)賬,該方法是明顯的bug,因為money對應(yīng)的貨幣不一定是CNY。
在這個case里,當(dāng)我們說“支付x元”時,除了x本身的數(shù)字之外,實際上是有一個隱含的概念那就是貨幣“元”。但是在原始的入?yún)⒗?,之所以只用了BigDecimal的原因是我們認(rèn)為CNY貨幣是默認(rèn)的,是一個隱含的條件,但是在我們寫代碼時,需要把所有隱性的條件顯性化,而這些條件整體組成當(dāng)前的上下文。所以DP的第二個原則是:
所以當(dāng)我們做這個支付功能時,實際上需要的一個入?yún)⑹侵Ц督痤~ + 支付貨幣。我們可以把這兩個概念組合成為一個獨立的完整概念: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);
}
通過將默認(rèn)貨幣這個隱性的上下文概念顯性化,并且和金額合并為Money,我們可以避免很多當(dāng)前看不出來,但未來可能會暴雷的bug。
案例2 - 跨境轉(zhuǎn)賬
前面的案例升級一下,假設(shè)用戶可能要做跨境轉(zhuǎn)賬從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一致,需要調(diào)用一個服務(wù)去取匯率,然后做計算。最后用計算后的結(jié)果做轉(zhuǎn)賬。
這個case最大的問題在于,金額的計算被包含在了支付的服務(wù)中,涉及到的對象也有2個Currency,2個Money,1個BigDecimal,總共5個對象。這種涉及到多個對象的業(yè)務(wù)邏輯,需要用DP包裝掉,所以這里引出DP的第三個原則:
在這個case 里,可以將轉(zhuǎn)換匯率的功能,封裝到一個叫做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);
}
4. 討論和總結(jié)
Domain Primitive的定義
讓我們重新來定義一下Domain Primitive:Domain Primitive是一個在特定領(lǐng)域里,擁有精準(zhǔn)定義的、可自我驗證的、擁有行為的Value Object。
- DP是一個傳統(tǒng)意義上的Value Object,擁有Immutable的特性
- DP是一個完整的概念整體,擁有精準(zhǔn)定義
- DP使用業(yè)務(wù)域中的原生語言
- DP可以是業(yè)務(wù)域的最小組成部分、也可以構(gòu)建復(fù)雜組合
注:Domain Primitive的概念和命名來自于Dan Bergh Johnsson & Daniel Deogun的書 Secure by Design。
使用Domain Primitive的三原則
- 讓隱性的概念顯性化
- 讓隱性的上下文顯性化
- 封裝多對象行為
Domain Primitive和DDD里Value Object的區(qū)別
在DDD中,Value Object這個概念其實已經(jīng)存在:
- 在Evans的DDD藍(lán)皮書中,Value Object更多的是一個非Entity的值對象
- 在Vernon的IDDD紅皮書中,作者更多的關(guān)注了Value Object的Immutability、Equals方法、Factory方法等
Domain Primitive是Value Object的進(jìn)階版,在原始VO的基礎(chǔ)上要求每個DP擁有概念的整體,而不僅僅是值對象。在VO的Immutable基礎(chǔ)上增加了Validity和行為。當(dāng)然同樣的要求無副作用(side-effect free)。
Domain Primitive和Data Transfer Object (DTO)的區(qū)別
在日常開發(fā)中經(jīng)常會碰到的另一個數(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)性 |
| 行為 | 無行為 | 豐富的行為和業(yè)務(wù)邏輯 |
什么情況下應(yīng)該用Domain Primitive
常見的DP的使用場景包括:
- 有格式限制的String:比如Name,PhoneNumber,OrderNumber,ZipCode,Address等
- 有限制的Integer:比如OrderId(>0),Percentage(0-100%),Quantity(>=0)等
- 可枚舉的int:比如Status(一般不用Enum因為反序列化問題)
Double或BigDecimal:一般用到的Double或BigDecimal都是有業(yè)務(wù)含義的,比如Temperature、Money、Amount、ExchangeRate、Rating等 - 復(fù)雜的數(shù)據(jù)結(jié)構(gòu):比如Map<String, List<Integer>>等,盡量能把Map的所有操作包裝掉,僅暴露必要行為
5. 實戰(zhàn) - 老應(yīng)用重構(gòu)的流程
在新應(yīng)用中使用DP是比較簡單的,但在老應(yīng)用中使用DP是可以遵循以下流程按部就班的升級。在此用本文的第一個case為例。
第一步 - 創(chuàng)建Domain Primitive,收集所有DP行為
在前文中,我們發(fā)現(xiàn)取電話號的區(qū)號這個是一個可以獨立出來的、可以放入PhoneNumber這個Class的邏輯。類似的,在真實的項目中,以前散落在各個服務(wù)或工具類里面的代碼,可以都抽出來放在DP里,成為DP自己的行為或?qū)傩?。這里面的原則是:所有抽離出來的方法要做到無狀態(tài),比如原來是static的方法。如果原來的方法有狀態(tài)變更,需要將改變狀態(tài)的部分和不改狀態(tài)的部分分離,然后將無狀態(tài)的部分融入DP。因為DP本身不能帶狀態(tài),所以一切需要改變狀態(tài)的代碼都不屬于DP的范疇。
(代碼參考PhoneNumber的代碼,這里不再重復(fù))
第二步 - 替換數(shù)據(jù)校驗和無狀態(tài)邏輯
為了保障現(xià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);
// 其他代碼...
}
通過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()替換了原有的無狀態(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號");
改為:
service.register(new Name("殷浩"), new PhoneNumber("0571-12345678"), new Address("浙江省杭州市余杭區(qū)文三西路969號"));
通過以上4步,就能讓你的代碼變得更加簡潔、優(yōu)雅、健壯、安全。你還在等什么?今天就去嘗試吧!