領先一步
VMware 提供培訓和認證,助您加速進步。
瞭解更多我很高興地宣佈,我剛剛加入了 Pivotal 的開發者佈道團隊,專注於 Spring。我很榮幸能有機會與來自世界各地優秀而充滿激情的工程師學習和協作。因此,我不得不說,我對即將到來的旅程感到非常興奮。
如果你想關注我,我的 Twitter 是 @JakubPilimon ,我的部落格在 這裡。
在加入 Pivotal 之前,我有幸在各種領域與軟體開發團隊進行諮詢並向他們學習。無論是電子商務、製藥、金融科技還是保險——軟體領域的所有共同點都是使用者的期望。在這篇文章中,我將介紹一些我使用 DDD 構建 Spring 應用程式的原則。
在提高可靠性的同時更快地交付軟體的原則:
領域建模
說到理解你正在為其構建軟體的業務,沒有任何程式設計框架可以神奇地幫助我們理解和建模複雜的領域。我也不期望這種工具會出現,因為它通常不可能預測一個領域未來會如何演變和變化。然而,有一些大多數人都應該熟悉的常見抽象業務領域——比如銷售、庫存或產品目錄。從頭開始進行領域建模時,沒有必要重複發明輪子。這裡有一個我推薦的關於複雜領域建模的很棒的資源:企業模式與 MDA:使用原型模式和 UML 構建更好的軟體。
理解、劃分並持續征服
在快速交付軟體時,我們絕不能犧牲程式碼日後被他人理解的程度。幸運的是,我們有一套原則和實踐來幫助我們——這就是領域驅動設計(Domain-Driven Design)。我個人喜歡將 DDD 視為一個迭代學習未知事物的過程。應用 DDD 的副作用是,我們能夠使我們的程式碼對於開發者和業務人員都更易於理解、更易於擴充套件和更連貫。有了 DDD,就可以使我們的原始碼成為領域如何運作的唯一真相來源。軟體功能是註定要改變的。但是,當開發者無法用業務人員能夠理解的術語來闡述原始碼時,該功能就會變得華而不實,並且難以更改或替換。
即使最複雜的領域也可以劃分為…
識別這些較小的產品,為我們如何將程式碼組織成模組提供了初步草案。每個子領域對應一個獨立的模組。理解核心領域和通用領域之間的區別有助於我們認識到它們可能需要不同的架構風格。
幸運的是,我們有很多可供選擇的“配料”!
示例
在此,我很高興地宣佈,我與我的朋友 Michał Michaluk 一起建立了一個名為 #dddbyexamples 的倡議。該倡議的目的是將 Spring 生態系統的諸多不同部分與 DDD 愛好者的興趣聯絡起來。你可以在這裡檢視我們的示例。目前,共有兩個示例。一個示例側重於事件溯源(Event Sourcing)和命令查詢責任分離(Command Query Responsibility Segregation),另一個示例側重於端到端的 DDD 示例。兩者都使用 Spring Boot 實現。
讓我們深入瞭解這個端到端的示例。我們將實現一個簡化的信用卡管理系統。我們將工作分為 理解、劃分、實現和部署 四個階段。目前需求尚不完全明確,但我們知道系統應該能夠:
理解
為了理解我們的業務問題中真正發生的事情,我們可以利用一種輕量級技術,稱為事件風暴(Event Storming)。我們所需要的只是寬敞牆壁上的無限空間、便利貼以及聚集在同一房間裡的業務人員和技術人員。第一步是用橙色便利貼寫下我們的領域中可能發生什麼。這些就是領域事件(domain events)。注意使用過去時,並且不按特定順序。
然後我們必須確定每個事件的原因。領域專家知道原因,並且很可能可以將其歸類為:
還有一張綠色便利貼:實體卡個性化檢視(plastic card personalization view)。這是給系統的一個直接訊息,導致了實體卡個性化已顯示(plastic card personalization displayed)事件。但這是一種查詢(query),而不是命令。對於檢視和讀模型,我們將使用綠色便利貼。
下一步至關重要。我們需要知道僅憑原因是否足以觸發領域事件。可能還需要滿足其他條件,甚至不止一個。這些條件被稱為不變項(invariants)。如果是這樣,我們將其寫在黃色便利貼上,並放在事件和原因之間。
如果我們按時間順序排列事件,我們將對我們的領域有一個非常好的概覽。此外,我們還將瞭解基本的業務流程。這項技術輕量、快速、有趣,並且比大量文字文件或 UI 模型更具描述性。但它還沒有產生一行程式碼,對吧?
劃分
為了找到業務模組之間的邊界,我們可以應用內聚性規則:一起變化和一起使用的東西應該放在一起。例如,放在一個模組中。我們如何僅憑一堆彩色便利貼來談論內聚性呢?讓我們看看。
為了檢查不變項(黃色便利貼),系統必須提出一些問題。例如,為了取款,必須已經分配了額度。系統必須執行一個查詢:“你好,它有分配額度嗎?”另一方面,有些命令和事件可能會改變對該問題的回答。例如,第一個分配額度的命令將答案從否永遠改變為是。這清晰地表明瞭可能組合到同一個模組或類中的高度內聚的行為。
讓我們在所有地方應用這個啟發式方法。在綠色便利貼上,我們將寫下系統在處理每個不變項時需要檢查的查詢/檢視的名稱。此外,讓我們強調一下該查詢/檢視的答案何時可能由於事件而改變。這樣,綠色便利貼就可以出現在不變項旁邊或事件旁邊。
讓我們尋找以下模式:
CmdA
被觸發,導致 EventA
發生。EventA
影響檢視 SomeView
。CmdB
的不變項時,也需要 SomeView
。CmdA
和 CmdB
可能是放在同一模組中的良好候選者!這樣做可能會將我們的領域分割成非常內聚的部分。下面我們可以找到一個建議的模組化方案。請記住,這只是一個啟發式方法,你最終可能會得到不同的設定。建議的技術為我們提供了識別鬆散耦合模組的好機會。這種方法只是一種啟發式方法(不是硬性規則),可以幫助我們找到獨立的模組。此外,如果你仔細想想,建議的模組具有語言邊界。對於會計和市場營銷來說,即使是同一個詞,“信用卡”的含義也不同。在 DDD 術語中,這些被稱為限界上下文(Bounded Contexts)。這些將是我們的部署單元。此外,這種泛化必須考慮到效果是即時還是最終一致的。如果可以是最終一致的,那麼即使存在關係,這個啟發式方法也不是那麼強有力。
“劃分”階段的最後一步是確定模組之間如何通訊。這就是所謂的上下文對映。以下是一些整合策略的列表:
實現
對軟體進行功能分解極大地有助於維護。模組化單體是一個好的開始,但它是單一部署單元這一事實可能會導致問題。所有模組必須一起部署。在某些企業中,採用微服務可能是更好的選擇。請參考 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
模組請求的檢視 取款列表(list of withdrawals) 的響應,我們將建立一個 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
模組非常簡單。它沒有不變項,唯一的職責是與資料庫和/或訊息代理通訊。讓我們不要把事情複雜化,首先注意它有四個主要功能:建立(create)、更新(update)、讀取(read)和刪除(delete)。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
模組的團隊提出一份契約。因此,可以啟動該模組的樁(stub)版本進行測試。從契約生成的 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 關於微服務和測試的無數次討論。我可以坦誠地說,你很少能在一個人身上看到如此多的激情和熱情。