阿里技術專家詳解 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 的建模的思想,而其對於整個架構體系的思想無法落地。
我第一次接觸 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 是一個強大的概念。
1
案例分析
我們先看一個簡單的例子,這個 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
個測試用例才能完全覆蓋所有數據驗證的問題,在日常項目中,這個測試的成本非常之高,導致大量的代碼沒被覆蓋到。而沒被測試覆蓋到的代碼纔是最有可能出現問題的地方。
在這個情況下,降低測試成本 == 提升代碼質量,如何能夠降低測試的成本呢?
2
解決方案
我們回頭先重新看一下原始的 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 ,讓測試成本極大的降低。
▍評估總結
3
進階使用
在上文我介紹了 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);
}
4
討論和總結
▍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<Integer>>
等,儘量能把Map
的所有操作包裝掉,僅暴露必要行爲
5
實戰 - 老應用重構的流程
在新應用中使用 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 步,就能讓你的代碼變得更加簡潔、優雅、健壯、安全。你還在等什麼?今天就去嘗試吧!
鏈接:
① https://martinfowler.com/bliki/AnemicDomainModel.html
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/kpXklmidsidZEiHNw57QAQ