使用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/

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,646評論 6 533
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,595評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,560評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,035評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,814評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,224評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,301評論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,444評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,988評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,804評論 3 355
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,998評論 1 370
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,544評論 5 360
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,237評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,665評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,927評論 1 287
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,706評論 3 393
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,993評論 2 374

推薦閱讀更多精彩內容