領先一步
VMware 提供培訓和認證,以加速您的進展。
了解更多我很榮幸地宣布,我剛加入了 Pivotal 的開發人員推廣團隊,專注於 Spring。我感到非常榮幸有機會向來自世界各地優秀且充滿熱情的工程師學習和合作。因此,我必須說我對即將到來的旅程感到非常興奮。
如果您想關注我,我的 Twitter 帳號是 @JakubPilimon ,部落格 在此。
在加入 Pivotal 之前,我很高興能與各個領域的軟體開發團隊諮詢和學習。無論領域是電子商務、製藥、金融科技還是保險——所有軟體領域的共通點都是使用者的期望。在這篇文章中,我將介紹一些我使用 DDD 建構 Spring 應用程式的原則。
更快交付軟體同時提高可靠性的原則:
領域建模
當談到理解您正在為其建構軟體的業務時,沒有任何程式設計框架可以神奇地幫助我們理解和建模複雜的領域。我不期望這樣的工具會實現,因為通常不可能預測這樣的領域將如何在未來演進和變化。然而,有一些常見的抽象業務領域是大多數人應該熟悉的——例如銷售、庫存或產品目錄。當從頭開始進行領域建模時,無需重新發明輪子。以下是我推薦用於複雜領域建模的絕佳資源:Enterprise Patterns and MDA: Building Better Software with Archetype Patterns and UML。
理解、劃分和持續征服
在快速交付軟體時,我們絕不能犧牲以後其他人理解程式碼的方式。幸運的是,我們有一套原則和實踐來幫助我們——以領域驅動設計的形式。就我個人而言,我喜歡將 DDD 視為迭代學習未知事物的過程。應用 DDD 的副作用是,我們能夠使我們的程式碼對於開發人員和業務都更易於理解、擴展和連貫。透過 DDD,我們可以使我們的原始碼成為領域應如何運作的單一事實來源。軟體功能註定要被改變。但是,當開發人員無法以業務人員理解的術語來闡明原始碼時,該功能就會變得裝飾性且難以更改或替換。
即使是最複雜的領域也可以劃分為…
識別這些較小的產品為我們提供了如何將程式碼組織成模組的初步草案。每個子領域都等於一個單獨的模組。理解核心領域和通用領域之間的區別有助於我們看到它們可能需要不同的架構風格。
幸運的是,我們有很多配料可以挑選!
範例
我很榮幸在此宣布,我與我的朋友 Michał Michaluk 共同創建了一個名為 #dddbyexamples 的倡議。該倡議的目的是將 Spring 生態系統的許多不同部分與 DDD 愛好者的興趣聯繫起來。您可以在此處查看我們的範例。到目前為止,有兩個範例。一個範例側重於事件溯源和命令查詢職責分離,而另一個範例則側重於端到端 DDD 範例。兩者都是使用 Spring Boot 實作的。
讓我們深入研究端到端範例。我們將實作一個簡化的信用卡管理系統。我們將工作劃分為理解 (Understand)、劃分 (Divide)、實作 (Implement) 和部署 (Deploy) 四個部分。需求尚不明確,到目前為止我們知道系統應該能夠
理解 (Understand)
為了理解我們的業務問題中真正發生了什麼,我們可以利用一種稱為事件風暴法的輕量級技術。我們只需要一面寬牆上的無限空間、便利貼以及聚集在一個房間裡的業務和技術人員。第一步是將我們領域中可能發生的事情寫在橘色便利貼上。這些是領域事件。請注意過去式和沒有特定順序。
然後我們必須確定每個事件的原因。領域專家知道原因,並且很可能可以將其歸類為
還有一張綠色便利貼:塑膠卡個人化檢視。這是發送到系統的直接訊息,導致塑膠卡個人化已顯示事件。但這是一個查詢,而不是命令。對於檢視和讀取模型,我們將使用綠色便利貼。
下一步至關重要。我們需要知道原因本身是否足以使領域事件發生。可能還有另一個必須滿足的條件。可能不止一個。這些條件稱為不變量。如果是這樣,我們將它們寫在黃色便利貼上,並放置在事件和原因之間。
如果我們將時間順序應用於我們的事件,我們將對我們的領域有很好的概觀。此外,我們將了解基本的業務流程。該技術輕量級、快速、有趣,並且與大量的文字文件或 UI 模型相比更具描述性。但它還沒有交付一行程式碼,對吧?
劃分 (Divide)
為了找到業務模組之間的邊界,我們可以應用內聚規則:一起更改和一起使用的事物應該放在一起。例如,在一個模組中。只有一組彩色便利貼,我們如何談論內聚性?讓我們看看。
為了檢查不變量(黃色便利貼),系統必須提出一些問題。例如,為了提款,必須已經分配了額度。系統必須運行一個查詢:「嗨,它是否已分配額度?」。另一方面,有些命令和事件可能會更改該問題的答案。例如,分配額度的第一個命令將答案從否更改為是,永遠如此。這是高度內聚行為的明確指標,這些行為可能會一起進入一個模組或類別。
讓我們在所有地方應用這個啟發式方法。在綠色便利貼上,我們將寫下系統在處理每個不變量期間需要檢查的查詢/檢視的名稱。此外,讓我們強調何時查詢/檢視的答案可能會因事件而改變。這樣,綠色便利貼可以被發現位於不變量旁邊或事件旁邊。
讓我們搜尋以下模式
CmdA
被觸發,它導致 EventA
EventA
影響檢視 SomeView
。CmdB
的不變量時也需要 SomeView
CmdA
和 CmdB
可能是落入同一個模組的好候選者!這樣做可能會將我們的領域劃分為非常內聚的地點。下面我們可以找到一個建議的模組化。請記住,這只是一種啟發式方法,您最終可能會得到不同的設置。建議的技術為我們提供了很好的機會來識別鬆散耦合的模組。此方法只是一種啟發式方法(不是強規則),可以幫助我們找到獨立的模組。此外,如果您考慮一下,建議的模組具有語言邊界。信用卡對於會計和行銷來說意味著不同的東西,即使它是同一個詞。在 DDD 術語中,這些被稱為限界上下文。這些將成為我們的部署單元。此外,這種概括必須考慮到效果應該是立即的還是最終的。如果它可以是最終一致的,則這種啟發式方法就沒有那麼強大,即使存在關係。
劃分 (DIVIDE) 部分的最後一步是識別模組之間如何通信。這就是所謂的上下文映射。以下是一些整合策略的列表
實作 (Implement)
在功能上分解軟體對其維護非常有幫助。模組化單體架構是一個好的開始,但它是一個單一部署單元的事實可能會導致問題。所有模組都必須一起部署。在某些企業中,採用微服務可能是一個更好的選擇。請參考 這篇文章,作者是 Nate Shutta,以了解更多關於何時這個決策是正確的資訊。
讓我們假設我們的範例適合微服務架構。每個模組都可以是一個單獨的 Spring Boot 應用程式。我們知道模組的邊界。不同的架構風格可以應用於每個模組。包含最多業務邏輯的地方應仔細實作。另一方面,有些模組清晰而簡單。如何找到兩者?
這種知識是一個非常重要的架構驅動因素,可以使我們決定將命令暴露(例如 REST 資源)與命令處理(帶有不變量的領域模型)解耦。應用於卡片操作的這種架構驅動因素將我們引導到以下技術堆疊
查看命令和相關的不變量(藍色和黃色便利貼)。在牆上,我們有一整套測試場景!唯一剩下要做的就是將它們寫下來
class CreditCardTest {
@Test
public void cannot_withdraw_when_limit_not_assigned() {
}
@Test
public void cannot_withdraw_when_not_enough_money() {
}
@Test
public void cannot_withdraw_when_there_was_withdrawal_within_lastH() {
}
@Test
public void can_withdraw() {
}
@Test
public void cannot_assign_limit_when_it_was_already_assigned() {
}
@Test
public void can_assign_limit() {
}
@Test
public void can_repay() {
}
}
遵循 TDD 原則,我們可以設計我們的程式碼以滿足這些場景。接下來是我們可以從藍色和黃色便利貼建構的初始設計。
@Entity
class CreditCard {
//..fields will pop-up during TDD!
void assignLimit(BigDecimal money) {
if(limitAlreadyAssigned()) {
// throw
}
//...
}
void withdraw(BigDecimal money) {
if(limitNotAssigned()) {
// throw
}
if(notEnoughMoney()) {
// throw
}
if(withdrawalWithinLastHour()) {
// throw
}
//...
}
void repay(BigDecimal money) {
}
}
因為我們使用了便利貼,所以我們在設計階段進行了思考。我們只是複製了便利貼上的內容,並將其貼到程式碼中。相同的語言出現在便利貼和程式碼中,這也是事件風暴法強大的部分原因。作為開發人員,此過程使我們能夠專注於我們最擅長的事情,即編寫穩健的程式碼。語言和模型只是與業務領域專家協同工作過程的一部分。
現在讓我們實作整合層。為了實作對 Statements
模組請求的檢視提款列表的回應,我們將建立 REST 提款資源。此外,這也將是暴露 withdraw
命令的自然候選者。與往常一樣,讓我們從測試開始
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = RANDOM_PORT)
class WithdrawalControllerTest {
private static final String ANY_CARD_NO = "no";
@Autowired
TestRestTemplate testRestTemplate;
@Test
public void should_show_correct_number_of_withdrawals() {
// when
testRestTemplate.postForEntity("/withdrawals/" + ANY_CARD_NO,
new WithdrawRequest(TEN),
WithdrawRequest.class);
// then
ResponseEntity res = testRestTemplate.getForEntity(
"/withdrawals/" + ANY_CARD_NO,
WithdrawRequest.class);
assertThat(res.getStatusCode().is2xxSuccessful()).isTrue();
assertThat(res.getBody()).hasSize(1);
}
}
以及實作
@RestController("/withdrawals")
class WithdrawalController {
@GetMapping("/{cardNo}")
ResponseEntity withdrawalsForCard(@PathVariable String cardNo) {
//.. stack for query
// - direct call to DB to Withdrawals
}
@PostMapping("/{cardNo}")
ResponseEntity withdraw(@PathVariable String cardNo, @RequestBody WithdrawRequest r) {
//.. stack for commands
// - call to CreditCard.withdraw(r.amount)
// - insert new Withdrawal to DB
}
}
根據上下文地圖,Repay
命令發出 MoneyRepaid
事件。訊息代理將是異步傳輸領域事件的自然候選者。為了實作訊息傳遞,我們將使用 Spring Cloud Stream 來節省一些時間。讓我們建立一個端到端測試
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = RANDOM_PORT)
class RepaymentsTest {
private static final String ANY_CARD_NO = "no";
@Autowired
TestRestTemplate testRestTemplate;
@Autowired
MessageCollector messageCollector;
@Autowired
Source source;
BlockingQueue<Message<?>> outputEvents;
@BeforeClass
public void setup() {
outputEvents = messageCollector.forChannel(source.output());
}
@Test
public void should_show_correct_number_of_withdrawals_after_1st_withdrawal() {
// given
testRestTemplate.postForEntity("/withdrawals/" + ANY_CARD_NR,
new WithdrawRequest(TEN),
WithdrawRequest.class);
// when
testRestTemplate.postForEntity("/repayments/" + ANY_CARD_NR,
new RepaymentRequest(TEN),
RepaymentRequest.class);
// then
assertThat(
outputEvents.poll()
.getPayload() instanceof MoneyRepaid)
.isTrue();
}
}
以及實作
@RestController("/repayments")
class RepaymentController {
private final Source source;
RepaymentController(Source source) {
this.source = source;
}
@PostMapping("/{cardNr}")
ResponseEntity repay(@PathVariable String cardNo, @RequestBody RepaymentRequest r) {
//.. stack for commands
// - call to CreditCard.repay(r)
// - source.output().send(... new MoneyRepaid(...));
}
}
class RepaymentRequest {
final BigDecimal amount;
RepaymentRequest(BigDecimal amount) {
this.amount = amount;
}
}
PlasticCards
模組非常簡單。沒有 不變量,唯一的職責是與資料庫和/或訊息代理通信。讓我們不要過於複雜化問題,首先注意到它具有四個主要功能:建立、更新、讀取和刪除。Spring Data REST 是一個很棒的專案,可以輕鬆建立基本的 CRUD 儲存庫,而無需任何繁重的工作或過多擔心底層細節。
Spring Data 允許我們僅用幾行程式碼從上面的設計中實作儲存庫。有人可能會說,一個簡單的測試來檢查上下文和實體映射是否正常,這似乎是一個好主意。為了簡潔起見,讓我們跳過它,直接進入實作
@RepositoryRestResource(path = "plastic-cards",
collectionResourceRel = "plastic-cards",
itemResourceRel = "plastic-cards")
interface PlasticCardController extends CrudRepository<PlasticCard, Long> {
}
@Entity
class PlasticCard {
//..
}
雖然 Statements
模組包含一個不變量,但該模組也非常接近簡單的 CRUD 介面。雖然,Statements
有一個不變量。為了處理不變量,該模組與 CardOperations
模組互動。為了隔離測試該行為(在我們的 Spring Boot 應用程式中沒有 CardOperations
的實際實例),我們應該看看 Spring Cloud Contract 並開始將其引入我們建議的堆疊中。Statements
本質上是簡單的文件,而 Spring Data MongoDB 提供了開箱即用的文件集合功能。Statements
模組不公開任何命令端點,但它訂閱 MoneyRepaid
命令並利用 Spring Cloud Stream 的訊息傳遞功能。
有一個有趣的場景:由於收到 MoneyRepaid
事件而關閉帳單。測試可能會使用 Spring Cloud Stream 測試工具觸發虛假事件
@RunWith(SpringRunner.class)
@SpringBootTest
class MoneyRepaidListenerTest {
private static final String ANY_CARD_NR = "nr";
@Autowired Sink sink;
@Autowired StatementRepository statementRepository;
@Test
public void should_close_the_statement_when_money_repaid_event_happens() {
// when
sink.input()
.send(new GenericMessage<>(new MoneyRepaid(ANY_CARD_NR, TEN)));
// then
assertThat(statementRepository
.findLastByCardNr(ANY_CARD_NR).isClosed()).isTrue();
}
}
以及實作
@Component
class MoneyRepaidListener {
@StreamListener("card-operations")
public void handle(MoneyRepaid moneyRepaid) {
//..close statement
}
}
class MoneyRepaid {
final String cardNo;
final BigDecimal amount;
MoneyRepaid(String cardNo, BigDecimal amount) {
this.cardNo = cardNo;
this.amount = amount;
}
}
另一方面,產生帳單的過程需要向 CardOperations
模組查詢,以檢查是否存在提款。如前所述,這應該隔離測試。為此,可以提出與負責 CardOperations
模組的團隊的契約。因此,可以觸發該模組的存根版本以進行測試。從契約生成的 WireMock 存根可能如下所示…
{
"request" : {
"url" : "/withdrawals/123",
"method" : "GET"
},
"response" : {
"status" : 200,
"body" : "{\"withdrawals\":\"["first", "second", "third"]\"}"
}
}
{
"request" : {
"url" : "/withdrawals/456",
"method" : "GET"
},
"response" : {
"status" : 204,
"body" : "{}"
}
}
以下是由於契約而無需任何 CardOperations
實際實例即可運作的測試
@RunWith(SpringRunner.class)
class StatementGeneratorTest {
private static final String USED_CARD = "123";
private static final String NOT_USED_CARD = "456";
@Autowired StatementGenerator statementGenerator;
@Autowired StatementRepository statementRepository;
@Test
public void should_create_statement_only_if_there_are_withdrawals() {
// when
statementGenerator.generateStatements();
// then
assertThat(statementRepository
.findOpenByCardNr(USED_CARD)).hasSize(1);
assertThat(statementRepository
.findOpenByCardNr(NOT_USED_CARD)).hasSize(0);
}
}
最後一件事情是實作
@Component
class StatementGenerator {
@Scheduled
public void generateStatements() {
allCardNumbers()
.forEach(this::generateIfNeeded);
}
private void generateIfNeeded(CardNr cardNo) {
//query to card-operations
//if 200 OK - generate and statement
}
private List<CardNr> allCardNumbers() {
return callToCardRepository();
}
}
使用 Spring Cloud Pipelines,我們可以輕鬆引入 CI/CD 並完成部署部分。
如果您有興趣,請不要錯過 Cora Iberkleid 和 Marcin Grzejszczak 的演講,內容關於 Spring Cloud Pipelines。
結論
事件風暴法幫助我們快速理解我們的領域是關於什麼的。遵循 DDD 原則,我們可以將企業劃分為更小的內聚且鬆散耦合的問題。了解每個模組的複雜性以及它們需要如何相互通信,我們可以從 Spring 生態系統中廣泛的工具集中挑選工具,以便快速實作和部署。
特別感謝
我要感謝 Kenny Bastani 對本文早期草稿提出了許多有用的意見。但首先,我要感謝他在我們創建和排練我們在 SpringOne 的 演講 時,提出了如此多偉大的想法。
此外,我要感謝 Marcin Grzejszczak 就微服務和測試進行了無休止的討論。我可以說你很少在一個人身上看到如此多的熱情和熱情。