後端開發實踐系列——領域驅動設計(DDD)編碼實踐

Martin Fowler在《企業應用架構模式》一書中寫道:

I found this(business logic) a curious term because there are few things that are less logical than business logic.

初略翻譯過來可以理解爲:業務邏輯是很沒有邏輯的邏輯。

的確,很多時候軟件的業務邏輯是無法通過推理而得到的,有時甚至是被臆想出來的。這樣的結果使得原本已經很複雜的業務變得更加複雜而難以理解。而在具體編碼實現時,除了應付業務上的複雜性,技術上的複雜性也不能忽略,比如我們要講究技術上的分層,要遵循軟件開發的基本原則,又比如要考慮到性能和安全等等。

在很多項目中,技術複雜度與業務複雜度相互交錯糾纏不清,這種火上澆油的做法成爲不少軟件項目無法繼續往下演進的原因。然而,在合理的設計下,技術和業務是可以分離開來或者至少它們之間的耦合度是可以降低的。在不同的軟件建模方法中,領域驅動設計(Domain Driven Design,DDD)嘗試通過其自有的原則與套路來解決軟件的複雜性問題,它將研發者的目光首先聚焦在業務本身上,使技術架構和代碼實現成爲軟件建模過程中的“副產品”。

DDD總覽

DDD分爲戰略設計和戰術設計。在戰略設計中,我們講求的是子域和限界上下文(Bounded Context,BC)的劃分,以及各個限界上下文之間的上下游關係。當前如此火熱的“在微服務中使用DDD”這個命題,究其最初的邏輯無外乎是“DDD中的限界上下文可以用於指導微服務中的服務劃分”。事實上,限界上下文依然是軟件模塊化的一種體現,與我們一直以來追求的模塊化原則的驅動力是相同的,即通過一定的手段使軟件系統在人的大腦中更加有條理地呈現,讓作爲“目的”的人能夠更簡單地瞭解進而掌控軟件系統。

如果說戰略設計更偏向於軟件架構,那麼戰術設計便更偏向於編碼實現。DDD戰術設計的目的是使得業務能夠從技術中分離並突顯出來,讓代碼直接表達業務的本身,其中包含了聚合根、應用服務、資源庫、工廠等概念。雖然DDD不一定通過面向對象(OO)來實現,但是通常情況下在實踐DDD時我們採用的是OO編程範式,行業中甚至有種說法是“DDD是OO進階”,意思是面向對象中的基本原則(比如SOLID)在DDD中依然成立。本文主要講解DDD的戰術設計。

本文以一個簡單的電商訂單系統爲例,通過以下方式可以獲取源代碼:

git clone https://github.com/e-commerce-sample/order-backend
git checkout a443dace

實現業務的3種常見方式

在講解DDD之前,讓我們先來看一下實現業務代碼的幾種常見方式,在示例項目中有個“修改Order中Product的數量”的業務需求如下:

可以修改Order中Product的數量,但前提是Order處於未支付狀態,Product數量變更後Order的總價(totalPrice)應該隨之更新。

1. 基於“Service + 貧血模型”的實現

這種方式當前被很多軟件項目所採用,主要的特點是:存在一個貧血的“領域對象”,業務邏輯通過一個Service類實現,然後通過setter方法更新領域對象,最後通過DAO(多數情況下可能使用諸如Hibernate之類的ORM框架)保存到數據庫中。實現一個OrderService類如下:

@Transactional
public void changeProductCount(String id, ChangeProductCountCommand command) {
    Order order = DAO.findById(id);
    if (order.getStatus() == PAID) {
        throw new OrderCannotBeModifiedException(id);
    }
    OrderItem orderItem = order.getOrderItem(command.getProductId());
    orderItem.setCount(command.getCount());
    order.setTotalPrice(calculateTotalPrice(order));
    DAO.saveOrUpdate(order);
}

這種方式依然是一種面向過程的編程範式,違背了最基本的OO原則。另外的問題在於職責劃分模糊不清,使本應該內聚在Order中的業務邏輯泄露到了其他地方(OrderService),導致Order成爲一個只是充當數據容器的貧血模型(Anemic Model),而非真正意義上的領域模型。在項目持續演進的過程中,這些業務邏輯會分散在不同的Service類中,最終的結果是代碼變得越來越難以理解進而逐漸喪失擴展能力。

2. 基於事務腳本的實現

在上一種實現方式中,我們會發現領域對象(Order)存在的唯一目的其實是爲了讓ORM這樣的工具能夠一次性地持久化,在不使用ORM的情況下,領域對象甚至都沒有必要存在。於是,此時的代碼實現便退化成了事務腳本(Transaction Script),也就是直接將Service類中計算出的結果直接保存到數據庫(或者有時都沒有Service類,直接通過SQL實現業務邏輯):

@Transactional
public void changeProductCount(String id, ChangeProductCountCommand command) {
    OrderStatus orderStatus = DAO.getOrderStatus(id);
    if (orderStatus == PAID) {
        throw new OrderCannotBeModifiedException(id);
    }
    DAO.updateProductCount(id, command.getProductId(), command.getCount());
    DAO.updateTotalPrice(id);
}

