使用java和spring來實現六邊形架構

“六角形架構”已經存在很長時間了,的確相當長的時間了,這個玩意兒從主流架構中消失了很久,直到最近才慢慢的才回到大眾視野里。

但是,我發現關于如何用這種架構在實際應用程序的項目很少。本文的目的是提供一種用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/

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容