前言
網上那麼多DDD的文章,但程式碼工程卻沒有一個比較好的例子,本文將手把手跟你一起寫DDD程式碼,學習DDD思想與程式碼相結合帶來的好處。
一、從六邊形架構談起
六邊形架構也稱埠與介面卡。對於每種外界型別,都有一個介面卡與之相對應。外界通過應用層API與內部進行互動。
六邊形架構提倡用一種新的視角來看待整個系統。該架構中存在兩個區域,分別是“外部區域”和“內部區域”。在外部區域中,不同的客戶均可以提交輸入;而內部的系統則用於獲取持久化資料,並對程式輸出進行儲存(比如資料庫),或者在中途將輸出轉發到另外的地方(比如訊息,轉發到訊息佇列)。
每種型別的客戶都有自己的介面卡,該介面卡用於將客戶輸入轉化為程式內部API所理解的輸入。六邊形每條不同的邊代表了不同種型別的埠,埠要麼處理輸入,要麼處理輸出。圖中有3個客戶請求均抵達相同的輸入埠(介面卡A、B和C),另一個客戶請求使用了介面卡D。可能前3個請求使用了HTTP協議(瀏覽器、REST和SOAP等),而後一個請求使用了MSMQ的協議。埠並沒有明確的定義,它是一個非常靈活的概念。無論採用哪種方式對埠進行劃分,當客戶請求到達時,都應該有相應的介面卡對輸入進行轉化,然後介面卡將呼叫應用程式的某個操作或者嚮應用程式傳送一個事件,控制權由此交給內部區域。
二、依賴倒置
依賴倒置的原則(DIP)由Robert C. Martin提出,核心的定義是:
高層模組不應該依賴於底層模組,兩者都應該依賴於抽象
抽象不應該依賴於實現細節,實現細節應該依賴於介面
按照DIP的原則,領域層就可以不再依賴於基礎設施層,基礎設施層通過注入持久化的實現就完成了對領域層的解耦,採用依賴注入原則的新分層架構模型就變成如下所示:
採用了依賴注入方式後,其實可以發現事實上已經沒有分層概念了。無論高層還是底層,實際只依賴於抽象,整個分層好像被推平了。
三、DDD 程式碼分層
整體程式碼結構
- com.${company}.${system}.${appname}
|- ui(使用者介面層)
|service
|- impl
|- web
|- controller
|- filter
|- application(應用層)
|- service
|- impl
|- command
|- query
|- dto
|- mq
|- domain(領域層)
|- service
|- facade
|- model
|- event
|- repository
|- infrastructure(基礎設施層)
|- dal
|-dos
|-dao
|- mapper
|- factory
3.1 使用者介面層
使用者介面層作為對外的門戶,將網路協議與業務邏輯解耦。可以包含鑑權、Session管理、限流、異常處理、日誌等功能。
返回值一般使用{"code":0,"msg":"success","data":{}}
的格式進行返回。
一般會封裝一些公共的Response
物件,參考如下:
public class Response implements Serializable {
private boolean success;
private String code;
private String msg;
private Map<String, Object> errData;
}
public class SingleResponse<T> extends Response {
private T data;
}
public class ListResponse<T> extends Response {
private int count = 0;
private int pageSize = 20;
private int pageNo = 1;
private List<T> data;
}
使用者介面層的介面,無需與應用介面保持一一對應,應該保證不同的場景使用不同的介面,保證後續業務的相容性與可維護性。
3.2 應用層
應用層連線使用者介面層和領域層,主要協調領域層,面向用例和業務流程,協調多個聚合完成服務的組合和編排,在這一層不實現任何業務邏輯,只是很薄的一層。
應用層的核心類:
- ApplicationService應用服務:最核心的類,負責業務流程的編排,但本身不負責任何業務邏輯。有時會簡寫為“AppService”。一個ApplicationService類是一個完整的業務流程,其中每個方法負責處理一個Use Case,比如訂單的各種用例(下單、支付成功、發貨、收貨、查詢)。
- DTO Assembler:負責將內部領域模型轉化為可對外的DTO。
- 返回的DTO:作為ApplicationService的出參。
- Command指令:指呼叫方明確想讓系統操作的指令,其預期是對一個系統有影響,也就是寫操作。通常來講指令需要有一個明確的返回值(如同步的操作結果,或非同步的指令已經被接受)。
- Query查詢:指呼叫方明確想查詢的東西,包括查詢引數、過濾、分頁等條件,其預期是對一個系統的資料完全不影響的,也就是隻讀操作。
- Event事件:指一件已經發生過的既有事實,需要系統根據這個事實作出改變或者響應的,通常事件處理都會有一定的寫操作。事件處理器不會有返回值。這裡需要注意一下的是,Application層的Event概念和Domain層的DomainEvent是類似的概念,但不一定是同一回事,這裡的Event更多是外部一種通知機制而已。
ApplicationService的介面入參只能是一個Command、Query或Event物件,CQE物件需要能代表當前方法的語意。這樣的好處是提升了介面的穩定性、降低低階的重複,並且讓介面入參更加語意化。
案例程式碼
public interface UserAppService {
UserDTO add(@Valid AddUserCommand cmd);
List<UserDTO> query(UserQuery query);
}
@Data
public class AddUserCommand {
private Integer age;
private String name;
...
}
@Data
public class OrderQuery {
private Long userId;
private int pageNo;
private int pageSize;
}
@Data
public class UserDTO {
private Long userId;
private Integer age;
private String name;
...
}
針對於不同語意的指令,要避免CQE物件的複用。反例:一個常見的場景是“Create建立”和“Update更新”,一般來說這兩種型別的物件唯一的區別是一個ID,建立沒有ID,而更新則有。所以經常能看見有的同學用同一個物件來作為兩個方法的入參,唯一區別是ID是否賦值。這個是錯誤的用法,因為這兩個操作的語意完全不一樣,他們的校驗條件可能也完全不一樣,所以不應該複用同一個物件。正確的做法是產出兩個物件。
3.2 1 Response vs Exception
Interface層的HTTP和RPC介面,返回值為Result,捕捉所有異常。
Application層的所有介面返回值為DTO,不負責處理異常。
3.2.2 CQE vs DTO
表面上看,兩種物件都是簡單的POJO物件,但其實是有很大區別的:
- CQE: 是ApplicationService的輸入,有明確的“意圖”,物件的內部需要保證其正確性。每一個CQE都是有明確“意圖”的,所以要儘量避免CQE的複用,哪怕所有引數都一樣,只要語義不同,就不應該複用。
- DTO: 只是資料容器,只是為了和外部互動,所以本身不包含任何邏輯,只是貧血物件。
因為CQE是有“意圖”的,所以,理論上CQE的數量是無限的。但DTO作為資料容器,是和模型對應的,所以是有限的。
3.2.3 Anti-Corruption Layer防腐層
在ApplicationService中,經常會依賴外部服務,從程式碼層面對外部系統產生了依賴。比如建立一個使用者時,可能依賴了帳號服務,這個時候我們引入防腐層。防腐層的類名一般用“Facade”。
ACL防腐層的實現方式:
- 對於依賴的外部物件,我們抽取出所需要的欄位,生成一個內部所需的VO或DTO類。
- 構建一個新的Facade,在Facade中封裝呼叫鏈路,將外部類轉化為內部類。
- 針對外部系統呼叫,同樣的用Facade方法封裝外部呼叫鏈路。
3.3 領域層
領域層是領域模型的核心,主要實現領域模型的核心業務邏輯,體現領域模型的業務能力。領域層關注實現領域物件的充血模型和聚合本身的原子業務邏輯,至於使用者操作和業務流程,則交給應用層去編排。這樣設計可以保證領域模型不容易受外部需求變化的影響,保證領域模型的穩定。
領域層的核心類:
- 實體類(Entity):大多數DDD架構的核心都是實體類,實體類包含了一個領域裡的狀態、以及對狀態的直接操作。Entity最重要的設計原則是保證實體的不變性(Invariants),也就是說要確保無論外部怎麼操作,一個實體內部的屬性都不能出現相互衝突,狀態不一致的情況。
- 值物件(VO):通常是用來度量和描述事物。我們可以非常容易的對其進行建立,測試,使用,優化和維護,所以在建模時,我們儘量採用值物件來建模。
- 聚合根(Aggr):聚合是由業務和邏輯緊密關聯的實體和值物件組合而成的。聚合是資料修改和持久化的基本單元。每個聚合都有一個根實體,叫做聚合根,外界只能通過聚合根跟聚合通訊。聚合根的主要目的是為了避免由於複雜資料模型缺少統一的業務規則控制,而導致聚合、實體之間資料不一致的問題。
- 領域服務(DomainService):當某個操作不適合放在聚合和值物件上時,最好的方式便是使用領域服務了。可以使用領域服務的地方,過度使用領域服務將導致貧血領域模型。執行一個顯著的業務操作過程;對領域物件進行轉換;已多個領域物件作為輸入進行計算,結果產生一個值物件。
- 倉儲層介面(Repository):把我們要的資料當做一個集合放在倉儲裡面,想要的時候直接獲取。倉儲作為領域層和基礎結構層的連線元件,使得領域層不必過多的關注儲存細節。在設計時,將倉儲介面放在領域層,而將倉儲的具體實現放在基礎結構層,領域層通過介面訪問資料儲存,而不必過多的關注倉儲儲存資料的細節,這樣使得領域層將更多的關注點放在領域邏輯上面。
- 工廠(Factory):對於實體等物件構造比較麻煩的,可以藉助工廠進行構造。
通常,對於實體、值物件、聚合根,我們不可以不加類字尾,這樣更能體現領域物件本身的含義。
public class Order {
// OrderId是隱性的概念顯性化,而不是直接使用一個String,String就只能表示一個值了
private OrderId orderId;
private BuyerId buyerId;
private OrderStatus status;
private Long amount;
private List<OrderItem> orderItems;
public static Order create(...) {
// 如果引數比較多,構造比較麻煩,可以遷移到 Factory
...
}
public void pay(...) {
}
public void deliver(...) {
}
...
}
public class OrderItem {
private Long goodsId;
private Integer count;
public static OrderItem create(Long goodsId, Integer count) {
...
}
}
// 領域服務一般無需介面定義
public class OrderDomainService {
@Resource
private OrderRepository orderRepository;
public Order create(Order order) {
...
orderRepository.create(order);
return order;
}
}
public interface OrderRepository {
void add(Order order);
Order getByOrderId(OrderId orderId);
}
3.4 基礎設施層
主要負責技術細節處理,比如資料庫CRUD、快取、訊息服務等。
public class OrderDO {
}
public class OrderItemDO {
}
public class OrderDao implements OrderRepository {
@Resource
private OrderMapper orderMapper;
@Resource
private OrderItemMapper orderItemMapper;
@Override
public void add(Order order) {
OrderDO orderDO = OrderFactory.build(order);
List<OrderItemDO> orderItemDOList = OrderFactory.build(order);
orderMapper.insert(orderDO);
orderItemMapper.batchInsert(orderItemDOList);
}
}
參考資料
- 《實現領域驅動設計》
- 殷浩詳解DDD:如何避免寫流水賬程式碼
- 殷浩詳解DDD:領域層設計規範