可以看到,DAO中多出了很多方法,此時的DAO不再只是對持久化的封裝,而是也會包含業務邏輯。另外,DAO.updateTotalPrice(id)方法的實現中將直接調用SQL來實現Order總價的更新。與“Service+貧血模型”方式相似,事務腳本也存在業務邏輯分散的問題。

事實上,事務腳本並不是一種全然的反模式,在系統足夠簡單的情況下完全可以採用。但是:一方面“簡單”這個度其實並不容易把握;另一方面軟件系統通常會在不斷的演進中加入更多的功能,使得原本簡單的代碼逐漸變得複雜。因此,事務腳本在實際的應用中使用得並不多。

3. 基於領域對象的實現

在這種方式中,核心的業務邏輯被內聚在行爲飽滿的領域對象(Order)中,實現Order類如下:

public void changeProductCount(ProductId productId, int count) {
    if (this.status == PAID) {
        throw new OrderCannotBeModifiedException(this.id);
    }
    OrderItem orderItem = retrieveItem(productId);
    orderItem.updateCount(count);
}

然後在Controller或者Service中,調用Order.changeProductCount()

@PostMapping("/order/{id}/products")
public void changeProductCount(@PathVariable(name = "id") String id, @RequestBody @Valid ChangeProductCountCommand command) {
    Order order = DAO.byId(orderId(id));
    order.changeProductCount(ProductId.productId(command.getProductId()), command.getCount());
    order.updateTotalPrice();
    DAO.saveOrUpdate(order);
}

可以看到,所有業務(“檢查Order狀態”、“修改Product數量”以及“更新Order總價”)都被包含在了Order對象中,這些正是Order應該具有的職責。(不過示例代碼中有個地方明顯違背了內聚性原則,下文會講到,作爲懸念讀者可以先行嘗試着找一找)

事實上,這種方式與本文要講的DDD戰術模式已經很相近了,只是DDD抽象出了更多的概念與原則。

基於業務的分包

在本系列的上一篇:Spring Boot項目模板文章中,其實我已經講到了基於業務的分包,結合DDD的場景,這裏再簡要討論一下。所謂基於業務分包即通過軟件所實現的業務功能進行模塊化劃分,而不是從技術的角度劃分(比如首先劃分出serviceinfrastruture等包)。在DDD的戰略設計中,我們關注於從一個宏觀的視角俯視整個軟件系統,然後通過一定的原則對系統進行子域和限界上下文的劃分。在戰術實踐中,我們也通過類似的提綱挈領的方法進行整體的代碼結構的規劃,所採用的原則依然逃離不了“內聚性”和“職責分離”等基本原則。此時,首先映入眼簾的便是軟件的分包。

在DDD中,聚合根(下文會講到)是主要業務邏輯的承載體,也是“內聚性”原則的典型代表,因此通常的做法便是基於聚合根進行頂層包的劃分。在示例電商項目中,有兩個聚合根對象OrderProduct,分別創建order包和product包,然後在各自的頂層包下再根據代碼結構的複雜程度劃分子包,比如對於product包:

└── product
    ├── CreateProductCommand.java
    ├── Product.java
    ├── ProductApplicationService.java
    ├── ProductController.java
    ├── ProductId.java
    ├── ProductNotFoundException.java
    ├── ProductRepository.java
    └── representation
        ├── ProductRepresentationService.java
        └── ProductSummaryRepresentation.java

可以看到,ProductRepositoryProductController等多數類都直接放在了product包下,而沒有單獨分包;但是展現類ProductSummaryRepresentation卻做了單獨分包。這裏的原則是:在所有類已經被內聚在了product包下的情況下,如果代碼結構足夠的簡單,那麼沒有必要再次進行子包的劃分,ProductRepositoryProductController便是這種情況;而如果多個類需要做再次的內聚,那麼需要另行分包,比如通過REST API接口返回Product數據時,代碼中涉及到了兩個對象ProductRepresentationServiceProductSummaryRepresentation,這兩個對象是緊密關聯的,因此將他們放在representation子包下。而對於更加複雜的Order,分包如下:

├── order
│   ├── OrderApplicationService.java
│   ├── OrderController.java
│   ├── OrderPaymentProxy.java
│   ├── OrderPaymentService.java
│   ├── OrderRepository.java
│   ├── command
│   │   ├── ChangeAddressDetailCommand.java
│   │   ├── CreateOrderCommand.java
│   │   ├── OrderItemCommand.java
│   │   ├── PayOrderCommand.java
│   │   └── UpdateProductCountCommand.java
│   ├── exception
│   │   ├── OrderCannotBeModifiedException.java
│   │   ├── OrderNotFoundException.java
│   │   ├── PaidPriceNotSameWithOrderPriceException.java
│   │   └── ProductNotInOrderException.java
│   ├── model
│   │   ├── Order.java
│   │   ├── OrderFactory.java
│   │   ├── OrderId.java
│   │   ├── OrderIdGenerator.java
│   │   ├── OrderItem.java
│   │   └── OrderStatus.java
│   └── representation
│       ├── OrderItemRepresentation.java
│       ├── OrderRepresentation.java
│       └── OrderRepresentationService.java

可以看到,我們專門創建了一個model包用於放置所有與Order聚合根相關的領域對象;另外,基於同類型相聚原則,創建command包和exception包分別用於放置請求類和異常類。

領域模型的門面——應用服務

