DDD方法論核心是將問題不斷分解,把大問題分解為小問題,大業務分解小領域,簡而言之就是分而治之,各個擊破。
分而治之是指直接面對大業務我們無從下手,需要按照一定方法進行分解,分解為高內聚的小領域,使得業務有邊界清晰,而這些小領域是我們有能力處理的,這就是領域驅動設計的核心。
各個擊破是指當問題被拆分為小領域後,因為小領域業務內聚,其子領域高度相關,我們在技術維度可以對其進行詳細設計,在管理維度可以按照領域對專案進行分工。需要指出DDD不能替代詳細設計,DDD是為了更清晰地行詳細設計。
在微服務流行的網際網路行業,當業務逐漸複雜時,技術人員需要解決如何劃分微服務邊界的問題,DDD這種清晰化業務邊界的特性正好可以用來解決這個問題。
我們的目標是將業務劃分清晰的邊界,而DDD是達成目標的有效方法之一,這一點是需要格外注意的。DDD是方法不是目標,不需要為了使用而使用。例如業務模型比較簡單可以很容易分析的業務就不需要使用DDD,還有一些目標是快速驗證型別的專案,追求短平快,前期可能也不需要使用領域驅動設計。
領域可以劃分多個子領域,子域可以再劃分多個子子域,限界上下文字質上也是一種子子域,那麼在業務分解時一個業務模組到底是領域、子域還是子子域?
我認為不用糾結在這個問題,因為這取決於看待這個模組的角度。你認為整體可能是別人的區域性,你認為的區域性可能是別人的整體,叫什麼名字不重要,最重要的是按照高內聚的原則將業務高度相關的模組收斂在一起。
業務劃分粒度的粗細並沒有統一的標準,還是要根據業務需要、開發資源、技術實力等因素綜合考量。例如微服務拆分過細反而會增加開發、部署和維護的複雜度,但是拆分過粗可能會導致大量業務高度耦合,開發部署起來是挺快的,但是缺失可維護性和可延伸性,這需要根據實際情況做出權衡。
領域物件與資料物件一個重要的區別是值物件儲存方式。在討論領域物件和資料物件之前,我們首先討論實體和值物件這一組概念。實體是具有唯一標識的物件,而唯一標識會伴隨實體物件整個生命週期並且不可變更。值物件本質上是屬性的集合,並沒有唯一標識。
領域物件在包含值物件的同時也保留了值物件的業務含義,而資料物件可以使用更加鬆散的結構儲存值物件,簡化資料庫設計。
現在假設我們需要管理足球運動員資訊,對應的領域模型和資料模型應該如何設計?姓名、身高、體重是一名運動員本質屬性,加上唯一編號可以對應實體物件。跑動距離,傳球成功率,進球數是運動員比賽中的表現,這些屬性的集合可以對應值物件。
值物件在資料物件中可以用鬆散的資料結構進行儲存,而值物件在領域物件中需要保留其業務含義:
抽象的核心是找相同,對不同事物提取公因式。實現的核心是找不同,擴充套件各自的屬性和特點。例如模板方法設計模式正是用抽象構建框架,用實現擴充套件細節。
我們再回到資料模型的討論,可以發現指令碼化是一種拓展靈活性的方式,指令碼化不僅指使用groovy、QLExpress指令碼增強系統靈活性,還包括鬆散可延伸的資料結構。資料模型抽象出了姓名、身高、體重這些基本屬性,對於頻繁變化的比賽表現屬性,這些屬性值可能經常變化,甚至屬性本身也是經常變化,例如可能會加上射門次數,突破次數等,所以採用鬆散的JSON資料結構進行儲存。
這三個詞雖然不同但是實際上都是在描述範圍這個概念。正如牛頓三定律有其適用範圍,程式中變數有其作用域一樣,DDD方法論也會將整體業務拆分成不同範圍,在同一個範圍內進行才可以進行分析和處理。
限界上下文(Bounded contenxt)比較難理解可以從四個維度分析:
第一個維度是限界上下文字身含義。限界表示了規定一個邊界,上下文表示在這個邊界內使用相同語意物件。例如goods這個詞,在商品邊界內被稱為商品,但是快遞邊界內被稱為貨物。
第二個維度是子域與限界上下文關係。子域可以對應一個,也可以對應多個限界上下文。如果子域劃分足夠小,那麼就是限界上下文。如果子域可以再細分,那麼可以劃分多個限界上下文。
第三維度是服務如何劃分。子域和限界上下文都可以作為微服務,這裡微服務是指獨立部署的程式程序,具體拆分到什麼維度是根據業務需要、開發資源、維護成本、技術實力等因素綜合考量。
第四個維度是互動維度。在同一個限界上下文中實體物件和值物件可以自由交流,在不同限界上下文中必須通過聚合根進行交流。聚合根可以理解為一個按照業務聚合的代理物件。
領域模型分為三類:實體、值物件和聚合。實體是具有唯一標識的物件,唯一標識會伴隨實體物件整個生命週期並且不可變更。值物件本質上是屬性的集合,沒有唯一標識。
聚合包括聚合根和聚合邊界兩個概念,聚合根可以理解為一個按照業務聚合的代理物件,一個限界上下文企圖存取另一個限界上下文內部物件,必須通過聚合根進行存取。例如產品經理作為需求收口人,任何需求應該先提給產品經理,通過產品經理整合後再提給程式設計師,而不是直接提給開發人員。
當某個領域發生一件事情時,如果其它領域有後續動作跟進,我們把這件事情稱為領域事件,這個事件需要被感知。
通過事件互動有一個問題需要注意,通過事件訂閱實現業務只能採用最終一致性,需要放棄強一致性,可能會引入新的複雜度需要權衡。同一個程序間事件互動可以用EventBus,跨程序事件互動可以用RocketMQ等訊息中介軟體。
每個維度描述系統的一個側面,組合在一起最終描繪出整個系統,這些維度分別是:
四色分領域
用例看功能
流程三劍客
領域與資料
縱橫做設計
分層看架構
介面看對接
本文我們分析一個足球運動員資訊管理系統,這個系統大家可能也沒有做過,我們一起分析這個系統。需要說明本文著重介紹方法論的落地,業務細節難以面面俱到。
首先梳理業務流程,這裡有兩個問題需要考慮,第一個問題是從什麼視角去梳理?因為不同的人看到的流程是不一樣的。答案是取決於系統需要解決什麼問題,因為我們要管理運動員從轉會到上場比賽整條鏈路資訊,所以從運動員視角出發是一個合適的選擇。
第二個問題是對業務不熟悉怎麼辦?因為我們不是體育和運動專家,並不清楚整條鏈路的業務細節。答案是梳理流程時一定要有業務專家在場,因為沒有真實業務細節,無法領域驅動設計。同理在網際網路梳理複雜業務流程時,一定要有對相關業務熟悉的產品經理或者運營一起參與。
假設足球業務專家梳理出了業務流程,運動員提出轉會,協商一致後到新俱樂部體檢,體檢通過就進行簽約。進入新俱樂部後進行訓練,訓練指標達標後上場比賽,賽後參加新聞釋出會。當然實際流程會複雜很多,本文還是著重講解方法論。
四色建模第一種顏色是紅色,表示時標物件。時標物件是四色建模最重要的物件,可以理解為核心業務單據。在業務進行過程中一定要對關鍵業務留下單據,通過這些單據可以追溯出整個業務流程。
時標物件具有兩個特點:第一是事實不可變性,記錄了過去某個時間點或時間段內發生的事實。第二是責任可追溯性,記錄了管理者關注的資訊。現在我們分析本系統時標物件有哪些,需要留下哪些核心業務單據。
轉會對應轉會單據,體檢對應體檢單據,籤合同對應合同單據,訓練對應訓練指標單據,比賽對應比賽指標單據,新聞釋出會對應採訪單據。根據分析繪製如下時標物件:
這三類物件在四色建模中用綠色表示,我們以電商場景為例進行說明。使用者支付購買商家的商品時,使用者和商家是參與方。物流系統發貨時配送單據需要有配送地址物件,地址物件就是地。訂單需要商品物件,物流配送需要有貨品,商品和貨品就是物。
我們分析本例可以知道參與方包含總經理、隊醫、教練、球迷、記者,地包含訓練地址、比賽地址、採訪地址,物包含簽名球衣和簽名足球:
在四色建模中用黃色表示,這類物件表示參與方、地、物以什麼角色參與到業務流程:
我們可以為物件增加相關描述資訊,在四色建模中用藍色表示:
在四色建模過程中我們體會到時標物件是最重要的物件,因為其承載了業務系統核心單據。在劃分領域時我們同樣離不開時標物件,通過收斂相關時標物件劃分領域。
當業務系統發生一件事情時,如果本領域或其它領域有後續動作跟進,那麼我們把這件事情稱為領域事件,這個事件需要被感知。
例如球員比賽受傷了,這是比賽子域事件,但是醫療和訓練子域是需要感知的,那麼比賽子域就發出一個事件,醫療和訓練子域會訂閱。球員比賽取得進球,這也是比賽子域事件,但是訓練和合同子域也會關注這個事件,所以比賽子域也會發出一個比賽進球事件,訓練和合同子域會訂閱。
通過事件互動有一個問題需要注意,通過事件訂閱實現業務只能採用最終一致性,需要放棄強一致性,可能會引入新的複雜度需要權衡。
目前為止領域已經確定了,大領域已經拆分成了小領域,我們已經不再束手無策,而是可以對小領域進行用例分析了。用例圖由參與者和用例組成,目的是回答這樣一個問題:什麼人使用系統幹什麼事。
下圖表示在比賽領域,運動員視角(什麼人)使用系統進行進球統計,助攻統計,犯規統計,跑動距離統計,比賽評分統計,傳球成功率統計,受傷統計(幹什麼事),同理我們也可以選擇四色建模中其它參與者視角繪製用例圖。
include關鍵字表示包含關係。例如比賽是基用例,包含了進球統計,助攻統計,犯規統計,跑動距離統計,比賽評分統計,傳球成功率統計,受傷統計七個子用例。包含關係表示法有兩個優點:第一是可以清晰地組織子用例,第二是有利於子用例複用,例如主教練視角用例圖也包含比賽評分,那麼就可以直接指向比賽評分子用例。
extend關鍵字表示擴充套件關係。例如點球統計是進球統計的擴充套件,因為不一定可以獲得點球,所以點球統計即使不存在,也不會影響進球統計功能。黃牌統計、紅牌統計是犯規統計的擴充套件,因為普通犯規不會獲得紅黃牌,所以紅黃牌統計不存在,也不會影響犯規統計功能。
用例圖不關心實現細節,而是從一種外部視角描述系統功能,即使不瞭解實現細節的人,通過看用例圖也可以快速瞭解系統功能,這個特性規定了用例圖不宜過於複雜,能夠說明核心功能即可。
用例圖是從外部視角描述系統,但是分析系統總是要深入系統內部的,其中流程檢視就是描述系統內如何流轉的檢視。活動圖、序列圖、狀態機圖是流程檢視中最重要的三種檢視,我們稱為流程三劍客。三者側重點有所不同:活動圖側重於邏輯分支,順序圖側重於互動,狀態機圖側重於狀態流轉。
活動圖適合描述複雜邏輯分支,設想這樣一種業務場景,球隊需要選出一名球員成為球隊的足球先生,選拔標準如下:前場、中場、後場、門將各選出一名候選球員。前場隊員依次比較進球數、助攻數,中場隊員依次比較助攻數、搶斷數,後場隊員依次比較解圍數、搶斷數,門將依次比較撲救數、撲點數,如果所有指標均相同則抽籤。每個位置有人選之後,全體教練組投票,如果投票數相同則抽籤。
我們經常說一圖勝千言,其中一個重要原因是文字是線性的,所以表達邏輯分支能力不如流程檢視,而在流程檢視中表達邏輯分支能力最強的是活動圖。
順序圖側重於互動,適合按照時間順序體現一個業務流程中互動細節,但是順序圖並不擅長體現複雜邏輯分支。
如果某個邏輯分支特別重要,可以選擇再畫一個順序圖。例如支付流程中有支付成功正常流程,也有支付失敗異常流程,這兩個流程都非常重要,所以可以用兩張順序圖體現。回到本文範例,我們可以通過順序圖體現球員從提出轉會到比賽全流程。
假設一條資料有ABC三種狀態,從正常業務角度來看,狀態只能從A流轉到B,再從B流轉到C,不能亂序也不可逆。但是可能出現這種異常情況:資料當前狀態為A,接收非同步訊息更改狀態,B訊息由於延時晚於C訊息,最終導致狀態先改為C再改為B,那麼此時狀態就是錯誤的。
狀態機圖側重於狀態流轉,說明了哪些狀態之間可以相互流轉,再結合狀態機程式碼模式,可以解決上述狀態異常情況。回到本文範例,我們可以通過狀態機圖表示球員從提出轉會到簽約整個狀態流程。
上述章節從功能層面和流程層面進行了系統分析,現在需要從資料層分析系統,我們首先對比兩組概念:值物件與實體,領域物件與資料物件。
實體是具有唯一標識的物件,唯一標識會伴隨實體物件整個生命週期並且不可變更。值物件本質上是屬性的集合,沒有唯一標識。
領域物件與資料物件一個重要的區別是值物件儲存方式。領域物件在包含值物件的同時也保留了值物件的業務含義,而資料物件可以使用更加鬆散的結構儲存值物件,簡化資料庫設計。
現在我們需要管理足球運動員基本資訊和比賽資料,對應領域模型和資料模型應該如何設計?姓名、身高、體重是一名運動員本質屬性,加上唯一編號可以對應實體物件。跑動距離,傳球成功率,進球數是運動員比賽表現,這些屬性的集合可以對應值物件。
我們根據圖示編寫領域物件與資料物件程式碼:
// 資料物件
public class FootballPlayerDO {
private Long id;
private String name;
private Integer height;
private Integer weight;
private String gamePerformance;
}
// 領域物件
public class FootballPlayerDMO {
private Long id;
private String name;
private Integer height;
private Integer weight;
private GamePerformanceVO gamePerformanceVO;
}
public class GamePerformanceVO {
private Double runDistance;
private Double passSuccess;
private Integer scoreNum;
}
如果需要根據JSON結構中KEY進行檢索,例如查詢進球數大於5的球員,這也不是沒有辦法。我們可以將MySQL表中資料平鋪到ES中,一條資料根據JSON KEY平鋪變成多條資料,這樣就可以進行檢索了。
複雜業務之所以複雜,一個重要原因是涉及角色或者型別較多,很難平鋪直敘地進行設計,所以我們需要增加分析維度。其中最常見的是增加橫向和縱向兩個維度,本文也著重討論兩個維度。總體而言橫向擴充套件的是思考廣度,縱向擴充套件的是思考深度,對應到系統設計而言可以總結為:縱向做隔離,橫向做編排。
我們首先分析一個下單場景做鋪墊。當前有ABC三種訂單型別,A訂單價格9折,物流最大重量不能超過8公斤,不支援退款。B訂單價格8折,物流最大重量不能超過5公斤,支援退款。C訂單價格7折,物流最大重量不能超過1公斤,支援退款。按照需求字面含義平鋪直敘地寫程式碼也並不難:
public class OrderServiceImpl implements OrderService {
@Resource
private OrderMapper orderMapper;
@Override
public void createOrder(OrderBO orderBO) {
if (null == orderBO) {
throw new RuntimeException("引數異常");
}
if (OrderTypeEnum.isNotValid(orderBO.getType())) {
throw new RuntimeException("引數異常");
}
// A型別訂單
if (OrderTypeEnum.A_TYPE.getCode().equals(orderBO.getType())) {
orderBO.setPrice(orderBO.getPrice() * 0.9);
if (orderBO.getWeight() > 9) {
throw new RuntimeException("超過物流最大重量");
}
orderBO.setRefundSupport(Boolean.FALSE);
}
// B型別訂單
else if (OrderTypeEnum.B_TYPE.getCode().equals(orderBO.getType())) {
orderBO.setPrice(orderBO.getPrice() * 0.8);
if (orderBO.getWeight() > 8) {
throw new RuntimeException("超過物流最大重量");
}
orderBO.setRefundSupport(Boolean.TRUE);
}
// C型別訂單
else if (OrderTypeEnum.C_TYPE.getCode().equals(orderBO.getType())) {
orderBO.setPrice(orderBO.getPrice() * 0.7);
if (orderBO.getWeight() > 7) {
throw new RuntimeException("超過物流最大重量");
}
orderBO.setRefundSupport(Boolean.TRUE);
}
// 儲存資料
OrderDO orderDO = new OrderDO();
BeanUtils.copyProperties(orderBO, orderDO);
orderMapper.insert(orderDO);
}
}
上述程式碼從功能上完全可以實現業務需求,但是程式設計師不僅要滿足功能,還需要思考程式碼的可維護性。如果新增一種訂單型別,或者新增一個訂單屬性處理邏輯,那麼我們就要在上述邏輯中新增程式碼,如果處理不慎就會影響原有邏輯。
為了避免牽一髮而動全身這種情況,設計模式中的開閉原則要求我們面向新增開放,面向修改關閉,我認為這是設計模式中最重要的一條原則。
需求變化通過擴充套件,而不是通過修改已有程式碼實現,這樣就保證程式碼穩定性。擴充套件也不是隨意擴充套件,因為事先定義了演演算法,擴充套件也是根據演演算法擴充套件,用抽象構建框架,用實現擴充套件細節。標準意義的二十三種設計模式說到底最終都是在遵循開閉原則。
如何改變平鋪直敘的思考方式?這就要為問題分析加上縱向和橫向兩個維度,我選擇使用分析矩陣方法,其中縱向表示策略,橫向表示場景。
縱向維度表示策略,不同策略在邏輯上和業務上應該是隔離的,本範例包括優惠策略、物流策略和退款策略,策略作為抽象,不同訂單型別去擴充套件這個抽象,策略模式非常適合這種場景。本文詳細分析優惠策略,物流策略和退款策略同理。
// 優惠策略
public interface DiscountStrategy {
public void discount(OrderBO orderBO);
}
// A型別優惠策略
@Component
public class TypeADiscountStrategy implements DiscountStrategy {
@Override
public void discount(OrderBO orderBO) {
orderBO.setPrice(orderBO.getPrice() * 0.9);
}
}
// B型別優惠策略
@Component
public class TypeBDiscountStrategy implements DiscountStrategy {
@Override
public void discount(OrderBO orderBO) {
orderBO.setPrice(orderBO.getPrice() * 0.8);
}
}
// C型別優惠策略
@Component
public class TypeCDiscountStrategy implements DiscountStrategy {
@Override
public void discount(OrderBO orderBO) {
orderBO.setPrice(orderBO.getPrice() * 0.7);
}
}
// 優惠策略工廠
@Component
public class DiscountStrategyFactory implements InitializingBean {
private Map<String, DiscountStrategy> strategyMap = new HashMap<>();
@Resource
private TypeADiscountStrategy typeADiscountStrategy;
@Resource
private TypeBDiscountStrategy typeBDiscountStrategy;
@Resource
private TypeCDiscountStrategy typeCDiscountStrategy;
public DiscountStrategy getStrategy(String type) {
return strategyMap.get(type);
}
@Override
public void afterPropertiesSet() throws Exception {
strategyMap.put(OrderTypeEnum.A_TYPE.getCode(), typeADiscountStrategy);
strategyMap.put(OrderTypeEnum.B_TYPE.getCode(), typeBDiscountStrategy);
strategyMap.put(OrderTypeEnum.C_TYPE.getCode(), typeCDiscountStrategy);
}
}
// 優惠策略執行
@Component
public class DiscountStrategyExecutor {
private DiscountStrategyFactory discountStrategyFactory;
public void discount(OrderBO orderBO) {
DiscountStrategy discountStrategy = discountStrategyFactory.getStrategy(orderBO.getType());
if (null == discountStrategy) {
throw new RuntimeException("無優惠策略");
}
discountStrategy.discount(orderBO);
}
}
橫向維度表示場景,一種訂單型別在廣義上可以認為是一種業務場景,在場景中將獨立的策略進行串聯,模板方法設計模式適用於這種場景。
模板方法模式一般使用抽象類定義一個演演算法骨架,同時定義一些抽象方法,這些抽象方法延遲到子類實現,這樣子類不僅遵守了演演算法骨架約定,也實現了自己的演演算法。既保證了規約也兼顧靈活性,這就是用抽象構建框架,用實現擴充套件細節。
// 建立訂單服務
public interface CreateOrderService {
public void createOrder(OrderBO orderBO);
}
// 抽象建立訂單流程
public abstract class AbstractCreateOrderFlow {
@Resource
private OrderMapper orderMapper;
public void createOrder(OrderBO orderBO) {
// 引數校驗
if (null == orderBO) {
throw new RuntimeException("引數異常");
}
if (OrderTypeEnum.isNotValid(orderBO.getType())) {
throw new RuntimeException("引數異常");
}
// 計算優惠
discount(orderBO);
// 計算重量
weighing(orderBO);
// 退款支援
supportRefund(orderBO);
// 儲存資料
OrderDO orderDO = new OrderDO();
BeanUtils.copyProperties(orderBO, orderDO);
orderMapper.insert(orderDO);
}
public abstract void discount(OrderBO orderBO);
public abstract void weighing(OrderBO orderBO);
public abstract void supportRefund(OrderBO orderBO);
}
// 實現建立訂單流程
@Service
public class CreateOrderFlow extends AbstractCreateOrderFlow {
@Resource
private DiscountStrategyExecutor discountStrategyExecutor;
@Resource
private ExpressStrategyExecutor expressStrategyExecutor;
@Resource
private RefundStrategyExecutor refundStrategyExecutor;
@Override
public void discount(OrderBO orderBO) {
discountStrategyExecutor.discount(orderBO);
}
@Override
public void weighing(OrderBO orderBO) {
expressStrategyExecutor.weighing(orderBO);
}
@Override
public void supportRefund(OrderBO orderBO) {
refundStrategyExecutor.supportRefund(orderBO);
}
}
上述範例業務和程式碼並不複雜,其實複雜業務場景也不過是簡單場景的疊加、組合和交織,無外乎也是通過縱向做隔離、橫向做編排尋求答案。
縱向維度抽象出能力池這個概念,能力池中包含許多能力,不同的能力按照不同業務維度聚合,例如優惠能力池,物流能力池,退款能力池。我們可以看到兩種程度的隔離性,能力池之間相互隔離,能力之間也相互隔離。
橫向維度將能力從能力池選出來,按照業務需求串聯在一起,形成不同業務流程。因為能力可以任意組合,所以體現了很強的靈活性。除此之外,不同能力既可以序列執行,如果不同能力之間沒有依賴關係,也可以如同流程Y一樣並行執行,提升執行效率。
此時可以回到本文足球運動員管理系統,如果我們採用縱橫思維,分析3.3.1足球先生選拔業務場景可以得到下圖:
縱向隔離出進攻能力池,防守能力池,門將能力池,橫向編排出前場、中場、後場、門將四個流程,在不同流程中可以任意從能力池中選擇能力進行組合,而不是編寫冗長的判斷邏輯,顯著提升了程式碼可延伸性。
第一種層次關係是指本專案在整個公司位於哪一層。持久層、快取層、中介軟體、業務中臺、服務層、閘道器層、使用者端和代理層是常見的分層架構。
第二種層次是指中臺和前臺的關係。一個系統在業務上通常分為三個端:面向B端使用者,面向C端使用者,面向運營使用者。面對這種情況可以劃分前臺、中臺、後臺三類應用:
第一中臺應用承載核心邏輯,暴露核心介面,中臺並不要理解所有端資料結構,而是通過client介面暴露相對穩定的資料。
第二針對面向B端、面向C端、面向運營三種端,各自拆分出一個應用,在此應用中進行轉換、適配和裁剪,並且處理各自業務。
第三什麼是大中臺、小前臺思想?中臺提供穩定服務,前臺提供靈活入口。
第四如果後續要做秒殺系統,那麼也可以理解其為一個前臺應用(seckill-front)聚合各種中臺介面。
第三種層次是程式碼層次結構。分層優點是每層只專注本層工作,可以類比設計模式單一職責原則,或者經濟學比較優勢原理,每層只做本層最擅長的事情。
分層缺點是層之間通訊時,需要通過介面卡,翻譯成本層或者下層可以理解的資訊,通訊成本有所增加。我認為工程分層需要從六個維度思考:
每層只處理一類事情,滿足單一職責原則
資訊在每一層進行傳輸,滿足最小知識原則,只向下層傳輸必要資訊
每層都需要一個介面卡,翻譯資訊為本層或者下層可以理解的資訊
縱向做隔離,同一個領域內業務要在本領域內聚
橫向做編排,應用層聚合多個領域進行業務編排
資料物件儘量純淨,儘量使用基本型別
程式碼可以分為九層結構:
當一個介面程式碼編寫完成後,那麼這個介面如何呼叫,輸入和輸出引數是什麼,這些問題需要在介面檔案中得到回答。介面檔案生成有兩種方式:第一種是自動生成,例如使用Swagger,第二種方式是手工生成。
自動生成優點是程式碼即檔案,還具有偵錯功能,在公司內部進行聯調時非常方便。但是如果介面是提供給外部第三方使用,那麼還是需要手工編寫介面檔案。對於一個介面的描述無外乎介面名稱、介面說明、介面協定,輸入引數、輸出引數資訊。
user-demo-service
-user-demo-service-application
-user-demo-service-boot
-user-demo-service-client
-user-demo-service-controller
-user-demo-service-domain
-user-demo-service-facade
-user-demo-service-infrastructure
-user-demo-service-integration
-user-demo-service-util
工具層承載工具程式碼
不依賴本專案其它模組
只依賴一些通用工具包
user-demo-service-util
-/src/main/java
-date
-DateUtil.java
-json
-JsonUtil.java
-validate
-BizValidator.java
基礎層承載資料存取和entity
同時承載基礎服務(ES、Redis、MQ)
user-demo-service-infrastructure
-/src/main/java
-base
-service
-redis
-RedisService.java
-mq
-ProducerService.java
-player
-entity
-PlayerEntity.java
-mapper
-PlayerEntityMapper.java
-game
-entity
-GameEntity.java
-mapper
-GameEntityMapper.java
-/src/main/resources
-mybatis
-sqlmappers
-gameEntityMapper.xml
-playerEntityMapper.xml
建立運動員資料表:
CREATE TABLE `player` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
`player_id` varchar(256) NOT NULL COMMENT '運動員編號',
`player_name` varchar(256) NOT NULL COMMENT '運動員名稱',
`height` int(11) NOT NULL COMMENT '身高',
`weight` int(11) NOT NULL COMMENT '體重',
`game_performance` text COMMENT '最近一場比賽表現',
`creator` varchar(256) NOT NULL COMMENT '建立人',
`updator` varchar(256) NOT NULL COMMENT '修改人',
`create_time` datetime NOT NULL COMMENT '建立時間',
`update_time` datetime NOT NULL COMMENT '修改時間',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8
運動員實體物件,gamePerformance欄位作為string儲存在資料庫,體現了資料層儘量純淨,不要整合過多業務,解析任務應該放在業務層:
public class PlayerEntity {
private Long id;
private String playerId;
private String playerName;
private Integer height;
private Integer weight;
private String creator;
private String updator;
private Date createTime;
private Date updateTime;
private String gamePerformance;
}
運動員Mapper物件:
@Repository
public interface PlayerEntityMapper {
int insert(PlayerEntity record);
int updateById(PlayerEntity record);
PlayerEntity selectById(@Param("playerId") String playerId);
}
本層呼叫外部服務,轉換外部DTO成為本專案可以理解物件。
本專案呼叫使用者中心服務:
user-demo-service-integration
-/src/main/java
-user
-adapter
-UserClientAdapter.java
-proxy
-UserClientProxy.java
-vo // 本專案物件
-UserSimpleAddressVO.java
-UserSimpleContactVO.java
-UserSimpleBaseInfoVO.java
// 外部物件
public class UserInfoClientDTO implements Serializable {
private String id;
private String name;
private Date createTime;
private Date updateTime;
private String mobile;
private String cityCode;
private String addressDetail;
}
// 外部服務
public class UserClientService {
// RPC
public UserInfoClientDTO getUserInfo(String userId) {
UserInfoClientDTO userInfo = new UserInfoClientDTO();
userInfo.setId(userId);
userInfo.setName(userId);
userInfo.setCreateTime(DateUtil.now());
userInfo.setUpdateTime(DateUtil.now());
userInfo.setMobile("test-mobile");
userInfo.setCityCode("test-city-code");
userInfo.setAddressDetail("test-address-detail");
return userInfo;
}
}
// 基本物件
public class UserBaseInfoVO {
private UserContactVO contactInfo;
private UserAddressVO addressInfo;
}
// 地址值物件
public class UserAddressVO {
private String cityCode;
private String addressDetail;
}
// 聯絡方式值物件
public class UserContactVO {
private String mobile;
}
public class UserClientAdapter {
public UserBaseInfoVO convert(UserInfoClientDTO userInfo) {
// 基礎資訊
UserBaseInfoVO userBaseInfo = new UserBaseInfoVO();
// 聯絡方式
UserContactVO contactVO = new UserContactVO();
contactVO.setMobile(userInfo.getMobile());
userBaseInfo.setContactInfo(contactVO);
// 地址資訊
UserAddressVO addressVO = new UserAddressVO();
addressVO.setCityCode(userInfo.getCityCode());
addressVO.setAddressDetail(userInfo.getAddressDetail());
userBaseInfo.setAddressInfo(addressVO);
return userBaseInfo;
}
}
public class UserClientProxy {
@Resource
private UserClientService userClientService;
@Resource
private UserClientAdapter userIntegrationAdapter;
// 查詢使用者
public UserBaseInfoVO getUserInfo(String userId) {
UserInfoClientDTO user = userClientService.getUserInfo(userId);
UserBaseInfoVO result = userIntegrationAdapter.convert(user);
return result;
}
}
通過三組對比理解領域層:
資料物件使用基本型別保持純淨:
public class PlayerEntity {
private Long id;
private String playerId;
private String playerName;
private Integer height;
private Integer weight;
private String creator;
private String updator;
private Date createTime;
private Date updateTime;
private String gamePerformance;
}
領域物件需要體現業務含義:
public class PlayerQueryResultDomain {
private String playerId;
private String playerName;
private Integer height;
private Integer weight;
private GamePerformanceVO gamePerformance;
}
public class GamePerformanceVO {
// 跑動距離
private Double runDistance;
// 傳球成功率
private Double passSuccess;
// 進球數
private Integer scoreNum;
}
業務物件同樣會體現業務,領域物件和業務物件有什麼不同?最大不同是領域物件採用充血模型聚合業務。
運動員新增業務物件:
public class PlayerCreateBO {
private String playerName;
private Integer height;
private Integer weight;
private GamePerformanceVO gamePerformance;
private MaintainCreateVO maintainInfo;
}
運動員新增領域物件:
public class PlayerCreateDomain implements BizValidator {
private String playerName;
private Integer height;
private Integer weight;
private GamePerformanceVO gamePerformance;
private MaintainCreateVO maintainInfo;
@Override
public void validate() {
if (StringUtils.isEmpty(playerName)) {
throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
}
if (null == height) {
throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
}
if (height > 300) {
throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
}
if (null == weight) {
throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
}
if (null != gamePerformance) {
gamePerformance.validate();
}
if (null == maintainInfo) {
throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
}
maintainInfo.validate();
}
}
第一個區別:領域層關注縱向,應用層關注橫向。領域層縱向做隔離,本領域業務行為要在本領域內處理完。應用層橫向做編排,聚合和編排領域服務。
第二個區別:應用層可以更加靈活組合不同領域業務,並且可以增加流控、監控、紀錄檔、許可權,分散式鎖,相較於領域層功能更為豐富。
user-demo-service-domain
-/src/main/java
-base
-domain
-BaseDomain.java
-event
-BaseEvent.java
-vo
-BaseVO.java
-MaintainCreateVO.java
-MaintainUpdateVO.java
-player
-adapter
-PlayerDomainAdapter.java
-domain
-PlayerCreateDomain.java // 領域物件
-PlayerUpdateDomain.java
-PlayerQueryResultDomain.java
-event // 領域事件
-PlayerUpdateEvent.java
-PlayerMessageSender.java
-service // 領域服務
-PlayerDomainService.java
-vo // 值物件
-GamePerformanceVO.java
-game
-adapter
-GameDomainAdapter.java
-domain
-GameCreateDomain.java
-GameUpdateDomain.java
-GameQueryResultDomain.java
-service
-GameDomainService.java
領域物件進行業務校驗,所以需要依賴client模組:
// 修改領域物件
public class PlayerUpdateDomain extends BaseDomain implements BizValidator {
private String playerId;
private String playerName;
private Integer height;
private Integer weight;
private String updator;
private Date updatetime;
private GamePerformanceVO gamePerformance;
private MaintainUpdateVO maintainInfo;
@Override
public void validate() {
if (StringUtils.isEmpty(playerId)) {
throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
}
if (StringUtils.isEmpty(playerName)) {
throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
}
if (null == height) {
throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
}
if (height > 300) {
throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
}
if (null == weight) {
throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
}
if (null != gamePerformance) {
gamePerformance.validate();
}
if (null == maintainInfo) {
throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
}
maintainInfo.validate();
}
}
// 比賽表現值物件
public class GamePerformanceVO implements BizValidator {
// 跑動距離
private Double runDistance;
// 傳球成功率
private Double passSuccess;
// 進球數
private Integer scoreNum;
@Override
public void validate() {
if (null == runDistance) {
throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
}
if (null == passSuccess) {
throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
}
if (Double.compare(passSuccess, 100) > 0) {
throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
}
if (null == runDistance) {
throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
}
if (null == scoreNum) {
throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
}
}
}
// 修改人值物件
public class MaintainUpdateVO implements BizValidator {
// 修改人
private String updator;
// 修改時間
private Date updateTime;
@Override
public void validate() {
if (null == updator) {
throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
}
if (null == updateTime) {
throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
}
}
}
// 領域服務
public class PlayerDomainService {
@Resource
private UserClientProxy userClientProxy;
@Resource
private PlayerRepository playerEntityMapper;
@Resource
private PlayerDomainAdapter playerDomainAdapter;
@Resource
private PlayerMessageSender playerMessageSender;
public boolean updatePlayer(PlayerUpdateDomain player) {
AssertUtil.notNull(player, new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT));
player.validate();
// 更新運動員資訊
PlayerEntity entity = playerDomainAdapter.convertUpdate(player);
playerEntityMapper.updateById(entity);
// 傳送更新訊息
playerMessageSender.sendPlayerUpdatemessage(player);
// 查詢使用者資訊
UserSimpleBaseInfoVO userInfo = userClientProxy.getUserInfo(player.getMaintainInfo().getUpdator());
log.info("updatePlayer maintainInfo={}", JacksonUtil.bean2Json(userInfo));
return true;
}
}
本層關注橫向維度聚合領域服務,引出一種新物件稱為聚合物件。因為本層需要聚合多個維度,所以需要通過聚合物件聚合多領域屬性,例如提交訂單需要聚合商品、物流、優惠券多個領域。
// 訂單提交聚合物件
public class OrderSubmitAgg {
// userId
private String userId;
// skuId
private String skuId;
// 購買量
private Integer quantity;
// 地址資訊
private String addressId;
// 可用優惠券
private String couponId;
}
// 訂單應用服務
public class OrderApplicationService {
@Resource
private OrderDomainService orderDomainService;
@Resource
private CouponDomainService couponDomainService;
@Resource
private ProductDomainService productDomainService;
// 提交訂單
public String submitOrder(OrderSubmitAgg orderSumbitAgg) {
// 訂單編號
String orderId = generateOrderId();
// 商品校驗
productDomainService.queryBySkuId(orderSumbitAgg.getSkuId());
// 扣減庫存
productDomainService.subStock(orderSumbitAgg.getStockId(), orderSumbitAgg.getQuantity());
// 優惠券校驗
couponDomainService.validate(userId, couponId);
// ......
// 建立訂單
OrderCreateDomain domain = OrderApplicationAdapter.convert(orderSubmitAgg);
orderDomainService.createOrder(domain);
return orderId;
}
}
user-demo-service-application
-/src/main/java
-player
-adapter
-PlayerApplicationAdapter.java
-agg
-PlayerCreateAgg.java
-PlayerUpdateAgg.java
-service
-PlayerApplicationService.java
-game
-listener
-PlayerUpdateListener.java // 監聽運動員更新事件
本專案領域事件互動使用EventBus框架:
// 運動員應用服務
public class PlayerApplicationService {
@Resource
private LogDomainService logDomainService;
@Resource
private PlayerDomainService playerDomainService;
@Resource
private PlayerApplicationAdapter playerApplicationAdapter;
public boolean updatePlayer(PlayerUpdateAgg agg) {
// 運動員領域
boolean result = playerDomainService.updatePlayer(agg.getPlayer());
// 紀錄檔領域
LogReportDomain logDomain = playerApplicationAdapter.convert(agg.getPlayer().getPlayerName());
logDomainService.log(logDomain);
return result;
}
}
// 比賽領域監聽運動員變更事件
public class PlayerUpdateListener {
@Resource
private GameDomainService gameDomainService;
@PostConstruct
public void init() {
EventBusManager.register(this);
}
@Subscribe
public void listen(PlayerUpdateEvent event) {
// 更新比賽計劃
gameDomainService.updateGameSchedule();
}
}
設計模式中有一種Facade模式,稱為門面模式或者外觀模式。這種模式提供一個簡潔對外語意,遮蔽內部系統複雜性。
client承載資料對外傳輸物件DTO,facade承載對外服務,必須滿足最小知識原則,無關資訊不必對外透出。這樣做有兩個優點:
user-demo-service-client
-/src/main/java
-base
-dto
-BaseDTO.java
-error
-BizException.java
-BizErrorCode.java
-event
-BaseEventDTO.java
-result
-ResultDTO.java
-player
-dto
-PlayerCreateDTO.java
-PlayerQueryResultDTO.java
-PlayerUpdateDTO.java
-enums
-PlayerMessageTypeEnum.java
-service
-PlayerClientService.java
user-demo-service-facade
-/src/main/java
-player
-adapter
-PlayerFacadeAdapter.java
-impl
-PlayerClientServiceImpl.java
-game
-adapter
-GameFacadeAdapter.java
-impl
-GameClientServiceImpl.java
client不依賴本專案其它模組,這一點非常重要:因為client會被外部參照,必須保證本層簡潔和安全。
facade依賴本專案三個模組:
以查詢運動員資訊為例,查詢結果DTO只封裝強業務欄位,運動員ID、建立時間、修改時間等業務不強欄位無須透出:
public class PlayerQueryResultDTO implements Serializable {
private String playerName;
private Integer height;
private Integer weight;
private GamePerformanceDTO gamePerformanceDTO;
}
public interface PlayerClientService {
public ResultDTO<PlayerQueryResultDTO> queryById(String playerId);
}
public class PlayerFacadeAdapter {
// domain -> dto
public PlayerQueryResultDTO convertQuery(PlayerQueryResultDomain domain) {
if (null == domain) {
return null;
}
PlayerQueryResultDTO result = new PlayerQueryResultDTO();
result.setPlayerId(domain.getPlayerId());
result.setPlayerName(domain.getPlayerName());
result.setHeight(domain.getHeight());
result.setWeight(domain.getWeight());
if (null != domain.getGamePerformance()) {
GamePerformanceDTO performance = convertGamePerformance(domain.getGamePerformance());
result.setGamePerformanceDTO(performance);
}
return result;
}
}
本層可以參照applicationService,也可以參照domainService,因為對於類似查詢等簡單業務場景,沒有多領域聚合,可以直接使用領域服務。
public class PlayerClientServiceImpl implements PlayerClientService {
@Resource
private PlayerDomainService playerDomainService;
@Resource
private PlayerFacadeAdapter playerFacadeAdapter;
@Override
public ResultDTO<PlayerQueryResultDTO> queryById(String playerId) {
PlayerQueryResultDomain resultDomain = playerDomainService.queryPlayerById(playerId);
if (null == resultDomain) {
return ResultCommonDTO.success();
}
PlayerQueryResultDTO result = playerFacadeAdapter.convertQuery(resultDomain);
return ResultCommonDTO.success(result);
}
}
facade服務實現可以作為RPC提供服務,controller則作為本專案HTTP介面提供服務,供前端呼叫。
controller需要注意HTTP相關特性,敏感資訊例如登陸使用者ID不能依賴前端傳遞,登陸後前端會在請求頭帶一個登陸使用者資訊,伺服器端需要從請求頭中獲取並解析。
user-demo-service-controller
-/src/main/java
-controller
-player
-PlayerController.java
-game
-GameController.java
@RestController
@RequestMapping("/player")
public class PlayerController {
@Resource
private PlayerClientService playerClientService;
@PostMapping("/add")
public ResultDTO<Boolean> add(@RequestHeader("test-login-info") String loginUserId, @RequestBody PlayerCreateDTO dto) {
dto.setCreator(loginUserId);
ResultCommonDTO<Boolean> resultDTO = playerClientService.addPlayer(dto);
return resultDTO;
}
@PostMapping("/update")
public ResultDTO<Boolean> update(@RequestHeader("test-login-info") String loginUserId, @RequestBody PlayerUpdateDTO dto) {
dto.setUpdator(loginUserId);
ResultCommonDTO<Boolean> resultDTO = playerClientService.updatePlayer(dto);
return resultDTO;
}
@GetMapping("/{playerId}/query")
public ResultDTO<PlayerQueryResultDTO> queryById(@RequestHeader("test-login-info") String loginUserId, @PathVariable("playerId") String playerId) {
ResultCommonDTO<PlayerQueryResultDTO> resultDTO = playerClientService.queryById(playerId);
return resultDTO;
}
}
boot作為啟動層承載啟動入口
所有模組程式碼均必須屬於com.user.demo.service子路徑:
user-demo-service-boot
-/src/main/java
-com.user.demo.service
-MainApplication.java
@MapperScan("com.user.demo.service.infrastructure.*.mapper")
@SpringBootApplication
public class MainApplication {
public static void main(final String[] args) {
SpringApplication.run(MainApplication.class, args);
}
}
本文第一提出並回答了六個問題,第二介紹了DDD相關基本概念,第三介紹了DDD分析七大步驟,第四介紹了程式碼分層結構,希望本文對大家有所幫助。歡迎大家關注公眾號「JAVA前線」檢視更多精彩分享文章,主要包括原始碼分析、實際應用、架構思維、職場分享、產品思考等等,同時歡迎大家加我個人微信「java_front」一起交流學習。