這篇文章是軟體架構編年史的一部分,一系列關于軟體架構的文章。在這些文章中,我寫了我對軟體架構的了解,我如何看待它,以及我如何使用這些知識。如果您閱讀了本系列以前的文章,那麼本文的内容可能更有意義。
大學畢業後,我從事了高中教師的職業,直到幾年前,我決定放棄它,成為一名全職軟體開發人員。
從那以後,我總是覺得我需要找回失去的時間,盡可能多地、盡可能快地學習。是以,我有點沉迷于試驗、閱讀和寫作,特别關注軟體設計和體系結構。這就是我寫這些文章的原因,來幫助我學習。
在我的上一篇文章中,我寫了很多我學過的概念和原則,以及我是如何推理的。但我認為這些隻是拼圖的一部分。
今天的文章是關于我如何将所有這些部分組合在一起的,我似乎應該給它起個名字,我稱它為顯式架構(Explicit Architecture)。此外,這些概念都“通過了它們的考驗”,并被用于高要求平台上的生産代碼中。一個是SaaS的e-com平台,在全球擁有數千個網絡商店,另一個是市場,在兩個國家都有一個消息總線,每個月處理超過2000萬條消息。
- 系統的基本子產品
- 工具
- 将工具和傳遞機制連接配接到應用程式核心
- 端口
- 主擴充卡或驅動擴充卡
- 輔助或被驅動擴充卡
- 控制反轉
- 應用程式的核心組織
- 域服務
- 域模型
- 應用程式層
- 領域層
- 元件
- 元件之間共享的資料存儲
- 每個元件隔離資料存儲
- 解耦的元件
- 觸發邏輯在其他元件
- 從其他元件擷取資料
- 控制流
系統的基本子產品
我首先回顧一下EBI和端口及擴充卡架構。它們都明确區分了哪些代碼是應用程式内部的,哪些是外部的,以及哪些用于連接配接内部和外部代碼。
此外,端口和擴充卡體系結構明确辨別了系統中的三個基本代碼塊:
- 是什麼使得運作一個使用者界面成為可能,不管它是什麼類型的使用者界面;
- 系統業務邏輯,或應用程式核心,由使用者界面使用,以實際使事情發生;
- 基礎構架代碼,它将我們的應用核心與資料庫、搜尋引擎或第三方api等工具連接配接起來。

