领先一步
VMware 提供培训和认证,助您加速进步。
了解更多我很高兴地宣布,我刚刚加入了 Pivotal 的开发者布道团队,专注于 Spring。我很荣幸能有机会与来自世界各地优秀而充满激情的工程师学习和协作。因此,我不得不说,我对即将到来的旅程感到非常兴奋。
如果你想关注我,我的 Twitter 是 @JakubPilimon ,我的博客在 这里。
在加入 Pivotal 之前,我有幸在各种领域与软件开发团队进行咨询并向他们学习。无论是电子商务、制药、金融科技还是保险——软件领域的所有共同点都是用户的期望。在这篇文章中,我将介绍一些我使用 DDD 构建 Spring 应用程序的原则。
在提高可靠性的同时更快地交付软件的原则:
领域建模
说到理解你正在为其构建软件的业务,没有任何编程框架可以神奇地帮助我们理解和建模复杂的领域。我也不期望这种工具会出现,因为它通常不可能预测一个领域未来会如何演变和变化。然而,有一些大多数人都应该熟悉的常见抽象业务领域——比如销售、库存或产品目录。从头开始进行领域建模时,没有必要重复发明轮子。这里有一个我推荐的关于复杂领域建模的很棒的资源:企业模式与 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)。注意使用过去时,并且不按特定顺序。
然后我们必须确定每个事件的原因。领域专家知道原因,并且很可能可以将其归类为:
还有一张绿色便利贴:实体卡个性化视图(plastic card personalization view)。这是给系统的一个直接消息,导致了实体卡个性化已显示(plastic card personalization displayed)事件。但这是一种查询(query),而不是命令。对于视图和读模型,我们将使用绿色便利贴。
下一步至关重要。我们需要知道仅凭原因是否足以触发领域事件。可能还需要满足其他条件,甚至不止一个。这些条件被称为不变项(invariants)。如果是这样,我们将其写在黄色便利贴上,并放在事件和原因之间。
如果我们按时间顺序排列事件,我们将对我们的领域有一个非常好的概览。此外,我们还将了解基本的业务流程。这项技术轻量、快速、有趣,并且比大量文字文档或 UI 模型更具描述性。但它还没有产生一行代码,对吧?
划分
为了找到业务模块之间的边界,我们可以应用内聚性规则:一起变化和一起使用的东西应该放在一起。例如,放在一个模块中。我们如何仅凭一堆彩色便利贴来谈论内聚性呢?让我们看看。
为了检查不变项(黄色便利贴),系统必须提出一些问题。例如,为了取款,必须已经分配了额度。系统必须运行一个查询:“你好,它有分配额度吗?”另一方面,有些命令和事件可能会改变对该问题的回答。例如,第一个分配额度的命令将答案从否永远改变为是。这清晰地表明了可能组合到同一个模块或类中的高度内聚的行为。
让我们在所有地方应用这个启发式方法。在绿色便利贴上,我们将写下系统在处理每个不变项时需要检查的查询/视图的名称。此外,让我们强调一下该查询/视图的答案何时可能由于事件而改变。这样,绿色便利贴就可以出现在不变项旁边或事件旁边。
让我们寻找以下模式:
CmdA
被触发,导致 EventA
发生。EventA
影响视图 SomeView
。CmdB
的不变项时,也需要 SomeView
。CmdA
和 CmdB
可能是放在同一模块中的良好候选者!这样做可能会将我们的领域分割成非常内聚的部分。下面我们可以找到一个建议的模块化方案。请记住,这只是一个启发式方法,你最终可能会得到不同的设置。建议的技术为我们提供了识别松散耦合模块的好机会。这种方法只是一种启发式方法(不是硬性规则),可以帮助我们找到独立的模块。此外,如果你仔细想想,建议的模块具有语言边界。对于会计和市场营销来说,即使是同一个词,“信用卡”的含义也不同。在 DDD 术语中,这些被称为限界上下文(Bounded Contexts)。这些将是我们的部署单元。此外,这种泛化必须考虑到效果是即时还是最终一致的。如果可以是最终一致的,那么即使存在关系,这个启发式方法也不是那么强有力。
“划分”阶段的最后一步是确定模块之间如何通信。这就是所谓的上下文映射。以下是一些集成策略的列表:
实现
对软件进行功能分解极大地有助于维护。模块化单体是一个好的开始,但它是单一部署单元这一事实可能会导致问题。所有模块必须一起部署。在某些企业中,采用微服务可能是更好的选择。请参考 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) {
}
}
因为我们使用了便利贴,所以在设计阶段就完成了思考。我们只需将便利贴上的内容复制并粘贴到代码中。便利贴和代码中使用了相同的语言,这是事件风暴强大之处的一部分。作为一名开发者,这个过程使我们能够专注于我们最擅长的事情,即编写健壮的代码。语言和模型只是与业务领域专家协作过程的一部分。
现在让我们实现集成层。为了实现由 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 仓库,无需繁重的工作或过多担心底层细节。
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 的消息传递能力。
有一个有趣的场景:由于收到 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 关于微服务和测试的无数次讨论。我可以坦诚地说,你很少能在一个人身上看到如此多的激情和热情。