美文网首页微服务
使用java和spring来实现六边形架构

使用java和spring来实现六边形架构

作者: water_lang | 来源:发表于2020-04-12 17:17 被阅读0次

    “六角形架构”已经存在很长时间了,的确相当长的时间了,这个玩意儿从主流架构中消失了很久,直到最近才慢慢的才回到大众视野里。

    但是,我发现关于如何用这种架构在实际应用程序的项目很少。本文的目的是提供一种用Java和Spring来实现六边形架构的Web应用程序。

    如果您想更深入地研究该主题,请看一下我的书

    例子代码

    本文的代码示例在github

    什么是六边形架构?

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


    图片.png

    六边形只是一种描述应用程序核心的方法,该应用程序由领域对象,用例(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应用程序,那么这样的体系结构可能就是巨大的开销。如果我们要构建一个具有丰富业务规则的应用程序,并且可以在将状态与行为结合在一起的丰富域模型中的应用程序,那么该体系结构确实会发光,因为它将域模型置于全局的中心。

    原文:https://reflectoring.io/spring-hexagonal/

    相关文章

      网友评论

        本文标题:使用java和spring来实现六边形架构

        本文链接:https://www.haomeiwen.com/subject/iytduhtx.html