1. 程式人生 > >後端開發實踐系列之二——領域驅動設計(DDD)編碼實踐

後端開發實踐系列之二——領域驅動設計(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物件。

業務的載體——聚合根

接地氣一點地講,聚合根(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類,雖然名字相同,卻體現著不同的業務。

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

  • 聚合根的實現應該與框架無關:既然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中如何新建聚合根。

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

聚合根的建立過程可簡單可複雜,有時可能直接呼叫建構函式即可,而有時卻存在一個複雜的構造流程,比如需要呼叫其他系統獲取資料等。通常來講,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中的讀操作

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

在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本身是一個很大的話題,已經超出了本文的範圍,讀者可以自行研究。

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

總結

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

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

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

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

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