天天看點

洋蔥/六邊形架構中的過度工程 – Victor

Clean Architecture、Onion Architecture和Hexagonal Architecture(又名端口和擴充卡)已成為當今後端系統設計的規範。

有影響力的人在推廣這些架構時并沒有過分強調它們是(過于)複雜、詳盡無遺的藍圖,需要針對手頭的實際問題進行簡化。“按照書本”應用這些架構可能會導緻過度工程,進而導緻無用的開發工作和風險,并可能阻礙明天在不同軸上進行深入的戰略重構。

本文的目的是指出可以對這些軟體架構進行簡化的常見位置,并解釋所涉及的權衡。

縱觀軟體架構的曆史,許多有影響力的人都強調要讓外圍的內建問題遠離處理應用程式主要複雜性的中心域區域。這與領域驅動設計的興起達到了頂峰,它強調保持你的領域的靈活性,不斷尋找方法來深化它,提煉它,重構它,以更好的方式來模組化你的問題。

上述3種架構風格可以被稱為 "同心架構",因為它們不是以層為機關組織代碼,而是以環為機關,圍繞領域同心地組織。注意:在這篇文章中,層和環這兩個詞是可以互換使用的。

本文中的務實的變化可能會幫助你簡化應用程式的設計,但每一個都需要整個團隊了解代碼氣味和設計價值(内聚力、耦合、DRY、OOP、将核心邏輯保持在一個不可知的域中)。你打算采取的任何行動,都要從小處着手,不斷權衡簡化與代碼庫中引入的異質性。

1、無用的接口

在我的職業生涯中,我聽到過以下不同的想法(都是錯誤的)。

  • 領域實體必須隻通過它們實作的接口來使用。
  • 每個層都必須隻暴露和消費接口。(在分層架構中)
  • 應用層必須實作輸入端口接口。(在六邊形架構中)

除了多餘的接口檔案必須與它們的實作簽名的變化保持同步外,在這樣的項目中浏覽代碼既神秘(是否有另一種實作?)又令人沮喪(在一個方法上按下ctrl鍵可能會把你扔進一個接口而不是方法定義)。

但為什麼首先要建立更多的接口呢?

對接口的過度使用可能源于一個非常古老的設計原則:"依賴于抽象,而不是實作"。對這一原則的第一層了解是,使用更多的接口和抽象類,以便在運作時通過多态性換取不同的實作。第二個層次的了解要強大得多,但與接口關鍵字無關:它說的是将問題分解成子問題(抽象),在此基礎上建立一個更簡單、更幹淨的解決方案(例如TCP/IP棧)。但是,在這次讨論中,讓我們把重點放在前者上。

使用接口可以在運作時換上不同的實作:

  • 同一契約的其他實作(又稱政策設計模式)
  • 一系列的 "過濾器"(又稱責任鍊模式),例如網絡/安全過濾器
  • 測試假象(幾乎被現代嘲諷架構所消滅)。MyRepoFake實作了IMyRepo
  • 豐富的實作變化(裝飾器模式)。Collections.unmodifiableList()
  • 對具體實作的代理(注意:基于JVM的語言不需要接口來代理具體的類)。

以上都是使用多态性來增加設計靈活性的方法。

然而,在實際使用之前引入這些模式是一個錯誤。換句話說,要警惕 "為了以防萬一,我們在那裡引入一個接口 "這樣的想法,因為這正是推測性通用性代碼氣味的定義。

提前設計也許在幾十年前更有意義,當時編輯代碼需要大量的精力。但從那時起,工具已經有了很大的發展。使用現代IDE(如IntelliJ)的重構工具,你可以從現有的類中提取一個接口,并在幾秒鐘内将整個代碼庫中的類引用替換成新的接口,而且幾乎沒有任何風險。有了這樣的工具,我們應該鼓勵自己隻在真正需要的時候提取接口。

事實上,容忍一個隻有一個實作的接口的唯一理由是,當這個接口駐紮在一個與它的實作不同的編譯單元中時。

  • 在你的客戶使用的庫中,例如my-api-client.jar。
  • 内環中的接口,外環中的實作=依賴反轉原則

