Java和Spring的六邊形架構 - reflectoring

banq發表於2019-11-07

本文的目的是提供一種用Java和Spring以六邊形樣式實現Web應用程式的自以為是的方式。
本文隨附GitHub上的示例程式碼。

什麼是“六邊形架構”?
與常見的分層體系結構樣式相反,“六角形體系結構”的主要特徵是元件之間的依賴關係“指向內部”,指向我們的領域物件:

Java和Spring的六邊形架構 - reflectoring

六邊形只是一種描述應用程式核心的好方法,該應用程式由領域物件、對其進行操作的用例以及為外界提供介面的輸入和輸出埠組成。

領域物件
在具有業務規則的域中,域物件是應用程式的命脈。域物件可以包含狀態和行為。行為與狀態越接近,程式碼就越容易理解,推理和維護。
域物件沒有任何外部依賴性。它們是純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應用程式,則這種架構可能會產生開銷。如果我們正在構建具有可以在結合了狀態與行為的豐富域模型中表達的豐富業務規則的應用程式,那麼該體系結構確實會發光,因為它將域模型置於事物的中心。

如果您想更深入地研究這個主題,請看一看我的,它會更詳細,並討論諸如測試,對映策略和快捷方式之類的內容。

相關文章