從貧血模型到DDD的重構
我們將重構一個簡單的問題跟蹤應用程式,通過典型的層隔離,根據領域驅動的戰術設計模式進行建模。
這個問題跟蹤應用程式非常簡單。您可以使用它執行多項業務操作 - 全部通過REST API,並且所有操作都完全由整合測試覆蓋(請參閱ofollow,noindex" target="_blank">此處的 測試)。您可以:
- 創造一個新問題
- 得到所有問題
- 評論一個問題
- 改變問題狀態
某些操作具有驗證規則:
- 可用的狀態轉換是:new - > in_progress,in_progress - > done
- 問題的註釋只能新增到狀態為new或in_progress的問題中
第一個實施 - 貧血模型
我們的第一個實現 是非常常見的。我們有4個包負責我們的應用程式的給定層。所以我們有一個帶有IssueController 的控制器包,我們處理所有的http請求。我們的問題還有一個模型包,它是JPA實體,以及IssueComment。最後,有服務類IssueService,以及與儲存庫包中的實體相關的兩個儲存庫。
典型的請求呼叫往返非常簡單:
- 在控制器中我們處理http請求,從url或從請求內容中獲取引數並將它們傳遞給服務
- 服務是我們應用程式的核心 - 所有邏輯都在這裡。我們使用儲存庫載入實體,執行一些業務操作,並在修改後返回物件(如果需要)
- Controller在呼叫服務後檢索域物件,並將其(如果有)轉換為json
服務可以更改問題狀態:
<b>public</b> <b>void</b> update(Long issueId, IssueStatus newStatus) { Issue issue = issueRepository.findOne(issueId); <b>if</b> (issue.getStatus() == DONE && newStatus == NEW || issue.getStatus() == NEW && newStatus == DONE) { <b>throw</b> <b>new</b> RuntimeException(String.format(<font>"Cannot change issue status from %s to %s"</font><font>, issue.getStatus(), newStatus)); } issue.setStatus(newStatus); } </font>
你可以在這裡 找到所有的服務操作,控制器呼叫它看起來像這樣。
讓我們嘗試將此應用程式重構為領域驅動設計。
重構實體 - 豐富其行為
根據DDD概念,我們需要考慮我們的域模型及其不變數,識別實體,值物件 以及聚合根。 我們的實體候選人是Issue和IssueComment,因為這些物件是我們系統中需要識別的物件。事實上,IssueComment不必是一個實體 - 我們不使用它的id,也不需要區分這些物件。我們將其建模為具有id的JPA實體,以簡化ORM對映。所以在DDD世界中,“問題Issue”成為唯一的實體,也成為聚合根 - 它包含對註釋的引用,但在修改時我們將它們視為一個單元。
如果我們知道我們的聚合根,那麼很容易開始重構。所有改變聚合狀態的操作都需要在其中。因此,我們需要改變狀態並將註釋方法從服務新增到“問題Issue”模型中。
@Entity <b>public</b> <b>class</b> Issue { <font><i>// some mapping</i></font><font> <b>public</b> <b>void</b> changeStatusTo(IssueStatus newStatus) { <b>if</b> (<b>this</b>.status == IssueStatus.DONE && newStatus == IssueStatus.NEW || <b>this</b>.status == IssueStatus.NEW && newStatus == IssueStatus.DONE) { <b>throw</b> <b>new</b> RuntimeException(String.format(</font><font>"Cannot change issue status from %s to %s"</font><font>, <b>this</b>.status, newStatus)); } <b>this</b>.status = newStatus; } <b>public</b> <b>void</b> addComment(String comment) { <b>if</b> (status == IssueStatus.DONE) { <b>throw</b> <b>new</b> RuntimeException(</font><font>"Cannot add comment to done issue"</font><font>); } comments.add(<b>new</b> IssueComment(comment)); } } </font>
當然要實現這一點,我們需要稍微調整一下hibernate對映。我們改變了評論欄位:
@Transient <b>private</b> List comments = <b>new</b> ArrayList<>();
改為:
@OneToMany(cascade = CascadeType.MERGE) <b>private</b> List comments;
我們使用延遲載入和級聯,替代另外通過儲存庫載入,由於這個原因,我們的聚合可以修改其不變數(欄位),而無需載入任何其他資源。
此外,所有可用的操作現在都在問題Issue類中,它至少有3個優點:
- 所有驗證邏輯現在都可以放在發生更改的物件中
- 我們能一下子看到了“問題Issue”API - 這使我們能夠非常快速地瞭解從業務角度可以解決的問題
- 沒有人可以引入我們物件的不一致狀態,因為沒有公共修飾符(如setStatus)可用
另一個不那麼明顯的好處是聚合內部的操作只能修改其不變數。讓我們假設一個需求,需要對問題Issue發表評論,如果採取在服務中修改實體的辦法,我們只要將UserRepository注入到IssueService中,新增評論後我們更改User模型並儲存它。在DDD模型中沒有辦法做到這一點 - 我們沒有任何機制在Issue實體內來載入和修改使用者User模型。
重構服務 - 簡化
由於業務邏輯從服務轉移到實體,現在簡化了服務。它只做3件事:
- 從儲存庫載入聚合
- 在載入的聚合上呼叫方法來修改它
- 儲存修改後的物件
服務的更新狀態方法示例是:
<b>public</b> <b>void</b> update(String issueId, IssueStatus newStatus) { Issue issue = issueRepository.findBy(IssueId.from(issueId)); issue.changeStatusTo(newStatus); issueRepository.save(issue); }
所有的業務邏輯都歸於問題Issue模型。如果服務沒有執行其他操作,比如在其他聚合根上傳送事件或操作,我們可以做更多。擺脫服務並在控制器中完成所有邏輯。
重構儲存庫 - 獨立於具體實現
為了與DDD儲存庫概念保持一致,我們需要對其進行一些重構。在貧血模型中,我們使用了2個儲存庫 - 一個用於Issue,第二個用於IssueComment。都通過建立擴充套件CrudRepository的介面,使用spring-data儲存庫建立儲存庫。這種方案有一些缺點。
首先,它直接與具體實現耦合。如果我們想要更改它(例如測試時使用記憶體儲存),我們需要做一些模擬或提供一些自定義bean,其中包含我們在CrudRepository中實現的所有方法。
其次,使用spring-data儲存庫,我們得到了許多我們不想要的方法的預設實現,比如count,exists或deleteAll。
因此,我們將儲存庫重構為一個滿足我們的希望只擁有一些方法的介面。
<b>public</b> <b>interface</b> IssueRepository { List findAll(); Issue save(Issue issue); Issue findBy(IssueId issueId); }
此外,您可以看到現在使用IssueId值物件而不是Long來查詢問題。這樣我們就避免了從不同的實體提供一些不同的Long的錯誤。
此介面的實現使用下面的spring data儲存庫,但當然您可以根據使用情況輕鬆地將其替換為您想要的任何內容。
重構包
最後值得一提的是在從貧血模型遷移到ddd時我們的應用程式的重新打包。我們從4個分組開始分組。在DDD模型中,我們有3個包:應用程式,域和基礎結構。
- 域包含我們的實體和值物件以及儲存庫介面(我們在這裡也有IssueIdSequenceGenerator,但它是另一個我們將在另一篇文章中描述的故事)。所有的業務邏輯都屬於這裡。
- 應用程式具有控制器和與從json轉換為模型和返回相關的所有內容。它還包含應用程式服務(我們的IssueService)。應用程式使用域物件來檢測它們(載入聚合,呼叫業務操作)。
- 最後一個包是基礎結構,它包含域中使用的所有介面的實現,以及內部用於提供此實現的所有類(例如CrudIssueRepository)。
多虧了這樣的重新打包,我們在定位新內容的位置方面沒有任何問題,這會在新的業務需求到來時出現。問題可能出現在哪裡放置新類,例如,如果我們想要引入使用者user的模組'。我們是否應該在應用程式,域和基礎架構中新增新軟體包,並在每個軟體包下放置當前問題模型“模組”內部的問題包中,並建立新使用者模組?
當然不是。根據DDD概念,使用者'模組'是一個不同的有界上下文, 所以我們應該建立單獨的模組(maven one)或者至少建立2個不同的根包:
DDD值得做嗎?
我們剛剛經歷了從貧血模型 到DDD的 遷移過程,正如您所看到的,它並不那麼簡單。在更大的應用程式中,它可能非常困難,甚至也許是不可能實現的。值得做嗎?當然答案是:這取決於:
DDD不是銀彈。對於簡單的CRUD應用程式或具有很少業務邏輯的應用程式,它可能是一種過度殺傷力。一旦您的應用程式變得非常大,DDD值得考慮。再次指出使用DDD可以獲得的主要好處:
- 通過有意義的方法更好地表達域物件中的業務邏輯;
- 域物件通過僅操作其內部來封閉事務邊界,這簡化了業務邏輯實現,
- 非常簡單的包結構
- 更好地區分領域和永續性機制