UML中有用例(Use Case)的概念,表示的是軟件向外提供業務功能的基本邏輯單元。在DDD中,由於業務被提到了第一優先級,那麼自然地我們希望對業務的處理能夠顯現出來,爲了達到這樣的目的,DDD專門提供了一個名爲應用服務(ApplicationService)的抽象層。ApplicationService採用了門面模式,作爲領域模型向外提供業務功能的總出入口,就像酒店的前臺處理客戶的不同需求一樣。

在編碼實現業務功能時,通常用2種工作流程:

  • 自底向上:先設計數據模型,比如關係型數據庫的表結構,再實現業務邏輯。我在與不同的程序員結對編程的時候,總會是聽到這麼一句話:“讓我先把數據庫表的字段設計出來吧”。這種方式將關注點優先放在了技術性的數據模型上,而不是代表業務的領域模型,是DDD之反。
  • 自頂向下:拿到一個業務需求,先與客戶方確定好請求數據格式,再實現Controller和ApplicationService,然後實現領域模型(此時的領域模型通常已經被識別出來),最後實現持久化。

在DDD實踐中,自然應該採用自頂向下的實現方式。ApplicationService的實現遵循一個很簡單的原則,即一個業務用例對應ApplicationService上的一個業務方法。比如,對於上文提到的“修改Order中Product的數量”業務需求實現如下:

實現OrderApplicationService:

@Transactional
public void changeProductCount(String id, ChangeProductCountCommand command) {
    Order order = orderRepository.byId(orderId(id));
    order.changeProductCount(ProductId.productId(command.getProductId()), command.getCount());
    orderRepository.save(order);
}

OrderController調用OrderApplicationService:

@PostMapping("/{id}/products")
public void changeProductCount(@PathVariable(name = "id") String id, @RequestBody @Valid ChangeProductCountCommand command) {
    orderApplicationService.changeProductCount(id, command);
}

此時,order.changeProductCount()orderRepository.save()都沒有必要實現,但是由OrderControllerOrderApplicationService所構成的業務處理的架子已經搭建好了。

可以看到,“修改Order中Product的數量”用例中的OrderApplicationService.changeProductCount()方法實現中只有不多的3行代碼,然而,如此簡單的ApplicationService卻存在很多講究。

ApplicationService需要遵循以下原則:

  • 業務方法與業務用例一一對應:前面已經講到,不再贅述。
  • 業務方法與事務一一對應:也即每一個業務方法均構成了獨立的事務邊界,在本例中,OrderApplicationService.changeProductCount()方法標記有Spring的@Transactional註解,表示整個方法被封裝到了一個事務中。
  • 本身不應該包含業務邏輯:業務邏輯應該放在領域模型中實現,更準確的說是放在聚合根中實現,在本例中,order.changeProductCount()方法纔是真正實現業務邏輯的地方,而ApplicationService只是作爲代理調用order.changeProductCount()方法,因此,ApplicationService應該是很薄的一層。
  • 與UI或通信協議無關:ApplicationService的定位並不是整個軟件系統的門面,而是領域模型的門面,這意味着ApplicationService不應該處理諸如UI交互或者通信協議之類的技術細節。在本例中,Controller作爲ApplicationService的調用者負責處理通信協議(HTTP)以及與客戶端的直接交互。這種處理方式使得ApplicationService具有普適性,也即無論最終的調用方是HTTP的客戶端,還是RPC的客戶端,甚至一個Main函數,最終都統一通過ApplicationService才能訪問到領域模型。
  • 接受原始數據類型:ApplicationService作爲領域模型的調用方,領域模型的實現細節對其來說應該是個黑盒子,因此ApplicationService不應該引用領域模型中的對象。此外,ApplicationService接受的請求對象中的數據僅僅用於描述本次業務請求本身,在能夠滿足業務需求的條件下應該儘量的簡單。因此,ApplicationService通常處理一些比較原始的數據類型。在本例中,OrderApplicationService所接受的Order ID是Java原始的String類型,在調用領域模型中的Repository時,才被封裝爲OrderId對象。
    應用服務(ApplicationService)是領域模型的門面

業務的載體——聚合根

接地氣一點地講,聚合根(Aggreate Root, AR)就是軟件模型中那些最重要的以名詞形式存在的領域對象,比如本文示例項目中的OrderProduct。又比如,對於一個會員管理系統,會員(Member)便是一個聚合根;對於報銷系統,報銷單(Expense)便是一個聚合根;對於保險系統,保單(Policy)便是一個聚合根。聚合根是主要的業務邏輯載體,DDD中所有的戰術實現都圍繞着聚合根展開。

然而,並不是說領域模型中的所有名詞都可以建模爲聚合根。所謂“聚合”,顧名思義,即需要將領域中高度內聚的概念放到一起組成一個整體。至於哪些概念才能聚到一起,需要我們對業務本身有很深刻的認識,這也是爲什麼DDD強調開發團隊需要和領域專家一起工作的原因。近年來流行起來的事件風暴建模活動,究其本意也是通過羅列出領域中發生的所有事件可以讓我們全面的瞭解領域中的業務,進而識別出聚合根。

對於“更新Order中Product數量”用例,聚合根Order的實現如下:

