“六角形架構”已經存在很長時間了,的確相當長的時間了,這個玩意兒從主流架構中消失了很久,直到最近才慢慢的才回到大眾視野里。
但是,我發現關于如何用這種架構在實際應用程序的項目很少。本文的目的是提供一種用Java和Spring來實現六邊形架構的Web應用程序。
如果您想更深入地研究該主題,請看一下我的書。
例子代碼
本文的代碼示例在github。
什么是六邊形架構?
與常見的分層體系架構相反,“六角形架構”的主要特征是組件之間的依賴關系“指向內部”,指向我們的領域對象:
六邊形只是一種描述應用程序核心的方法,該應用程序由領域對象,用例(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應用程序,那么這樣的體系結構可能就是巨大的開銷。如果我們要構建一個具有豐富業務規則的應用程序,并且可以在將狀態與行為結合在一起的豐富域模型中的應用程序,那么該體系結構確實會發光,因為它將域模型置于全局的中心。