依賴反轉原則(任何同心結構的标志)允許内環(如領域)調用外環(如基礎設施)的方法,但不與它的實作相聯系。例如,領域環内的服務可以通過調用在領域環内聲明但在外部基礎設施環内實作的接口方法來從另一個系統擷取資訊。

換句話說,Domain能夠調用Infrastructure基礎設施層,但卻看不到實際調用的方法實作。調用的方向(Domain→Infra)和代碼依賴的方向(Infra→Domain)是相反的,是以它被稱為 "依賴反轉"。

這裡還有一個我曾經聽到的支援接口的(悲劇性的)論點。

- 我們需要接口來明确我們的類的公共契約!但是,看一看我們的類的公共契約有什麼錯呢?

- 但是從類的結構上看公共方法有什麼不對呢?我回答說

- 是的......但是那個類裡有50多個公共方法,是以我們喜歡把它們列在一個單獨的檔案裡,很好地分組。

- 我:(無語)...

- 哦,還有,這個實作類有超過2000行的代碼。

我想很明顯,那個無用的接口并不是他們真正的問題......

總而言之:

一個接口值得存在,當且僅當:

  • 它在項目中擁有一個以上的實作,或者
  • 它被用來實作依賴反轉以保護一個内環,或
  • 它被打包在一個客戶庫中

如果一個接口不符合上述任何一個參數,請考慮銷毀它(例如通過使用IntelliJ的 "Inline "重構)。

但讓我們回到開頭:

域實體的接口--是錯誤的!上面的三個原因都不适用:

  1. 實體的替代實作是荒謬的
  2. 沒有任何代碼比你的領域更珍貴(Dep Inversion against Domain?)
  3. 在你的API中暴露實體是一個非常危險的舉動

六角/六邊形架構中的輸入端口接口--是錯誤的!:

  • 它們隻有一個實作(應用程式本身)。
  • API控制器不需要被保護(它是一個外環)。
  • 如果你直接暴露了輸入端口接口,那就不再是經典的六邊形架構了。

嚴格的層級

這種方法的支援者指出:

  • "每個層/環應該隻調用緊接着的一個層"

對于這個論點,讓我們假設有4個層/環:

  • 控制器
  • 應用服務
  • 域服務
  • 存儲器

如果我們執行嚴格層,控制器(1)隻能與應用服務(2)→域服務(3)→存儲庫(4)調用方向。

換句話說,應用服務(2)不允許直接與存儲庫(4)對話;調用必須總是通過域服務(3)。

這樣的決定導緻了這樣的模闆方法:

class CustomerService {
  ..
  public Customer findById(Long id) {
    return customerRepo.findById(id);
  }
}
           

上面的代碼幾乎是一個被稱為 "中間人 "的代碼氣味的教科書式的例子,因為這個方法代表了 "沒有抽象的間接性"--它沒有給它所委托的方法(customerRepo.findById)增加任何新的語義(抽象性)。上面的方法并沒有提高代碼的清晰度,相反,它隻是在調用鍊中增加了一個額外的 "跳"。

如今嚴格層的(少數)支援者認為,這個規則需要采取更少的決策(對中層團隊來說是可取的),并且減少了上層的耦合。然而,應用服務的一個關鍵角色是協調用例,是以通常會對所涉及的部分進行高耦合。換句話說,應用服務的一個設計目标是主持協調邏輯,以使低層元件彼此之間的耦合度降低。

嚴格層的替代方案被稱為 "寬松層",它允許跳過層,隻要調用的方向是一緻的。例如,應用服務(2)可以自由地直接調用存儲庫(4),而在其他時候,如果有任何邏輯需要推送到DS,它可以先通過領域服務(3)來調用。這可以導緻更少的模闆,但确實需要一種重構的習慣,以不斷地将越來越複雜的邏輯提取到領域服務中。

2、單行的REST控制器方法

十多年來,我們都認為:

  • REST控制器層的責任是處理HTTP問題,然後将所有的邏輯委托給應用服務。

