Java和Spring的六边形架构 - reflectoring

19-11-07 banq
                   

本文的目的是提供一种用Java和Spring以六边形样式实现Web应用程序的自以为是的方式。

本文随附GitHub上的示例代码。

什么是“六边形架构”?

与常见的分层体系结构样式相反,“六角形体系结构”的主要特征是组件之间的依赖关系“指向内部”,指向我们的领域对象:

六边形只是一种描述应用程序核心的好方法,该应用程序由领域对象、对其进行操作的用例以及为外界提供接口的输入和输出端口组成。

领域对象

在具有业务规则的域中,域对象是应用程序的命脉。域对象可以包含状态和行为。行为与状态越接近,代码就越容易理解,推理和维护。

域对象没有任何外部依赖性。它们是纯Java,并提供用于用例的API。

由于域对象不依赖于应用程序的其他层,因此其他层的更改不会影响它们。它们可以不受依赖地演变。这是“单一责任原则”(“ SOLID”中的“ S”)的主要示例,该原则指出组件应该只有一个更改的理由。对于我们的域对象,这是业务需求的变化。

只需承担一项责任,我们就可以演化域对象,而不必考虑外部依赖关系。这种可扩展性使六角形体系结构样式非常适合您在实践领域驱动设计。在开发过程中,我们只是遵循自然的依赖关系流程:我们开始在域对象中进行编码,然后从那里开始。如果这还不是领域驱动的,那么我不知道是什么。

用例

我们知道用例是用户使用我们的软件所做的抽象描述。在六角形体系结构样式中,将用例提升为我们代码库的一等公民是有意义的。

从这个意义上讲,用例是一个类,它处理某个用例周围的所有事情。例如,让我们考虑用例“银行应用程序中的将钱从一个帐户发送到另一个帐户”。我们将创建一个SendMoneyUseCase具有独特API 的类,该API允许用户转移资金。该代码包含所有针对用例的业务规则验证和逻辑,这些无法在域对象中实现。其他所有内容都委托给域对象(例如,可能有一个域对象Account)。

与域对象类似,用例类不依赖于外部组件。当它需要六角形之外的东西时,我们创建一个输出端口。

输入和输出端口

域对象和用例在六边形内,即在应用程序的核心内。每次与外部的通信都是通过专用的“端口”进行的。

输入端口是一个简单的接口,可由外部组件调用,并由用例实现。调用此类输入端口的组件称为输入适配器或“驱动”适配器。

输出端口还是一个简单的接口,如果我们的用例需要外部的东西(例如,数据库访问),则可以用它们来调用。该接口旨在满足用例的需求,但由称为输出或“驱动”适配器的外部组件实现。如果您熟悉SOLID原理,则这是依赖关系反转原理(在SOLID中为“ D”)的应用,因为我们正在使用接口将依赖关系从用例转换为输出适配器。

有了适当的输入和输出端口,我们就有了非常不同的数据进入和离开我们系统的地方,这使得对架构的推理变得容易。

转接器Adapter

适配器形成六角形结构的外层。它们不是核心的一部分,但可以与之交互。

输入适配器或“驱动”适配器调用输入端口以完成操作。例如,输入适配器可以是Web界面。当用户单击浏览器中的按钮时,Web适配器将调用某个输入端口以调用相应的用例。

输出适配器或“驱动”适配器由我们的用例调用,例如,可能提供来自数据库的数据。输出适配器实现一组输出端口接口。需要注意的是该接口由用例周围支配,而不是其他方式。

适配器使交换应用程序的特定层变得容易。如果该应用程序还可以从胖客户端使用到Web,则可以添加胖客户端输入适配器。如果应用程序需要其他数据库,则添加一个新的持久性适配器,该适配器实现与旧的持久性适配器相同的输出端口接口。

代码演示

在简要介绍了上面的六边形体系结构样式之后,让我们最后看一些代码。将体系结构样式的概念转换为代码始终受解释和影响,因此,请不要按照给出的以下代码示例进行操作,而应作为创建自己的样式的灵感。

这些代码示例全部来自我在GitHub上的 “ BuckPal”示例应用程序并围绕着将资金从一个帐户转移到另一个帐户的用例进行讨论。出于本博客文章的目的,对某些代码段进行了稍微的修改,因此请查看原始代码的存储库。

1.领域对象

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

}

一个Account可以具有许多相关Activitys表示各自表示取款或存款到该帐户。由于我们并不总是希望加载给定帐户的所有活动,因此我们将其限制为一定ActivityWindow。为了仍然能够计算帐户的总余额,Account该类具有baselineBalance在活动窗口开始时包含帐户余额的属性。

如您在上面的代码中看到的,我们完全没有外部依赖关系地构建了域对象。我们可以自由地对我们认为合适的代码进行建模,在这种情况下,将创建一个非常接近模型状态的“丰富”行为,以使其更易于理解。

在Account类现在让我们撤出,并把钱存入一个账户,但我们要在两个帐户间转帐。因此,我们创建了一个用例类来为我们精心安排。

2.建立输入端口

在实际实现用例之前,我们先为该用例创建外部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()方法中进行了验证。这样,实际的用例代码就不会被嘈杂的验证代码所污染。

3. 建立用例和输出端口

在用例实现中,我们使用域模型从源帐户中提取资金,并向目标帐户中存款:

@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,您会发现这是一个非常无聊的Web控制器。它只是从请求路径中读取所需的参数,将其放入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应用程序,则这种架构可能会产生开销。如果我们正在构建具有可以在结合了状态与行为的丰富域模型中表达的丰富业务规则的应用程序,那么该体系结构确实会发光,因为它将域模型置于事物的中心。

如果您想更深入地研究这个主题,请看一看我的,它会更详细,并讨论诸如测试,映射策略和快捷方式之类的内容。

                   

2