事件風暴與 Spring,淺談 DDD

工程 | Jakub Pilimon | 2018年4月11日 | ...

我很高興地宣佈,我剛剛加入了 Pivotal 的開發者佈道團隊,專注於 Spring。我很榮幸能有機會與來自世界各地優秀而充滿激情的工程師學習和協作。因此,我不得不說,我對即將到來的旅程感到非常興奮。

如果你想關注我,我的 Twitter 是 @JakubPilimon ,我的部落格在 這裡

在加入 Pivotal 之前,我有幸在各種領域與軟體開發團隊進行諮詢並向他們學習。無論是電子商務、製藥、金融科技還是保險——軟體領域的所有共同點都是使用者的期望。在這篇文章中,我將介紹一些我使用 DDD 構建 Spring 應用程式的原則。

在提高可靠性的同時更快地交付軟體的原則:

  • 理解(UNDERSTAND) - 幫助團隊理解並彌合複雜業務問題(即所謂的“領域”)與程式碼中表示該問題的模型之間的差距。我遇到的最常見問題是,最終投入生產的領域模型往往與領域專家的設想相去甚遠。
  • 劃分(DIVIDE) - 將軟體按功能分解為模組。我所說的模組,是指企業中任何獨立的組成部分,它可以是一個或多個部署單元。關鍵在於每個模組都應作為獨立的產品釋出,以便我們可以應用不同的架構風格。
  • 實現(IMPLEMENT) - 透過將思維模式從單體轉移到分散式系統來重構至微服務——或者在不必要時勸阻走這條路!
  • 部署(DEPLOY) - 透過提高對諸如測試驅動開發(Test Driven Development)持續整合(Continuous Integration)持續交付(Continuous Delivery)等習慣的認識來改進交付流程。
  • 構建價值(BUILD VALUE) - 使用 Spring Boot 和 Spring Cloud 來縮短交付業務價值所需的時間。讓開發人員花足夠的時間去理解業務領域本身。

領域建模

說到理解你正在為其構建軟體的業務,沒有任何程式設計框架可以神奇地幫助我們理解和建模複雜的領域。我也不期望這種工具會出現,因為它通常不可能預測一個領域未來會如何演變和變化。然而,有一些大多數人都應該熟悉的常見抽象業務領域——比如銷售庫存產品目錄。從頭開始進行領域建模時,沒有必要重複發明輪子。這裡有一個我推薦的關於複雜領域建模的很棒的資源:企業模式與 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)。注意使用過去時,並且不按特定順序。

events

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

  • 系統收到的一個直接的命令(command) - 放在事件旁的藍色便利貼
  • 另一個事件 - 在這種情況下,我們將這些事件放在一起
  • 一段時間過去 - 寫有時間的小便利貼

events-and-commands

還有一張綠色便利貼:實體卡個性化檢視(plastic card personalization view)。這是給系統的一個直接訊息,導致了實體卡個性化已顯示(plastic card personalization displayed)事件。但這是一種查詢(query),而不是命令。對於檢視和讀模型,我們將使用綠色便利貼。

下一步至關重要。我們需要知道僅憑原因是否足以觸發領域事件。可能還需要滿足其他條件,甚至不止一個。這些條件被稱為不變項(invariants)。如果是這樣,我們將其寫在黃色便利貼上,並放在事件和原因之間。

invariants

如果我們按時間順序排列事件,我們將對我們的領域有一個非常好的概覽。此外,我們還將瞭解基本的業務流程。這項技術輕量、快速、有趣,並且比大量文字文件或 UI 模型更具描述性。但它還沒有產生一行程式碼,對吧?

劃分

為了找到業務模組之間的邊界,我們可以應用內聚性規則:一起變化和一起使用的東西應該放在一起。例如,放在一個模組中。我們如何僅憑一堆彩色便利貼來談論內聚性呢?讓我們看看。

為了檢查不變項(黃色便利貼),系統必須提出一些問題。例如,為了取款,必須已經分配了額度。系統必須執行一個查詢:“你好,它有分配額度嗎?”另一方面,有些命令和事件可能會改變對該問題的回答。例如,第一個分配額度的命令將答案從永遠改變為。這清晰地表明瞭可能組合到同一個模組或類中的高度內聚的行為。

讓我們在所有地方應用這個啟發式方法。在綠色便利貼上,我們將寫下系統在處理每個不變項時需要檢查的查詢/檢視的名稱。此外,讓我們強調一下該查詢/檢視的答案何時可能由於事件而改變。這樣,綠色便利貼就可以出現在不變項旁邊或事件旁邊。

