領域驅動設計的概念
大家都知道軟件開發不是一蹴而就的事情,我們不可能在不瞭解產品(或行業領域)的前提下進行軟件開發,在開發前通常需要進行大量的業務知識梳理,然後才能到軟件設計的層面,最後纔是開發。而在業務知識梳理的過程中,必然會形成某個領域知識,根據領域知識來一步步驅動軟件設計,就是領域驅動設計(DDD,Domain-Driven Design)的基本概念 。
爲什麼需要 DDD
在業務初期,功能大都非常簡單,普通的 CRUD 就基本能滿足要求,此時系統是清晰的。但隨着產品的不斷迭代和演化,業務邏輯變得越來越複雜,我們的系統也越來越冗雜。各個模塊之間彼此關聯,甚至到後期連相應的開發者都很難說清模塊的具體功能和意圖到底是啥。這就會導致在想要修改一個功能時,要追溯到這個功能需要修改的點就要很長時間,更別提修改帶來的不可預知的影響面。 比如下圖所示:
訂單服務中提供了查詢、創建訂單相關的接口,也提供了訂單評價、支付的接口。同時訂單表是個大表,包含了非常多字段。我們在維護代碼時,將會導致牽一髮而動全身,很可能原本我們只是想改下評價相關的功能,卻影響到了創建訂單的核心流程。雖然我們可以通過測試來保證功能的完備性,但當我們在訂單領域有大量需求同時並行開發時將會出現改動重疊、惡性循環、疲於奔命修改各種問題的局面,而且大量的全量回歸會給測試帶來不可接受的災難。
但現實中絕大部分公司都是這樣一個狀態,然後一般他們的解決方案是不斷的重構系統,讓系統的設計隨着業務成長也進行不斷的演進。通過重構出一些獨立的類來存放某些通用的邏輯解決混亂問題,但是我們很難給它一個業務上的含義,只能以技術緯度進行描述,那麼帶來的問題就是其他人接手這塊代碼的時候不知道這個的含義或者只能通過修改通用邏輯來達到某些需求。
領域模型追本溯源
實際上,領域模型本身也不是一個陌生的單詞,說直白點,在早期開發中,領域模型就是數據庫設計。因爲你想:我們做傳統項目的流程或者說包括現在我們做項目的流程,都是首先討論需求,然後是數據庫建模,在需求逐步確定的過程不斷的去變更數據庫的設計,接着我們在項目開發階段,發現有些關係沒有建、有些字段少了、有些表結構設計不合理,又在不斷的去調整設計,最後上線。在傳統項目中,數據庫是整個項目的根本,數據模型出來以後後續的開發都是圍繞着數據展開,然後形成如下的一個架構 :
很顯然,這其中存在的問題如下:
我們試想一下如果一個軟件產品不依賴數據庫存儲設備,那我們怎麼去設計這個軟件呢?如果沒有了數據存儲,那麼我們的領域模型就得基於程序本身來設計。那這個就是 DDD 需要去考慮的問題。
DDD中的基本概念
實體(Entity)
當一個對象由其標識(而不是屬性)區分時,這種對象稱爲實體(Entity)。比如當兩個對象的標識不同時,即使兩個對象的其他屬性全都相同,我們也認爲他們是兩個完全不同的實體。
值對象(Value Object)
當一個對象用於對事物進行描述而沒有唯一標識時,那麼它被稱作值對象。因爲在領域中並不是任何時候一個事物都需要有一個唯一的標識,也就是說我們並不關心具體是哪個事物,只關心這個事物是什麼。比如下單流程中,對於配送地址來說,只要是地址信息相同,我們就認爲是同一個配送地址。由於不具有唯一標示,我們也不能說"這一個"值對象或者"那一個"值對象。
領域服務(Domain Service)
一些重要的領域行爲或操作,它們不太適合建模爲實體對象或者值對象,它們本質上只是一些操作,並不是具體的事物,另一方面這些操作往往又會涉及到多個領域對象的操作,它們只負責來協調這些領域對象完成操作而已,那麼我們可以歸類它們爲領域服務。它實現了全部業務邏輯並且通過各種校驗手段保證業務的正確性。同時呢,它也能避免在應用層出現領域邏輯。理解起來,領域服務有點facade的味道。
聚合及聚合根(Aggregate,Aggregate Root)
聚合是通過定義領域對象之間清晰的所屬關係以及邊界來實現領域模型的內聚,以此來避免形成錯綜複雜的、難以維護的對象關係網。聚合定義了一組具有內聚關係的相關領域對象的集合,我們可以把聚合看作是一個修改數據的單元。
聚合根屬於實體對象,它是領域對象中一個高度內聚的核心對象。(聚合根具有全局的唯一標識,而實體只有在聚合內部有唯一的本地標識,值對象沒有唯一標識,不存在這個值對象或那個值對象的說法)
若一個聚合僅有一個實體,那這個實體就是聚合根;但要有多個實體,我們就要思考聚合內哪個對象有獨立存在的意義且可以和外部領域直接進行交互。
工廠(Factory)
DDD中的工廠也是一種封裝思想的體現。引入工廠的原因是:有時創建一個領域對象是一件相對比較複雜的事情,而不是簡單的new操作。工廠的作用是隱藏創建對象的細節。事實上大部分情況下,領域對象的創建都不會相對太複雜,故我們僅需使用簡單的構造函數創建對象就可以。隱藏創建對象細節的好處是顯而易見的,這樣就可以不會讓領域層的業務邏輯泄露到應用層,同時也減輕應用層負擔,它只要簡單調用領域工廠來創建出期望的對象就可以了。
倉儲(Repository)
資源倉儲封裝了基礎設施來提供查詢和持久化聚合操作。這樣能夠讓我們始終關注在模型層面,把對象的存儲和訪問都委託給資源庫來完成。它不是數據庫的封裝,而是領域層與基礎設施之間的橋樑。DDD 關心的是領域內的模型,而不是數據庫的操作。
實戰演練如何讓DDD落地
DDD 概念理解起來有點抽象, 這個有點像設計模式,感覺很有用,但是自己開發的時候又不知道怎麼應用到代碼裏面,或者生搬硬套後自己看起來都很彆扭,那麼接下來我們就以一個簡單的轉盤抽獎案例來分析一下 DDD 的應用。
針對功能層面劃分邊界
這個系統可以劃分爲運營管理平臺和用戶使用層,運營平臺對於抽獎的配置比較複雜但是操作頻率會比較低。而用戶對抽獎活動頁面的使用是高頻率的但是對於配置規則來 說是誤感知的,根據這樣的特點,我們把抽獎平臺劃分針 對 C 端抽獎和 M 端抽獎兩個子域。
在確認了 M 端領域和 C 端的限界上下文後,我們再對各 自上下文內部進行限界上下文的劃分,接下來以 C 端用戶爲例來劃分界限上下文。
確認基本需求
首先我們要來了解該產品的基本需求
-
抽獎資格(什麼情況下會有抽獎機會、抽獎次數、抽 獎的活動起始時間) 。
-
抽獎的獎品(實物、優惠券、理財金、購物卡…) 。
-
獎品自身的配置,概率、庫存、某些獎品在有限的概率下還只能被限制抽到多少次等。
-
風控對接, 防止惡意薅羊毛。
針對產品功能劃分邊界
抽獎上下文是整個領域的核心,負責處理用戶抽獎的核心業務。
-
對於抽獎的限制,我們定義了抽獎資格的通用語言,將抽獎開始 / 結束時間,抽獎可參與次數等限制條件都收攏到抽獎資格子域中。
-
由於 C 端存在一些刷單行爲,我們根據產品需求定義了風控上下文,用於對抽獎進行風控。
-
由於抽獎和發放獎品其實可以認爲是兩個領域,一個負責根據概率去抽獎、另一個負責將抽中的獎品發放出去,所以對於這一塊也獨立出來一個領域。
細化上下文
通過上下文劃分以後,我們還需要進一步梳理上下文之間的關係,梳理的好處在於:
-
任務更好拆分(一個開發人員可以全身心投入到相關子域的上下文中) 。
-
方便溝通,明確自身上下文和其他上下文之間的依賴關 系,可以實現更好的對接。
代碼設計
在實際開發中,我們一般會採用模塊來表示一個領域的界 限上下文,比如:
對於模塊內的組織結構,一般情況下我們是按照領域對象、 領域服務、領域資源庫、防腐層等組織方式定義的。
部分代碼如下:
抽獎聚合根:
擁有抽獎活動id和該活動下所有可用的獎池列表,它最主要的領域功能是根據一個抽獎的場景(DrawLotteryContext),通過chooseAwardPool方法篩選出一個匹配的獎池。
package com.hafiz.business.lottery.domain.aggregate;import ...;public class DrawLottery { private int lotteryId; // 抽獎id private List<AwardPool> awardPools; // 獎池列表 public void setLotteryId(int lotteryId) { if (lotteryId < 0) { throw new IllegalArgumentException("非法的抽獎id"); } this.lotteryId = lotteryId; } public AwardPool chooseAwardPool(DrawLotteryContext context) { ... }}
import ...;
public class DrawLottery {
private int lotteryId; // 抽獎id
private List<AwardPool> awardPools; // 獎池列表
public void setLotteryId(int lotteryId) {
if (lotteryId < 0) {
throw new IllegalArgumentException("非法的抽獎id");
}
this.lotteryId = lotteryId;
}
public AwardPool chooseAwardPool(DrawLotteryContext context) {
...
}
}
獎池值對象:
package com.hafiz.business.lottery.domain.valobj;import ...;public class AwardPool { private String cityIds; // 獎池支持的城市 private String scores; // 獎池支持的得分 private int userGroupType; // 獎池匹配的用戶類型 private List<Award> awards; // 獎池中包含的獎品 public boolean matchedCity(int cityId) { ... } public boolean matchedScore(int score) { ... } public Award randomGetAward() { int sumOfProbablity = 0; for (Award award : awards) { sumOfProbablity += award.getAwardProbablity(); } int randomNumber = ThreadLocalRandom.current().netInt(sumOfProbablity); int range = 0; for (Award award : awards) { range += award.getAwardProbablity(); if (randomNumber < range) { return award; } } return null; }}
import ...;
public class AwardPool {
private String cityIds; // 獎池支持的城市
private String scores; // 獎池支持的得分
private int userGroupType; // 獎池匹配的用戶類型
private List<Award> awards; // 獎池中包含的獎品
public boolean matchedCity(int cityId) {
...
}
public boolean matchedScore(int score) {
...
}
public Award randomGetAward() {
int sumOfProbablity = 0;
for (Award award : awards) {
sumOfProbablity += award.getAwardProbablity();
}
int randomNumber = ThreadLocalRandom.current().netInt(sumOfProbablity);
int range = 0;
for (Award award : awards) {
range += award.getAwardProbablity();
if (randomNumber < range) {
return award;
}
}
return null;
}
}
抽獎資源庫:
我們屏蔽對底層獎池及獎品的直接訪問,僅對抽獎的聚合根資源進行管理。
package com.hafiz.business.lottery.domain.repo;import ...;public class DrawLotteryRepository { @Autowried private AwardDao awardDao; @Autowried private AwardPoolDao awardPoolDao; @Autowried private DrawLotteryCacheAccessObj drawLotteryCacheAccessObj; public DrawLottery getDrawLotteryById(int lotteryId) { DrawLottery drawLottery = drawLotteryCacheAccessObj.get(lotteryId); if (drawLottery != null) { return drawLottery; } drawLottery = getDrawLotteryFromDB(lotteryId); drawLotteryCacheAccessObj.add(lotteryId, drawLottery); return drawLottery; } private DrawLottery getDrawLotteryFromDB() { ... }}
import ...;
public class DrawLotteryRepository {
@Autowried
private AwardDao awardDao;
@Autowried
private AwardPoolDao awardPoolDao;
@Autowried
private DrawLotteryCacheAccessObj drawLotteryCacheAccessObj;
public DrawLottery getDrawLotteryById(int lotteryId) {
DrawLottery drawLottery = drawLotteryCacheAccessObj.get(lotteryId);
if (drawLottery != null) {
return drawLottery;
}
drawLottery = getDrawLotteryFromDB(lotteryId);
drawLotteryCacheAccessObj.add(lotteryId, drawLottery);
return drawLottery;
}
private DrawLottery getDrawLotteryFromDB() {
...
}
}
防腐層:
以用戶信息防腐層爲例,它的入參是抽獎請求參數(LotteryContext),輸出爲城市信息(CityInfo)。
package com.hafiz.business.lottery.domain.facade;import ...;public class UserCityInfoFacade { @Autowried private CityService cityService; public CityInfo getCityInfo (LotteryContext context) { CityRequest request = new CityRequest(); request.setLat(context.getLat()); request.setLng(context.getLng()); CityReponse reponse = cityService.getCityInfo(request); return buildCityInfo(reponse); } private CityInfo buildCityInfo(CityReponse reponse) { ... }}
import ...;
public class UserCityInfoFacade {
@Autowried
private CityService cityService;
public CityInfo getCityInfo (LotteryContext context) {
CityRequest request = new CityRequest();
request.setLat(context.getLat());
request.setLng(context.getLng());
CityReponse reponse = cityService.getCityInfo(request);
return buildCityInfo(reponse);
}
private CityInfo buildCityInfo(CityReponse reponse) {
...
}
}
抽獎領域服務:
package com.hafiz.business.lottery.domain.service.impl;import ...;@Servicepublic class LotteryServiceImpl implements LotteryService { @Autowried private DrawLotteryRepository drawLotteryRepository; @Autowried private UserCityInfoFacade userCityInfoFacade; @Autowried private AwardSenderService awardSenderService; @Autowried private AwardCountFacade awardCountFacade; public LotteryReponse drawLottery(LotteryContext context) { // 獲取抽獎聚合根 DrawLottery drawLottery = drawLotteryRepository.getDrawLotteryById(context.getLotteryId()); // 增加抽獎計數信息 awardCountFacade.incrTryCount(context); // 選中獎池 AwardPool awardPool = drawLottery.chooseAwardPool(context); // 抽出獎品 Award award = awardPool.randomGetAward(); // 發出獎品 return buildLotteryReponse(awardSenderService.sendeAward(award, context)); } private LotteryReponse buildLotteryReponse(AwardSendReponse awardSendReponse) { ... }}
import ...;
@Service
public class LotteryServiceImpl implements LotteryService {
@Autowried
private DrawLotteryRepository drawLotteryRepository;
@Autowried
private UserCityInfoFacade userCityInfoFacade;
@Autowried
private AwardSenderService awardSenderService;
@Autowried
private AwardCountFacade awardCountFacade;
public LotteryReponse drawLottery(LotteryContext context) {
// 獲取抽獎聚合根
DrawLottery drawLottery = drawLotteryRepository.getDrawLotteryById(context.getLotteryId());
// 增加抽獎計數信息
awardCountFacade.incrTryCount(context);
// 選中獎池
AwardPool awardPool = drawLottery.chooseAwardPool(context);
// 抽出獎品
Award award = awardPool.randomGetAward();
// 發出獎品
return buildLotteryReponse(awardSenderService.sendeAward(award, context));
}
private LotteryReponse buildLotteryReponse(AwardSendReponse awardSendReponse) {
...
}
}
領域驅動的好處
用 DDD 可以很好的解決領域模型到設計模型的同步、演進最後映射到實際的代碼邏輯,總的來說,DDD 開發模式有以下幾個好處 :
-
DDD 能讓我們知道如何抽象出限界上下文以及如何去分而治之。
-
分而治之 : 把複雜的大規模軟件拆分成若干個子模塊,每一個模塊都能獨立運行和解決相關問題。並且分割後各個部分可以組裝成爲一個整體。
-
抽象 : 使用抽象能夠精簡問題空間,而且問題越小越容易理解,比如說我們要對接支付,抽象的緯度應該是支付,而不是具體的微信支付還是支付寶支付。
-
-
DDD 的限界上下文可以完美匹配微服務的要求。在系統複雜之後,我們都需要用分治來拆解問題。一般有兩種方式,技術維度和業務維度。技術維度是類似 MVC 這 樣,業務維度則是指按業務領域來劃分系統。 微服務架構更強調從業務維度去做分治來應對系統複雜度, 而 DDD 也是同樣的着重業務視角。
總結
其實我們可以簡單認爲領域驅動設計是一種指導思想,是一種軟件開發方法。通過 DDD 我們可以將系統結構設計的更加合理, 以便最終滿足高內聚低耦合的要求。在我的觀點來看,有點類似數據庫的三範式,我們開始在學的時候並不太理解,當有足夠的設計經驗以後慢慢會體會到三範式帶來的好處。同時我們也並不一定需要完全嚴格按照這三範式去進行實踐,有些情況下是可以也需要靈活調整的。
推薦一個很不錯的DDD課程,作者結合十餘年實踐領域驅動設計的經驗與心得,並糅合了 DDD 社區最新發展的理論知識與最佳實踐,策劃了《領域驅動設計實踐》系列課程,可以稱得上是一個全面系統講解 DDD 的原創課程。