事件风暴与 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 社区所有即将到来的活动。

查看全部