public void changeProductCount(ProductId productId, int count) {
    if (this.status == PAID) {
        throw new OrderCannotBeModifiedException(this.id);
    }

    OrderItem orderItem = retrieveItem(productId);
    orderItem.updateCount(count);
    this.totalPrice = calculateTotalPrice();
}

private BigDecimal calculateTotalPrice() {
    return items.stream()
            .map(OrderItem::totalPrice)
            .reduce(ZERO, BigDecimal::add);
}


private OrderItem retrieveItem(ProductId productId) {
    return items.stream()
            .filter(item -> item.getProductId().equals(productId))
            .findFirst()
            .orElseThrow(() -> new ProductNotInOrderException(productId, id));
}

在本例中,Order中的品項(orderItems)和總價(totalPrice)是密切相關的,orderItems的變化會直接導致totalPrice的變化,因此,這二者自然應該內聚在Order下。此外,totalPrice的變化是orderItems變化的必然結果,這種因果關係是業務驅動出來的,爲了保證這種“必然”,我們需要在Order.changeProductCount()方法中同時實現“因”和“果”,也即聚合根應該保證業務上的一致性。在DDD中,業務上的一致性被稱爲不變條件(Invariants)

還記得上文中提到的“違背內聚性的懸念”嗎?當時調用Order上的業務方式如下:

.....
   order.changeProductCount(ProductId.productId(command.getProductId()), command.getCount());
   order.updateTotalPrice();
.....

爲了實現“更新Order中Product數量”業務功能,這裏先後調用了Order上的兩個public方法changeProductCount()updateTotalPrice()。雖然這種做法也能正確地實現業務邏輯,但是它將保證業務一致性的職責交給了Order的調用方(上文中的Controller)而不是Order自身,此時調用方需要確保在調用了changeProductCount()之後必須調用updateTotalPrice()方法,這一方面是Order中業務邏輯的泄露,另一方面調用方並不承擔這樣的職責,而Order才最應該承擔這樣的職責。

對內聚性的追求會自然地延伸出聚合根的邊界。在DDD的戰略設計中,我們已經通過限界上下文的劃分將一個大的軟件系統拆分爲了不同的“模塊”,在這樣的前提下,再在某個限界上下文中來討論內聚性將比在大泥球系統中討論變得簡單得多。

對聚合根的設計需要提防上帝對象(God Object),也即用一個大而全的領域對象來實現所有的業務功能。上帝對象的背後存在着一種表面上看似合理的邏輯:既然要內聚,那麼讓我們把所有相關的東西都聚到一起吧,比如用一個Product類來應付所有的業務場景,包括訂單、物流、發票等等。這種機械的方式看似內聚,實則恰恰是內聚性的反面。要解決這樣的問題依然需要求助於限界上下文,不同限界上下文使用各自的通用語言(Ubiquitous Language),通用語言要求一個業務概念不應該有二義性,在這樣的原則下,不同的限界上下文可能都有自己的Product類,雖然名字相同,卻體現着不同的業務。

不同的限界上下文中都有各自的Product,有些Product是聚合根,有些不是

除了內聚性和一致性,聚合根還有以下特徵:

  • 聚合根的實現應該與框架無關:既然DDD講求業務複雜度和技術複雜度的分離,那麼作爲業務主要載體的聚合根應該儘量少地引用技術框架級別的設施,最好是POJO。試想一下,如果你的項目哪天需要從Spring遷移到Play,而你可以自信地給老闆說,直接將核心Java代碼拷貝過去即可,這將是一種多麼美妙的體驗。又或者說,很多時候技術框架會有“大步”的升級,這種升級會導致框架中API的變化並且不再支持向後兼容,此時如果我們的領域模與框架無關,那麼便可做到在框架升級的過程中倖免於難。

  • 聚合根之間的引用通過ID完成:在聚合根邊界設計合理的情況下,一次業務用例只會更新一個聚合根,此時你在該聚合根中去引用另外聚合根的整體有什麼好處呢?在本文示例中,一個Order下的OrderItem引用了ProductId,而不是整個Product

  • 聚合根內部的所有變更都必須通過聚合根完成:爲了保證聚合根的一致性,同時避免聚合根內部邏輯向外泄露,客戶方只能將整個聚合根作爲統一調用入口。

  • 如果一個事務需要更新多個聚合根,首先思考一下自己的聚合根邊界處理是否出了問題,因爲在設計合理的情況下通常不會出現一個事務更新多個聚合根的場景。如果這種情況的確是業務所需,那麼考慮引入消息機制事件驅動架構,保證一個事務只更新一個聚合根,然後通過消息機制異步更新其他聚合根。

  • 聚合根不應該引用基礎設施。

  • 外界不應該持有聚合根內部的數據結構。

  • 儘量使用小聚合。

實體 vs 值對象

軟件模型中存在實體對象(Entity)和值對象(Value Object)之說,這種劃分方式事實上並不是DDD的專屬,但是在DDD中我們非常強調這兩者之間的區別。

實體對象表示的是具有一定生命週期並且擁有全局唯一標識(ID)的對象,比如本文中的OrderProduct,而值對象表示用於起描述性作用的,沒有唯一標識的對象,比如Address對象。

