Рубрики
Без рубрики

Шестиугольная архитектура с Java и Spring

Термин “Шестиугольная архитектура” существует уже давно. Достаточно долго, чтобы первоисточник… Помечено шестиугольником, архитектура, java, весенняя загрузка.

Термин “Шестиугольная архитектура” существует уже давно. Достаточно долго, чтобы первоисточник по этой теме некоторое время был отключен и только недавно был извлечен из архивов.

Однако я обнаружил, что существует очень мало ресурсов о том, как на самом деле реализовать приложение в этом архитектурном стиле. Цель этой статьи – предоставить самоуверенный способ реализации веб-приложения в шестиугольном стиле с использованием Java и Spring.

Если вы хотите глубже погрузиться в эту тему, взгляните на мою книгу .

Пример кода

Эта статья сопровождается примером рабочего кода на Github .

Что такое “Шестиугольная архитектура”?

Основная особенность “шестиугольной архитектуры”, в отличие от общего стиля многоуровневой архитектуры, заключается в том, что зависимости между нашими компонентами указывают “внутрь”, на наши доменные объекты:

Шестиугольник – это просто причудливый способ описать ядро приложения, состоящее из объектов домена, вариантов использования, которые работают с ними, и портов ввода и вывода, которые обеспечивают интерфейс с внешним миром.

Давайте рассмотрим каждый из стереотипов в этом архитектурном стиле.

Объекты домена

В домене, богатом бизнес-правилами, объекты домена являются источником жизненной силы приложения. Объекты домена могут содержать как состояние, так и поведение. Чем ближе поведение к состоянию, тем легче код будет понимать, рассуждать и поддерживать.

Доменные объекты не имеют никакой внешней зависимости. Они являются чистой Java и предоставляют API для вариантов использования для работы с ними.

Поскольку объекты домена не имеют зависимостей от других уровней приложения, изменения в других слоях на них не влияют. Они могут развиваться без зависимостей. Это яркий пример Принципа единой ответственности (“S” в “SOLID”), который гласит, что компоненты должны иметь только одну причину для изменения. Для нашего доменного объекта этой причиной является изменение бизнес-требований.

Наличие единой ответственности позволяет нам развивать наши доменные объекты без необходимости учитывать внешние зависимости. Эта эволюционируемость делает шестиугольный архитектурный стиль идеальным для тех случаев, когда вы практикуете доменный дизайн. При разработке мы просто следуем естественному потоку зависимостей: мы начинаем кодировать в объектах домена и выходим оттуда наружу. Если это не зависит от домена, то я не знаю, что это такое.

Примеры использования

Мы знаем примеры использования как абстрактные описания того, что пользователи делают с нашим программным обеспечением. В стиле шестиугольной архитектуры имеет смысл продвигать варианты использования для первоклассных пользователей нашей кодовой базы.

Вариант использования в этом смысле – это класс, который обрабатывает все вокруг, ну, в общем, определенный вариант использования. В качестве примера давайте рассмотрим вариант использования “Отправить деньги с одного счета на другой” в банковском приложении. Мы бы создали класс Вариант использования отправки денег с отдельным API, который позволяет пользователю переводить деньги. Код содержит все проверки бизнес-правил и логику, которые специфичны для конкретного случая использования и, следовательно, не могут быть реализованы в объектах домена. Все остальное делегируется объектам домена (может быть объект домена Учетная запись , например).

Подобно объектам домена, класс вариантов использования не зависит от внешних компонентов. Когда ему нужно что-то извне шестиугольника, мы создаем выходной порт.

Порты ввода и вывода

Объекты домена и варианты использования находятся в шестиугольнике, то есть в ядре приложения. Каждое сообщение с внешним миром и извне происходит через выделенные “порты”.

Входной порт – это простой интерфейс, который может вызываться внешними компонентами и который реализуется с помощью варианта использования. Компонент, вызывающий такой входной порт, называется входным адаптером или “управляющим” адаптером.

