“六角形架构”已经存在很长时间了,的确相当长的时间了,这个玩意儿从主流架构中消失了很久,直到最近才慢慢的才回到大众视野里。
但是,我发现关于如何用这种架构在实际应用程序的项目很少。本文的目的是提供一种用Java和Spring来实现六边形架构的Web应用程序。
如果您想更深入地研究该主题,请看一下我的书。
例子代码
本文的代码示例在github。
什么是六边形架构?
与常见的分层体系架构相反,“六角形架构”的主要特征是组件之间的依赖关系“指向内部”,指向我们的领域对象:
六边形只是一种描述应用程序核心的方法,该应用程序由领域对象,用例(Use Case),以及为外界提供接口的输入和输出端口组成。
我们先来对这种架构的每一层进行学习吧。
领域对象
在拥有业务规则的域中,域对象是应用程序的命脉。域对象了包含状态和行为。行为越接近状态,代码将越容易理解,维护。
域对象没有任何外部依赖性。它们是纯Java code,并用例提供了API。
由于域对象不依赖于应用程序的其他层,因此其他层的更改不会对其产生影响。也就是他们的改变可以不用依赖其他层的代码。这是“单一责任原则”(“ SOLID”中的“ S”)的一个主要例子,该原则指出组件应该只有一个更改的理由。对于我们的域对象,须要改变的原因是业务需求的变化。
只需承担一项责任,我们就可以改变域对象,而不必考虑外部依赖关系。这种可扩展性使六角形架构风格非常适合您练习域驱动设计。在开发过程中,我们只是遵循自然的依赖关系流程:我们开始在域对象中进行编码,然后从那里开始。
用例
我们知道用例是用户使用我们的软件所做的抽象描述。在六角形体系架构中,将用例提升为我们代码库的一等公民是有意义的。
用例是一个处理特定场景所有内容的类。作为示例,我们考虑银行应用程序中的一个用例:“将钱从一个帐户发送到另一个帐户”。我们将创建一个API类SendMoneyUseCase,该API允许用户进行汇款。该代码包含特定于用例的所有业务规则验证和逻辑,因此无法在域对象中实现。其他所有内容都委托给域对象(例如,可能有一个域对象Account)。
与域对象类似,用例类不依赖于外部组件。当它需要六角形之外的东西时,我们创建一个输出端口。
输入输出端口
域对象和用例在六边形内,即在应用程序的核心内。他们每次与外部的通信都是通过专用的“端口”进行的。
输入端口是一个简单的接口,可由外部组件调用,并由用例实现。调用该类输入端口的组件称为输入适配器或“驱动”适配器。
输出端口还是一个简单的接口,如果我们的用例需要外部的东西(例如,数据库访问),则可以通过它们来调用。该接口目的是满足用例的需求,也称为输出或“驱动”适配器的外部组件实现。如果您熟悉SOLID原理,则这是Dependency Inversion Principle(SOLID中的应用),因为我们通过接口将依赖关系从用例转换为输出适配器。
有了适当的输入和输出端口,我们就有了不同的数据进入和离开我们的系统的地方,这使得对架构的推理变得容易。
适配器
适配器在六角形架构的外层。它们不是核心的一部分,但可以与之交互。
输入适配器或“驱动”适配器调用输入端口以完成操作。例如,输入适配器可以是Web界面。当用户单击浏览器中的按钮时,Web适配器将调用某个输入端口以调用相应的用例。
输出适配器或“驱动”适配器由我们的用例调用,例如,可能提供来自数据库的数据。输出适配器实现一组输出端口接口。请注意,接口由用例决定。
适配器使外部和应用程序的特定层交互变得很简单。如果该应用程序想在新的Web端使用,则可以添加新客户端输入适配器。如果应用程序需要其他数据库,则添加一个新的持久性适配器,该适配器保持与旧的持久性适配器实现相同的输出端口接口。
Show Code!
在简要介绍了上面的六边形架构之后,我们最后来看一些代码。将该体系结构样式的概念转换为代码时始终要遵循解释和风格,因此,请不要按照给定的以下代码示例进行操作,而应创建你自己的风格。
这些代码示例全部来自我在GitHub上的“ BuckPal”示例应用程序,并围绕着将资金从一个帐户转移到另一个帐户的用例进行讨论。出于此博客文章的目的,对某些代码段进行了稍微的修改。
构建领域对象
我们首先构建一个可以满足用例需求的领域对象。我们将创建一个Account类对一个帐户的取款和存款的管理:
@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类拥有baselineBalance属性,该属性包含了操作窗口开始时帐户的余额。
如您在上面的代码中看到的,我们完全不依赖于其他层就构建了领域对象。我们可以按照自己认为合适的方式对代码进行建模,在这种情况下,可以创建一种非常接近模型状态的“丰富”行为,以便于理解。
如果你愿意,也可以在领域模型中使用外部库,但是这些依赖关系应该相对稳定,以防止强制更改我们的代码。例如,在上面的代码中,我们包含了Lombok库。
现在,Account 类允许我们将资金在一个帐户中进行取款和存入操作,但是我们希望在两个帐户之间转移资金。因此,我们创建了一个用例类来为我们完成这件事。
构建输入端口
但是,在实现用例之前,我们先为该用例创建外部API,它将成为六边形架构中的输入端口:
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()方法进行验证。这样,实际的用例代码就不会被嘈杂的验证代码所污染。
我们后面需要实现该接口就可以了.
构建Use Case和输出端口
在用例实现中,我们使用领域模型从源帐户中提取资金,并向目标帐户中存款:
@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公开了我们的应用程序核心:
@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 MVC,就会发现这是一个非常无聊的Controller。它只是从请求路径中读取所需的参数,将它们放入SendMoneyCommand中并调用用例。例如,在更复杂的场景中,Web控制器还可以检查身份验证和授权,并对JSON输入进行更复杂的映射。
上面的控制器通过将HTTP请求映射到用例的输入端口来向外界展示我们的用例。现在,让我们看看如何通过连接输出端口将应用程序连接到数据库。
构建持久化适配器
输入端口由用例服务实现,而输出端口由持久化适配器实现。假设我们使用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,可以将其注入上述用例服务中。
值得吗?
人们经常问自己,这样的架构是否有价值(我在这里包括我自己)。毕竟,我们必须创建如此多的端口接口,并且每个还有那么多的不同的实现。
所以,他真的值得吗?
作为专业顾问,我的答案当然是“看情况”。
如果我们要构建一个仅保存数据的CRUD应用程序,那么这样的体系结构可能就是巨大的开销。如果我们要构建一个具有丰富业务规则的应用程序,并且可以在将状态与行为结合在一起的丰富域模型中的应用程序,那么该体系结构确实会发光,因为它将域模型置于全局的中心。