事件風暴、Spring 與 DDD 小結

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

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

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

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

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

  • 理解 - 幫助團隊理解並彌補複雜業務問題(即“領域”)與代表它的程式碼模型之間的差距。我遇到的最常見問題是,最終投入生產的領域模型通常與領域專家設想的相去甚遠。
  • 劃分 - 將軟體按功能分解為模組。我所說的模組是指企業中任何獨立的單元,它可能是一個或多個部署單元。至關重要的是,每個模組都應作為獨立產品釋出,以便我們可以應用不同的架構風格。
  • 實現 - 透過將思維模式從單體系統轉變為分散式系統來重構為微服務——或者在不需要時不鼓勵走這條路!
  • 部署 - 透過擴大對測試驅動開發持續整合持續交付等習慣的認識來改進交付流程。
  • 創造價值 - 使用 Spring Boot 和 Spring Cloud 縮短交付業務價值所需的時間。允許開發人員花費盡可能多的時間來理解業務領域本身。

領域建模

在理解您正在為其構建軟體的業務時,沒有任何程式設計框架可以神奇地幫助我們理解和建模複雜的領域。我不期望這樣的工具能夠實現,因為它通常不可能預測這樣的領域未來將如何演變和變化。然而,有一些常見的抽象業務領域是大多數人應該熟悉的——例如銷售庫存產品目錄。當從頭開始進行領域建模時,沒有必要重複造輪子。這是一個我推薦用於複雜領域建模的極佳資源:企業模式和 MDA:使用原型模式和 UML 構建更好的軟體

理解、劃分和持續征服

在快速交付軟體時,我們絕不能犧牲程式碼未來被他人理解的方式。值得慶幸的是,我們有一套原則和實踐可以幫助我們——以領域驅動設計的形式。就我個人而言,我喜歡將 DDD 視為一個迭代學習未知事物的過程。應用 DDD 的副作用是,我們能夠使我們的程式碼對於開發人員和業務來說都更加易於理解、可擴充套件和連貫。透過 DDD,使我們的原始碼成為領域應如何運作的唯一真相來源成為可能。軟體功能旨在更改。但是,當開發人員無法以業務理解的術語向業務闡明原始碼時,該功能就會變得華而不實且難以更改或替換。

即使是最複雜的領域也可以分為…

  • 更小但仍然相當複雜的子域(即核心域)——這可能是我企業最大的競爭優勢,因此,我們在此投入了大量精力。
  • 簡單易懂的子域,可能並非我企業獨有(即通用子域)——我們企業運營需要它們,但它們並不能為我們的客戶帶來競爭優勢。想想庫存發票。我們的使用者不會因為最漂亮的發票而再次光顧。

識別這些較小的產品為我們提供瞭如何將程式碼組織成模組的初步草案。每個子域都等於一個單獨的模組。理解核心域和通用域之間的區別有助於我們看到它們可能需要不同的架構風格。

幸運的是,有許多我們可以選擇的元件

示例

在此,我很高興地宣佈,我和我的朋友Michał Michaluk共同發起了一項名為 #dddbyexamples 的倡議。這項倡議旨在將 Spring 生態系統的許多不同部分與 DDD 愛好者的興趣聯絡起來。您可以在此處檢視我們的示例。到目前為止,有兩個示例。一個示例側重於事件溯源和命令查詢責任分離,而另一個示例側重於端到端的 DDD 示例。兩者都使用 Spring Boot 實現。

讓我們深入研究端到端示例。我們將實現一個簡化的信用卡管理系統。我們將工作劃分為理解、劃分、實現和部署。需求尚不明確,到目前為止我們知道系統應該能夠:

  • 為卡片分配初始額度
  • 取款
  • 建立包含還款金額的賬單(在一個賬單週期結束時)
  • 還款
  • 訂購或更改個性化實體卡

理解

要理解我們業務問題中真正發生的事情,我們可以利用一種輕量級技術,稱為事件風暴。我們所需要做的就是一面寬闊的牆壁上的無限空間、便利貼,以及聚集在一個房間裡的業務和技術人員。第一步是在橙色便籤上寫下我們領域中可能發生的事情。這些是領域事件。請注意過去時態,並且沒有特定的順序。

events

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

  • 對系統的直接命令 - 事件旁邊的藍色便籤
  • 另一個事件 - 在這種情況下,我們將這些事件彼此相鄰放置
  • 已過去的一段時間 - 寫著時間的小便籤

events-and-commands

還有一張綠色便籤:實體卡個性化檢視。它是導致實體卡個性化顯示事件的直接系統訊息。但它是一個查詢,而不是命令。對於檢視和讀取模型,我們將使用綠色便籤。

下一步至關重要。我們需要知道僅憑原因是否足以導致領域事件發生。也許還有另一個條件必須滿足。也許不止一個。這些條件稱為不變式。如果是,我們將其寫在黃色便籤上,並放置在事件和原因之間。

invariants

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

劃分

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

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

讓我們在所有地方應用這個啟發式方法。在綠色便籤上,我們將寫下系統在處理每個不變式時需要檢查的查詢/檢視的名稱。此外,讓我們突出顯示該查詢/檢視的答案何時可能因事件而改變。這樣,綠色便籤可以放置在不變式旁邊或事件旁邊。

invariants-view-events-view-changes

讓我們搜尋以下模式:

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

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

modules

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

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

contextmap

實現

擁有功能分解的軟體極大地有助於其維護。模組化單體是一個好的開始,但它是一個單一的部署單元這一事實可能會導致問題。所有模組必須一起部署。在某些企業中,採用微服務可能是一個更好的選擇。請參閱 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 社群所有即將舉行的活動。

檢視所有