Java和Spring的六边形架构

The term "Hexagonal Architecture" has been around for a long time. Long enough that the primary source on this topic has been offline for a while and has only recently been rescued from the archives.

但是,我发现关于如何以这种体系结构样式实际实现应用程序的资源很少。 本文的目的是提供一种用Java和Spring以六边形样式实现Web应用程序的自以为是的方式。

If you'd like to dive deeper into the topic, have a look at my book.

Code Example

This article is accompanied by a working code example on Github.

What is "Hexagonal Architecture"?

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

Java和Spring的六边形架构_第1张图片

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

让我们看看这种架构样式中的每个构造型。

Domain Objects

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

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

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

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

Use Cases

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

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

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

Input and Output Ports

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

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

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

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

Adapters

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

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

输出适配器或“驱动”适配器由我们的用例调用,例如,可能提供来自数据库的数据。 输出适配器实现一组输出端口接口。 请注意,接口由用例决定,而不是相反。

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

Show Me Some Code!

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

The code examples are all from my "BuckPal" example application on GitHub and revolve around the use case of transferring money from one account to another. Some code snippets are slightly modified for the purpose of this blog post, so have a look at the repo for the original code.

Building a Domain Object

我们首先构建一个可以满足用例需求的领域对象。 我们创建一个帐户管理取款和向帐户存款的类:

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

}

一个帐户可以有许多关联活动,每个代表该帐户的取款或存款。 由于我们并不总是想要加载所有给定帐户的活动,我们将其限制为活动Window。 为了仍然能够计算帐户的总余额,帐户类有基线平衡属性,该属性包含活动窗口开始时的帐户余额。

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

如果愿意,我们可以在域模型中使用外部库,但是这些依赖关系应该相对稳定,以防止强制更改我们的代码。 例如,在上述情况下,我们包含了Lombok批注。

的帐户现在,class允许我们取款并将资金存入一个帐户,但是我们希望在两个帐户之间进行转帐。 因此,我们创建了一个用例类来为我们精心安排。

Building an Input Port

但是,在实际实现用例之前,我们先为该用例创建外部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();
    }
  }

}

通过致电寄钱(),我们应用程序核心之外的适配器现在可以调用此用例。

我们将所需的所有参数汇总到SendMoneyCommand价值对象。 这使我们可以在value对象的构造函数中进行输入验证。 在上面的示例中,我们甚至使用了Bean Validation批注@NotNull,已在validateSelf()方法。 这样,实际的用例代码就不会被嘈杂的验证代码所污染。

现在我们需要该接口的实现。

Building a Use Case and Output Ports

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

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

}

基本上,用例实现从数据库中加载源帐户和目标帐户,锁定帐户,以使其他事务无法同时进行,进行取款和存款,最后将帐户的新状态写回到 数据库。

另外,通过使用@零件,我们将此服务作为Spring bean注入到需要访问SendMoneyUseCase输入端口,而不依赖于实际的实现。

为了在数据库中加载和存储帐户,实现取决于输出端口LoadAccountPort和UpdateAccountStatePort,这是我们稍后将在持久性适配器中实现的接口。

输出端口接口的形状由用例决定。 在编写用例时,我们可能会发现我们需要从数据库中加载某些数据,因此我们为其创建了输出端口接口。 这些端口当然可以在其他用例中重复使用。 在我们的例子中,输出端口如下所示:

public interface LoadAccountPort {

  Account loadAccount(AccountId accountId, LocalDateTime baselineDate);

}
public interface UpdateAccountStatePort {

  void updateActivities(Account account);

}

Building a Web Adapter

借助域模型,用例以及输入和输出端口,我们现在已经完成了应用程序的核心(即六边形内的所有内容)。 但是,如果我们不将其与外界联系起来,那么这个核心将无济于事。 因此,我们构建了一个适配器,通过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请求映射到用例的输入端口来向世界展示我们的用例。 现在,让我们看看如何通过连接输出端口将应用程序连接到数据库。

Building a Persistence Adapter

输入端口由用例服务实现,而输出端口由持久性适配器实现。 假设我们使用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));
      }
    }
  }

}

适配器实现load帐户()和updateActivities() methods required by the implemented output ports. It uses Spring Data repositories to load data from和save data to the database和an 帐户Mapper映射帐户域对象成帐户JpaEntity代表数据库中帐户的对象。

同样,我们使用@零件使它成为可以注入到上述用例服务中的Spring bean。

Is it Worth the Effort?

人们经常问自己,这样的架构是否值得努力(我在​​这里包括我自己)。 毕竟,我们必须创建端口接口,并且必须使用x来映射域模型的多种表示形式。 Web适配器中可能存在域模型表示,而持久性适配器中可能存在另一个域模型表示。

So, is it worth the effort?

作为专业顾问,我的答案当然是“取决于”。

如果我们要构建一个仅存储和保存数据的CRUD应用程序,则这种架构可能会产生开销。 如果我们要构建一个具有丰富业务规则的应用程序,并且可以在将状态与行为结合在一起的丰富域模型中表达该应用程序,那么该体系结构确实会发光,因为它将域模型置于事物的中心。

Dive Deeper

上面仅给出了六边形体系结构在实际代码中的外观的想法。 还有其他方法可以做到,因此请随时尝试并找到最适合您需求的方法。 而且,Web和持久性适配器只是外部适配器的示例。 可能有到其他第三方系统或其他面向用户的前端的适配器。

If you want to dive deeper into this topic, have a look at my book which goes into much more detail and also discusses things like testing, mapping strategies, and shortcuts.

from: https://dev.to//thombergs/hexagonal-architecture-with-java-and-spring-abl

你可能感兴趣的:(Java和Spring的六边形架构)