的确,這在10-20年前是非常合理的。當在伺服器端生成HTML網頁時(想想.jsp + Struts2),這需要在控制器中進行相當多的儀式。但是今天,如果他們通過HTTP對話,我們的應用程式和微服務通常隻暴露一個REST API,将所有的螢幕邏輯推到前端/移動應用程式。此外,我們今天使用的架構(如Spring)發展得非常好,如果小心使用,可以将控制器的責任減少到隻是一系列的注釋。

今天,REST控制器的HTTP相關職責可以總結為:

  • 将HTTP請求映射到方法上--通過注解(@GetMapping)。
  • 授權使用者操作--通過注解(@Secured, @PreAuthorized)。
  • 驗證請求的有效載荷--通過注解(@Validated)。
  • 讀取/寫入HTTP請求/響應标頭--最好通過網絡過濾器完成
  • 設定響應狀态代碼--最好在一個全局異常處理程式中完成(@RestControllerAdvice)
  • 處理檔案上傳/下載下傳--不再是花哨的了,但這是唯一真正醜陋的HTTP相關的東西了

上面提供的例子來自Spring架構(Java中使用最廣泛的架構),但在其他Java架構和其他具有網絡能力的語言中,幾乎所有的功能都有對應的例子。

是以,除非你正在上傳一些檔案或做一些其他的HTTP功夫(為什麼?!),否則在你的REST控制器中不應該再有與HTTP相關的邏輯了--架構滅了它。如果我們假設資料轉換(Dto Domain)不是發生在控制器中,而是發生在下一層,例如應用層中,那麼REST控制器的方法将是單行的,将每個調用委托給下一層中具有類似名稱的方法。

@RestController
class WhiteController {
  ..
  @GetMapping("/white")
  public WhiteDto getWhite() {
    return whiteService.getWhite();
  }
}
           

哦,不!這又是我們之前看到的 "中間人 "代碼的味道--模闆代碼。

如果你讀到的内容與你的設定相符,你可以考慮将你的控制器與下一層(如應用服務)合并:

在開發REST API時,将控制器與應用服務合并。

是的,我的意思是将第一層邏輯中的方法注釋為HTTP端點(@GetMapping...),不管是應用服務還是 "服務"(在我們的例子中,在WhiteService)。

我知道,這違背了我們在不久前才遵循的一些非常古老的習慣。可能你在這個領域呆得越久,就越覺得這個決定很奇怪。但時代已經變了。在一個暴露于REST API的系統中,事情可以(也應該)更加簡化。

在我主持的研讨會上,我經常遇到這樣的架構:允許REST控制器包含邏輯:映射、更複雜的驗證、協調邏輯,甚至是一些商業邏輯。這是一個同等的解決方案,在技術上,應用服務與它前面的控制器被 "向上 "合并了。兩者都同樣好:(a)讓控制器做一些應用邏輯,或者(b)将應用服務暴露為REST API。

但有一個陷阱:不要在REST API元件中積累複雜的業務規則。相反,要不斷地尋找有凝聚力的領域邏輯,将其轉移到領域模型(例如在一個實體中)或領域服務中。

最後一點:如果你出于文檔的目的大量注釋你的REST端點方法(OpenAPI的東西),你可以考慮提取一個接口并将所有的REST注釋轉移到它。然後,應用服務将實作該接口并接管該中繼資料。

3、模拟完整的測試

單元測試是王道。專業的開發人員會對他們的代碼進行徹底的單元測試。是以。

每一層都應該通過模拟下面的層進行測試

當架構規定了嚴格的層或允許單行的REST控制器方法(即使資料轉換是在控制器中進行的),一個嚴謹的團隊會問一個明顯的問題:那些愚蠢的單行方法是否應該進行單元測試?如何測試?用mocks來測試這些愚蠢的方法會導緻測試代碼比被測試代碼大5倍。更糟的是,這感覺毫無用處--在這樣的方法中發生錯誤的可能性有多大?

人們可能會感到沮喪("測試太爛了!"),或者更糟的是,放松他們的測試嚴格性("我們不要測試這一個")。