聚合根一定是實體對象,但是並不是所有實體對象都是聚合根,同時聚合根還可以擁有其他子實體對象。聚合根的ID在整個軟件系統中全局唯一,而其下的子實體對象的ID只需在單個聚合根下唯一即可。 在本文示例項目中,OrderItem是聚合根Order下的子實體對象:

public class OrderItem {
    private ProductId productId;
    private int count;
    private BigDecimal itemPrice;
}

可以看到,雖然OrderItem使用了ProductID作爲ID,但是此時我們並沒有享受ProductID的全局唯一性,事實上多個Order可以包含相同ProductIDOrderItem,也即多個訂單可以包含相同的產品。

區分實體和值對象的一個很重要的原則便是根據相等性來判斷,實體對象的相等性是通過ID來完成的,對於兩個實體,如果他們的所有屬性均相同,但是ID不同,那麼他們依然兩個不同的實體,就像一對長得一模一樣的雙胞胎,他們依然是兩個不同的自然人。對於值對象來說,相等性的判斷是通過屬性字段來完成的。比如,訂單下的送貨地址Address對象便是一個典型的值對象:

public class Address  {
    private String province;
    private String city;
    private String detail;

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        Address address = (Address) o;
        return province.equals(address.province) &&
                city.equals(address.city) &&
                detail.equals(address.detail);
    }

    @Override
    public int hashCode() {
        return Objects.hash(province, city, detail);
    }

}

Addressequals()方法中,通過判斷Address所包含的所有屬性(provincecitydetail)來決定兩個Address的相等性。

值對象還有一個特點是不變的(Immutable),也就說一個值對象一旦被創建出來了便不能對其進行變更,如果要變更,必須重新創建一個新的值對象整體替換原有的。比如,示例項目有一個業務需求:

在訂單未支付的情況下,可以修改訂單送貨地址的詳細地址(detail)

由於AddressOrder聚合根中的一個對象,對Address的更改只能通過Order完成,在Order中實現changeAddressDetail()方法:

public void changeAddressDetail(String detail) {
    if (this.status == PAID) {
        throw new OrderCannotBeModifiedException(this.id);
    }

    this.address = this.address.changeDetailTo(detail);
}

可以看到,通過調用address.changeDetailTo()方法,我們獲取到了一個全新的Address對象,然後將新的Address對象整體賦值給address屬性。此時Address.changeDetailTo()的實現如下:

public Address changeDetailTo(String detail) {
    return new Address(this.province, this.city, detail);
}

這裏的changeDetailTo()方法使用了新的詳細地址detail和未發生變更的provincecity重新創建出了一個Address對象。

值對象的不變性使得程序的邏輯變得更加簡單,你不用去維護複雜的狀態信息,需要的時候創建,不要的時候直接扔掉即可,使得值對象就像程序中的過客一樣。在DDD建模中,一種受推崇的做法便是將業務概念儘量建模爲值對象。

對於OrderItem來說,由於我們的業務需要對OrderItem的數量進行修改,也即擁有生命週期的意味,因此本文將OrderItem建模爲了實體對象。但是,如果沒有這樣的業務需求,那麼將OrderItem建模爲值對象應該更合適一些。

另外,需要指明的是,實體和值對象的劃分並不是一成不變的,而應該根據所處的限界上下文來界定,相同一個業務名詞,在一個限界上下文中可能是實體,在另外的限界上下文中可能是值對象。比如,訂單Order在採購上下文中應該建模爲一個實體,但是在物流上下文中便可建模爲一個值對象。

聚合根的家——資源庫

通俗點講,資源庫(Repository)就是用來持久化聚合根的。從技術上講,Repository和DAO所扮演的角色相似,不過DAO的設計初衷只是對數據庫的一層很薄的封裝,而Repository是更偏向於領域模型。另外,在所有的領域對象中,只有聚合根才“配得上”擁有Repository,而DAO沒有這種約束。

實現Order的資源庫OrderRepository如下:

public void save(Order order) {
    String sql = "INSERT INTO ORDERS (ID, JSON_CONTENT) VALUES (:id, :json) " +
            "ON DUPLICATE KEY UPDATE JSON_CONTENT=:json;";
    Map<String, String> paramMap = of("id", order.getId().toString(), "json", objectMapper.writeValueAsString(order));
    jdbcTemplate.update(sql, paramMap);
}

public Order byId(OrderId id) {
    try {
        String sql = "SELECT JSON_CONTENT FROM ORDERS WHERE ID=:id;";
        return jdbcTemplate.queryForObject(sql, of("id", id.toString()), mapper());
    } catch (EmptyResultDataAccessException e) {
        throw new OrderNotFoundException(id);
    }
}

OrderRepository中,我們只定義了save()byId()方法,分別用於保存/更新聚合根和通過ID獲取聚合根。這兩個方法是Repository中最常見的方法,有的DDD實踐者甚至認爲一個純粹的Repository只應該包含這兩個方法。

