摘要:本文從DDD的核心概念講起,重點放在如何把理論落地成程式碼,期望給那些正在探索DDD的同學一些指引和啟發。
本文分享自華為雲社群《跨越DDD從理論到工程落地的鴻溝》,作者:敏捷小智。
DDD作為一種優秀的設計思想,的確為複雜業務治理帶來了曙光。然而又因為DDD本身難以掌握,很容易造成DDD從理論到工程落地之間出現巨大的鴻溝。就像電影裡面的橋段,只談DDD理論姿勢很優美,一旦工程落地就跪了......所以DDD的專案,工程落地很重要,否則很容易變成“懂得了很多道理,卻依然過不好這一生”。
1. DDD的核心概念
DDD難以掌握的原因之一是因為其涉及很多概念,比如像Ubiquitous Language、Bounded Context、Context Mapping、Domain、Domain Service、Repository、Aggregation root、Entity、Value Object等等。這裡簡要介紹一下DDD的核心概念,瞭解這些概念可以更好的幫助我們落地DDD。
1.1 統一語言
Eric Evans在解釋DDD本質的時候,重點提到“Exploration and reshaping the ubiquitous languages",也就是探索並重塑統一語言。統一語言是DDD中非常重要的概念,因為語言是我們認知的基礎,語言都不統一,就像一個人說阿拉伯語,一個人說漢語,那怎麼能交流的起來呢?
對於統一語言,我建議每個專案都要有一份自己的核心領域詞彙表。這個詞彙表至少要包含中文、英文、縮寫、解釋四列,中文是我們日常交流和文件中經常要體現的,所以需要統一,這樣我們在交流的時候才能高效,沒有歧義;英文和英文縮寫主要體現在我們的設計和程式碼上,也就是說我們的“統一語言”不僅僅是停留在交流和文件中,還要和程式碼保持一致,這樣才能做到知行合一,提升程式碼的可讀性和系統的可理解性。
比如一個CRM系統,我們可以從業務需求中挖掘出一些重要的領域概念,把這些概念整理成詞彙表會如下所示。
有了這個核心領域詞彙表,以後團隊的交流、文件、設計和程式碼都應該以這個詞彙表為準,這裡需要注意的是,詞彙表中英文對中文的翻譯不一定非常“準確”,不過沒關係,語言就是一個符號,共識即正確,只要大家容易理解達成一致即可。就像上面詞彙表中私海這個概念的翻譯是Private Sea,這是一個典型的Chinglish,正統的翻譯是Territory,但是大家都認為Private Sea更容易理解,只要達成共識,用這個名稱也挺好。
1.2 限界上下文
大型軟體系統的單體結構很難應付日益膨脹的複雜度。和解決所有複雜問題一樣,除了分而治之,各個擊破,別無他法。事實證明,對於微服務的邊界劃分使用DDD的戰略設計是一個有效手段。AWS全球雲架構戰略副總裁Adrian Cockcroft就曾說過:
Microservices is a loosely-coupled, service-oriented architecture with bounded context.(微服務就是在限界上下文下的鬆耦合的SOA。)
如上圖所示,通過服務劃分,我們可以聚焦在一個大系統下的一個Bounded Context裡面,從而把原來大而複雜的問題空間,劃分成多個小的、可以理解的小問題域。
如何把一個大的模型分解成小的部分沒有什麼具體的公式。如果非要給服務劃分一個評判標準的話,那麼這個評判標準應該是高內聚低耦合。
高內聚體現在要儘量把那些相關聯的以及能形成一個自然概念的因素放在一個模型裡。如果我們發現兩個服務之間的互動過於緊密,比如有非常頻繁的API呼叫或者資料同步,那麼這兩個域可能都不夠內聚,放在一起可能會更好。
低耦合是和內聚性相對應的,如果領域不夠內聚,他們之間的耦合自然就高了。如果兩個領域,界限不清晰,領域高度重合,就會造成了嚴重的耦合問題。
系統耦合是一方面,人員耦合是另外一個考量因素。總體上來說,我不提倡微服務(Bounded Context)劃分的太細,因為服務太多,會加重運維成本。但服務也不能太粗,試想一下,如果一個服務需要8個人去維護,在上面做開發。那麼解決程式碼衝突,環境衝突,釋出等待都將是一個問題。通常一個服務,只需要一到兩個人維護是相對比較合理的粒度。
除了服務的粒度之外,關於領域型別我們也有必要去了解一下,領域的型別劃分旨在幫助我們理解領域的主次之分,從而知道什麼是我當前Bounded Context的核心。在DDD中,領域被分成三種型別。
- 核心域(Core Domain),顧名思義這是我領域的核心。有一點需要注意,Core的概念是隨著你視角的變化而變化的。對於本領域來說是Core,對於另外一個領域而言可能只是Support。
- 支撐子域(Supporting Subdomian),雖然不是當前問題的核心領域,但也是必不可少的。比如授信子服務離不開客戶資訊,所以客戶服務是授信服務的支撐子域。
- 通用子域(Generic Subdomain),如果一個子域被用於整個業務系統,那麼這個子域便是通用子域。通常像賬號、角色、許可權都是常見的通用子域,每個系統都需要。
1.3 上下文對映
通過上面的戰略設計,一個大型業務系統,會被劃分成多個各自獨立的Bounded Context,也就是多個微服務,這些服務需要互相協作,來完成完整的業務功能。
每一個限界上下文都有一套自己的“語言”,如果在該領域要使用其它領域的資訊,我們就需要一個“翻譯器”,把外域資訊翻譯成本領域的概念。這個在不同領域之間進行概念轉化、資訊傳遞的動作就叫上下文對映(Context Mapping)。上下文對映主要有兩種解決方案:共享核心和防腐層。
所謂的共享核心(Shared Kernel),是指把兩個子域中共同的實體概念抽取出來,形成一個元件(java中的jar包),然後通過內聯(inline)的方式,分別被不同的子域使用。
共享核心的最大好處是程式碼複用和能力共享,然而壞處也很明顯,即高耦合:任何對於“共享核心”的改動都要小心翼翼的協調兩個領域的技術團隊,且會影響兩個領域。說實話,這個副作用有點傷不起,所以在實踐中,更推薦的上下文對映方法是防腐層。
所謂的防腐層(Anti-Corruption,AC),是指在一個領域中,如果需要使用其它領域的資訊,可以通過AC進行防腐和轉義。實際上,在微服務的環境下,服務呼叫是一個普遍的訴求,因為沒有一個服務是孤立的,都需要藉助其它服務提供的資料,共同完成業務活動。
就像中國傍邊需要有個朝鮮,俄羅斯旁邊需要一箇中立的烏克蘭一樣,我們不能讓外領域的東西隨便“入侵”滲透到本領域,為了保證本領域的完整性和獨立性,我們需要做一層隔離和防腐,否則脣亡齒寒,國將不國。
AC的做法有一定的代價,因為你要做一次資訊轉換,把外域的資訊轉成本域的領域概念。其好處是雙方都擁有了更大的自主權和靈活度。系統架構就是這樣,我們永遠要在重複(Duplication)耦合低和複用(Reuse)耦合高之間取一個折中,進行權衡。
1.4 領域模型
領域模型將現實世界抽象為了資訊世界,把現實世界中的客觀物件,抽象為某一種資訊結構,而這種資訊結構並不依賴於具體的計算機系統。它不是對軟體設計的描述,它是和技術無關的(Technology-Free)。
例如,電商的核心領域模型就是商品、會員、訂單、營銷等實體,和你使用什麼技術實現是沒有關係的,你用Java可以實現,用PHP,GO也能實現。但不管是哪種技術實現方式,都不應該影響我們對領域模型的抽象和理解。
正因為領域模型的技術無關性,並且領域模型是我們的核心,這才有了洋蔥圈架構,即領域模型處在架構的最核心,並且不依賴任何外圍的技術細節。
這裡順便回答一下同學經常問的事務(Transaction)在哪裡實現的問題,為了保持領域的技術無關性,事務最好被管理在App的Service中。
關於如何設計領域模型,簡單來說,就是分析語言。這也是為什麼我們一直在強調統一語言的重要性,因為只有真正的理解了業務,把重要的領域概念闡述清楚,才有可能設計出比較好的領域模型。
具體的建立領域模型的步驟,可以分為以下三步:
- 理解問題:我們需要用簡短的語句把問題域描述清楚,使用者故事或者用例,是建模的關鍵前序動作。除了使用者故事外,我們當然也可以使用事件風暴(Event Storming),四色建模法等手段,只是我覺得使用者故事比較簡單易行,所以推薦用這種方式。
- 挖掘概念(Digging out concepts):領域概念隱含在語句中,重點關注語句中的名詞(nouns),因為nouns常常以為這重要的領域概念。這一步不容易做到,因為自然語言有很大的隨意性,很多同義詞、多義詞混淆其中。而且,有些關鍵概念也不一定就是名詞,也可能通過動詞(verbs)進行偽裝。
- 建立關聯:尋找關係,需要關注動詞(verbs)。因為關聯意味著兩個模型之間存在語義聯絡,在用例中的表現通常為兩個名詞被動詞連線起來。
2. 工程落地
Talk is cheap,show me the code。一切的一切,最終還是要落到程式碼上,而這一步也是造成問題最多的地方。
DDD本身是一個非常優秀的設計思想,關於這一點應該爭議不大。很多同學的困惑不在於DDD的思想,而在於不知道如何把DDD落到程式碼上。
“我的業務只是CRUD,為什麼還要Domain呢?”
“既然Domain是承載業務邏輯的地方,那我把業務邏輯都放進Domain可以嗎?”
2.1 都是CRUD為什麼要Domain?
任何的應用都是有一系列功能(functionality)和資料(data)組成。如果只有function沒有data,那麼它只是一個函式。相反,如果只有CRUD對資料的操作,那麼,它只是一個資料庫(database)。
可以說,一點業務邏輯沒有的應用基本上是不存在的,Domian層的價值就在於,它為我們提供了一種內聚業務邏輯、顯性化表達業務語義的地方。
以客戶註冊這個場景為例,如果註冊沒有什麼業務邏輯,只是往資料庫中插入一條記錄,那麼有沒有Domain都無所謂。然而,真實的業務當然不允許我們這樣做,業務專家們會提出很多業務規則,來防止那些不夠資格的人註冊成功。
而且業務需求還在不斷變化,有一些業務規則還被用在不同的地方,比如業務那邊發起了一個新的規則:從3月份起,註冊資本在1000萬以上的公司是大客戶,會有特殊的優惠政策。顯然,大客戶是一個比較重要的領域知識,而且可以預判這個概念不僅在註冊的時候,在其它地方也可能被用到。
現在你有兩種選擇,一種是如下所示,直接把這個業務規則追加到原來的業務邏輯上。
if(registeredCapital >= 1000W){ // do something }
另一種是,我們把這個重要的業務概念,內聚到領域實體身上,顯性化的表達出來。
// 領域能力沉澱 Customer{ private long registeredCapital; // 判斷是否大客戶 public boolean isBigClient(){ return registeredCapital >= 1000W; } } // 使用 if(customer.isBigClient()){ // do something }
很明顯,第二種領域封裝的方式會更好,它至少有兩個好處。其一,業務語義得到顯性化的表達,大客戶(bigClient)的概念就直接呈現在程式碼中。其二,能更好的應對變化,比如有一天我們對大客戶的定義發生變化,除了註冊資本之外,還要看員工數,那麼只需要修改isBigClient( ),而第一種做法要散彈式修改所有需要關注大客戶概念的地方。
類似於“大客戶”這樣的領域知識(Domain Knowledge),就非常適合Domain層來承載。因為Domain裡面有我們最重要的領域概念、領域實體,再加上領域能力(也就是那些業務規則),從而形成所謂的 Knowledge Rich Design (知識豐富的設計)。從這個意義上來說,領域模型只是我們領域知識的一部分,業務活動和規則如同所涉及的實體一樣,都是領域的核心。
除此之外,在當前服務化、分散式大行其道的今天,我們的資料也不一定就是存在本地的資料庫,很可能這個資料是來自於另一個服務,這種情況下,Domain層給我們提供了一個在當前限界上下文(Bounded Context)裡,對外域進行防腐、隔離的機會。
2.2 Domain層是必選的嗎?
“按你這麼說,我一定需要這個Domain層咯?可是Domain層的實體模型和資料模型的轉換,成本有點高啊!”
有此顧慮的同學不在少數,的確,Domain層作為原來三層架構之外新引入的層次,會帶來一些額外的成本。關於這個問題,與其把Domain層當成負擔,不如把它當成是一個機會或者投資,既然是投資,我們就要看ROI(投入產出比)。
捫心自問,我當前對Domain的投資——抽象、領域建模、領域能力沉澱等,是否提升了我程式碼的可讀性、可理解性,或者從長期來看提升了系統的可維護性,如果ROI成正比,就值得去做。
有沒有ROI不成正比的時候呢?有的,比如簡單的Query,可能就是讀取資料,沒有什麼業務邏輯,那麼我們也完全可以繞過Domain層,讓資料模型直接轉換成DTO,減少一層資料轉換,這也是CQRS(Command Query Responsibility Segregation)所提倡的。
作為一個“沒有銀彈”的信徒,我很認同佛瑞德·布魯克斯的觀點。雖然Domain非常有用,但也不是“銀彈”。所以如下圖所示,在設計DDD的應用架構時,比如我開源的COLA架構。我更願意把Domain層設計成開放的,這種開放性不僅體現在CQRS的時候,App可以繞過Domain層直達Infrastructure;也體現在當你的團隊實在hold不住DDD的時候,可以選擇退化到老的三層架構。
雖然可以退化,但不應該成為你輕易放棄Domain層的理由。據我觀察,很多同學不喜歡DDD,其根本原因還不在於物件之間的轉換成本(實際上,這個轉換成本也沒那麼大),而在於他不清楚Domain的職責,不知道哪些東西應該放到Domain裡面。一種典型的錯誤做法是把所有的業務邏輯都放到了Domain層,包括我們上面說的CRUD統統放到了領域層,這樣的DDD當然沒人喜歡。
2.3 把業務邏輯都寫進Domain?
每當我看到同學把所有業務邏輯都寫進Domain層,我就會問他,“你這樣把App層的所有業務邏輯都搬到Domain層,能得到什麼益處呢?和把這些程式碼直接放在App層的區別在哪裡呢?況且,放在App層,因為少了一個層次,程式碼會更加簡單,為什麼要勞心勞力的再加一個Domain層呢?”
“那要怎麼辦呢?”同學一邊點頭一邊疑惑地問。
我給的方案是“先把App做厚,再把App做薄”。什麼意思?就是我們先可以把業務邏輯都寫到App裡面,在寫的過程中,我們會發現有一些業務邏輯,不僅僅是過程式的程式碼,它也是領域知識(Domain knowledge),應該被更加清晰、更加內聚的表達出來,那麼我們就可以把這段程式碼沉澱為領域能力。
舉一個例子,還是以使用者註冊為例,一開始,正如我們一直這樣做的,直接在App層寫出如下的過程程式碼:
public class CustomerServiceImpl { private CustomerGateway customerGateway; private HealthCodeService healthCodeService; public void register(CustomerDTO customerDTO){ Customer customer = Customer.fromDTO(customerDTO); // 1. 校驗年齡 if(customer.getAge() < 18){ BizException.of("對不起,你未滿18歲"); } // 2. 校驗國籍 if(!customer.getCountry().equals("china")){ BizException.of("對不起,你不是中國人"); } // 3. 檢視健康碼,需要呼叫另外一個服務。 HealthCodeRequest request = new HealthCodeRequest(); request.idCardNo = customer.getIdCardNo(); HealthCodeResponse response = healthCodeService.check(request); if(!response.isSuccess()){ BizException.of("無法驗證健康碼,請稍後再試"); } if(!response.getHealthCode().equals("green")){ BizException.of("對不起,你不是綠碼"); } // 4. 註冊使用者 customerGateway.save(customer); } }
寫好後,我們再回過頭來審視一下,看看哪些東西可以沉澱為領域能力,然後優化我們的程式碼。
我們先看年齡和國籍校驗,年齡和國籍都是customer的屬性,那麼誰對它們最熟悉呢?當然是customer自身了,對於這樣的業務知識,無能是從可理解性的角度,還是從功能內聚和複用性的角度,把它們沉澱到customer身上都會更合適,於是,我們可以在customer實體上沉澱這些業務知識:
public void isRequiredAge(){ if(age < 18){ BizException.of("對不起,你未滿18歲"); } } public void isValidCountry(){ if(!country.equals("china")){ BizException.of("對不起,你不是中國人"); } }
健康碼有點特殊,雖然它也是Customer的健康碼,但是它並不存在於本應用中,而是存在於另一個服務中,需要通過遠端呼叫的方式來獲取。這在我們的分散式系統中,是非常常見的現象,即我們要通過分散式的服務互動來共同完成業務功能。
如果直接呼叫外部系統,基於外系統的DTO,當然也能完成程式碼功能,但這樣做會有三個問題:
- 表達晦澀,我只是要檢查一下健康碼,卻有一堆的程式碼。(這只是示意,真實的遠端呼叫肯定要比這個程式碼多)
- 複用性差,校驗健康碼不僅僅客戶註冊會用到,可能很多客戶相關的操作都會用到,難道都要這麼寫一遍?
- 沒有防腐和隔離,HealthCodeResponse不是我這個領域的東西,怎麼能讓它如此輕易的侵入到我的業務程式碼中呢?
解決上面的問題,我們就可以充分發揮Domain層防腐的作用,使用上下文對映(Context Mapping),把外領域的資訊對映到本領域。即我可以認為HealthCode就是屬於Customer的,至於這個HealthCode是怎麼來的,那是Gateway和infrastructure要幫我處理的問題,它可能來自於自身的資料庫,也可能來自於RPC的遠端呼叫,總之那是infrastructure要處理的“技術細節”問題,對於上層的業務程式碼不需要關心。
按照這樣的思路,我們可以新建一個HealthCodeGateway來解開對健康碼系統的耦合。
/** * 對外系統的依賴通過gateway進行解耦 */ public interface HealthCodeGateway { public String getHealthCode(String idCardNo); }
於此同時,把如何獲取HealthCode這樣的技術細節問題丟給infrastructure去處理。
/** * 在infrastructure中,完成如何獲取healthCode的細節問題 */ public class HealthCodeGatewayImpl implements HealthCodeGateway{ private HealthCodeService healthCodeService; @Override public String getHealthCode(String idCardNo) { HealthCodeRequest request = new HealthCodeRequest(); request.idCardNo = idCardNo; HealthCodeResponse response = healthCodeService.check(request); if(!response.isSuccess()){ BizException.of("無法驗證健康碼,請稍後再試"); } return response.getHealthCode(); } }
最後,我們把從gateway獲取到的healthCode賦值給customer,對於customer來說,這個healthCode是遠端呼叫拿到的,還是從資料庫拿到的,它並不需要關心。
public class Customer { ... // 你雖然是遊蕩在外面的遊子,但我帶你如同己出 private String healthCode; public void isHealthCodeGreen(){ if(healthCode == null){ healthCode = healthCodeGateway.getHealthCode(idCardNo); } if(!healthCode.equals("green")){ BizException.of("對不起,你不是綠碼"); } } ... }
經過一系列的“能力下沉”之後,我們原來的客戶註冊邏輯,醜小鴨變白天鵝,成了下面這樣的clean code。
public class CustomerServiceImpl { private CustomerGateway customerGateway; public void register(CustomerDTO customerDTO){ Customer customer = Customer.fromDTO(customerDTO); // 1. 校驗年齡 customer.isRequiredAge(); // 2. 校驗國籍 customer.isValidCountry(); // 3. 檢視健康碼,需要呼叫另外一個服務。 customer.isHealthCodeGreen(); // 4. 註冊使用者 customerGateway.save(customer); } }
除了程式碼變得clean之外,程式碼的可理解性也提高了,因為原來那些過程式平鋪的程式碼,被合理的內聚到領域實體身上之後,其程式碼的表達能力獲得了提升。閱讀這種程式碼的體驗應該和閱讀語句通順的短文無異,差不多是這樣的感覺:“if customer is required age, customer is in valid country, customer's health code is green, then save this customer to be registered”
所以,我們在落地DDD的時候,千萬要小心不要落入概念的教條,而是要始終盯著我們的北極星目標——即系統的可理解性、可維護性,以及程式碼的可讀性。如果你的DDD不僅沒有到達這些目標,反而讓系統變得更復雜,更難理解,給開發者帶來額外的負擔。那麼就應該停下來,反思一下,我是不是走偏了。
3. 上下結合跨越鴻溝
本文通過程式碼案例的方式,嘗試解答一下大家在實施DDD過程中,常見的困惑問題。希望給到大家一個相對正確的落地DDD工程的開發正規化,總結一下,這個正規化大概可以分為以下七個步驟:
- 梳理業務:梳理業務流程,挖掘領域概念,形成統一語言。
- 戰略設計:劃分領域邊界,建立限界上下文。
- 戰術設計:尋找實體,建立關係,形成領域模型。
- API設計:根據使用者故事,輸出服務功能API。
- 做厚App:根據API功能要求,在App層編寫業務過程程式碼。
- 做薄App:以領域模型為基礎,優化過程程式碼,沉澱領域能力和領域知識,讓業務語義顯性化,做到Knowledge Rich Design (知識豐富的設計)。
- 技術細節:完善技術細節程式碼,比如API的暴露方式(RPC 或者 Restful),資料的儲存方式(關聯式資料庫 或者 NoSQL),ORM框架的選用(MyBatis 或者 JPA)等等。
最後,我想再次強調,好的Domain層,不僅僅需要設計,更是在開發過程中,迴圈迭代沉澱出來的。用一句話來形容這個過程就是:自上而下的結構化分解,自下而上的抽象建模,迴圈迭代沉澱領域能力。