事件風暴法與 Spring 框架及領域驅動設計 (DDD) 的應用

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

我很榮幸地宣布,我剛加入了 Pivotal 的開發人員推廣團隊,專注於 Spring。我感到非常榮幸有機會向來自世界各地優秀且充滿熱情的工程師學習和合作。因此,我必須說我對即將到來的旅程感到非常興奮。

如果您想關注我,我的 Twitter 帳號是 @JakubPilimon ,部落格 在此

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

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

  • 理解 (UNDERSTAND) - 協助團隊理解並彌合複雜業務問題(所謂的「領域」)與程式碼中代表它的模型之間的差距。我遇到的最常見問題是,最終進入生產環境的領域模型通常與領域專家最初的想法相去甚遠。
  • 劃分 (DIVIDE) - 將軟體在功能上分解為模組。我所說的模組是指我們企業的任何獨立部分,它可以是一個或多個部署單元。至關重要的是,每個模組都應作為獨立產品交付,以便我們可以應用不同的架構風格。
  • 實作 (IMPLEMENT) - 通過將思維模式從單體架構轉向分散式系統來重構為微服務——或者在不必要時勸阻走上這條路!
  • 部署 (DEPLOY) - 透過擴大對測試驅動開發持續整合持續交付等習慣的認識,來改進交付流程
  • 建立價值 (BUILD VALUE) - 使用 Spring Boot 和 Spring Cloud 來縮短交付業務價值所需的時間。讓開發人員盡可能多地將時間花在理解業務領域本身上。

領域建模

當談到理解您正在為其建構軟體的業務時,沒有任何程式設計框架可以神奇地幫助我們理解和建模複雜的領域。我不期望這樣的工具會實現,因為通常不可能預測這樣的領域將如何在未來演進和變化。然而,有一些常見的抽象業務領域是大多數人應該熟悉的——例如銷售庫存產品目錄。當從頭開始進行領域建模時,無需重新發明輪子。以下是我推薦用於複雜領域建模的絕佳資源: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)

為了理解我們的業務問題中真正發生了什麼,我們可以利用一種稱為事件風暴法的輕量級技術。我們只需要一面寬牆上的無限空間、便利貼以及聚集在一個房間裡的業務和技術人員。第一步是將我們領域中可能發生的事情寫在橘色便利貼上。這些是領域事件。請注意過去式和沒有特定順序。

events

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

  • 對系統的直接命令——事件旁邊的藍色便利貼
  • 另一個事件——在這種情況下,我們將這些事件放在彼此旁邊
  • 經過一段時間——標有時間的小便利貼

events-and-commands

還有一張綠色便利貼:塑膠卡個人化檢視。這是發送到系統的直接訊息,導致塑膠卡個人化已顯示事件。但這是一個查詢,而不是命令。對於檢視和讀取模型,我們將使用綠色便利貼。

下一步至關重要。我們需要知道原因本身是否足以使領域事件發生。可能還有另一個必須滿足的條件。可能不止一個。這些條件稱為不變量。如果是這樣,我們將它們寫在黃色便利貼上,並放置在事件和原因之間。

invariants

如果我們將時間順序應用於我們的事件,我們將對我們的領域有很好的概觀。此外,我們將了解基本的業務流程。該技術輕量級、快速、有趣,並且與大量的文字文件或 UI 模型相比更具描述性。但它還沒有交付一行程式碼,對吧?

劃分 (Divide)

為了找到業務模組之間的邊界,我們可以應用內聚規則:一起更改和一起使用的事物應該放在一起。例如,在一個模組中。只有一組彩色便利貼,我們如何談論內聚性?讓我們看看。

為了檢查不變量(黃色便利貼),系統必須提出一些問題。例如,為了提款,必須已經分配了額度。系統必須運行一個查詢:「嗨,它是否已分配額度?」。另一方面,有些命令和事件可能會更改該問題的答案。例如,分配額度的第一個命令將答案從更改為,永遠如此。這是高度內聚行為的明確指標,這些行為可能會一起進入一個模組或類別。

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

invariants-view-events-view-changes

讓我們搜尋以下模式

  • 命令 CmdA 被觸發,它導致 EventA
  • EventA 影響檢視 SomeView
  • 在處理保護 CmdB 的不變量時也需要 SomeView
  • 這表示 CmdACmdB 可能是落入同一個模組的好候選者!
  • 讓我們將這些命令(連同不變量和事件)放在彼此旁邊。

這樣做可能會將我們的領域劃分為非常內聚的地點。下面我們可以找到一個建議的模組化。請記住,這只是一種啟發式方法,您最終可能會得到不同的設置。建議的技術為我們提供了很好的機會來識別鬆散耦合的模組。此方法只是一種啟發式方法(不是強規則),可以幫助我們找到獨立的模組。此外,如果您考慮一下,建議的模組具有語言邊界。信用卡對於會計和行銷來說意味著不同的東西,即使它是同一個詞。在 DDD 術語中,這些被稱為限界上下文。這些將成為我們的部署單元。此外,這種概括必須考慮到效果應該是立即的還是最終的。如果它可以是最終一致的,則這種啟發式方法就沒有那麼強大,即使存在關係。

modules

劃分 (DIVIDE) 部分的最後一步是識別模組之間如何通信。這就是所謂的上下文映射。以下是一些整合策略的列表

  • 一個模組向另一個模組發送查詢——帳單模組需要詢問卡片操作是否有任何提款。因為如果沒有,它就不會發出任何帳單。
  • 一個模組監聽另一個模組發送的事件——還款事件的直接後果是帳單已關閉事件。這表示帳單應訂閱卡片操作拋出的事件。這是事件風暴法會議開始時錯過的。上下文映射實際上是我們發現大量新資訊的時刻
  • 一個模組向另一個模組觸發命令——我們的系統中沒有這樣的範例。

contextmap

實作 (Implement)

在功能上分解軟體對其維護非常有幫助。模組化單體架構是一個好的開始,但它是一個單一部署單元的事實可能會導致問題。所有模組都必須一起部署。在某些企業中,採用微服務可能是一個更好的選擇。請參考 這篇文章,作者是 Nate Shutta,以了解更多關於何時這個決策是正確的資訊。

讓我們假設我們的範例適合微服務架構。每個模組都可以是一個單獨的 Spring Boot 應用程式。我們知道模組的邊界。不同的架構風格可以應用於每個模組。包含最多業務邏輯的地方應仔細實作。另一方面,有些模組清晰而簡單。如何找到兩者?

  • 尋找有很多黃色便利貼(不變量)的地點。這是我們在命令和最終事件之間有很多邏輯的地方。系統需要在這裡處理複雜的命令。這是我們期望突然變化的地方,也是我們可能建立競爭優勢的地方。我們希望在這裡特別注意,因此例如應用領域驅動設計技術或六邊形架構。
  • 尋找包含少量或零黃色便利貼的地點。這些地點清晰易於實作。命令和事件之間幾乎沒有任何東西,系統不需要在這裡做任何複雜的事情。這裡唯一的工作是與資料庫互動,因此我們應該小心並盡量避免意外的複雜性。

這種知識是一個非常重要的架構驅動因素,可以使我們決定將命令暴露(例如 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 模組請求的檢視提款列表的回應,我們將建立 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 儲存庫,而無需任何繁重的工作或過多擔心底層細節。

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 模組的團隊的契約。因此,可以觸發該模組的存根版本以進行測試。從契約生成的 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 社群中所有即將到來的活動。

查看全部