
DDD, Hexagonal, Onion, Clean, CQRS 整合
我首先回顧了EBI和Ports & Adapters架構(gòu)(如果不了解可以點擊進行查看)。它們都顯式分離了應用程序內(nèi)部的代碼、外部的代碼以及用于連接內(nèi)部和外部代碼的內(nèi)容。此外Ports & Adapters架構(gòu)明確標識了系統(tǒng)中的三個基本代碼塊:
是什么使運行用戶界面成為可能,無論它是什么類型的用戶界面;
系統(tǒng)業(yè)務邏輯,或應用程序核心,由用戶界面用來實際實現(xiàn)事情;
基礎架構(gòu)代碼,將我們的應用程序核心連接到數(shù)據(jù)庫、搜索引擎或第三方 API 等工具。

應用程序核心是我們真正應該關(guān)心的。正是代碼允許我們的代碼做它應該做的事情,它是我們的應用程序。它可能使用多個用戶界面(漸進式Web應用程序,移動,CLI,API等),但實際執(zhí)行工作的代碼是相同的,并且位于應用程序核心中,什么UI觸發(fā)它并不重要。可以想象,典型的應用程序流從用戶界面中的代碼,通過應用程序核心到基礎結(jié)構(gòu)代碼,再回到應用程序核心,最后向用戶界面提供響應。遠離我們系統(tǒng)中最重要的代碼,即應用程序核心,我們有應用程序使用的工具,例如,數(shù)據(jù)庫引擎,搜索引擎,Web服務器或CLI控制臺(盡管最后兩個也是交付機制)
雖然將 CLI 控制臺放在與數(shù)據(jù)庫引擎相同的"存儲桶"中可能會感覺很奇怪,盡管它們具有不同類型的用途,但它們實際上是應用程序使用的工具。關(guān)鍵的區(qū)別在于,雖然 CLI 控制臺和 Web 服務器用于指示我們的應用程序執(zhí)行某些操作,但數(shù)據(jù)庫引擎由我們的應用程序指示執(zhí)行某些操作。這是一個非常相關(guān)的區(qū)別,因為它對我們構(gòu)建將這些工具與應用程序核心連接起來的代碼有很大的影響。
將工具連接到應用程序核心的代碼單元稱為適配器(端口和適配器體系結(jié)構(gòu))。適配器是有效實現(xiàn)代碼的適配器,這些代碼將允許業(yè)務邏輯與特定工具進行通信,反之亦然。指示應用程序執(zhí)行某些操作的適配器稱為主適配器或驅(qū)動適配器,而應用程序指示執(zhí)行某些操作的適配器稱為輔助或驅(qū)動適配器。但是,這些適配器*不是隨機創(chuàng)建的。創(chuàng)建它們是為了將非常具體的入口點適合應用程序核心,即 端口*。端口只不過是工具如何使用應用程序核心或應用程序核心如何使用它的規(guī)范*。在大多數(shù)語言中,以最簡單的形式,這個規(guī)范,端口,將是一個接口,但它實際上可能由幾個接口和DTO組成。
請務必注意,端口(接口)屬于業(yè)務邏輯內(nèi)部,而適配器屬于外部。要使此模式正常工作,創(chuàng)建端口以滿足應用程序核心需求,而不僅僅是模仿工具 API,這一點至關(guān)重要。
主適配器或驅(qū)動程序適配器環(huán)繞端口,并使用它來告訴應用程序核心要執(zhí)行的操作。它們將來自傳遞機制的任何內(nèi)容轉(zhuǎn)換為應用程序核心中的方法調(diào)用。換句話說,我們的驅(qū)動適配器是控制器或控制臺命令,它們在其構(gòu)造函數(shù)中注入了某個對象,這些對象的類實現(xiàn)了控制器或控制臺命令所需的接口(Port)。
在更具體的示例中,Port 可以是服務接口,也可以是控制器所需的存儲庫接口。然后,服務、存儲庫或查詢的具體實現(xiàn)被注入并在控制器中使用。
或者,端口可以是命令總線或查詢總線接口。在這種情況下,命令或查詢總線的具體實現(xiàn)被注入到控制器中,然后控制器構(gòu)造一個命令或查詢并將其傳遞給相關(guān)的總線。與環(huán)繞端口的驅(qū)動程序適配器不同,驅(qū)動適配器實現(xiàn)端口,接口,然后注入應用程序核心,無論需要端口的位置(類型提示)。例如,假設我們有一個需要持久化數(shù)據(jù)的樸素應用程序。因此,我們創(chuàng)建了一個滿足其需求的持久性接口,其中包含一個保存數(shù)據(jù)數(shù)組的方法和一個按 ID刪除表中行的方法。從那時起,無論我們的應用程序需要保存或刪除數(shù)據(jù),我們都需要在其構(gòu)造函數(shù)中實現(xiàn)我們定義的持久性接口的對象。現(xiàn)在,我們創(chuàng)建了一個特定于MySQL的適配器,它將實現(xiàn)該接口。它將具有保存數(shù)組和刪除表中的行的方法,我們將在需要持久性接口的任何地方注入它。如果在某些時候我們決定更改數(shù)據(jù)庫供應商,比如PostgreSQL或MongoDB,我們只需要創(chuàng)建一個實現(xiàn)持久性接口并特定于PostgreSQL的新適配器,并注入新適配器而不是舊適配器。關(guān)于此模式需要注意的一個特征是適配器依賴于特定工具和特定端口(通過實現(xiàn)接口)。但是我們的業(yè)務邏輯僅依賴于端口(接口),該端口(接口)旨在滿足業(yè)務邏輯需求,因此它不依賴于特定的適配器或工具。
這意味著依賴關(guān)系的方向是朝向中心,這是架構(gòu)級別的控制原則的反轉(zhuǎn)。
不過,同樣重要的是,創(chuàng)建端口是為了滿足應用程序核心需求,而不是簡單地模仿工具 API。
Onion Architecture 拾取DDD層,并將它們合并到端口和適配器架構(gòu)中。這些層旨在為業(yè)務邏輯,端口和適配器的內(nèi)部"六邊形"帶來一些組織,就像在端口和適配器中一樣,依賴方向是朝向中心。用例是可以在應用程序核心中由應用程序中的一個或多個用戶界面觸發(fā)的進程。例如,在 CMS 中,我們可以擁有普通用戶使用的實際應用程序 UI、CMS 管理員的另一個獨立 UI、另一個 CLI UI 和 Web API。這些 UI(應用程序)可能會觸發(fā)特定于其中一個或由其中幾個重用的用例。用例在應用層中定義,這是由 DDD 提供并由 Onion 架構(gòu)使用的第一層。
該層包含作為一等公民的應用程序服務(及其接口),但它也包含端口和適配器接口(端口),其中包括ORM接口,搜索引擎接口,消息傳遞接口等。在我們使用命令總線和/或查詢總線的情況下,此層是命令和查詢的相應處理程序所屬的位置。
應用程序服務和/或命令處理程序包含展開用例和業(yè)務流程的邏輯。通常,他們的作用是:
使用存儲庫查找一個或多個實體;
告訴這些實體做一些領(lǐng)域邏輯;
并使用存儲庫再次保留實體,從而有效地保存數(shù)據(jù)更改。
命令處理程序可以通過兩種不同的方式使用:
它們可以包含執(zhí)行用例的實際邏輯;
它們可以用作我們架構(gòu)中的簡單連接部分,接收命令并簡單地觸發(fā)應用程序服務中存在的邏輯。
使用哪種方法取決于上下文,例如:
該層還包含應用程序事件的觸發(fā),這些事件表示用例的一些結(jié)果。這些事件觸發(fā)的邏輯是用例的副作用,例如發(fā)送電子郵件、通知第三方 API、發(fā)送推送通知,甚至啟動屬于應用程序不同組件的另一個用例。再往內(nèi),我們有域?qū)印4藢又械膶ο蟀瑪?shù)據(jù)和操作該數(shù)據(jù)的邏輯,這些數(shù)據(jù)特定于 Domain 本身,并且獨立于觸發(fā)該邏輯的業(yè)務流程,它們是獨立的,并且完全不知道應用程序?qū)印?/span>域名服務
正如我上面提到的,應用程序服務的角色是:
使用存儲庫查找一個或多個實體;
告訴這些實體做一些領(lǐng)域邏輯;
并使用存儲庫再次保留實體,從而有效地保存數(shù)據(jù)更改。
然而,有時我們會遇到一些涉及不同實體的領(lǐng)域邏輯,無論類型與否相同,我們覺得領(lǐng)域邏輯不屬于實體本身,我們覺得這種邏輯不是他們的直接責任。因此,我們的第一反應可能是將該邏輯放在實體外部,即應用程序服務中。但是,這意味著域邏輯在其他用例中將不可重用:域邏輯應遠離應用層!解決方案是創(chuàng)建一個域服務,其角色是接收一組實體并對其執(zhí)行一些業(yè)務邏輯。域服務屬于域?qū)樱虼怂鼘贸绦驅(qū)又械念悾ㄈ鐟贸绦蚍栈虼鎯欤┮粺o所知。另一方面,它可以使用其他域服務,當然還有域模型對象。域模型
在最中心,依賴于它之外的任何東西,是域模型,它包含表示域中某些內(nèi)容的業(yè)務對象。這些對象的示例首先是實體,還包括值對象、枚舉和域模型中使用的任何對象。域模型也是域事件"生存"的位置。當一組特定的數(shù)據(jù)發(fā)生更改時,將觸發(fā)這些事件,并且它們會隨身攜帶這些更改。換句話說,當實體發(fā)生更改時,將觸發(fā)域事件,并且它攜帶更改的屬性新值。例如,這些事件非常適合在事件溯源中使用。
到目前為止,我們一直在根據(jù)層來隔離代碼,但這是細粒度的代碼隔離。代碼的粗粒度分離至少同樣重要,它是關(guān)于根據(jù)子域和有界上下文來分離代碼,遵循羅伯特·C·馬?。≧obert C. Martin)在尖叫的架構(gòu)中表達的想法。這通常被稱為"逐個功能打包"或"逐個組件打包",而不是"逐層打包",Simon Brown在他的博客文章"逐個組件和架構(gòu)對齊的測試"中對此進行了很好的解釋:
這些代碼部分與前面描述的層交叉切割,它們是我們應用程序的組件。組件的示例可以是~~身份驗證、授權(quán)、計費、~~用戶、審閱或帳戶,但它們始終與域相關(guān)。像授權(quán)和/或身份驗證這樣的有界上下文應該被視為外部工具,我們?yōu)槠鋭?chuàng)建適配器并隱藏在某種端口后面。
解耦組件
就像細粒度的代碼單元(類、接口、特性、mixins 等)一樣,粗粒度的代碼單元(組件)也受益于低耦合和高內(nèi)聚性。為了解耦類,我們利用依賴注入,通過將依賴項注入到類中而不是在類內(nèi)實例化它們,以及依賴關(guān)系反轉(zhuǎn),使類依賴于抽象(接口和/或抽象類)而不是具體的類。這意味著依賴類對它將要使用的具體類一無所知,它沒有對它所依賴的類的完全限定類名的引用。同樣,擁有完全解耦的組件意味著一個組件對任何其他組件沒有直接了解。換句話說,它沒有引用來自另一個組件的任何細粒度代碼單元,甚至沒有接口!這意味著依賴注入和依賴關(guān)系反轉(zhuǎn)不足以解耦組件,我們將需要某種架構(gòu)結(jié)構(gòu)。我們可能需要事件、共享內(nèi)核、最終一致性,甚至是發(fā)現(xiàn)服務!觸發(fā)其他組件中的邏輯
當我們的一個組件(組件 B)需要在另一個組件(組件 A)中發(fā)生其他操作時執(zhí)行某些操作時,我們不能簡單地從組件 A 直接調(diào)用組件 B 中的類/方法,因為這樣 A 就會耦合到 B。但是,我們可以讓 A 使用事件調(diào)度程序來調(diào)度應用程序事件,該事件將傳遞到偵聽它的任何組件(包括 B),并且 B 中的事件偵聽器將觸發(fā)所需的操作。這意味著組件 A 將依賴于事件調(diào)度程序,但它將與 B 分離。然而,如果事件本身"存在于"A中,這意味著B知道A的存在,它被耦合到A。要刪除此依賴項,我們可以創(chuàng)建一個庫,其中包含一組應用程序核心功能,這些功能將在所有組件之間共享,共享內(nèi)核。這意味著這些組件都將依賴于共享內(nèi)核,但它們將彼此分離。共享內(nèi)核將包含應用程序和域事件等功能,但它也可以包含規(guī)范對象以及任何有意義的共享對象,請記住,它應該盡可能少,因為對共享內(nèi)核的任何更改都會影響應用程序的所有組件。此外,如果我們有一個多語言系統(tǒng),比如說一個用不同語言編寫的微服務生態(tài)系統(tǒng),共享內(nèi)核需要與語言無關(guān),以便所有組件都可以理解它,無論它們用什么語言編寫。例如,它將以不可知語言(如 JSON)包含事件描述(即名稱、屬性,甚至可能方法,盡管這些在規(guī)范對象中更有用),而不是包含 Event 類的共享內(nèi)核,以便所有組件/微服務都可以解釋它,甚至可能自動生成自己的具體實現(xiàn)。在我的后續(xù)帖子中閱讀更多相關(guān)信息:不僅僅是同心圖層。種方法既適用于整體式應用程序,也適用于分布式應用程序,如微服務生態(tài)系統(tǒng)。但是,當事件只能異步傳遞時,對于需要立即執(zhí)行其他組件中的觸發(fā)邏輯的上下文,這種方法是不夠的!組件 A 需要對組件 B 進行直接 HTTP 調(diào)用。在這種情況下,要使組件解耦,我們將需要一個發(fā)現(xiàn)服務,A 將詢問它應該將請求發(fā)送到何處以觸發(fā)所需的操作,或者向發(fā)現(xiàn)服務發(fā)出請求,該發(fā)現(xiàn)服務可以將其代理到相關(guān)服務并最終將響應返回給請求者。此方法會將組件耦合到發(fā)現(xiàn)服務,但會使它們彼此分離。
從其他組件獲取數(shù)據(jù)
在我看來,組件不允許更改它不"擁有"的數(shù)據(jù),但是它可以查詢和使用任何數(shù)據(jù)。在組件之間共享數(shù)據(jù)存儲
當一個組件需要使用屬于另一個組件的數(shù)據(jù)時,假設一個計費組件需要使用屬于該帳戶組件的客戶端名稱,則計費組件將包含一個查詢對象,該對象將查詢數(shù)據(jù)存儲中的該數(shù)據(jù)。這僅僅意味著計費組件可以知道任何數(shù)據(jù)集,但它必須通過查詢的方式將其不"擁有"的數(shù)據(jù)用作只讀數(shù)據(jù)。每個組件隔離的數(shù)據(jù)存儲
在這種情況下,相同的模式適用,但我們在數(shù)據(jù)存儲級別具有更大的復雜性。擁有具有自己的數(shù)據(jù)存儲的組件意味著每個數(shù)據(jù)存儲都包含:它擁有的一組數(shù)據(jù),是唯一允許更改的數(shù)據(jù),使其成為唯一的事實來源;
一組數(shù)據(jù),它是其他組件數(shù)據(jù)的副本,它不能自行更改,但組件功能需要這些數(shù)據(jù),并且每當它在所有者組件中發(fā)生更改時都需要更新。每個組件都將從其他組件創(chuàng)建所需數(shù)據(jù)的本地副本,以便在需要時使用。當擁有它的組件中的數(shù)據(jù)發(fā)生更改時,該所有者組件將觸發(fā)承載數(shù)據(jù)更改的域事件。保存該數(shù)據(jù)副本的組件將偵聽該域事件,并相應地更新其本地副本。
正如我上面所說,控制流當然是從用戶到應用程序核心,再到基礎設施工具,再回到應用程序核心,最后回到用戶。但是類究竟是如何組合在一起的呢?哪些取決于哪些?我們?nèi)绾谓M合它們?繼鮑勃叔叔之后,在他關(guān)于清潔架構(gòu)的文章中,我將嘗試用 UMLish 圖表來解釋控制流程……
沒有命令/查詢總線
在我們不使用命令總線的情況下,控制器將依賴于應用程序服務或查詢對象。[編輯 – 2017-11-18 ] 我完全錯過了用于從查詢中返回數(shù)據(jù)的 DTO,所以我現(xiàn)在添加了它。Tkx 給MorphineAdministered,他為我指出了這一點。
在上圖中,我們?yōu)閼贸绦蚍帐褂昧艘粋€接口,盡管我們可能會爭辯說它并不是真正需要的,因為應用程序服務是我們應用程序代碼的一部分,我們不希望將它換成另一個實現(xiàn),盡管我們可能會對其進行重構(gòu)完全。
Query 對象將包含一個優(yōu)化的查詢,該查詢將簡單地返回一些要顯示給用戶的原始數(shù)據(jù)。該數(shù)據(jù)將在 DTO 中返回,該 DTO 將被注入到 ViewModel 中。ThisViewModel 中可能有一些視圖邏輯,它將用于填充視圖。
另一方面,應用程序服務將包含用例邏輯,當我們想在系統(tǒng)中做某事時我們將觸發(fā)的邏輯,而不是簡單地查看一些數(shù)據(jù)。應用程序服務依賴于將返回包含需要觸發(fā)的邏輯的實體的存儲庫。它還可能依賴于域服務來協(xié)調(diào)多個實體中的域進程,但情況幾乎不是這樣。
在展開用例之后,應用程序服務可能想要通知整個系統(tǒng)該用例已經(jīng)發(fā)生,在這種情況下,它還將依賴事件調(diào)度程序來觸發(fā)事件。
有趣的是,我們在持久性引擎和存儲庫上都放置了接口。雖然看起來多余,但它們有不同的用途:
持久性接口是 ORM 之上的一個抽象層,因此我們可以在不更改應用程序核心的情況下交換正在使用的 ORM。
存儲庫接口是對持久性引擎本身的抽象。假設我們想從 MySQL 切換到 MongoDB。持久化接口可以相同,如果我們想繼續(xù)使用相同的 ORM,即使持久化適配器也將保持不變。但是,查詢語言完全不同,因此我們可以創(chuàng)建使用相同持久性機制的新存儲庫,實現(xiàn)相同的存儲庫接口,但使用 MongoDB 查詢語言而不是 SQL 構(gòu)建查詢。
使用命令/查詢總線
在我們的應用程序使用命令/查詢總線的情況下,圖表幾乎保持不變,只是控制器現(xiàn)在依賴于總線和命令或查詢。它將實例化命令或查詢,并將其傳遞給總線,總線將找到適當?shù)奶幚沓绦騺斫邮蘸吞幚砻睢?/span>在下圖中,命令處理程序然后使用應用程序服務。但是,這并不總是需要,事實上在大多數(shù)情況下,處理程序?qū)美乃羞壿?。如果我們需要在另一個處理程序中重用相同的邏輯,我們只需要從處理程序中提取邏輯到一個單獨的應用程序服務中。[編輯 – 2017-11-18 ] 我完全錯過了用于從查詢中返回數(shù)據(jù)的 DTO,所以我現(xiàn)在添加了它。Tkx 給MorphineAdministered,他為我指出了這一點。

你可能已經(jīng)注意到總線和命令、查詢和處理程序之間沒有依賴關(guān)系。這是因為它們實際上應該不知道彼此以提供良好的解耦。Bus 知道哪個 Handler 應該處理什么 Command 或 Query 的方式應該僅通過配置進行設置。如您所見,在這兩種情況下,跨越應用程序核心邊界的所有箭頭(即依賴項)都指向內(nèi)部。如前所述,這是端口和適配器架構(gòu)、洋蔥架構(gòu)和清潔架構(gòu)的基本規(guī)則。
與往常一樣,目標是擁有一個松散耦合和高內(nèi)聚的代碼庫,以便輕松、快速且安全地進行更改。