在本文中,我们将首先更详细地解释六边形架构,然后将基于此架构创建一个 spring boot 应用。
概述
我们将在本教程中了解 Java 的六边形架构。我们将构建一个 Spring Boot 应用程序来进一步演示这一点。软件设计通常使用六边形架构,通常称为端口和适配器架构。它尝试基于松散耦合的应用程序组件构建系统,这些组件很容易通过端口和适配器连接到它们的软件环境。由于这些部件是模块化和可互换的,因此提高了处理一致性并使测试自动化变得更加容易。
六边形架构
六边形架构描述了一种围绕域逻辑设计软件应用程序的模式。六边形描述了由域对象和应用程序的用例组成的应用程序的核心。六边形的边缘为六边形的外部部分(如 Web 界面、数据库等)提供入站和出站端口。
因此,在这种软件设计风格中,领域对象是组件之间所有依赖关系的焦点。因此,只有端口和适配器可以用于主程序和外部组件之间的通信。我们可以在接下来的部分深入探讨六边形结构的各个层次。
六边形架构的主要思想是将域与所有依赖项分离,包括框架依赖项。
这使您能够利用业务领域,而不管技术堆栈如何变化。由于集成问题不再是您域的一部分,它将有助于使您的域更易于测试。
这种架构使得更换适配器变得简单。
六边形提供两种业务接口,使外部各方能够与域进行通信:
- API收集了所有需要查询域的接口。这些接口由六边形实现。
- SPI(服务提供者接口)收集域从第三方检索信息所需的所有接口。这些接口在六边形中定义,并由基础设施的右侧实现。
在此架构中,所有依赖项都归于域,这意味着应用程序和基础设施部分必须将域添加到它们的依赖项中。领域对象
域对象是应用程序的核心部分。状态和行为都是可能的。但是,它不依赖于自身之外的任何事物。因此,域对象不受其他层中任何更改的影响。
只有当业务需求发生变化时,领域对象才会发生变化。因此,在软件设计的 SOLID 原则中,这可以作为单一职责原则的例证。
让我们先创建一个域对象。程序的主要组成部分。它包括业务验证和有关产品的信息:
现在让我们开始构建应用程序:
在本教程中,我们将使用带有Java 11和Maven的Spring Boot 。
因为项目架构与 Spring 初始化器提供的有很大不同,我们将手动开发项目框架而不是使用它。
为此,我将使用Intellij 。你可以使用你最喜欢的 IDE 。
现在让我们使用六边形架构创建一个图书馆应用程序 。
@AllArgsConstructor(access = AccessLevel.PRIVATE) public class Account {
@Getter private final AccountId id;
@Getter private final Money baselineBalance;
@Getter private final ActivityWindow activityWindow;
public static Account account( AccountId accountId, Money baselineBalance, ActivityWindow activityWindow) { return new Account(accountId, baselineBalance, activityWindow); }
public Optional<AccountId> getId(){ return Optional.ofNullable(this.id); }
public Money calculateBalance() { return Money.add( this.baselineBalance, this.activityWindow.calculateBalance(this.id)); }
public boolean withdraw(Money money, AccountId targetAccountId) {
if (!mayWithdraw(money)) { return false; }
Activity withdrawal = new Activity( this.id, this.id, targetAccountId, LocalDateTime.now(), money); this.activityWindow.addActivity(withdrawal); return true; }
private boolean mayWithdraw(Money money) { return Money.add( this.calculateBalance(), money.negate()) .isPositiveOrZero(); }
public boolean deposit(Money money, AccountId sourceAccountId) { Activity deposit = new Activity( this.id, sourceAccountId, this.id, LocalDateTime.now(), money); this.activityWindow.addActivity(deposit); return true; }
@Value public static class AccountId { private Long value; }
}
|
一个帐户可能与许多活动相关联,每个活动都表示从帐户中提取或存入帐户。我们将其限制为特定的 ActivityWindow,因为我们并不总是希望加载给定帐户的所有活动。Account 类有一个基线 Balance 属性,它包含截至活动窗口开始时间的帐户余额,因此仍然可以计算帐户的总余额。
从上面的代码中可以看出,我们完全消除了领域对象对其他架构级别的依赖。我们可以自由地对代码建模,但我们认为合适;在这种情况下,我们创建了一个非常类似于模型当前状态的“丰富”行为,以使其更易于理解。
如果我们愿意,我们可以在我们的领域模型中使用外部库,但这些依赖关系应该是稳定的,以防止强制修改代码。例如,我们在上面的场景中包含了 Lombok 注释。
我们已经可以使用 Account 类从单个账户中提取资金并将其存入,但我们需要在两个账户之间转移资金。因此,我们开发了一个用例类来为我们管理它。
端口Port
端口是允许进出通道的接口。结果,程序的主要组件使用特定端口与外部组件连接。
输入端口Input Port
输入端口将核心应用程序暴露给外部。它是一个可以被外部组件调用的接口。这些调用输入端口的外部组件称为主适配器或输入适配器。
输入端口:
public interface SendMoneyUseCase {
boolean sendMoney(SendMoneyCommand command);
@Value @EqualsAndHashCode(callSuper = false) class SendMoneyCommand extends SelfValidating<SendMoneyCommand> {
@NotNull private final AccountId sourceAccountId;
@NotNull private final AccountId targetAccountId;
@NotNull private final Money money;
public SendMoneyCommand( AccountId sourceAccountId, AccountId targetAccountId, Money money) { this.sourceAccountId = sourceAccountId; this.targetAccountId = targetAccountId; this.money = money; this.validateSelf(); } }
}
|
通过调用sendMoney(),我们应用程序核心之外的适配器现在可以调用此用例。
我们将我们需要的所有参数聚合到SendMoneyCommand值对象中。这允许我们在值对象的构造函数中进行输入验证。在上面的示例中,我们甚至使用了 Bean Validation 注释@NotNull,它在validateSelf()方法中进行了验证。这样,实际用例代码就不会被嘈杂的验证代码污染。
现在我们需要这个接口的实现。输出端口Outout Port
端口是允许进出通道的接口。结果,程序的主要组件使用特定端口与外部组件连接。
下面输出端口:
@RequiredArgsConstructor @Component @Transactional public class SendMoneyService implements SendMoneyUseCase {
private final LoadAccountPort loadAccountPort; private final AccountLock accountLock; private final UpdateAccountStatePort updateAccountStatePort;
@Override public boolean sendMoney(SendMoneyCommand command) {
LocalDateTime baselineDate = LocalDateTime.now().minusDays(10);
Account sourceAccount = loadAccountPort.loadAccount( command.getSourceAccountId(), baselineDate);
Account targetAccount = loadAccountPort.loadAccount( command.getTargetAccountId(), baselineDate);
accountLock.lockAccount(sourceAccountId); if (!sourceAccount.withdraw(command.getMoney(), targetAccountId)) { accountLock.releaseAccount(sourceAccountId); return false; }
accountLock.lockAccount(targetAccountId); if (!targetAccount.deposit(command.getMoney(), sourceAccountId)) { accountLock.releaseAccount(sourceAccountId); accountLock.releaseAccount(targetAccountId); return false; }
updateAccountStatePort.updateActivities(sourceAccount); updateAccountStatePort.updateActivities(targetAccount);
accountLock.releaseAccount(sourceAccountId); accountLock.releaseAccount(targetAccountId); return true; }
}
|
基本上,用例实现从数据库中加载源账户和目标账户,锁定账户以防止其他交易同时发生,进行取款和存款,最后将账户的新状态写回数据库。
此外,通过使用@Component,我们使该服务成为一个 Spring bean,可以注入到任何需要访问SendMoneyUseCase输入端口的组件中,而不依赖于实际实现。对于从数据库加载和存储帐户,实现取决于输出端口LoadAccountPort和UpdateAccountStatePort,它们是我们稍后将在持久性适配器中实现的接口。
输出端口接口的形状由用例决定。在编写用例时,我们可能会发现需要从数据库中加载某些数据,因此我们为其创建一个输出端口接口。当然,这些端口可以在其他用例中重复使用。在我们的例子中,输出端口如下所示:
public interface LoadAccountPort { Account loadAccount (AccountId accountId, LocalDateTime baselineDate) ; } public interface UpdateAccountStatePort { void updateActivities (Account account) ; }
|
适配器
适配器是六边形架构的外部部分。因此,它们仅通过使用入站和出站端口与核心应用程序交互。
主要适配器
主适配器也称为输入或驱动适配器。因此,他们使用入端口来调用驱动程序的核心应用程序的相关用例。例如,Web 界面或 REST API 是主要的适配器。
如果您熟悉 Spring MVC,您会发现这是一个非常无聊的 Web 控制器。它只是从请求路径中读取所需的参数,将它们放入SendMoneyCommand并调用用例。例如,在更复杂的情况下,Web 控制器还可以检查身份验证和授权,并对 JSON 输入进行更复杂的映射。
上面的控制器通过将 HTTP 请求映射到用例的输入端口来向世界公开我们的用例。现在让我们看看如何通过连接输出端口将我们的应用程序连接到数据库。
@RestController @RequiredArgsConstructor public class SendMoneyController {
private final SendMoneyUseCase sendMoneyUseCase;
@PostMapping(path = "/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}") void sendMoney( @PathVariable("sourceAccountId") Long sourceAccountId, @PathVariable("targetAccountId") Long targetAccountId, @PathVariable("amount") Long amount) {
SendMoneyCommand command = new SendMoneyCommand( new AccountId(sourceAccountId), new AccountId(targetAccountId), Money.of(amount));
sendMoneyUseCase.sendMoney(command); }
}
|
持久化适配器
输入端口由用例服务实现,而输出端口由持久适配器实现。假设我们使用 Spring Data JPA 作为在我们的代码库中管理持久性的首选工具。实现输出端口的持久性适配器LoadAccountPort可能UpdateAccountStatePort如下所示:
@RequiredArgsConstructor @Component class AccountPersistenceAdapter implements LoadAccountPort, UpdateAccountStatePort {
private final AccountRepository accountRepository; private final ActivityRepository activityRepository; private final AccountMapper accountMapper;
@Override public Account loadAccount( AccountId accountId, LocalDateTime baselineDate) {
AccountJpaEntity account = accountRepository.findById(accountId.getValue()) .orElseThrow(EntityNotFoundException::new);
List<ActivityJpaEntity> activities = activityRepository.findByOwnerSince( accountId.getValue(), baselineDate);
Long withdrawalBalance = orZero(activityRepository .getWithdrawalBalanceUntil( accountId.getValue(), baselineDate));
Long depositBalance = orZero(activityRepository .getDepositBalanceUntil( accountId.getValue(), baselineDate));
return accountMapper.mapToDomainEntity( account, activities, withdrawalBalance, depositBalance);
}
private Long orZero(Long value){ return value == null ? 0L : value; }
@Override public void updateActivities(Account account) { for (Activity activity : account.getActivityWindow().getActivities()) { if (activity.getId() == null) { activityRepository.save(accountMapper.mapToJpaEntity(activity)); } } }
}
|
适配器实现了实现的输出端口所需的loadAccount()和updateActivities()方法。它使用Spring Data资源库从数据库中加载数据并将数据保存到数据库中,使用AccountMapper将Account域对象映射到AccountJpaEntity对象中,这些对象在数据库中代表一个账户。
同样,我们使用@Component来使其成为一个Spring Bean,可以被注入到上面的用例服务中。结论
与分层架构相比,六边形架构具有多项优势:
- 通过分离程序的内部和外部组件,简化了架构设计。
- 由于主要业务逻辑与任何外部依赖的隔离,高度解耦是可能的。
- 这些端口使新的 Web 客户端或数据库等适配器能够以灵活的方式连接。
六边形架构可能是创建简单的 CRUD 应用程序的负担。但是,在创建领域驱动的应用程序时,这种架构很有帮助。