讀到這裏,你可能會有些疑問:爲什麼OrderRepository中沒有更新和查詢等方法?事實上,Repository所扮演的角色只是向領域模型提供聚合根而已,就像一個聚合根的“容器”一樣,這個“容器”本身並不關心客戶端對聚合根的操作到底是新增還是更新,你給一個聚合根對象,Repository只是負責將其狀態從計算機的內存同步到持久化機制中,從這個角度講,Repository只需要一個類似save()的方法便可完成同步操作。當然,這個是從概念的出發點得出的設計結果,在技術層面,新增和更新還是需要區別對待,比如SQL語句有insertupdate之分,只是我們將這樣的技術細節隱藏在了save()方法中,客戶方並無需知道這些細節。在本例中,我們通過MySQL的ON DUPLICATE KEY UPDATE特性同時處理對數據庫的新增和更新操作。當然,我們也可以通過編程判斷聚合根在數據庫中是否已經存在,如果存在則update,否則insert。另外,諸如Hibernate這樣的持久化框架自動提供saveOrUpate()方法可以直接用於對聚合根的持久化。

對於查詢功能來說,在Repository中實現查詢本無不合理之處,然而項目的演進可能導致Repository中充斥着大量的查詢代碼“喧賓奪主”似的掩蓋了Repository原本的目的。事實上,DDD中讀操作和寫操作是兩種很不一樣的過程,筆者的建議是儘量將此二者分開實現,由此查詢功能將從Repository中分離出去,在下文中我將詳細講到。

在本例中,我們在技術實現上使用到了Spring的JdbcTemplate和JSON格式持久化Order聚合根,其實Repository並不與某種持久化機制綁定,一個被抽象出來的Repository向外暴露的功能“接口”始終是向領域模型提供聚合根對象,就像“聚合根的家”一樣。

好了,至此讓我們來做個回顧,上文中我們以“更新Order中的Product數量”業務需求爲例,講到了應用服務、聚合根和資源庫,對該業務需求的處理流程體現了DDD處理業務需求的最常見最典型的形式:

應用服務作爲總體協調者,先通過資源庫獲取到聚合根,然後調用聚合根中的業務方法,最後再次調用資源庫保存聚合根。

流程示意圖如下:

DDD處理業務流程的典型流程

創生之柱——工廠

稍微提煉一下,我們便知道軟件裏面的寫操作要麼是修改既有數據,要麼是新建數據。對於前者,DDD給出的答案已經在上文中講到,接下來我們講講在DDD中如何新建聚合根。

創建聚合根通常通過設計模式中的工廠(Factory)模式完成,這一方面可以享受到工廠模式本身的好處,另一方面,DDD中的Factory還具有將“聚合根的創建邏輯”顯現出來的效果。

創生之柱——恆星誕生的地方,距地球約6500光年,由哈勃太空望遠鏡於1995年拍攝

聚合根的創建過程可簡單可複雜,有時可能直接調用構造函數即可,而有時卻存在一個複雜的構造流程,比如需要調用其他系統獲取數據等。通常來講,Factory有兩種實現方式:

  • 直接在聚合根中實現Factory方法,常用於簡單的創建過程
  • 獨立的Factory類,用於有一定複雜度的創建過程,或者創建邏輯不適合放在聚合根上

讓我們先演示一下簡單的Factory方法,在示例訂單系統中,有個業務用例是“創建Product”:

創建Product,屬性包括名稱(name),描述(description)和單價(price),ProductId爲UUID

Product類中實現工廠方法create()

public static Product create(String name, String description, BigDecimal price) {
    return new Product(name, description, price);
}

private Product(String name, String description, BigDecimal price) {
    this.id = ProductId.newProductId();
    this.name = name;
    this.description = description;
    this.price = price;
    this.createdAt = Instant.now();
}

這裏,Product中的create()方法並不包含創建邏輯,而是將創建過程直接代理給了Product的構造函數。你可能覺得這個create()方法有些多此一舉,然而這種做法的初衷依然是:我們希望將聚合根的創建邏輯突顯出來。構造函數本身是一個非常技術的東西,任何地方只要涉及到在計算機內存中新建對象都需要使用構造函數,無論創建的初始原因是業務需要,還是從數據庫加載,亦或是從JSON數據反序列化。因此程序中往往存在多個構造函數用於不同的場景,而爲了將業務上的創建與技術上的創建區別開來,我們引入了create()方法用於表示業務上的創建過程。

“創建Product”所設計到的Factory的確簡單,讓我們再來看看另外一個例子:“創建Order”:

創建Order,包含用戶選擇的Product及其數量,OrderId必須調用第三方的OrderIdGenerator獲取

這裏的OrderIdGenerator是具有服務性質的對象(即下文中的領域服務),在DDD中,聚合根通常不會引用其他服務類。另外,調用OrderIdGenerator生成ID應該是一個業務細節,如前文所講,這種細節不應該放在ApplicationService中。此時,可以通過Factory類來完成Order的創建:

@Component
public class OrderFactory {
    private final OrderIdGenerator idGenerator;

    public OrderFactory(OrderIdGenerator idGenerator) {
        this.idGenerator = idGenerator;
    }

    public Order create(List<OrderItem> items, Address address) {
        OrderId orderId = idGenerator.generate();
        return Order.create(orderId, items, address);
    }
}

必要的妥協——領域服務

前面我們提到,聚合根是業務邏輯的主要載體,也就是說業務邏輯的實現代碼應該儘量地放在聚合根或者聚合根的邊界之內。但有時,有些業務邏輯並不適合於放在聚合根上,比如前文的OrderIdGenerator便是如此,在這種“迫不得已”的情況下,我們引入領域服務(Domain Service)。還是先來看一個列子,對於Order的支付有以下業務用例:

