事件风暴与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
  • 处理保护CmdB的不变式时,也需要SomeView
  • 这意味着CmdACmdB可能是进入同一模块的好候选者!
  • 让我们将这些命令(以及不变式和事件)放在一起。

这样做可能会将我们的领域分割成非常内聚的部分。下面我们可以找到一个提议的模块化方案。记住,这只是一个启发式方法,你最终可能会得到不同的设置。建议的技术给了我们一个很好的机会来识别松散耦合的模块。这种方法只是一个启发式方法(不是一个强规则),可以帮助我们找到独立的模块。此外,如果你仔细想想,提议的模块具有语言边界。“信用卡”对会计和市场营销的含义不同,即使是同一个词。在DDD术语中,这些被称为限界上下文(Bounded Contexts)。这些将是我们的部署单元。此外,这种泛化必须考虑效果是立即的还是最终的。如果它可以最终一致,那么这个启发式方法就不那么强了,即使存在某种关系。

modules

DIVIDE 部分的最后一步是确定模块之间如何通信。这就是所谓的上下文映射。这里列出了一些集成策略

  • 一个模块向另一个模块发送查询——报表模块需要询问卡操作模块是否有任何取款。因为如果没有,它就不会发出任何报表。
  • 一个模块侦听另一个模块发送的事件——退款事件的直接结果是报表关闭事件。这意味着报表模块应该订阅卡操作模块抛出的事件。这在事件风暴会议开始时被忽略了。上下文映射实际上是我们发现许多新信息的时候。
  • 一个模块向另一个模块发出命令——在我们的系统中没有这样的例子。

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) {

    }

}

因为我们使用了便利贴,所以在设计阶段完成了我们的思考。我们只是复制了便利贴上的内容并将其粘贴到代码中。便签和代码中都存在相同的语言,这是事件风暴强大的部分原因。作为开发人员,此过程使我们能够专注于我们最擅长的事情,即编写健壮的代码。语言和模型只是与业务领域专家一起工作的过程的一部分。

现在让我们实现集成层。为了实现报表模块请求的视图取款列表的答案,我们将创建一个REST取款资源。此外,这将成为公开取款命令的自然候选者。像往常一样,让我们从测试开始。

@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
    }

}

根据上下文映射,偿还命令会发出退款事件。消息代理将成为异步传输领域事件的自然候选者。为了实现消息传递,我们将使用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;
    }
}

塑料卡模块非常简单。没有不变量,唯一的责任是与数据库和/或消息代理进行通信。让我们不要过度复杂化这个问题,首先要注意它有四个主要功能:创建、更新、读取和删除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 {

    //..
}

尽管报表模块包含一个不变量,但该模块也非常接近简单的CRUD接口。虽然报表有一个不变量。为了处理不变量,模块与卡操作模块交互。为了隔离地测试这种行为(在我们的Spring Boot应用程序中没有卡操作的真实实例),我们应该查看Spring Cloud Contract并开始将其引入我们提出的堆栈中。报表本质上是简单的文档,而Spring Data MongoDB提供了开箱即用的文档集合功能。报表模块不公开命令的端点,但它订阅退款命令并利用Spring Cloud Stream的消息传递功能。

statements

有一个有趣的场景:作为接收到的退款事件的结果关闭报表。测试可能会使用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;
    }
}

另一方面,生成报表的过程需要查询卡操作模块以检查是否存在取款。如前所述,这应该单独进行测试。为此,可以与负责卡操作模块的团队提出一个合同。因此,可以触发该模块的存根版本以进行测试。从合同生成的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" : "{}"
  }
}

这里有一些测试,由于合同的存在,无需任何真实的卡操作实例就可以工作。

@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社区中所有即将举行的活动。

查看全部