大家好,我是飄渺。今天我們繼續更新DDD(領域驅動設計) & 微服務系列。
在之前的文章中,我們探討了如何在DDD中結構化應用程序。我們了解到,在DDD中通常將應用程序分為四個層次,分別為用戶接口層(Interface Layer),應用層(Application Layer),領域層(DomAIn Layer),和基礎設施層(Infrastructure Layer)。此外,在用戶注冊的主題中,我們簡要地提及了資源庫模式。然而,那時我們并沒有深入探討。今天,我將為大家詳細介紹資源庫模式,這在DDD中是一個非常重要的概念。
1. 傳統開發流程分析
首先,讓我們回顧一下傳統的以數據庫為中心的開發流程。
在這種開發流程中,開發者通常會創建Data Access Object(DAO)來封裝對數據庫的操作。DAO的主要優勢在于它能夠簡化構建SQL查詢、管理數據庫連接和事務等底層任務。這使得開發者能夠將更多的精力放在業務邏輯的編寫上。然而,DAO雖然簡化了操作,但仍然直接處理數據庫和數據模型。
值得注意的是,Uncle Bob在《代碼整潔之道》一書中,通過一些術語生動地描述了這個問題。他將系統元素分為三類:
硬件(Hardware): 指那些一旦創建就不可(或難以)更改的元素。在開發背景下,數據庫被視為“硬件”,因為一旦選擇了一種數據庫,例如MySQL,轉向另一種數據庫,如MongoDB,通常會帶來巨大的成本和挑戰。
軟件(Software): 指那些創建后可以隨時修改的元素。開發者應該致力于使業務代碼作為“軟件”,因為業務需求和規則總是在不斷變化,因此代碼也應該具有相應的靈活性和可調整性。
固件(Firmware): 是那些與硬件緊密耦合,但具有一定的軟性特點的軟件。例如,路由器的固件或Android固件。它們為硬件提供抽象,但通常只適用于特定類型的硬件。
通過理解這些術語,我們可以認識到數據庫應視為“硬件”,而DAO在本質上屬于“固件”。然而,我們的目標是使我們的代碼保持像“軟件”那樣的靈活性。但是,當業務代碼過于依賴于“固件”時,它會受到限制,變得難以更改。
讓我們通過一個具體的例子來進一步理解這個概念。下面是一個簡單的代碼片段,展示了一個對象如何依賴于DAO(也就是依賴于數據庫):
private OrderDAO orderDAO;
public Long addOrder(RequestDTO request) {
// 此處省略很多拼裝邏輯
OrderDO orderDO = new OrderDO();
orderDAO.insertOrder(orderDO);
return orderDO.getId();
}
public void updateOrder(OrderDO orderDO, RequestDTO updateRequest) {
orderDO.setXXX(XXX); // 省略很多
orderDAO.updateOrder(orderDO);
}
public void doSomeBusiness(Long id) {
OrderDO orderDO = orderDAO.getOrderById(id);
// 此處省略很多業務邏輯
}
上面的代碼片段看似無可厚非,但假設在未來我們需要加入緩存邏輯,代碼則需要改為如下:
private OrderDAO orderDAO;
private Cache cache;
public Long addOrder(RequestDTO request) {
// 此處省略很多拼裝邏輯
OrderDO orderDO = new OrderDO();
orderDAO.insertOrder(orderDO);
cache.put(orderDO.getId(), orderDO);
return orderDO.getId();
}
public void updateOrder(OrderDO orderDO, RequestDTO updateRequest) {
orderDO.setXXX(XXX); // 省略很多
orderDAO.updateOrder(orderDO);
cache.put(orderDO.getId(), orderDO);
}
public void doSomeBusiness(Long id) {
OrderDO orderDO = cache.get(id);
if (orderDO == null) {
orderDO = orderDAO.getOrderById(id);
}
// 此處省略很多業務邏輯
}
可以看到,插入緩存邏輯后,原本簡單的代碼變得復雜。原本一行代碼現在至少需要三行。隨著代碼量的增加,如果你在某處忘記查看緩存或忘記更新緩存,可能會導致輕微的性能下降或者更糟糕的是,緩存和數據庫的數據不一致,從而導致bug。這種問題隨著代碼量和復雜度的增長會變得更加嚴重,這就是軟件被“固化”的后果。
因此,我們需要一個設計模式來隔離我們的軟件(業務邏輯)與固件/硬件(DAO、數據庫),以提高代碼的健壯性和可維護性。這個模式就是DDD中的資源庫模式(Repository Pattern)。
2. 深入理解資源庫模式
在DDD(領域驅動設計)中,資源庫起著至關重要的作用。資源庫的核心任務是為應用程序提供統一的數據訪問入口。它允許我們以一種與底層數據存儲無關的方式,來存儲和檢索領域對象。這對于將業務邏輯與數據訪問代碼解耦是非常有價值的。
2.1 資源庫模式在架構中的位置
資源庫是一種廣泛應用的架構模式。事實上,當你使用諸如Hibernate、MyBatis這樣的ORM框架時,你已經在間接地使用資源庫模式了。資源庫扮演著對象的提供者的角色,并且處理對象的持久化。讓我們看一下持久化:持久化意味著將數據保存在一個持久媒介,比如關系型數據庫或NoSQL數據庫,這樣即使應用程序終止,數據也不會丟失。這些持久化媒介具有不同的特性和優點,因此,資源庫的實現會依據所使用的媒介有所不同。
資源庫的設計通常包括兩個主要組成部分:定義和實現。定義部分是一個抽象接口,它只描述了我們可以對數據執行哪些操作,而不涉及具體如何執行它們。實現部分則是這些操作的具體實現。它依賴于一個特定的持久化媒介,并可能需要與特定的技術進行交互。
2.2 領域層與基礎設施層
根據DDD的分層架構,領域層包含所有與業務領域有關的元素,包括實體、值對象和聚合。領域層表示業務的核心概念和邏輯。
另一方面,基礎設施層包含支持其他層的通用技術,比如數據庫訪問、文件系統交互等。
資源庫模式很好地適用于這種分層結構。資源庫的定義部分,即抽象接口,位于領域層,因為它直接與領域對象交互。而資源庫的實現部分則屬于基礎設施層,它處理具體的數據訪問邏輯。
以DailyMart系統中的CustomerUser為例
圖片
如上圖所示,CustomerUserRepository是資源庫接口,位于領域層,操作的對象是CustomerUser聚合根。CustomerUserRepositoryImpl是資源庫的實現部分,位于基礎設施層。這個實現部分操作的是持久化對象,這就需要在基礎設施層中有一個組件來處理領域對象與數據對象的轉換,在之前的文章中已經推薦使用工具mapstruct來實現這種轉換。
2.3 小結
資源庫是DDD中一個強大的概念,允許我們以一種整潔和一致的方式來處理數據訪問。通過將資源庫的定義放在領域層,并將其實現放在基礎設施層,我們能夠有效地將業務邏輯與數據訪問代碼解耦,從而使應用程序更加靈活和可維護。
3. 倉儲接口的設計原則
當我們設計倉儲接口時,目標是創造一個清晰、可維護且松耦合的結構,這樣能夠讓應用程序更加靈活和健壯。以下是倉儲接口設計的一些原則和最佳實踐:
- 避免使用底層實現語法命名接口方法:倉儲接口應該與底層數據存儲實現保持解耦。使用像insert, select, update, delete這樣的詞語,這些都是SQL語法,等于是將接口與數據庫實現綁定。相反,應該視倉儲為一個類似集合的抽象,使用更通用的詞匯,如 **find、save、remove**。特別注意,區分insert/add 和 update 本身就是與底層實現綁定的邏輯,有時候存儲方式(如緩存)并不區分這兩者。在這種情況下,使用一個中立的save接口,然后在具體的實現中根據需要調用insert或update。
- 使用領域對象作為參數和返回值:倉儲接口位于領域層,因此它不應該暴露底層數據存儲的細節。當底層存儲技術發生變化時,領域模型應保持不變。因此,倉儲接口應以領域對象,特別是聚合根(Aggregate Root)對象,作為參數和返回值。
- 避免過度通用化的倉儲模式:雖然一些ORM框架(如Spring Data和Entity Framework)提供了高度通用的倉儲接口,通過注解自動實現接口,但這種做法在簡單場景下雖然方便,但通常缺乏擴展性(例如,添加自定義緩存邏輯)。使用這種通用接口可能導致在未來的開發中遇到限制,甚至需要進行大的重構。但請注意,避免過度通用化并不意味著不能有基本的接口或通用的輔助類。
- 定義清晰的事務邊界:通常,事務應該在應用服務層開始和結束,而不是在倉儲層。這樣可以確保事務的范圍明確,并允許更好地控制事務的生命周期。
通過遵循上述原則和最佳實踐,我們可以創建一個倉儲接口,不僅與底層數據存儲解耦,還能支持領域模型的演變和應用程序的可維護性。
4. Repository的代碼實現
在DailyMart項目中,為了實現DDD開發的最佳實踐,我們創建一個名為dailymart-ddd-spring-boot-starter的組件模塊,專門存放DDD相關的核心組件。這種做法簡潔地讓其他模塊通過引入此公共模塊來遵循DDD原則。
圖片
4.1 制定Marker接口類
Marker接口主要為類型定義和派生類分類提供標識,通常不包含任何方法。我們首先定義幾個核心的Marker接口。
public interface Identifiable<ID extends Identifier<?>> extends Serializable {
ID getId();
}
public interface Identifier<T> extends Serializable {
T getValue();
}
public interface Entity<ID extends Identifier<?>> extends Identifiable<ID> { }
public interface Aggregate<ID extends Identifier<?>> extends Entity<ID> { }
這里,聚合會實現Aggregate接口,而實體會實現Entity接口。聚合本質上是一種特殊的實體,這種結構使邏輯更加清晰。另外,我們引入了Identifier接口來表示實體的唯一標識符,它將唯一標識符視為值對象,這是DDD中常見的做法。如下面所示的案例
public class OrderId implements Identifier<Long> {
@Serial
private static final long serialVersionUID = -8658575067669691021L;
public Long id;
public OrderId(Long id){
this.id = id;
}
@Override
public Long getValue() {
return id;
}
}
4.2 創建通用Repository接口
接下來,我們定義一個基礎的Repository接口。
public interface Repository <T extends Aggregate<ID>, ID extends Identifier<?>> {
T find(ID id);
void remove(T aggregate);
void save(T aggregate);
}
業務特定的接口可以在此基礎上進行擴展。例如,對于訂單,我們可以添加計數和分頁查詢。
public interface OrderRepository extends Repository<Order, OrderId> {
// 自定義Count接口,在這里OrderQuery是一個自定義的DTO
Long count(OrderQuery query);
// 自定義分頁查詢接口
Page<Order> query(OrderQuery query);
}
請注意,Repository的接口定義位于Domain層,而具體的實現則位于Infrastructure層。
4.3 實施Repository的基本功能
下面是一個簡單的Repository實現示例。注意,OrderRepositoryNativeImpl在Infrastructure層。
@Repository
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
@Slf4j
public class OrderRepositoryNativeImpl implements OrderRepository {
private final OrderMapper orderMapper;
private final OrderItemMapper orderItemMapper;
private final OrderConverter orderConverter;
private final OrderItemConverter orderItemConverter;
@Override
public Order find(OrderId orderId) {
OrderDO orderDO = orderMapper.selectById(orderId.getValue());
return orderConverter.fromData(orderDO);
}
@Override
public void save(Order aggregate) {
if(aggregate.getId() != null && aggregate.getId().getValue() > 0){
// update
OrderDO orderDO = orderConverter.toData(aggregate);
orderMapper.updateById(orderDO);
}else{
// insert
OrderDO orderDO = orderConverter.toData(aggregate);
orderMapper.insert(orderDO);
aggregate.setId(orderConverter.fromData(orderDO).getId());
}
}
...
}
這段代碼展示了一個常見的模式:Entity/Aggregate轉換為Data Object(DO),然后使用Data Access Object(DAO)根據業務邏輯執行相應操作。在操作完成后,如果需要,還可以將DO轉換回Entity。代碼很簡單,唯一需要注意的是save方法,需要根據Aggregate的ID是否存在且大于0來判斷一個Aggregate是否需要更新還是插入。
4.4 Repository復雜實現
處理單一實體的Repository實現通常較為直接,但當聚合中包含多個實體時,操作的復雜性會增加。主要的問題在于,在單次操作中,并不是聚合中的所有實體都需要變更,而使用簡單的實現會導致許多不必要的數據庫操作。
以一個典型的場景為例:一個訂單中包含多個商品明細。如果修改了某個商品明細的數量,這會同時影響主訂單的總價,但對其他商品明細則沒有影響。
圖片
若采用基礎的實現方法,會多出兩個不必要的更新操作,如下所示:
@Repository
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
@Slf4j
public class OrderRepositoryNativeImpl implements OrderRepository {
//省略其他邏輯
@Override
public void save(Order aggregate) {
if(aggregate.getId() != null && aggregate.getId().getValue() > 0){
// 每次都將Order和所有LineItem全量更新
OrderDO orderDO = orderConverter.toData(aggregate);
orderMapper.updateById(orderDO);
for(OrderItem orderItem : aggregate.getOrderItems()){
save(orderItem);
}
}else{
//省略插入邏輯
}
}
private void save(OrderItem orderItem) {
if (orderItem.getId() != null && orderItem.getId().getValue() > 0) {
OrderItemDO orderItemDO = orderItemConverter.toData(orderItem);
orderItemMapper.updateById(orderItemDO);
} else {
OrderItemDO orderItemDO = orderItemConverter.toData(orderItem);
orderItemMapper.insert(orderItemDO);
orderItem.setItemId(orderItemConverter.fromData(orderItemDO).getId());
}
}
}
在此示例中,會執行4個UPDATE操作,而實際上只需2個。通常情況下,這個額外的開銷并不嚴重,但如果非Aggregate Root的實體數量很大,這會導致大量不必要的寫操作。
4.5 變更追蹤(Change-Tracking)
針對上述問題,核心在于Repository接口的限制使得調用者只能操作Aggregate Root,而不能單獨操作非Aggregate Root的實體。這與直接調用DAO的方式有顯著差異。
一種解決方案是通過變更追蹤能力來識別哪些實體有變更,并且僅對這些變更過的實體執行操作。這樣,先前需要手動判斷的代碼邏輯現在可以通過變更追蹤來自動實現,讓開發者真正只關注聚合的操作。以前面的示例為例,通過變更追蹤,系統可以判斷出只有OrderItem2和Order發生了變化,因此只需要生成兩個UPDATE操作。
變更追蹤有兩種主流實現方式:
- 基于快照Snapshot的方案: 數據從數據庫提取后,在內存中保存一份快照,然后在將數據寫回時與快照進行比較。Hibernate是采用此種方法的常見實現。
- 基于代理Proxy的方案: 當數據從數據庫提取后,通過織入的方式為所有setter方法增加一個切面來檢測setter是否被調用以及值是否發生變化。如果值發生變化,則將其標記為“臟”(Dirty)。在保存時,根據這個標記來判斷是否需要更新。Entity Framework是一個采用此種方法的常見實現。
代理Proxy方案的優勢是性能較高,幾乎沒有額外成本,但缺點是實現起來比較復雜,而且當存在嵌套關系時,不容易檢測到嵌套對象的變化(例如,子列表的增加和刪除),可能會導致bug。
而快照Snapshot方案的優勢是實現相對簡單,成本在于每次保存時執行全量比較(通常使用反射)以及保存快照的內存消耗。
由于代理Proxy方案的復雜性,業界主流(包括EF Core)更傾向于使用基于Snapshot快照的方案。
此外,通過檢測差異,我們能識別哪些字段發生了改變,并僅更新這些發生變化的字段,從而進一步降低UPDATE操作的開銷。無論是否在DDD上下文中,這個功能本身都是非常有用的。在DailyMart示例中,我們使用一個名為DiffUtils的工具類來輔助比較對象間的差異。
public class DiffUtilsTest {
@Test
public void diffObject() throws IllegalAccessException, IOException, ClassNotFoundException {
//實時對象
Order realObj = Order.builder()
.id(new OrderId(31L))
.customerId(100L)
.totalAmount(new BigDecimal(100))
.recipientInfo(new RecipientInfo("zhangsan","安徽省合肥市","123456"))
.build();
// 快照對象
Order snapshotObj = SnapshotUtils.snapshot(realObj);
snapshotObj.setId(new OrderId(2L));
snapshotObj.setTotalAmount(new BigDecimal(200));
EntityDiff diff = DiffUtils.diff(realObj, snapshotObj);
assertTrue(diff.isSelfModified());
assertEquals(2, diff.getDiffs().size());
}
}
詳細用法可以參考單元測試com.jianzh5.dailymart.module.order.infrastructure.util.DiffUtilsTest
通過變更追蹤的引入,我們能夠使聚合的Repository實現更加高效和智能。這允許開發人員將注意力集中在業務邏輯上,而不必擔心不必要的數據庫操作。
圖片
圖片
5 在DailyMart中集成變更追蹤
DailyMart系統內涵蓋了一個訂單子域,該子域以Order作為聚合根,并將OrderItem納入為其子實體。兩者之間構成一對多的聯系。在對訂單進行更新操作時,變更追蹤顯得尤為關鍵。
下面展示的是DailyMart系統中關于變更追蹤的核心代碼片段。值得注意的是,這些代碼僅用于展示如何在倉庫模式中融入變更追蹤,并非訂單子域的完整實現。
AggregateRepositorySupport 類
該類是聚合倉庫的支持類,它管理聚合的變更追蹤。
@Slf4j
public abstract class AggregateRepositorySupport<T extends Aggregate<ID>, ID extends Identifier<?>> implements Repository<T, ID> {
@Getter
private final Class<T> targetClass;
// 讓 AggregateManager 去維護 Snapshot
@Getter(AccessLevel.PROTECTED)
private AggregateManager<T, ID> aggregateManager;
protected AggregateRepositorySupport(Class<T> targetClass) {
this.targetClass = targetClass;
this.aggregateManager = AggregateManagerFactory.newInstance(targetClass);
}
/** Attach的操作就是讓Aggregate可以被追蹤 */
@Override
public void attach(@NotNull T aggregate) {
this.aggregateManager.attach(aggregate);
}
/** Detach的操作就是讓Aggregate停止追蹤 */
@Override
public void detach(@NotNull T aggregate) {
this.aggregateManager.detach(aggregate);
}
@Override
public T find(@NotNull ID id) {
T aggregate = this.onSelect(id);
if (aggregate != null) {
// 這里的就是讓查詢出來的對象能夠被追蹤。
// 如果自己實現了一個定制查詢接口,要記得單獨調用attach。
this.attach(aggregate);
}
return aggregate;
}
@Override
public void remove(@NotNull T aggregate) {
this.onDelete(aggregate);
// 刪除停止追蹤
this.detach(aggregate);
}
@Override
public void save(@NotNull T aggregate) {
// 如果沒有 ID,直接插入
if (aggregate.getId() == null) {
this.onInsert(aggregate);
this.attach(aggregate);
return;
}
// 做 Diff
EntityDiff diff = null;
try {
//aggregate = this.onSelect(aggregate.getId());
find(aggregate.getId());
diff = aggregateManager.detectChanges(aggregate);
} catch (IllegalAccessException e) {
//throw new RuntimeException("Failed to detect changes", e);
e.printStackTrace();
}
if (diff.isEmpty()) {
return;
}
// 調用 UPDATE
this.onUpdate(aggregate, diff);
// 最終將 DB 帶來的變化更新回 AggregateManager
aggregateManager.merge(aggregate);
}
/** 這幾個方法是繼承的子類應該去實現的 */
protected abstract void onInsert(T aggregate);
protected abstract T onSelect(ID id);
protected abstract void onUpdate(T aggregate, EntityDiff diff);
protected abstract void onDelete(T aggregate);
}
OrderRepositoryDiffImpl 類
這個類繼承自 AggregateRepositorySupport 類,并實現具體的訂單存儲邏輯。
@Repository
@Slf4j
@Primary
public class OrderRepositoryDiffImpl extends AggregateRepositorySupport<Order, OrderId> implements OrderRepository {
//省略其他邏輯
@Override
protected void onUpdate(Order aggregate, EntityDiff diff) {
if (diff.isSelfModified()) {
OrderDO orderDO = orderConverter.toData(aggregate);
orderMapper.updateById(orderDO);
}
Diff orderItemsDiffs = diff.getDiff("orderItems");
if ( orderItemsDiffs instanceof ListDiff diffList) {
for (Diff itemDiff : diffList) {
if(itemDiff.getType() == DiffType.REMOVED){
OrderItem orderItem = (OrderItem) itemDiff.getOldValue();
orderItemMapper.deleteById(orderItem.getItemId().getValue());
}
if (itemDiff.getType() == DiffType.ADDED) {
OrderItem orderItem = (OrderItem) itemDiff.getNewValue();
orderItem.setOrderId(aggregate.getId());
OrderItemDO orderItemDO = orderItemConverter.toData(orderItem);
orderItemMapper.insert(orderItemDO);
}
if (itemDiff.getType() == DiffType.MODIFIED) {
OrderItem line = (OrderItem) itemDiff.getNewValue();
OrderItemDO orderItemDO = orderItemConverter.toData(line);
orderItemMapper.updateById(orderItemDO);
}
}
}
}
}
ThreadLocalAggregateManager 類
這個類主要通過ThreadLocal來保證在多線程環境下,每個線程都有自己的Entity上下文。
public class ThreadLocalAggregateManager<T extends Aggregate<ID>, ID extends Identifier<?>> implements AggregateManager<T, ID> {
private final ThreadLocal<DbContext<T, ID>> context;
private Class<? extends T> targetClass;
public ThreadLocalAggregateManager(Class<? extends T> targetClass) {
this.targetClass = targetClass;
this.context = ThreadLocal.withInitial(() -> new DbContext<>(targetClass));
}
@Override
public void attach(T aggregate) {
context.get().attach(aggregate);
}
@Override
public void attach(T aggregate, ID id) {
context.get().setId(aggregate, id);
context.get().attach(aggregate);
}
@Override
public void detach(T aggregate) {
context.get().detach(aggregate);
}
@Override
public T find(ID id) {
return context.get().find(id);
}
@Override
public EntityDiff detectChanges(T aggregate) throws IllegalAccessException {
return context.get().detectChanges(aggregate);
}
@Override
public void merge(T aggregate) {
context.get().merge(aggregate);
}
}
SnapshotUtils 類
SnapshotUtils 是一個工具類,它利用深拷貝技術來為對象創建快照。
public class SnapshotUtils {
@SuppressWarnings("unchecked")
public static <T extends Aggregate<?>> T snapshot(T aggregate)
throws IOException, ClassNotFoundException {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(aggregate);
ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bis);
return (T) ois.readObject();
}
}
這個類中的 snapshot 方法采用序列化和反序列化的方式來實現對象的深拷貝,從而為給定的對象創建一個獨立的副本。注意,為了使此方法工作,需要確保 Aggregate 類及其包含的所有對象都是可序列化的。
6. 小結
在本文中,我們深入探討了DDD(領域驅動設計)的一個核心構件 —— 倉儲模式。借助快照模式和變更追蹤,我們成功解決了倉儲模式僅限于操作聚合根的約束,這為后續開發提供了一種實用的模式。
在互聯網上有豐富的DDD相關文章和討論,但值得注意的是,雖然許多項目宣稱使用Repository模式,但在實際實現上可能并未嚴格遵循DDD的關鍵設計原則。以訂單和訂單項為例,一些項目在正確地把訂單項作為訂單聚合的一部分時,卻不合理地為訂單項單獨創建了Repository接口。而根據DDD的理念,應當僅為聚合根配備對應的倉儲接口。通過今天的探討,我們應該更加明確地理解和運用DDD的原則,以確保更加健壯和清晰的代碼結構。