领先一步
VMware提供培训和认证,以快速提升您的进步。
了解更多我很高兴地宣布,我刚刚加入了Pivotal的开发者布道团队,专注于Spring。我很荣幸有机会向来自世界各地的优秀且充满激情的工程师学习和合作。因此,我必须说我对即将到来的旅程感到非常兴奋。
如果您想关注我,我在Twitter上使用 @JakubPilimon ,并在 这里写博客。
在加入Pivotal之前,我有幸与来自各种领域的软件开发团队进行咨询和学习。无论是电子商务、制药、金融科技还是保险领域——软件领域的所有领域都有一个共同点,那就是用户的期望。在这篇文章中,我将介绍一些我在使用DDD构建Spring应用程序的原则。
在提高可靠性的同时更快地交付软件的原则:
领域建模
在理解您正在为其构建软件的业务方面,没有任何编程框架可以神奇地帮助我们理解和建模复杂的领域。我不指望这样的工具能够实现,因为它通常不可能预测这样的领域将来如何发展和变化。但是,大多数人应该熟悉一些常见的抽象业务领域,例如销售、库存或产品目录。在从头开始进行领域建模时,无需重新发明轮子。以下是我推荐的一个用于复杂领域建模的优秀资源:企业模式和MDA:使用原型模式和UML构建更好的软件。
理解、划分和持续征服
在快速交付软件时,我们不能牺牲以后其他人如何理解代码。值得庆幸的是,我们有一套原则和实践来帮助我们——以领域驱动设计的形式。我个人喜欢将DDD视为对未知事物进行迭代学习的过程。应用DDD的副作用是我们能够使我们的代码更容易理解、可扩展和连贯,无论对于开发人员还是业务人员都是如此。使用DDD,可以使我们的源代码成为领域应该如何运作的唯一事实来源。软件功能旨在进行更改。但是,当开发人员无法用业务人员理解的术语向业务人员解释源代码时,该功能就会变得华而不实,难以更改或替换。
即使是最复杂的领域也可以划分为……
识别这些较小的产品可以让我们初步了解如何将代码组织成模块。每个子领域都等于一个单独的模块。理解核心领域和通用领域之间的区别有助于我们看到它们可能需要不同的架构风格。
幸运的是,有很多我们可以挑选和选择的成分!
示例
在此,我很高兴地宣布,我和我的朋友Michał Michaluk一起发起了一个名为#dddbyexamples的倡议。该倡议的目的是将Spring生态系统的许多不同部分与DDD爱好者的兴趣联系起来。您可以在此处查看我们的示例。到目前为止,有两个示例。一个示例侧重于事件溯源和命令查询责任隔离,而另一个示例侧重于端到端的DDD示例。两者均使用Spring Boot实现。
让我们深入探讨端到端示例。我们将实现一个简化的信用卡管理系统。我们将工作细分为理解、划分、实现和部署。需求尚不清楚,到目前为止,我们知道该系统应该能够
理解
为了理解我们的业务问题中真正发生了什么,我们可以利用一种名为事件风暴的轻量级技术。我们只需要在一面宽墙上拥有无限的空间、便利贴以及聚集在一个房间里的业务和技术人员。第一步是在橙色便利贴上写下我们领域中可能发生的事情。这些是领域事件。请注意过去时态,并且没有特定的顺序。
然后,我们必须确定每个事件的起因。领域专家知道起因,并且很可能可以将其分类为
还有一个绿色便利贴:信用卡个性化视图。它是一条直接发送给系统的消息,会导致显示信用卡个性化事件。但这是一个查询,而不是命令。对于视图和读取模型,我们将使用绿色便利贴。
下一步至关重要。我们需要知道起因本身是否足以使领域事件发生。可能还有其他条件需要满足。可能不止一个。这些条件称为不变式。如果是这样,我们将它们写在黄色便利贴上,并放在事件和起因之间。
如果我们将事件应用于时间顺序,我们将对我们的领域有一个很好的概述。此外,我们将了解基本的业务流程。与大量的文本文档或UI模型相比,该技术轻量级、快速、有趣且更具描述性。但是它还没有交付一行代码,是吗?
划分
为了找到业务模块之间的边界,我们可以应用内聚规则:一起更改并一起使用的内容应放在一起。例如,在一个模块中。我们如何只通过一套彩色的便利贴来谈论内聚性呢?让我们来看看。
为了检查不变式(黄色便利贴),系统必须提出一些问题。例如,为了取款,必须已经分配了限额。系统必须运行查询:“你好,它是否已分配限额?”。另一方面,可能会更改对该问题的答案的命令和事件。例如,分配限额的第一个命令将该答案从否永久更改为是。这是一个高度内聚行为的明确指标,这些行为可能一起进入一个模块或类。
让我们在所有地方应用这种启发式方法。在绿色便利贴上,我们将写下系统在处理每个不变式期间需要检查的查询/视图的名称。此外,让我们突出显示当某个事件导致对该查询/视图的答案发生更改时的情况。这样,绿色便利贴就可以放在不变式旁边或事件旁边。
让我们搜索以下模式
CmdA
,它会导致EventA
EventA
影响视图SomeView
。CmdB
的不变式时,也需要SomeView
CmdA
和CmdB
可能是进入同一模块的好候选者!这样做可能会将我们的领域分割成非常内聚的部分。下面我们可以找到一个提议的模块化方案。记住,这只是一个启发式方法,你最终可能会得到不同的设置。建议的技术给了我们一个很好的机会来识别松散耦合的模块。这种方法只是一个启发式方法(不是一个强规则),可以帮助我们找到独立的模块。此外,如果你仔细想想,提议的模块具有语言边界。“信用卡”对会计和市场营销的含义不同,即使是同一个词。在DDD术语中,这些被称为限界上下文(Bounded Contexts)。这些将是我们的部署单元。此外,这种泛化必须考虑效果是立即的还是最终的。如果它可以最终一致,那么这个启发式方法就不那么强了,即使存在某种关系。
DIVIDE 部分的最后一步是确定模块之间如何通信。这就是所谓的上下文映射。这里列出了一些集成策略
实现
对软件进行功能分解极大地有助于维护。模块化单体是一个良好的开端,但它是一个单一的部署单元这一事实可能会导致问题。所有模块必须一起部署。在某些企业中,使用微服务可能是一个更好的选择。请参考这篇文章,作者是Nate Shutta,以了解何时做出此决定是正确的。
让我们假设我们的示例适合微服务架构。每个模块都可以是一个单独的Spring Boot应用程序。我们知道模块的边界。可以在每个模块中应用不同的架构风格。包含最多业务逻辑的部分应该仔细实现。另一方面,也有一些模块清晰简单。如何找到两者?
这些知识是一个非常重要的架构驱动因素,可以让我们决定将命令暴露(例如REST资源)与命令处理(具有不变量的领域模型)解耦。应用于卡操作的这个架构驱动因素导致了以下技术栈
看看命令和相关的不变量(蓝色和黄色便签)。在墙上,我们有一套完整的测试场景!剩下的只是把它们写下来。
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存储库,而无需进行大量的工作或过度担心管道问题。
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的消息传递功能。
有一个有趣的场景:作为接收到的退款
事件的结果关闭报表。测试可能会使用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关于微服务和测试的无数讨论。我可以说,你很少在一个人的身上看到如此多的热情和激情。