Порт вывода – это опять же простой интерфейс, который может быть вызван нашими вариантами использования, если им нужно что-то извне (например, доступ к базе данных). Этот интерфейс разработан в соответствии с потребностями вариантов использования, но он реализован внешним компонентом, называемым выходным или “управляемым” адаптером. Если вы знакомы с принципами SOLID, это применение принципа инверсии зависимостей (“D” в SOLID), потому что мы инвертируем зависимость от вариантов использования к выходному адаптеру с помощью интерфейса.

Благодаря наличию портов ввода и вывода у нас есть очень четкие места, где данные поступают в нашу систему и покидают ее, что облегчает понимание архитектуры.

Адаптеры

Адаптеры из внешнего слоя шестиугольной архитектуры. Они не являются частью ядра, но взаимодействуют с ним.

Входные адаптеры или “управляющие” адаптеры вызывают входные порты, чтобы что-то сделать. Входным адаптером может быть, например, веб-интерфейс. Когда пользователь нажимает кнопку в браузере, веб-адаптер вызывает определенный входной порт для вызова соответствующего варианта использования.

Выходные адаптеры или “управляемые” адаптеры называются нашими вариантами использования и могут, например, предоставлять данные из базы данных. Выходной адаптер реализует набор интерфейсов выходного порта. Обратите внимание, что интерфейсы диктуются вариантами использования, а не наоборот.

Адаптеры облегчают обмен определенным уровнем приложения. Если приложение должно использоваться из клиента fat дополнительно к Интернету, мы добавляем адаптер ввода клиента fat. Если приложению требуется другая база данных, мы добавляем новый адаптер сохранения, реализующий те же интерфейсы выходного порта, что и старый.

Покажи Мне Какой-Нибудь Код!

После краткого ознакомления со стилем шестиугольной архитектуры выше, давайте, наконец, взглянем на некоторый код. Перевод концепций архитектурного стиля в код всегда зависит от интерпретации и вкуса, поэтому, пожалуйста, не принимайте следующие примеры кода как данность, а вместо этого вдохновляйте на создание своего собственного стиля.

Примеры кода взяты из моего примера приложения “Buckpal” на GitHub и вращаются вокруг варианта использования перевода денег с одного счета на другой. Некоторые фрагменты кода немного изменены для целей этого сообщения в блоге, поэтому взгляните на репозиторий исходного кода.

Создание объекта домена

Мы начинаем с создания объекта домена, который служит нашему варианту использования. Мы создаем Аккаунт класс, который управляет выводом средств и пополнением счета:

@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 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 теперь позволяет нам выводить и вносить деньги на один счет, но мы хотим переводить деньги между двумя счетами. Итак, мы создаем класс вариантов использования, который организует это для нас.

Построение входного порта

Однако, прежде чем мы фактически реализуем вариант использования, мы создадим внешний API для этого варианта использования, который станет входным портом в нашей шестиугольной архитектуре:

public interface SendMoneyUseCase {

  boolean sendMoney(SendMoneyCommand command);

  @Value
  @EqualsAndHashCode(callSuper = false)
  class SendMoneyCommand extends SelfValidating {

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

}

Вызвав отправить деньги() , адаптер за пределами нашего ядра приложения теперь может вызвать этот вариант использования.

Мы объединили все необходимые параметры в команду Отправить деньги объект value. Это позволяет нам выполнить проверку ввода в конструкторе объекта value. В приведенном выше примере мы даже использовали аннотацию проверки бобов @NotNull , который проверяется в проверьте себя() метод. Таким образом, фактический код варианта использования не будет загрязнен зашумленным кодом проверки.

Теперь нам нужна реализация этого интерфейса.

Построение варианта использования и выходных портов

В реализации варианта использования мы используем нашу доменную модель для вывода средств с исходного аккаунта и внесения депозита на целевой аккаунт:

@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, который будет внедрен в любые компоненты, которым требуется доступ к SendMoneyUseCase входному порту без зависимости от фактической реализации.

Для загрузки и хранения учетных записей из базы данных и в нее реализация зависит от портов вывода LoadAccountPort и Обновить Порт состояния учетной записи , которые являются интерфейсами, которые мы позже реализуем в нашем адаптере сохраняемости.

Форма интерфейсов выходного порта определяется вариантом использования. При написании варианта использования мы можем обнаружить, что нам нужно загрузить определенные данные из базы данных, поэтому мы создаем для них интерфейс порта вывода. Конечно, эти порты могут быть повторно использованы в других случаях использования. В нашем случае выходные порты выглядят так:

public interface LoadAccountPort {

