領先一步
VMware 提供培訓和認證,助您加速進步。
瞭解更多我很高興地宣佈,我剛剛加入了 Pivotal 的開發者宣傳團隊,專注於 Spring。我很榮幸有機會與來自世界各地優秀而充滿激情的工程師們學習和合作。因此,我必須說我對即將到來的旅程感到非常興奮。
如果您想關注我,我的 Twitter 賬號是 @JakubPilimon,部落格 在此。
在加入 Pivotal 之前,我很榮幸能與各種領域的軟體開發團隊進行諮詢並從中學習。無論是電子商務、製藥、金融科技還是保險領域,軟體領域的所有領域都有一個共同點,那就是使用者的期望。在這篇文章中,我將介紹一些我使用 DDD 構建 Spring 應用程式的原則。
更快交付軟體同時提高可靠性的原則:
領域建模
在理解您正在為其構建軟體的業務時,沒有任何程式設計框架可以神奇地幫助我們理解和建模複雜的領域。我不期望這樣的工具能夠實現,因為它通常不可能預測這樣的領域未來將如何演變和變化。然而,有一些常見的抽象業務領域是大多數人應該熟悉的——例如銷售、庫存或產品目錄。當從頭開始進行領域建模時,沒有必要重複造輪子。這是一個我推薦用於複雜領域建模的極佳資源:企業模式和 MDA:使用原型模式和 UML 構建更好的軟體。
理解、劃分和持續征服
在快速交付軟體時,我們絕不能犧牲程式碼未來被他人理解的方式。值得慶幸的是,我們有一套原則和實踐可以幫助我們——以領域驅動設計的形式。就我個人而言,我喜歡將 DDD 視為一個迭代學習未知事物的過程。應用 DDD 的副作用是,我們能夠使我們的程式碼對於開發人員和業務來說都更加易於理解、可擴充套件和連貫。透過 DDD,使我們的原始碼成為領域應如何運作的唯一真相來源成為可能。軟體功能旨在更改。但是,當開發人員無法以業務理解的術語向業務闡明原始碼時,該功能就會變得華而不實且難以更改或替換。
即使是最複雜的領域也可以分為…
識別這些較小的產品為我們提供瞭如何將程式碼組織成模組的初步草案。每個子域都等於一個單獨的模組。理解核心域和通用域之間的區別有助於我們看到它們可能需要不同的架構風格。
幸運的是,有許多我們可以選擇的元件!
示例
在此,我很高興地宣佈,我和我的朋友Michał Michaluk共同發起了一項名為 #dddbyexamples 的倡議。這項倡議旨在將 Spring 生態系統的許多不同部分與 DDD 愛好者的興趣聯絡起來。您可以在此處檢視我們的示例。到目前為止,有兩個示例。一個示例側重於事件溯源和命令查詢責任分離,而另一個示例側重於端到端的 DDD 示例。兩者都使用 Spring Boot 實現。
讓我們深入研究端到端示例。我們將實現一個簡化的信用卡管理系統。我們將工作劃分為理解、劃分、實現和部署。需求尚不明確,到目前為止我們知道系統應該能夠:
理解
要理解我們業務問題中真正發生的事情,我們可以利用一種輕量級技術,稱為事件風暴。我們所需要做的就是一面寬闊的牆壁上的無限空間、便利貼,以及聚集在一個房間裡的業務和技術人員。第一步是在橙色便籤上寫下我們領域中可能發生的事情。這些是領域事件。請注意過去時態,並且沒有特定的順序。

然後我們必須識別每個事件的原因。領域專家知道原因,並且很可能可以將其分類為:

還有一張綠色便籤:實體卡個性化檢視。它是導致實體卡個性化顯示事件的直接系統訊息。但它是一個查詢,而不是命令。對於檢視和讀取模型,我們將使用綠色便籤。
下一步至關重要。我們需要知道僅憑原因是否足以導致領域事件發生。也許還有另一個條件必須滿足。也許不止一個。這些條件稱為不變式。如果是,我們將其寫在黃色便籤上,並放置在事件和原因之間。

如果我們將時間順序應用於我們的事件,我們將對我們的領域有一個很好的概述。此外,我們將瞭解基本的業務流程。該技術輕巧、快速、有趣,並且與大量的文字文件或 UI 模型相比更具描述性。但它還沒有產生一行程式碼,不是嗎?
劃分
為了找到業務模組之間的邊界,我們可以應用內聚性規則:一起變化和一起使用的東西應該放在一起。例如,在一個模組中。我們如何只用一組彩色便籤來談論內聚性呢?讓我們看看。
為了檢查不變式(黃色便籤),系統必須提出一些問題。例如,為了取款,必須已經分配了額度。系統必須執行一個查詢:“嗨,它有分配額度嗎?”另一方面,有一些命令和事件可能會改變該問題的答案。例如,第一個分配額度的命令將該答案從“否”永久更改為“是”。這是一個高度內聚行為的明確指標,這些行為可能會一起進入一個模組或類。
讓我們在所有地方應用這個啟發式方法。在綠色便籤上,我們將寫下系統在處理每個不變式時需要檢查的查詢/檢視的名稱。此外,讓我們突出顯示該查詢/檢視的答案何時可能因事件而改變。這樣,綠色便籤可以放置在不變式旁邊或事件旁邊。

讓我們搜尋以下模式:
CmdA 被觸發並導致 EventAEventA 影響檢視 SomeView。SomeView 在處理保護 CmdB 的不變式時也需要。CmdA 和 CmdB 可能是放在同一個模組中的良好候選者!這樣做可以將我們的領域劃分為高度內聚的區域。下面我們可以找到一個模組化方案。請記住,這只是一個啟發式方法,您最終可能會得到不同的設定。所提出的技術為我們提供了識別鬆散耦合模組的良好機會。這種方法只是一種啟發式方法(而不是嚴格的規則),可以幫助我們找到獨立的模組。此外,如果您仔細想想,所提出的模組具有語言邊界。信用卡對於會計和營銷來說意味著不同的東西,即使它是同一個詞。在 DDD 術語中,這些被稱為限界上下文。這些將是我們的部署單元。此外,這種泛化必須考慮效果是即時的還是最終的。如果它最終可以保持一致,即使存在關係,這種啟發式方法也不是那麼強烈。

“劃分”部分的最後一步是識別模組之間如何通訊。這就是所謂的上下文對映。以下是一些整合策略的列表:

實現
擁有功能分解的軟體極大地有助於其維護。模組化單體是一個好的開始,但它是一個單一的部署單元這一事實可能會導致問題。所有模組必須一起部署。在某些企業中,採用微服務可能是一個更好的選擇。請參閱 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對微服務和測試的無休止討論。我可以說,你很少在一個人身上看到如此多的熱情和激情。