垂直切片架構
1. 概述
在本教程中,我們將了解垂直切片架構以及它如何嘗試解決與分層設計相關的問題。我們將討論按業務功能建立程式碼,這會導致組織成鬆散耦合和內聚模組的更具表現力的程式碼庫。之後,我們將從領域驅動設計 (DDD) 的角度探索這種方法並討論其靈活性。
2. 分層架構
在探索垂直切片架構之前,我們首先回顧其主要對應物分層架構的主要特徵。分層設計很流行並被廣泛使用,其變體包括六角形、洋蔥形、端口和適配器以及簡潔架構。
分層架構使用一系列堆疊或同心層來保護域邏輯免受外部元件和因素的影響。這些架構的一個關鍵特徵是所有依賴項都指向內部,指向域:
2.1.依技術問題將組件分組
分層方法僅著重於按技術問題對元件進行分組,而不是按業務功能進行分組。
對於本文的程式碼範例,我們假設我們正在建立部落格網站的後端應用程式。該應用程式將支援以下用例:
- 作者可以發布和編輯文章
- 作者可以看到包含文章統計資訊的儀表板
- 讀者可以閱讀、按讚以及對文章添加評論
- 讀者會收到有文章推薦的通知
例如,我們的包名稱反映了技術層面,但沒有傳達專案的真正目的:
2.2.高耦合
此外,對不相關的業務用例重複使用相同的領域服務可能會導致緊密耦合。例如, ArticleService目前依賴:
-
ArticleRepository –查詢資料庫 -
UserService –取得有關文章作者的數據 -
RecommendationService –在發布新文章時更新讀者推薦 -
CommentService– 管理文章評論
因此,當我們新增或修改用例時,我們面臨幹擾不相關流程的風險。此外,這種高度耦合的方法通常會導致充滿模擬的混亂測試。
2.3.低內聚力
最後,這種程式碼結構在組件內的內聚力往往較低。由於業務用例的程式碼分散在整個專案的各個套件中,因此任何微小的變更都需要我們修改每一層的檔案。
讓我們在Article實體中新增一個slug欄位。如果我們想要允許客戶端使用新欄位來查詢資料庫,我們必須更改整個層中的許多檔案:
即使是一個簡單的修改也會導致我們應用程式的幾乎每個套件都發生變化。事實上,一起變化的類別並不生活在一起,這表明凝聚力較低。
3. 垂直切片架構
垂直切片架構的開發是為了透過按業務功能組織程式碼來解決與分層架構相關的一些問題。按照這種方法,我們的元件反映了業務用例並跨越多個層。
因此,它們將被移動到與其各自切片關聯的包中,而不是將所有控制器分組在一個公共包中:
此外,我們可以將相關用例分組為與業務領域一致的內聚片段。讓我們用author, reader,和recommendation域重新組織我們的範例:
將項目劃分為垂直部分允許我們對大多數類別使用預設的包私有存取修飾符。這可以確保意外的依賴關係不會跨越域邊界。
最後,它使不熟悉程式碼庫的人能夠透過查看文件結構來了解應用程式的功能。 《Clean Code》的作者 Robert C. Martin 稱之為「尖叫架構」 。他認為軟體專案的設計應該清楚傳達其目的,就像建築物的建築藍圖揭示其功能一樣。
4. 耦合和內聚
如前所述,選擇垂直切片架構而不是洋蔥架構可以改善耦合和內聚的管理。
4.1.透過應用程式事件實現鬆散耦合
讓我們專注於定義跨邊界通訊的正確接口,而不是消除切片之間的耦合。使用應用程式事件是一種強大的技術,它使我們能夠保持鬆散耦合,同時促進跨界互動。
在分層架構方法中,不相關的服務會相互依賴來完成業務功能。具體來說, ArticleService依賴RecommendationService來通知它有關新文章的資訊。相反, recommendation流程可以非同步執行,並透過偵聽應用程式事件對主流程做出反應。
由於我們的程式碼範例使用 Spring 框架,因此我們在建立新文章時發布一個 Spring 事件:
@Component
class CreateArticleUseCase {
private final ApplicationEventPublisher eventPublisher;
// constructor
void createArticle(CreateArticleRequest article) {
saveToDatabase(article);
var event = new ArticleCreatedEvent(article.slug(), article.name(), article.category());
eventPublisher.publishEvent(event);
}
private void saveToDatabase(CreateArticleRequest aticle) { /* ... */ }
// ...
}
}
現在, SendArticleRecommendationUseCase可以使用@EventListener來回應ArticleCreatedEvent並執行其邏輯:
@Component
class SendArticleRecommendationUseCase {
@EventListener
void onArticleRecommendation(ArticleCreatedEvent article) {
findTopicFollowers(article.name(), article.category());
.forEach(follower -> sendArticleViaEmail(article.slug(), article.name(), follower));
}
private void sendArticleViaEmail(String slug, String name, TopicFollower follower) {
// ...
}
private List<TopicFollower> findTopicFollowers(String articleName, String topic) {
// ...
}
record TopicFollower(Long userId, String email, String name) {}
}
正如我們所看到的,這些模組獨立運作並且彼此不直接依賴。此外,任何對新建立的文章感興趣的元件只需要監聽ArticleCreatedEvent 。
4.2.更高的凝聚力
找到正確的邊界會產生有凝聚力的切片和用例。使用案例類別通常應該有一個公共方法和一個更改原因,遵循單一職責原則。
讓我們在垂直切片架構中的Article類別中新增一個slug字段,並建立一個端點來透過slug尋找文章。這次,更改範圍僅限於單一套件。我們將建立一個SearchArticleUseCase ,它使用JdbcClient查詢資料庫並傳回Article的投影。因此,我們只會修改一個套件中的兩個檔案:
我們建立了一個用例並修改了ReaderController以公開新端點。兩個文件位於同一個包中這一事實表明項目內具有更高的內聚力。
5. 設計彈性
垂直切片架構允許我們為每個元件自訂方法,並確定為每個用例組織程式碼的最有效方法。換句話說,我們可以利用各種工具、模式或範例,而無需在整個應用程式中強制執行特定的編碼風格或依賴關係。
此外,這種靈活性有利於領域驅動設計 (DDD) 和 CQRS 等方法。雖然不是強制性的,但它們非常適合垂直切片應用程式。
5.1.使用 DDD 進行領域建模
領域驅動設計是一種強調基於核心業務領域及其邏輯的軟體建模方法。在 DDD 中,程式碼必須使用業務人員和客戶熟悉的術語和語言,以協調技術和業務視角。
在垂直切片架構中,我們可能會遇到用例之間程式碼重複的問題。對於擴充的切片,我們可以決定提取一般業務規則並使用 DDD 建立特定於它們的領域模型:
此外, DDD使用有界上下文來定義特定的邊界,確保系統不同部分之間的清晰區分。
讓我們重新審視一個遵循分層方法的項目。我們會注意到,我們透過UserService, UserRepository,和User實體等物件與系統使用者進行互動。相較之下,在垂直切片的項目中,使用者的概念在不同的有界上下文中有所不同。每個切片都有自己的使用者表示,將他們稱為「讀者」、「作者」或「主題追隨者」 ,反映他們在該上下文中扮演的特定角色。
5.2.繞過簡單用例的網域
嚴格遵循分層架構的另一個缺點是,它可能會導致方法只是將呼叫傳遞到下一層,而不會增加價值。這也稱為“中間人”反模式,它會導致緊密耦合的層。
例如,當透過slug查找文章時,控制器會呼叫服務,然後服務會呼叫儲存庫。儘管在這種情況下服務沒有增加任何價值,但分層架構的嚴格規則阻止我們繞過網域直接存取持久層。
相比之下,垂直切片應用程式可以靈活地選擇每個特定用例所需的層。這使我們能夠繞過簡單用例的領域層,並直接查詢資料庫進行投影:
讓我們簡化透過slug查看文章的用例,使用垂直切片架構繞過領域層:
@Component
class ViewArticleUseCase {
private static final String FIND_BY_SLUG_SQL = """
SELECT id, name, slug, content, authorid
FROM articles
WHERE slug = ?
""";
private final JdbcClient jdbcClient;
// constructor
public Optional<ViewArticleProjection> view(String slug) {
return jdbcClient.sql(FIND_BY_SLUG_SQL)
.param(slug)
.query(this::mapArticleProjection)
.optional();
}
record ViewArticleProjection(String name, String slug, String content, Long authorId) {
}
private ViewArticleProjection mapArticleProjection(ResultSet rs, int rowNum) throws SQLException {
// ...
}
}
正如我們所看到的, ViewArticleUseCase直接使用JdbcClient查詢資料庫。此外,它定義了自己的文章投影,而不是重複使用通用的 DTO,後者會將此用例耦合到其他元件。因此,不相關的用例不會被迫進入相同的結構,並且我們消除了不必要的依賴關係。
六,結論
在本文中,我們了解了垂直切片架構,並將其與分層架構進行了比較。我們學習如何建立內聚的元件並避免不相關的業務用例之間的耦合。
我們討論了有界上下文以及它們如何幫助我們定義特定於系統一部分的不同投影。最後,我們發現這種方法在設計每個垂直切片時提供了更高的靈活性。
像往常一樣,完整的源代碼可以在 GitHub 上找到。