Martin Fowler在《企業應用架構模式》一書中寫道:html
I found this(business logic) a curious term because there are few things that are less logical than business logic.
初略翻譯過來能夠理解爲:業務邏輯是很沒有邏輯的邏輯。java
的確,不少時候軟件的業務邏輯是沒法經過推理而獲得的,有時甚至是被臆想出來的。這樣的結果使得本來已經很複雜的業務變得更加複雜而難以理解。而在具體編碼實現時,除了應付業務上的複雜性,技術上的複雜性也不能忽略,好比咱們要講究技術上的分層,要遵循軟件開發的基本原則,又好比要考慮到性能和安全等等。mysql
在不少項目中,技術複雜度與業務複雜度相互交錯糾纏不清,這種火上澆油的作法成爲很多軟件項目沒法繼續往下演進的緣由。然而,在合理的設計下,技術和業務是能夠分離開來或者至少它們之間的耦合度是能夠下降的。在不一樣的軟件建模方法中,領域驅動設計(Domain Driven Design,DDD)嘗試經過其自有的原則與套路來解決軟件的複雜性問題,它將研發者的目光首先聚焦在業務自己上,使技術架構和代碼實現成爲軟件建模過程當中的「副產品」。git
DDD分爲戰略設計和戰術設計。在戰略設計中,咱們講求的是子域和限界上下文(Bounded Context,BC)的劃分,以及各個限界上下文之間的上下游關係。當前如此火熱的「在微服務中使用DDD」這個命題,究其最初的邏輯無外乎是「DDD中的限界上下文能夠用於指導微服務中的服務劃分」。事實上,限界上下文依然是軟件模塊化的一種體現,與咱們一直以來追求的模塊化原則的驅動力是相同的,即經過必定的手段使軟件系統在人的大腦中更加有條理地呈現,讓做爲「目的」的人可以更簡單地瞭解進而掌控軟件系統。程序員
若是說戰略設計更偏向於軟件架構,那麼戰術設計便更偏向於編碼實現。DDD戰術設計的目的是使得業務可以從技術中分離並突顯出來,讓代碼直接表達業務的自己,其中包含了聚合根、應用服務、資源庫、工廠等概念。雖然DDD不必定經過面向對象(OO)來實現,可是一般狀況下在實踐DDD時咱們採用的是OO編程範式,行業中甚至有種說法是「DDD是OO進階」,意思是面向對象中的基本原則(好比SOLID)在DDD中依然成立。本文主要講解DDD的戰術設計。github
本文以一個簡單的電商訂單系統爲例,經過如下方式能夠獲取源代碼:sql
git clone https://github.com/e-commerce-sample/order-backend git checkout a443dace
在講解DDD以前,讓咱們先來看一下實現業務代碼的幾種常見方式,在示例項目中有個「修改Order中Product的數量」的業務需求以下:數據庫
能夠修改Order中Product的數量,但前提是Order處於未支付狀態,Product數量變動後Order的總價(totalPrice)應該隨之更新。
這種方式當前被不少軟件項目所採用,主要的特色是:存在一個貧血的「領域對象」,業務邏輯經過一個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類中,最終的結果是代碼變得愈來愈難以理解進而逐漸喪失擴展能力。json
在上一種實現方式中,咱們會發現領域對象(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+貧血模型」方式類似,事務腳本也存在業務邏輯分散的問題。
事實上,事務腳本並非一種全然的反模式,在系統足夠簡單的狀況下徹底能夠採用。可是:一方面「簡單」這個度其實並不容易把握;另外一方面軟件系統一般會在不斷的演進中加入更多的功能,使得本來簡單的代碼逐漸變得複雜。所以,事務腳本在實際的應用中使用得並很少。
在這種方式中,核心的業務邏輯被內聚在行爲飽滿的領域對象(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抽象出了更多的概念與原則。
所謂基於業務分包即經過軟件所實現的業務功能進行模塊化劃分,而不是從技術的角度劃分(好比首先劃分出service
和infrastruture
等包)。在DDD的戰略設計中,咱們關注於從一個宏觀的視角俯視整個軟件系統,而後經過必定的原則對系統進行子域和限界上下文的劃分。在戰術實踐中,咱們也經過相似的提綱挈領的方法進行總體的代碼結構的規劃,所採用的原則依然逃離不了「內聚性」和「職責分離」等基本原則。此時,首先映入眼簾的即是軟件的分包。
在DDD中,聚合根(下文會講到)是主要業務邏輯的承載體,也是「內聚性」原則的典型表明,所以一般的作法即是基於聚合根進行頂層包的劃分。在示例電商項目中,有兩個聚合根對象Order
和Product
,分別建立order
包和product
包,而後在各自的頂層包下再根據代碼結構的複雜程度劃分子包,好比對於product
包:
└── product
├── CreateProductCommand.java
├── Product.java
├── ProductApplicationService.java
├── ProductController.java
├── ProductId.java
├── ProductNotFoundException.java
├── ProductRepository.java
└── representation
├── ProductRepresentationService.java
└── ProductSummaryRepresentation.java
能夠看到,ProductRepository
和ProductController
等多數類都直接放在了product
包下,而沒有單獨分包;可是展示類ProductSummaryRepresentation
卻作了單獨分包。這裏的原則是:在全部類已經被內聚在了product
包下的狀況下,若是代碼結構足夠的簡單,那麼沒有必要再次進行子包的劃分,ProductRepository
和ProductController
即是這種狀況;而若是多個類須要作再次的內聚,那麼須要另行分包,好比經過REST API接口返回Product數據時,代碼中涉及到了兩個對象ProductRepresentationService
和ProductSummaryRepresentation
,這兩個對象是緊密關聯的,所以將他們放在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實踐中,天然應該採用自頂向下的實現方式。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()
都沒有必要實現,可是由OrderController
和OrderApplicationService
所構成的業務處理的架子已經搭建好了。
能夠看到,「修改Order中Product的數量」用例中的OrderApplicationService.changeProductCount()
方法實現中只有很少的3行代碼,然而,如此簡單的ApplicationService卻存在不少講究。
ApplicationService須要遵循如下原則:
OrderApplicationService.changeProductCount()
方法標記有Spring的@Transactional
註解,表示整個方法被封裝到了一個事務中。order.changeProductCount()
方法纔是真正實現業務邏輯的地方,而ApplicationService只是做爲代理調用order.changeProductCount()
方法,所以,ApplicationService應該是很薄的一層。OrderApplicationService
所接受的Order ID是Java原始的String類型,在調用領域模型中的Repository時,才被封裝爲OrderId
對象。
接地氣一點地講,聚合根(Aggreate Root, AR)就是軟件模型中那些最重要的以名詞形式存在的領域對象,好比本文示例項目中的Order
和Product
。又好比,對於一個會員管理系統,會員(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
類,雖然名字相同,卻體現着不一樣的業務。
除了內聚性和一致性,聚合根還有如下特徵:
Order
下的OrderItem
引用了ProductId
,而不是整個Product
。若是一個事務須要更新多個聚合根,首先思考一下本身的聚合根邊界處理是否出了問題,由於在設計合理的狀況下一般不會出現一個事務更新多個聚合根的場景。若是這種狀況的確是業務所需,那麼考慮引入消息機制和事件驅動架構,保證一個事務只更新一個聚合根,而後經過消息機制異步更新其餘聚合根。
聚合根不該該引用基礎設施。
外界不該該持有聚合根內部的數據結構。
儘可能使用小聚合。
軟件模型中存在實體對象(Entity)和值對象(Value Object)之說,這種劃分方式事實上並非DDD的專屬,可是在DDD中咱們很是強調這二者之間的區別。
實體對象表示的是具備必定生命週期而且擁有全局惟一標識(ID)的對象,好比本文中的Order
和Product
,而值對象表示用於起描述性做用的,沒有惟一標識的對象,好比Address
對象。
聚合根必定是實體對象,可是並非全部實體對象都是聚合根,同時聚合根還能夠擁有其餘子實體對象。聚合根的ID在整個軟件系統中全局惟一,而其下的子實體對象的ID只需在單個聚合根下惟一便可。 在本文示例項目中,OrderItem
是聚合根Order
下的子實體對象:
public class OrderItem { private ProductId productId; private int count; private BigDecimal itemPrice; }
能夠看到,雖然OrderItem
使用了ProductID
做爲ID,可是此時咱們並無享受ProductID
的全局惟一性,事實上多個Order
能夠包含相同ProductID
的OrderItem
,也即多個訂單能夠包含相同的產品。
區分實體和值對象的一個很重要的原則即是根據相等性來判斷,實體對象的相等性是經過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); } }
在Address
的equals()
方法中,經過判斷Address
所包含的全部屬性(province
,city
,detail
)來決定兩個Address
的相等性。
值對象還有一個特色是不變的(Immutable),也就說一個值對象一旦被建立出來了便不能對其進行變動,若是要變動,必須從新建立一個新的值對象總體替換原有的。好比,示例項目有一個業務需求:
在訂單未支付的狀況下,能夠修改訂單送貨地址的詳細地址(detail)
因爲Address
是Order
聚合根中的一個對象,對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
和未發生變動的province
、city
從新建立出了一個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語句有insert
和update
之分,只是咱們將這樣的技術細節隱藏在了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方法,在示例訂單系統中,有個業務用例是「建立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); }
這裏的PaymentProxy
與OrderIdGenerator
類似,並不適合於放在Order
中。能夠看到,在OrderApplicationService
中,咱們並無直接調用Order
中的業務方法,而是先調用OrderPaymentService.pay()
,而後在OrderPaymentService.pay()
中完成調用支付網關PaymentProxy.pay()
這樣的業務細節。
到此,再來反觀在一般的實踐中咱們編寫的Service類,事實上這些Servcie類將DDD中的ApplicationService和DomainService糅合在了一塊兒,好比在」基於Service + 貧血模型」的實現「小節中的OrderService即是如此。在DDD中,ApplicationService和DomainService是兩個很不同的概念,前者是必須有的DDD組件,然後者只是一種妥協的結果,所以程序中的DomainService應該越少越好。
一般來講,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,好比CreateOrderCommand
和ChangeProductCountCommand
。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種場景,分別是:
以上3種場景大體上涵蓋了DDD完成業務寫操做的基本方面,總結下來3句話:建立聚合根經過Factory完成;業務邏輯優先在聚合根邊界內完成;聚合根中不合適放置的業務邏輯才考慮放到DomainService中。
軟件中的讀模型和寫模型是很不同的,咱們一般所講的業務邏輯更多的時候是在寫操做過程當中須要關注的東西,而讀操做更多關注的是如何向客戶方返回恰當的數據展示。
在DDD的寫操做中,咱們須要嚴格地按照「應用服務 -> 聚合根 -> 資源庫」的結構進行編碼,而在讀操做中,採用與寫操做相同的結構有時不但得不到好處,反而使整個過程變得冗繁。這裏介紹3種讀操做的方式:
首先,不管哪一種讀操做方式,都須要遵循一個原則:領域模型中的對象不能直接返回給客戶端,由於這樣領域模型的內部便暴露給了外界,而對領域模型的修改將直接影響到客戶端。所以,在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
轉換爲展示對象OrderRepresentation
,OrderRepresentationService.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); }
能夠看到,真個過程並無使用到ProductRepository
和Product
,而是將SQL獲取到的數據直接新建爲ProductSummaryRepresentation
對象。
這種方式的優勢是讀操做的過程不用囿於領域模型,而是基於讀操做自己的需求直接獲取須要的數據便可,一方面簡化了整個流程,另外一方面大大提高了性能。可是,因爲讀操做和寫操做共享了數據庫,而此時的數據庫主要是對應於聚合根的結構建立的,所以讀操做依然會受到寫操做的數據模型的牽制。不過這種方式是一種很好的折中,微軟也提倡過這種方式,更多細節請參考微軟官網。
CQRS(Command Query Responsibility Segregation),即命令查詢職責分離,這裏的命令能夠理解爲寫操做,而查詢能夠理解爲讀操做。與「基於數據模型的讀操做」不一樣的是,在CQRS中寫操做和讀操做使用了不一樣的數據庫,數據從寫模型數據庫同步到讀模型數據庫,一般經過領域事件的形式同步變動信息。
這樣一來,讀操做即可以根據自身所需獨立設計數據結構,而不用受寫模型數據結構的牽制。CQRS自己是一個很大的話題,已經超出了本文的範圍,讀者能夠自行研究。
到此,DDD中的讀操做能夠大體分爲3種實現方式:
本文主要介紹了DDD中的應用服務、聚合、資源庫和工廠等概念以及與它們相關的編碼實踐,而後着重講到了軟件的讀寫操做在DDD中的實現方式,其中寫操做的3種場景爲:
對於讀操做,一樣給出了3種方式:
以上「3讀3寫」基本上涵蓋了程序員完成業務功能的平常開發之所需,原來DDD就這麼簡單,不是嗎?
做者:滕雲
原文地址:https://insights.thoughtworks.cn/backend-development-ddd/