阿里技术专家详解 DDD 系列- Domain Primitive
導(dǎo)讀:對(duì)于一個(gè)架構(gòu)師來(lái)說(shuō),在軟件開發(fā)中如何降低系統(tǒng)復(fù)雜度是一個(gè)永恒的挑戰(zhàn),無(wú)論是 94 年 GoF 的 Design Patterns , 99 年的 Martin Fowler 的 Refactoring , 02 年的 P of EAA ,還是 03 年的 Enterprise Integration Patterns ,都是通過(guò)一系列的設(shè)計(jì)模式或范例來(lái)降低一些常見(jiàn)的復(fù)雜度。但是問(wèn)題在于,這些書的理念是通過(guò)技術(shù)手段解決技術(shù)問(wèn)題,但并沒(méi)有從根本上解決業(yè)務(wù)的問(wèn)題。所以 03 年 Eric Evans 的 Domain Driven Design 一書,以及后續(xù) Vaughn Vernon 的 Implementing DDD , Uncle Bob 的 Clean Architecture 等書,真正的從業(yè)務(wù)的角度出發(fā),為全世界絕大部分做純業(yè)務(wù)的開發(fā)提供了一整套的架構(gòu)思路。
前言
由于 DDD 不是一套框架,而是一種架構(gòu)思想,所以在代碼層面缺乏了足夠的約束,導(dǎo)致 DDD 在實(shí)際應(yīng)用中上手門檻很高,甚至可以說(shuō)絕大部分人都對(duì) DDD 的理解有所偏差。舉個(gè)例子, Martin Fowler 在他個(gè)人博客里描述的一個(gè) Anti-pattern,Anemic Domain Model?(貧血域模型)在實(shí)際應(yīng)用當(dāng)中層出不窮,而一些仍然火熱的 ORM 工具比如 Hibernate,Entity Framework 實(shí)際上助長(zhǎng)了貧血模型的擴(kuò)散。同樣的,傳統(tǒng)的基于數(shù)據(jù)庫(kù)技術(shù)以及 MVC 的四層應(yīng)用架構(gòu)(UI、Business、Data Access、Database),在一定程度上和 DDD 的一些概念混淆,導(dǎo)致絕大部分人在實(shí)際應(yīng)用當(dāng)中僅僅用到了 DDD 的建模的思想,而其對(duì)于整個(gè)架構(gòu)體系的思想無(wú)法落地。
我第一次接觸 DDD 應(yīng)該是 2012 年,當(dāng)時(shí)除了大型互聯(lián)網(wǎng)公司,基本上商業(yè)應(yīng)用都還處于單機(jī)的時(shí)代,服務(wù)化的架構(gòu)還局限于單機(jī) +LB 用 MVC 提供 Rest 接口供外部調(diào)用,或者用 SOAP 或 WebServices 做 RPC 調(diào)用,但其實(shí)更多局限于對(duì)外部依賴的協(xié)議。讓我關(guān)注到 DDD 思想的是一個(gè)叫 Anti-Corruption Layer(防腐層)的概念,特別是其在解決外部依賴頻繁變更的情況下,如何將核心業(yè)務(wù)邏輯和外部依賴隔離的機(jī)制。到了 2014 年, SOA 開始大行其道,微服務(wù)的概念開始冒頭,而如何將一個(gè) Monolith 應(yīng)用合理的拆分為多個(gè)微服務(wù)成為了各大論壇的熱門話題,而 DDD 里面的 Bounded Context(限界上下文)的思想為微服務(wù)拆分提供了一套合理的框架。而在今天,在一個(gè)所有的東西都能被稱之為“服務(wù)”的時(shí)代(XAAS), DDD 的思想讓我們能冷靜下來(lái),去思考到底哪些東西可以被服務(wù)化拆分,哪些邏輯需要聚合,才能帶來(lái)最小的維護(hù)成本,而不是簡(jiǎn)單的去追求開發(fā)效率。
所以今天,我開始這個(gè)關(guān)于 DDD 的一系列文章,希望能繼續(xù)在總結(jié)前人的基礎(chǔ)上發(fā)揚(yáng)光大 DDD 的思想,但是通過(guò)一套我認(rèn)為合理的代碼結(jié)構(gòu)、框架和約束,來(lái)降低 DDD 的實(shí)踐門檻,提升代碼質(zhì)量、可測(cè)試性、安全性、健壯性。
未來(lái)會(huì)覆蓋的內(nèi)容包括:
- 最佳架構(gòu)實(shí)踐:六邊形應(yīng)用架構(gòu) / Clean 架構(gòu)的核心思想和落地方案
- 持續(xù)發(fā)現(xiàn)和交付:Event Storming > Context Map > Design Heuristics > Modelling
- 降低架構(gòu)腐敗速度:通過(guò) Anti-Corruption Layer 集成第三方庫(kù)的模塊化方案
- 標(biāo)準(zhǔn)組件的規(guī)范和邊界:Entity, Aggregate, Repository, Domain Service, Application Service, Event, DTO Assembler 等
- 基于 Use Case 重定義應(yīng)用服務(wù)的邊界
- 基于 DDD 的微服務(wù)化改造及顆粒度控制
- CQRS 架構(gòu)的改造和挑戰(zhàn)
- 基于事件驅(qū)動(dòng)的架構(gòu)的挑戰(zhàn)
- 等等
今天先給大家?guī)?lái)一篇最基礎(chǔ),但極其有價(jià)值的Domain Primitive的概念。
Domain Primitive
就好像在學(xué)任何語(yǔ)言時(shí)首先需要了解的是基礎(chǔ)數(shù)據(jù)類型一樣,在全面了解 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)的SalesRepString 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í)都跟這個(gè)是類似的,乍一看貌似沒(méi)啥問(wèn)題,但我們?cè)偕钊胍徊?#xff0c;從以下四個(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)⒍际?String 類型,不得不在方法名上面加上 ByXXX 來(lái)區(qū)分,而 findByNameAndPhone 同樣也會(huì)陷入前面的入?yún)㈨樞蝈e(cuò)誤的問(wèn)題,而且和前面的入?yún)⒉煌?#xff0c;這里參數(shù)順序如果輸錯(cuò)了,方法不會(huì)報(bào)錯(cuò)只會(huì)返回 null,而這種 bug 更加難被發(fā)現(xiàn)。這里的思考是,有沒(méi)有辦法讓方法入?yún)⒁荒苛巳?#xff0c;避免入?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)?#xff0c;在每個(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)?#xff0c;但是有個(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 ValidationExceptionValidationUtils.validatePhone(phone);ValidationUtils.validateAddress(address);... }但這幾個(gè)傳統(tǒng)的方法同樣有問(wèn)題,
BeanValidation:
- 通常只能解決簡(jiǎn)單的校驗(yàn)邏輯,復(fù)雜的校驗(yàn)邏輯一樣要寫代碼實(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)?#xff1a;
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 :
假如一個(gè)方法有 N 個(gè)參數(shù),每個(gè)參數(shù)有 M 個(gè)校驗(yàn)邏輯,至少要有 N * M 個(gè) TC 。
如果這時(shí)候在該方法中加入一個(gè)新的入?yún)⒆侄?fax ,即使 fax 和 phone 的校驗(yàn)邏輯完全一致,為了保證 TC 覆蓋率,也一樣需要 M 個(gè)新的 TC 。
而假設(shè)有 P 個(gè)方法中都用到了 phone 這個(gè)字段,這 P 個(gè)方法都需要對(duì)該字段進(jìn)行測(cè)試,也就是說(shuō)整體需要:
P?N?M
個(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 Explicit
將隱性的概念顯性化
在這里,我們可以看到,原來(lái)電話號(hào)僅僅是用戶的一個(gè)參數(shù),屬于隱形概念,但實(shí)際上電話號(hào)的區(qū)號(hào)才是真正的業(yè)務(wù)邏輯,而我們需要將電話號(hào)的概念顯性化,通過(guò)寫一個(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è)很重要的元素:
通過(guò) private final String number 確保 PhoneNumber 是一個(gè)(Immutable)Value Object。(一般來(lái)說(shuō) VO 都是 Immutable 的,這里只是重點(diǎn)強(qiáng)調(diào)一下)
校驗(yàn)邏輯都放在了 constructor 里面,確保只要 PhoneNumber 類被創(chuàng)建出來(lái)后,一定是校驗(yàn)通過(guò)的。
之前的 findAreaCode 方法變成了 PhoneNumber 類里的 getAreaCode ,突出了 areaCode 是 PhoneNumber 的一個(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)的SalesRepSalesRep rep = salesRepRepo.findRep(phone.getAreaCode());// 最后創(chuàng)建用戶,落盤,然后返回,這部分代碼實(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)在的寫法
service.register(new Name("殷浩"), new Address("浙江省杭州市余杭區(qū)文三西路969號(hào)"), new PhoneNumber("0571-12345678"));讓接口 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)?DP 的特性,只要是能夠帶到入?yún)⒗锏囊欢ㄊ钦_的或 null(Bean Validation 或 lombok 的注解能解決 null 的問(wèn)題)。所以我們把數(shù)據(jù)驗(yàn)證的工作量前置到了調(diào)用方,而調(diào)用方本來(lái)就是應(yīng)該提供合法數(shù)據(jù)的,所以更加合適。
再展開來(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ù)邏輯。( Entity 相關(guān)的重構(gòu)在后面文章會(huì)談到,這次先忽略)
▍評(píng)估4 - 可測(cè)試性
當(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)橹灰皇?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é)
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)?money 對(duì)應(yīng)的貨幣不一定是 CNY 。
在這個(gè) case 里,當(dāng)我們說(shuō)“支付 x 元”時(shí),除了 x 本身的數(shù)字之外,實(shí)際上是有一個(gè)隱含的概念那就是貨幣“元”。但是在原始的入?yún)⒗?#xff0c;之所以只用了 BigDecimal 的原因是我們認(rèn)為 CNY 貨幣是默認(rèn)的,是一個(gè)隱含的條件,但是在我們寫代碼時(shí),需要把所有隱性的條件顯性化,而這些條件整體組成當(dāng)前的上下文。所以 DP 的第二個(gè)原則是:
Make Implicit Context Explicit
將 隱性的 上下文 顯性化
所以當(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)?#xff1a;
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 不一定和 money 的 Curreny 一致,需要調(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的書 Secure by Design。
▍使用 Domain Primitive 的三原則
- 讓隱性的概念顯性化
- 讓隱性的上下文顯性化
- 封裝多對(duì)象行為
▍Domain Primitive 和 DDD 里 Value Object 的區(qū)別
在 DDD 中, Value Object 這個(gè)概念其實(shí)已經(jīng)存在:
- 在 Evans 的 DDD 藍(lán)皮書中,Value Object 更多的是一個(gè)非 Entity 的值對(duì)象
- 在Vernon的IDDD紅皮書中,作者更多的關(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ū)別
在日常開發(fā)中經(jīng)常會(huì)碰到的另一個(gè)數(shù)據(jù)結(jié)構(gòu)是 DTO ,比如方法的入?yún)⒑统鰠ⅰP 和 DTO 的區(qū)別如下:
▍什么情況下應(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> 等,盡量能把 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)?DP 本身不能帶狀態(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)雅、健壯、安全。你還在等什么?今天就去嘗試吧!
原文鏈接
本文為云棲社區(qū)原創(chuàng)內(nèi)容,未經(jīng)允許不得轉(zhuǎn)載。
總結(jié)
以上是生活随笔為你收集整理的阿里技术专家详解 DDD 系列- Domain Primitive的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: Timestream开发最佳实践
- 下一篇: 深度 | 打败围棋冠军后,机器智能下一步