應用程式核心是我們真正應該關心的。是代碼允許我們的代碼做它應該做的事情,是我們的應用程式。它可能使用多個使用者界面(漸進式web應用程式、移動應用程式、CLI、API等),但是實際執行工作的代碼是相同的,并且位于應用程式核心中,不管什麼UI觸發它,都應該是一樣的。
可以想象,典型的應用程式流從使用者界面中的代碼開始,通過應用程式核心到基礎設施代碼,然後傳回到應用程式核心,最後向使用者界面傳遞響應。
工具
遠離系統中最重要的代碼(應用程式核心),我們擁有應用程式使用的工具,例如資料庫引擎、搜尋引擎、Web伺服器或CLI控制台(盡管後兩個也是傳遞機制)。
雖然将CLI控制台與資料庫引擎放在同一個“bucket”中可能感覺有些奇怪,盡管它們有不同類型的用途,但它們實際上是應用程式使用的工具。關鍵的差別在于,雖然CLI控制台和web伺服器用于告訴應用程式執行某些操作,但是資料庫引擎是由應用程式執行某些操作的。這是一個非常相關的差別,因為它對我們如何建構将這些工具與應用程式核心連接配接起來的代碼有很強的影響。
将工具和傳送機制連接配接到應用程式核心
将工具連接配接到應用程式核心的代碼單元稱為擴充卡(端口和擴充卡體系結構)。擴充卡是那些有效地實作代碼的擴充卡,這些代碼将允許業務邏輯與特定的工具通信,反之亦然。
告訴我們的應用程式做某事的擴充卡稱為主擴充卡或驅動擴充卡,而由我們的應用程式告訴我們做某事的擴充卡稱為輔助擴充卡或驅動擴充卡。
端口
然而,這些擴充卡不是随機建立的。建立它們是為了将特定的入口點安裝到應用程式核心(一個端口)。端口隻不過是工具如何使用應用程式核心或應用程式核心如何使用它的規範。在大多數語言及其最簡單的形式中,這個規範,即端口,将是一個接口,但它實際上可能由幾個接口和dto組成。
需要注意的是,端口(接口)屬于業務邏輯内部,而擴充卡屬于業務邏輯外部。要使此模式正常工作,最重要的是建立适合應用程式核心需求的端口,而不是簡單地模仿工具api。
主擴充卡或驅動擴充卡
主擴充卡或驅動擴充卡圍繞一個端口,并使用它來告訴應用程式核心要做什麼。它們将來自傳遞機制的任何東西轉換為應用程式核心中的方法調用。
換句話說,我們的驅動擴充卡是控制器或控制台指令,它們在構造函數中注入一些對象,這些對象的類實作控制器或控制台指令所需的接口(端口)。
在更具體的示例中,端口可以是控制器所需的服務接口或存儲庫接口。然後将服務、存儲庫或查詢的具體實作注入并在控制器中使用。
或者,端口可以是指令總線或查詢總線接口。在這種情況下,将指令或查詢總線的具體實作注入控制器,然後控制器構造指令或查詢并将其傳遞給相關總線。
輔助或被驅動擴充卡
與圍繞端口的被驅動擴充卡不同,驅動擴充卡實作一個端口和一個接口,然後将其注入到應用程式核心中,無論哪裡需要端口(類型暗示)。
例如,假設我們有一個需要持久化資料的簡單應用程式。是以我們建立一個持久性接口,滿足其需要,用一個方法來儲存數組的資料和方法來删除表中的一行的ID。從那時起,無論應用程式需要儲存或删除資料,我們需要在其構造函數實作持久化的對象我們定義的接口。
現在我們建立一個特定于MySQL的擴充卡來實作這個接口。它将具有儲存數組和删除表中的一行的方法,并且我們将在需要持久性接口的地方注入它。
如果在某個時候我們決定改變資料庫供應商,比如PostgreSQL或MongoDB,我們隻需要建立一個新的擴充卡來實作PostgreSQL特定的持久化接口,并注入新的擴充卡而不是舊的。
控制反轉
關于此模式需要注意的一個特征是,擴充卡依賴于特定的工具和特定的端口(通過實作接口)。但是我們的業務邏輯隻依賴于端口(接口),它被設計成适合業務邏輯需求,是以它不依賴于特定的擴充卡或工具。
這意味着依賴的方向是朝向中心的,這是建築層面的控制原則的倒置。
盡管如此,建立端口是為了滿足應用程式的核心需求,而不是簡單地模仿工具api,這一點非常重要。
應用程式的核心組織
Onion架構采用DDD層,并将它們合并到端口和擴充卡架構中。這些層旨在為業務邏輯、端口和擴充卡的内部“六邊形”帶來一些組織,就像端口和擴充卡一樣,依賴關系的方向是向中心的。
應用程式層
用例是可以由應用程式中的一個或多個使用者接口在應用程式核心中觸發的流程。例如,在CMS中,我們可以有普通使用者使用的實際應用程式UI、CMS管理者使用的另一個獨立UI、另一個CLI UI和web API。這些ui(應用程式)可以觸發特定于其中一個或由其中幾個重用的用例。
用例在應用層中定義,這是DDD提供的第一層,由Onion Architecture使用。
這一層包含作為第一類公民的應用程式服務(及其接口),但它也包含端口和擴充卡接口(端口),其中包括ORM接口、搜尋引擎接口、消息傳遞接口等等。在我們使用指令總線和/或查詢總線的情況下,這一層是指令和查詢各自的處理程式所在的地方。
應用程式服務和/或指令處理程式包含展開用例(業務流程)的邏輯。一般來說,他們的職責是:
- 使用存儲庫查找一個或多個實體;
- 告訴那些實體去做一些域邏輯;
- 并使用存儲庫再次持久化實體,有效地儲存資料更改。
指令處理程式可以用兩種不同的方式使用:
- 它們可以包含執行用例的實際邏輯;
- 它們可以在我們的體系結構中用作簡單的連接配接塊,接收指令并簡單地觸發存在于應用程式服務中的邏輯。
使用哪種方法取決于上下文,例如:
我們是否已經準備好了應用程式服務并正在添加指令總線?
指令總線是否允許指定任何類/方法作為處理程式,或者它們是否需要擴充或實作現有的類或接口?
這一層還包含應用程式事件的觸發,這些事件表示用例的一些結果。這些事件觸發的邏輯是用例的副作用,比如發送電子郵件、通知第三方API、發送推送通知,甚至啟動屬于應用程式不同元件的另一個用例。
領域層
再往裡,我們有域層。這個層中的對象包含資料和操作資料的邏輯,這是特定于域本身的,它獨立于觸發邏輯的業務流程,它們是獨立的,完全不知道應用層。
域服務
如前所述,應用服務的作用是:
- 使用存儲庫查找一個或多個實體;
- 告訴那些實體去做一些域邏輯;
- 并使用存儲庫再次持久化實體,有效地儲存資料更改。
然而,有時我們會遇到一些涉及不同實體的域邏輯,不管它們是否屬于同一類型,我們覺得域邏輯不屬于實體本身,我們覺得那個邏輯不是它們的直接責任。
是以,我們的第一反應可能是将邏輯放在實體之外的應用程式服務中。然而,這意味着該域邏輯将不能在其他用例中重用:域邏輯應該遠離應用程式層!
解決方案是建立一個域服務,它的角色是接收一組實體并在其上執行一些業務邏輯。域服務屬于域層,是以它對應用層中的類一無所知,比如應用程式服務或存儲庫。另一方面,它可以使用其他域服務,當然還有域模型對象。
域模型
在最中心的是域模型,它不依賴于它之外的任何東西,它包含表示域内某些内容的業務對象。這些對象的示例首先是實體,但也包括值對象、枚舉和域模型中使用的任何對象。
域模型也是域事件“活動”的地方。當特定的一組資料發生更改時,将觸發這些事件,并将這些更改随身攜帶。換句話說,當一個實體發生更改時,将觸發一個域事件,它将攜帶更改後的屬性新值。例如,這些事件非常适合用于事件源。
元件
到目前為止,我們一直在基于層隔離代碼,但這是細粒度的代碼隔離。粗粒度的代碼隔離至少是同樣重要的,它是根據子域和有界上下文來隔離代碼的,遵循Robert C. Martin在尖叫聲架構中表達的思想。這通常被稱為“按功能包”或“按元件包”,而不是“按層包”,Simon Brown在他的部落格“按元件包和體系結構對齊測試”中對此做了很好的解釋:
我是“按元件打包”方法的倡導者,并且根據Simon Brown關于按元件打包的圖表,我将無恥地将其更改為以下内容:
這些代碼部分與前面描述的層是交叉的,它們是我們的應用程式的元件。元件的示例可以是身份驗證、授權、計費、使用者、審查或帳戶,但它們始終與域相關。像授權和/或身份驗證這樣的有界上下文應該被視為外部工具,我們為其建立擴充卡并隐藏在某種端口之後。
解耦的元件
就像細粒度的代碼單元(類、接口、特征、混合等)一樣,粗粒度的代碼單元(元件)也受益于低耦合和高内聚。
為了解耦類,我們使用依賴注入,将依賴注入到類中而不是在類中執行個體化,依賴倒置,使類依賴于抽象(接口和/或抽象類)而不是具體類。這意味着子類不知道它将要使用的具體類,它沒有引用它所依賴的類的完全限定類名。
同樣,完全解耦的元件意味着一個元件不直接知道任何其他元件。換句話說,它沒有引用來自另一個元件的任何細粒度代碼單元,甚至沒有接口!這意味着依賴注入和依賴倒置不足以解耦元件,我們需要某種架構結構。我們可能需要事件、共享核心、最終一緻性,甚至發現服務!
在其他元件觸發邏輯
當我們的一個元件(元件B)需要在另一個元件(元件A)中發生其他事情時執行某個操作時,我們不能簡單地從元件A直接調用元件B中的類/方法,因為這樣A就會被耦合到B。
然而,我們可以使用事件分派器來分派一個應用程式事件,該應用程式事件将被傳遞給監聽它的任何元件,包括B,而B中的事件偵聽器将觸發所需的操作。這意味着元件A将依賴于事件配置設定器,但它将與B解耦。
然而,如果事件本身“存在”于A中,這意味着B知道A的存在,它與A是耦合的。這意味着元件都依賴于共享核心,但是它們之間是解耦的。共享核心将包含應用程式和域事件之類的功能,但它也可以包含規範對象,以及任何需要共享的内容,請記住,共享核心的任何更改都将影響到應用程式的所有元件,是以共享核心應該盡可能少。此外,如果我們有一個多語言系統,假設是一個微服務生态系統,其中它們是用不同的語言編寫的,那麼共享核心需要是語言無關的,以便所有元件都可以了解它,無論它們是用什麼語言編寫的。例如,它将包含事件描述,而不是包含事件類的共享核心。名稱、屬性、甚至方法(盡管這些在JSON之類的不可知語言中可能更有用),這樣所有元件/微服務都可以解釋它,甚至自動生成它們自己的具體實作。請在我的後續文章中閱讀更多相關内容:不僅僅是同心圓層。
這種方法既适用于單片應用程式,也适用于像微服務生态系統這樣的分布式應用程式。然而,當事件隻能異步傳遞時,對于需要立即在其他元件中執行觸發邏輯的上下文,這種方法是不夠的!元件将需要一個直接的HTTP調用元件b。在這種情況下,解耦的元件,我們需要發現服務,将要求它應該發送請求來啟動所需的行動,或者使請求發現服務代理的相關服務,最終将響應傳回給請求者。此方法将把元件耦合到發現服務,但将使它們彼此解耦。
從其他元件擷取資料
在我看來,一個元件不允許改變它不“擁有”的資料,但是它可以查詢和使用任何資料。
元件之間共享的資料存儲
當一個元件需要使用屬于另一個元件的資料時,假設一個賬單元件需要使用屬于accounts元件的用戶端名稱,賬單元件将包含一個查詢對象,該對象将查詢該資料的資料存儲。這僅僅意味着賬單元件可以知道任何資料集,但是它必須通過查詢的方式将不“擁有”的資料作為隻讀資料使用。
每個元件隔離資料存儲
在本例中,應用了相同的模式,但是我們在資料存儲級别上更加複雜。元件擁有自己的資料存儲意味着每個資料存儲包含:
- 它擁有的一組資料,并且是唯一允許更改的資料,使其成為唯一的真理來源;
- 一組資料是其他元件資料的副本,它不能自己更改這些資料,但是元件功能需要它,并且需要在所有者元件中發生更改時對其進行更新。
每個元件将從其他元件建立所需資料的本地副本,以便在需要時使用。當擁有該元件的元件中的資料發生更改時,該所有者元件将觸發承載資料更改的域事件。持有該資料副本的元件将偵聽該域事件,并相應地更新其本地副本。
控制流
正如我上面所說的,控制流當然是從使用者到應用程式核心,再到基礎設施工具,最後回到應用程式核心,最後回到使用者。但是類到底是如何組合在一起的呢?哪些取決于哪些?我們如何組合它們?
在Bob叔叔關于幹淨架構的文章中,我将嘗試用UMLish圖來解釋控制流……
沒有指令/查詢總線
在我們不使用指令總線的情況下,控制器将依賴于應用程式服務或查詢對象。
[編輯- 2017-11-18]我完全錯過了我用來從查詢傳回資料的DTO,是以我現在添加了它。感謝MorphineAdministered公司為我指出了這一點。
在上面的圖中我們使用應用程式的接口服務,盡管我們可能認為這不是真正需要從應用程式服務是我們應用程式代碼的一部分,我們不會想交換另一個實作,盡管我們可能完全重構它。
查詢對象将包含一個優化的查詢,該查詢将簡單地傳回一些原始資料以顯示給使用者。該資料将以DTO的形式傳回,并注入到ViewModel中。這個視圖模型可能有一些視圖邏輯,它将被用來填充一個視圖。
另一方面,應用程式服務将包含用例邏輯,當我們希望在系統中執行某些操作時,而不是簡單地檢視某些資料時,将觸發該邏輯。應用程式服務依賴于存儲庫,存儲庫将傳回包含需要觸發的邏輯的實體。它還可能依賴于域服務來協調多個實體中的域流程,但情況并非如此。
在展開用例之後,應用程式服務可能希望通知整個系統該用例已經發生,在這種情況下,它還将依賴于事件分派器來觸發事件。
值得注意的是,我們在持久性引擎和存儲庫上都放置了接口。雖然看起來有些多餘,但它們有不同的用途:
- 持久性接口是ORM上的一個抽象層,是以我們可以交換正在使用的ORM,而不需要更改應用程式的核心。
- repository接口是對持久性引擎本身的抽象。假設我們想從MySQL切換到MongoDB。持久性接口可以是相同的,如果我們想繼續使用相同的ORM,那麼即使是持久性擴充卡也可以保持不變。但是,查詢語言是完全不同的,是以我們可以建立使用相同持久性機制的新存儲庫,實作相同的存儲庫接口,但是使用MongoDB查詢語言而不是SQL建構查詢。
使用指令/查詢總線
在我們的應用程式使用指令/查詢總線的情況下,除了控制器現在依賴于總線和指令或查詢外,關系圖幾乎保持不變。它将執行個體化指令或查詢,并将其傳遞給總線,總線将找到适當的處理程式來接收和處理指令。
在下面的關系圖中,指令處理程式然後使用應用程式服務。然而,這并不總是需要的,事實上在大多數情況下,處理程式将包含用例的所有邏輯。如果需要在另一個處理程式中重用相同的邏輯,則隻需要将邏輯從處理程式提取到單獨的應用程式服務中。
[編輯- 2017-11-18]我完全錯過了我用來從查詢傳回資料的DTO,是以我現在添加了它。感謝MorphineAdministered公司為我指出了這一點。
您可能已經注意到,總線與指令、查詢和處理程式之間沒有依賴關系。這是因為,為了提供良好的解耦,它們實際上應該彼此不了解。總線知道什麼處理程式應該處理什麼指令或查詢的方式應該通過簡單的配置來設定。
如您所見,在這兩種情況下,跨越應用程式核心邊界的所有箭頭和依賴項都指向内部。如前所述,這是端口和擴充卡體系結構、Onion體系結構和Clean體系結構的基本規則。
結論
一如既往,我們的目标是擁有一個松散耦合和高内聚的代碼庫,這樣修改起來就容易、快速和安全。
計劃是沒有價值的,但計劃就是一切。
-------------------------------艾森豪威爾
這個資訊圖是一個概念圖。了解和了解所有這些概念将幫助我們規劃一個健康的架構,一個健康的應用程式。
然而:
地圖不是領土。
-----------------------阿爾弗雷德Korzybski
這意味着這些隻是指導方針!應用程式是我們需要應用知識的領域、現實和具體用例,這就是定義實際體系結構的内容!
我們需要了解所有這些模式,但是為了解耦和内聚,我們還需要思考并準确地了解我們的應用程式需要什麼,我們應該走多遠。這個決策可以依賴于許多因素,從項目功能需求開始,但是也可以包括諸如建構應用程式的時間架構、應用程式的生命周期、開發團隊的經驗等因素。
就是這樣,這就是我了解這一切的方式。這就是我在腦海裡給它找的合了解釋。