實際上,你所面臨的是來自你的測試的誠實回報--你的系統被過度設計了。一個經驗豐富的TDD實踐者會很快接受這個想法,但其他人會很難接受它。"當測試是困難的,生産設計可以被改進"。

為了完整起見,我想在上面的經典語句中再加一條建議:

當測試很困難時,生産設計可以改進,或者你的測試太細了。

如果你的內建測試完全覆寫了該方法,你真的需要對其進行孤立的單元測試嗎?閱讀關于微服務的蜂巢式測試,探讨單元測試隻是一種必要的邪惡的想法。在測試微服務時,從外到内進行測試:從內建測試開始>然後是單元測試(以覆寫角落的情況)。

4、獨立的應用DTO與REST DTO的對比

在學習Bob叔叔的clean Architecture時,很多人的印象是,REST Endpoints暴露的資料結構應該與Application ring暴露的對象(我們稱之為 "ApplicationDto")是不同的結構。也就是說,有一套額外的類,從一個轉化為另一個。通常很快就會意識到這個決定的巨大代價,大多數工程師都放棄了。然而,其他人則堅持,他們後來添加到資料結構中的每一個字段都必須現在添加到3個資料結構中:一個在作為JSON發送/接收的Dto中,一個在ApplicationDto對象中,一個在持久化資料的領域實體中。

不要這樣做!

應該:

将REST API DTOs傳播到應用層

是的,應用服務會更難處理API模型,但這也是保持其輕量的一個原因,剝離了沉重的領域複雜性。

但是否有合理的理由将ApplicationDto與REST API DTO分開?

是的,有的。如果同一個用例通過2個或更多的管道被暴露出來:例如通過REST、Kafka、gRPC、WSDL、RMI、RSock、伺服器端HTML(例如Vaadin...)等。對于這些用例,讓你的應用服務通過其公共方法說出自己的資料結構(語言)是有意義的。然後,REST控制器将它們轉換為/從REST API DTO,而(例如)gRPC端點将轉換為/從它自己的protobuf對象。面對這種情況,一個務實的工程師可能隻在需要的幾個用例中應用這種技術。

5、将持久性與領域模型分開

我們終于達到了圍繞清潔/洋蔥架構的最激烈的争論之一。

我應該允許持久性問題污染我的領域模型嗎?

換句話說,我是否應該用@Entity來注釋我的領域模型,并讓我的ORM架構儲存/擷取我的領域模型對象的執行個體?

如果你對上述問題的回答是否定的,那麼你就需要。

  • 在域外建立所有域實體的副本,例如 CustomerModel (Domain) vs CustomerEntity (Persistence)
  • 為域中的存儲庫建立接口,隻檢索/儲存域實體,例如 customerRepo.save(CustomerModel)
  • 通過将領域實體轉換為/轉換為ORM實體來實作基礎設施中的存儲庫接口。

簡而言之:痛苦!錯誤!挫折!這是一個最昂貴的問題。

這是最昂貴的決定之一,因為它有效地将CRUD操作的代碼增加了4倍或更多。

我遇到過走這條路并付出上述代價的團隊,但他們都在1-2年後對自己的決定感到後悔,隻有少數例外。

但是,是什麼讓受人尊敬的技術上司和架構師做出如此昂貴的決定?

使用ORM的危險是什麼?

使用像ORM這樣強大的架構從來不是免費的。

以下是使用ORM的主要隐患:

  • 神奇的功能:自動沖刷髒的變化,寫在後面,懶加載,事務傳播,PK配置設定,合并(),orphanRemoval,...
  • 非顯而易見的性能問題,可能會對你造成傷害。N+1查詢、懶惰加載、擷取無用的資料量、天真的OOP模組化......。
  • 以資料庫為中心的模組化思維:從表和外鍵的角度思考,使你更難在更高的抽象層次上思考你的領域,找到更好的方法來重塑它(領域提煉)。

忽視以上幾點是不謹慎的。畢竟,它們涉及到你的應用程式中最深刻、最寶貴的部分:領域模型。

是以,這裡是你可以做的事情:

1) 神奇的功能,學習或避免。在我的JPA研讨會上,聽衆中的驚訝程度一直讓我感到擔憂--人們在沒有駕駛執照的情況下駕駛着法拉利。畢竟,Hibernate/JPA的功能要比Spring架構中的功能複雜得多。但不知何故,每個人都認為他們知道JPA。直到魔術師用一個黑暗的錯誤襲擊了你。

你也可以阻止一些神奇的功能,例如,從Repo中分離實體和不保持事務開放,但這種方法通常會産生性能影響和/或令人不愉快的角落案例。是以,最好確定你的整個團隊都能掌握JPA。

2) 盡早監控性能:有一些商業工具可以幫助經驗不足的團隊發現與ORM使用相關的典型性能問題,但防止這些問題的最簡單方法(同時也是學習)是用Glowroot、p6spy或任何其他方式記錄執行的JDBC語句等工具來關注生成的SQL語句。

3) 以面向對象的方式建立你的領域。即使你正在使用增量腳本(你應該這樣做!),也要時刻注意模型重構的想法,并通過讓你的ORM在探索時生成模式來嘗試它們。面向對象的推理總是能夠提供比從表、列和外鍵方面考慮要好得多的領域洞察力。

總而言之,我的經驗告訴我,從長遠來看,對一個團隊來說,學習ORM要比逃避它更便宜、更容易。是以,在你的領域中允許使用ORM,但要學會它!

6、應用層與基礎設施層解耦

在最初的洋蔥架構中,應用層與基礎設施層是解耦的,目的是讓應用服務邏輯與第三方API分離。每次我們想從應用層獲得第三方API的一些資料時,我們需要在應用層建立新的資料對象+新的接口,然後在基礎設施中實作它(我們前面提到的依賴反轉原則)。這對解耦來說是一個相當高的代價。

但是,由于我們大部分的業務規則都是在領域層實作的,應用層就隻能協調用例,而不需要自己做很多邏輯。是以,與解耦的成本相比,在應用層操縱第三方DTO的風險是可以容忍的。

将應用與基礎設施層合并="務實的洋蔥"

将應用程式與基礎設施層合并,允許從ApplicationServices自由通路第三方API DTOs。

這樣一來,你最終隻有2個層/環。應用(包括基礎設施)和領域。

風險:如果核心業務規則沒有被推入領域層,而是保留在應用層,它們的實作可能會被污染,并依賴于第三方DTO和API。是以,應用服務應該更努力地剝離業務規則。

這裡有一個有趣的角落案例。如果你缺乏持久性資料(如果你沒有存儲很多資料),那麼你就沒有一個明顯的領域模型來映射到/來自第三方資料。在這樣的系統中,如果有嚴重的邏輯需要在這些第三方對象之上實作,在某些時候,也許值得建立你自己的資料結構來映射外部DTO,這樣你就可以控制你編寫複雜邏輯的結構。

領域複雜度不夠

像任何工具一樣,同心架構不适合任何軟體項目。如果你的問題的領域複雜性相當低(類似CRUD),或者你的應用程式的挑戰不在于其業務規則的複雜性,那麼洋蔥/六角形/端口-擴充卡/清潔架構可能不是最好的選擇,你可能最好采用垂直切片、貧血模型、CQRS或其他類型的架構。

總結

在這篇文章中,我們研究了在應用同心架構(洋蔥/六邊形/清潔)時出現的過度工程、浪費和錯誤的以下來源

- 無用的接口 => 删除它們,除非≥2個實作或依賴性反轉

- 嚴格的層 => 寬松的層 = 允許在同一方向上的調用跳過層

- 單線REST控制器 => 與下一層合并,但要控制其複雜性

- 模拟測試 => 折疊層或測試一個更大的塊。

- 獨立的應用<>控制器Dtos => 使用單一的對象集,除非有多個輸出通道

- 從領域模型中分離出持久性 => 不要!使用單一的模型,但要學習ORM的魔力,以及性能的下降。

- 從基礎設施中解耦應用 => 合并應用+基礎設施層,但在領域中推動更多的業務規則。

更多:洋蔥/六邊形架構中的過度工程 – Victor