invariants-view-events-view-changes

讓我們尋找以下模式:

  • 命令 CmdA 被觸發,導致 EventA 發生。
  • EventA 影響檢視 SomeView
  • 在處理保護 CmdB 的不變項時,也需要 SomeView
  • 這意味著 CmdACmdB 可能是放在同一模組中的良好候選者!
  • 讓我們把這些命令(以及不變項和事件)放在一起。

這樣做可能會將我們的領域分割成非常內聚的部分。下面我們可以找到一個建議的模組化方案。請記住,這只是一個啟發式方法,你最終可能會得到不同的設定。建議的技術為我們提供了識別鬆散耦合模組的好機會。這種方法只是一種啟發式方法(不是硬性規則),可以幫助我們找到獨立的模組。此外,如果你仔細想想,建議的模組具有語言邊界。對於會計和市場營銷來說,即使是同一個詞,“信用卡”的含義也不同。在 DDD 術語中,這些被稱為限界上下文(Bounded Contexts)。這些將是我們的部署單元。此外,這種泛化必須考慮到效果是即時還是最終一致的。如果可以是最終一致的,那麼即使存在關係,這個啟發式方法也不是那麼強有力。

modules

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

  • 一個模組向另一個模組傳送查詢 - 賬單模組需要詢問卡片操作模組是否有任何取款記錄。因為如果沒有,它就不需要出具任何賬單。
  • 一個模組監聽另一個模組傳送的事件 - 已還款(Money Repaid)事件的直接後果是賬單已關閉(Statement Closed)事件。這意味著賬單模組應該訂閱卡片操作模組釋出的事件。這在事件風暴會議開始時被遺漏了。上下文對映實際上是我們發現很多新資訊的時刻。
  • 一個模組向另一個模組傳送命令 - 在我們的系統中沒有這樣的例子。

contextmap

實現

對軟體進行功能分解極大地有助於維護。模組化單體是一個好的開始,但它是單一部署單元這一事實可能會導致問題。所有模組必須一起部署。在某些企業中,採用微服務可能是更好的選擇。請參考 Nate Shutta 的這篇文章,以瞭解更多關於何時做出這個決定是正確的。

假設我們的示例適合微服務架構。每個模組可以是一個獨立的 Spring Boot 應用程式。我們知道模組的邊界。可以在每個模組中應用不同的架構風格。包含最多業務邏輯的地方應該特別小心地實現。另一方面,有一些模組清晰簡單。如何找到這兩類模組?

  • 尋找有大量黃色便利貼(不變項)的地方。這是命令和最終事件之間存在大量邏輯的地方。系統需要在這裡處理複雜的命令。這是我們預期會發生突然變化並可能構建競爭優勢的地方。我們希望在這裡應用特別的關注,因此例如可以應用領域驅動設計(Domain-Driven Design)技術或六邊形架構(hexagonal architecture)。
  • 尋找包含少量或零個黃色便利貼的地方。這些地方清晰且易於實現。命令和事件之間幾乎沒有任何內容,系統在這裡不需要做任何複雜的事情。這裡唯一的工作是與資料庫互動,因此我們應該小心並儘量避免在此引入意外的複雜性。

這些知識是非常重要的架構驅動因素,可以促使我們決定將命令暴露(例如 REST 資源)與命令處理(包含不變項的領域模型)解耦。將此架構驅動因素應用於卡片操作模組,我們得到以下技術棧:

cardoperations

看看這些命令和相關的不變項(藍色和黃色便利貼)。牆上有一整套測試場景!剩下的唯一事情就是把它們寫下來。

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 倉庫,無需繁重的工作或過多擔心底層細節。

plasticcards

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 的訊息傳遞能力。

statements

有一個有趣的場景:由於收到 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 關於微服務和測試的無數次討論。我可以坦誠地說,你很少能在一個人身上看到如此多的激情和熱情。

訂閱 Spring 新聞通訊

透過 Spring 新聞通訊保持聯絡

訂閱

領先一步

VMware 提供培訓和認證,助您加速進步。

瞭解更多

獲取支援

Tanzu Spring 透過一個簡單的訂閱,為 OpenJDK™、Spring 和 Apache Tomcat® 提供支援和二進位制檔案。

瞭解更多

即將到來的活動

檢視 Spring 社群所有即將到來的活動。

檢視全部