通過支付網關OrderPaymentService完成Order的支付。

OrderApplicationService中,直接調用領域服務OrderPaymentService

@Transactional
public void pay(String id, PayOrderCommand command) {
    Order order = orderRepository.byId(orderId(id));
    orderPaymentService.pay(order, command.getPaidPrice());
    orderRepository.save(order);
}

然後實現OrderPaymentService

public void pay(Order order, BigDecimal paidPrice) {
    order.pay(paidPrice);
    paymentProxy.pay(order.getId(), paidPrice);
}

這裏的PaymentProxyOrderIdGenerator相似,並不適合於放在Order中。可以看到,在OrderApplicationService中,我們並沒有直接調用Order中的業務方法,而是先調用OrderPaymentService.pay(),然後在OrderPaymentService.pay()中完成調用支付網關PaymentProxy.pay()這樣的業務細節。

到此,再來反觀在通常的實踐中我們編寫的Service類,事實上這些Servcie類將DDD中的ApplicationService和DomainService糅合在了一起,比如在”基於Service + 貧血模型”的實現“小節中的OrderService便是如此。在DDD中,ApplicationService和DomainService是兩個很不一樣的概念,前者是必須有的DDD組件,而後者只是一種妥協的結果,因此程序中的DomainService應該越少越好。

Command對象

通常來說,DDD中的寫操作並不需要向客戶端返回數據,在某些情況下(比如新建聚合根)可以返回一個聚合根的ID,這意味着ApplicationService或者聚合根中的寫操作方法通常返回void即可。比如,對於OrderApplicationService,各個方法簽名如下:

public OrderId createOrder(CreateOrderCommand command) ;
public void changeProductCount(String id, ChangeProductCountCommand command) ;
public void pay(String id, PayOrderCommand command) ;
public void changeAddressDetail(String id, String detail) ;

可以看到,在多數情況下我們使用了後綴爲Command的對象傳給ApplicationService,比如CreateOrderCommandChangeProductCountCommand。Command即命令的意思,也即寫操作表示的是外部向領域模型發起的一次命令操作。事實上,從技術上講,Command對象只是一種類型的DTO對象,它封裝了客戶端發過來的請求數據。在Controller中所接收的所有寫操作都需要通過Command進行包裝,在Command比較簡單(比如只有1-2個字段)的情況下Controller可以將Command解開之後,將其中的數據直接傳遞給ApplicationService,比如changeAddressDetail()便是如此;而在Command中數據字段比較多時,可以直接將Command對象傳遞給ApplicationService。當然,這並不是DDD中需要嚴格遵循的一個原則,比如無論Command的簡繁程度,統一將所有Command從Controller傳遞給ApplicationService,也不存在太大的問題,更多的只是一個編碼習慣上的選擇。不過有一點需要強調,即前文提到的“ApplicationService需要接受原始數據類型而不是領域模型中的對象”,在這裏意味着Command對象中也應該包含原始的數據類型。

統一使用Command對象還有個好處是,我們通過查找所有後綴爲Command的對象,便可以概覽性地瞭解軟件系統向外提供的業務功能。

階段性小結一下,以上我們主要圍繞着軟件的“寫操作”在DDD中的實現進行討論,並且講到了3種場景,分別是:

  • 通過聚合根完成業務請求
  • 通過Factory完成聚合根的創建
  • 通過DomainService完成業務請求

以上3種場景大致上涵蓋了DDD完成業務寫操作的基本方面,總結下來3句話:創建聚合根通過Factory完成;業務邏輯優先在聚合根邊界內完成;聚合根中不合適放置的業務邏輯才考慮放到DomainService中。

DDD實現軟件"寫操作"的3種場景

DDD中的讀操作

軟件中的讀模型和寫模型是很不一樣的,我們通常所講的業務邏輯更多的時候是在寫操作過程中需要關注的東西,而讀操作更多關注的是如何向客戶方返回恰當的數據展現。

在DDD的寫操作中,我們需要嚴格地按照“應用服務 -> 聚合根 -> 資源庫”的結構進行編碼,而在讀操作中,採用與寫操作相同的結構有時不但得不到好處,反而使整個過程變得冗繁。這裏介紹3種讀操作的方式:

  • 基於領域模型的讀操作
  • 基於數據模型的讀操作
  • CQRS

首先,無論哪種讀操作方式,都需要遵循一個原則:領域模型中的對象不能直接返回給客戶端,因爲這樣領域模型的內部便暴露給了外界,而對領域模型的修改將直接影響到客戶端。因此,在DDD中我們通常爲讀操作專門創建相應的模型用於數據展現。在寫操作中,我們通過Command後綴進行請求數據的統一,在讀操作中,我們通過Representation後綴進行展現數據的統一,這裏的Representation也即REST中的“R”。

基於領域模型的讀操作

這種方式將讀模型和寫模型糅合到一起,先通過資源庫獲取到領域模型,然後將其轉換爲Representation對象,這也是當前被大量使用的方式,比如對於“獲取Order詳情的接口”,OrderApplicationService實現如下:

@Transactional(readOnly = true)
public OrderRepresentation byId(String id) {
    Order order = orderRepository.byId(orderId(id));
    return orderRepresentationService.toRepresentation(order);
}