  Account loadAccount(AccountId accountId, LocalDateTime baselineDate);

}
public interface UpdateAccountStatePort {

  void updateActivities(Account account);

}

Создание веб-адаптера

С помощью модели предметной области, вариантов использования и портов ввода и вывода мы теперь завершили ядро нашего приложения (т. Е. Все, что находится в шестиугольнике). Однако это ядро не поможет нам, если мы не свяжем его с внешним миром. Следовательно, мы создаем адаптер, который предоставляет ядро нашего приложения через API REST:

@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, вы обнаружите, что это довольно скучный веб-контроллер. Он просто считывает необходимые параметры из пути запроса, помещает их в команду Отправить деньги и вызывает вариант использования. В более сложном сценарии веб-контроллер может также проверять аутентификацию и авторизацию и выполнять более сложное сопоставление входных данных JSON, например.

Вышеупомянутый контроллер предоставляет миру наш вариант использования, сопоставляя HTTP-запросы с входным портом варианта использования. Давайте теперь посмотрим, как мы можем подключить ваше приложение к базе данных, подключив выходные порты.

Создание адаптера сохраняемости

В то время как входной порт реализуется службой вариантов использования, выходной порт реализуется адаптером сохранения. Допустим, мы используем Spring Data JPA в качестве инструмента выбора для управления сохраняемостью в нашей кодовой базе. Адаптер сохраняемости, реализующий порты вывода Порт учетной записи загрузки и Обновить порт состояния учетной записи может выглядеть следующим образом:

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

}

Адаптер реализует учетную запись загрузки() и действия по обновлению() методы, требуемые реализованными выходными портами. Он использует хранилища данных Spring для загрузки данных и сохранения данных в базу данных, а также AccountMapper для отображения Учетная запись объекты домена в Сущность Jpa учетной записи объекты, которые представляют учетную запись в базе данных.

Опять же, мы используем @Component , чтобы сделать это компонентом Spring, который можно ввести в службу вариантов использования выше.

Стоит ли это Усилий?

Люди часто спрашивают себя, стоит ли такая архитектура таких усилий (я включаю себя сюда). В конце концов, нам нужно создать интерфейсы портов, и у нас есть x для сопоставления между несколькими представлениями модели предметной области. В веб-адаптере может быть представление модели домена, а в адаптере persistenceadapter – другое.

Итак, стоит ли затраченных усилий ?

Как профессиональный консультант, я, конечно, отвечаю: “это зависит”.

Если мы создаем приложение CRUD, которое просто хранит и сохраняет данные, а такая архитектура, вероятно, является накладной. Если мы создаем приложение с богатыми бизнес-правилами, которые могут быть выражены в богатой модели домена, сочетающей состояние с поведением, то эта архитектура действительно блистает, потому что она ставит модель домена в центр всего.

Ныряйте Глубже

Вышесказанное только дает представление о том, как может выглядеть шестиугольная архитектура в реальном коде. Есть и другие способы сделать это, так что не стесняйтесь экспериментировать и находить способ, который наилучшим образом соответствует вашим потребностям. Кроме того, веб-адаптеры и адаптеры сохраняемости являются лишь примерами внешних адаптеров. Могут быть адаптеры к другим сторонним системам или другим интерфейсам, ориентированным на пользователя.

Если вы хотите глубже погрузиться в эту тему, взгляните на мою книгу , в которой гораздо подробнее, а также обсуждаются такие вещи, как тестирование, стратегии сопоставления и ярлыки.

Оригинал: “https://dev.to/thombergs/hexagonal-architecture-with-java-and-spring-abl”