我們先通過orderRepository.byId()獲取到Order聚合根對象,然後調用orderRepresentationService.toRepresentation()Order轉換爲展現對象OrderRepresentationOrderRepresentationService.toRepresentation()實現如下:

public OrderRepresentation toRepresentation(Order order) {
    List<OrderItemRepresentation> itemRepresentations = order.getItems().stream()
            .map(orderItem -> new OrderItemRepresentation(orderItem.getProductId().toString(),
                    orderItem.getCount(),
                    orderItem.getItemPrice()))
            .collect(Collectors.toList());

    return new OrderRepresentation(order.getId().toString(),
            itemRepresentations,
            order.getTotalPrice(),
            order.getStatus(),
            order.getCreatedAt());
}

這種方式的優點是非常直接明瞭,也不用創建新的數據讀取機制,直接使用Repository讀取數據即可。然而缺點也很明顯:一是讀操作完全束縛於聚合根的邊界劃分,比如,如果客戶端需要同時獲取Order及其所包含的Product,那麼我們需要同時將Order聚合根和Product聚合根加載到內存再做轉換操作,這種方式既繁瑣又低效;二是在讀操作中,通常需要基於不同的查詢條件返回數據,比如通過Order的日期進行查詢或者通過Product的名稱進行查詢等,這樣導致的結果是Repository上處理了太多的查詢邏輯,變得越來越複雜,也逐漸偏離了Repository本應該承擔的職責。

基於數據模型的讀操作

這種方式繞開了資源庫和聚合,直接從數據庫中讀取客戶端所需要的數據,此時寫操作和讀操作共享的只是數據庫。比如,對於“獲取Product列表”接口,通過一個專門的ProductRepresentationService直接從數據庫中讀取數據:

 @Transactional(readOnly = true)
public PagedResource<ProductSummaryRepresentation> listProducts(int pageIndex, int pageSize) {
    MapSqlParameterSource parameters = new MapSqlParameterSource();
    parameters.addValue("limit", pageSize);
    parameters.addValue("offset", (pageIndex - 1) * pageSize);

    List<ProductSummaryRepresentation> products = jdbcTemplate.query(SELECT_SQL, parameters,
            (rs, rowNum) -> new ProductSummaryRepresentation(rs.getString("ID"),
                    rs.getString("NAME"),
                    rs.getBigDecimal("PRICE")));

    int total = jdbcTemplate.queryForObject(COUNT_SQL, newHashMap(), Integer.class);
    return PagedResource.of(total, pageIndex, products);
}

然後在Controller中直接返回:

@GetMapping
public PagedResource<ProductSummaryRepresentation> pagedProducts(@RequestParam(required = false, defaultValue = "1") int pageIndex,
                                                                 @RequestParam(required = false, defaultValue = "10") int pageSize) {
    return productRepresentationService.listProducts(pageIndex, pageSize);
}

可以看到,真個過程並沒有使用到ProductRepositoryProduct,而是將SQL獲取到的數據直接新建爲ProductSummaryRepresentation對象。

這種方式的優點是讀操作的過程不用囿於領域模型,而是基於讀操作本身的需求直接獲取需要的數據即可,一方面簡化了整個流程,另一方面大大提升了性能。但是,由於讀操作和寫操作共享了數據庫,而此時的數據庫主要是對應於聚合根的結構創建的,因此讀操作依然會受到寫操作的數據模型的牽制。不過這種方式是一種很好的折中,微軟也提倡過這種方式,更多細節請參考微軟官網

CQRS

CQRS(Command Query Responsibility Segregation),即命令查詢職責分離,這裏的命令可以理解爲寫操作,而查詢可以理解爲讀操作。與“基於數據模型的讀操作”不同的是,在CQRS中寫操作和讀操作使用了不同的數據庫,數據從寫模型數據庫同步到讀模型數據庫,通常通過領域事件的形式同步變更信息。

CQRS架構

這樣一來,讀操作便可以根據自身所需獨立設計數據結構,而不用受寫模型數據結構的牽制。CQRS本身是一個很大的話題,已經超出了本文的範圍,讀者可以自行研究。

到此,DDD中的讀操作可以大致分爲3種實現方式:

DDD讀操作的3種實現方式

總結

本文主要介紹了DDD中的應用服務、聚合、資源庫和工廠等概念以及與它們相關的編碼實踐,然後着重講到了軟件的讀寫操作在DDD中的實現方式,其中寫操作的3種場景爲:

  • 通過聚合根完成業務請求,這是DDD完成業務請求的典型方式
  • 通過Factory完成聚合根的創建,用於創建聚合根
  • 通過DomainService完成業務請求,當業務放在聚合根中不合適時才考慮放在DomainService中

對於讀操作,同樣給出了3種方式:

  • 基於領域模型的讀操作(讀寫操作糅合在了一起,不推薦)
  • 基於數據模型的讀操作(繞過聚合根和資源庫,直接返回數據,推薦)
  • CQRS(讀寫操作分別使用不同的數據庫)

以上“3讀3寫”基本上涵蓋了程序員完成業務功能的日常開發之所需,原來DDD就這麼簡單,不是嗎?


文/ThoughtWorks滕雲

更多精彩洞見,請關注微信公衆號:ThoughtWorks洞見

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章