如何實現軟體設計中的高凝聚和鬆耦合? - thevaluable

banq發表於2022-03-28

為什麼我們系統的模組耦合度如此之高?是因為他們缺乏凝聚力嗎?
(banq注:為什麼人員在團隊之間流動這麼頻繁?為什麼團隊之間開會如此頻繁?是因為這些團隊內部缺乏凝聚力嗎?缺乏核心凝聚嗎?)
 
案例:
有人說:
我們的系統是自 COBOL 和 FORTRAN 時代以來我們見過的最可怕的系統!萬事俱備,太可怕了!我要求用 78 個月的時間徹底重寫一切,終於擁有我夢寐以求的完美系統!
凝聚力不是這個系統的主要問題。我們只需要重寫所有內容,It Will Be Fine。
整個系統是由很久以前離開的不熟練的開發人員編寫的。

分析:
需要確信當時他們寫這個系統時已經盡了最大的努力,讓我們一起討論內聚和耦合的概念,看看我們可以做些什麼來改進我們的程式碼庫。
 
內聚和耦合確實是軟體開發中必不可少的兩個概念。它們是我們在構建應用程式時應始終牢記的這些首要原則的一部分。
  • 內聚和耦合的想法來自哪裡。
  • 我們可以在程式碼庫中找到哪些不同型別的耦合。
  • 耦合的概念是如何隨著時間的推移而演變的。
  • 即使在我們的程式碼庫之外,如何到處都可以找到耦合。
  • 什麼是內聚,它與耦合有何關係。
  • 我們可以在程式碼庫中找到哪些不同類別的凝聚力。
  • 什麼是最重要的概念:內聚還是耦合?
  • 一些方法和問題要問自己,以使我們的模組更具凝聚力。

 

內聚和耦合的起源
當年,軟體工程師大多使用如 COBOL 和 FORTRAN舊的程式語言。敏捷方法不存在,因此大多數公司遵循瀑布型別的組織,分析師建立功能規範,將它們提供給建立一些架構的設計師,讓開發人員編寫帶有框和箭頭的可愛圖表,最後將其交給編碼人員將編寫實際程式碼。

1968 年的北約會議突顯了軟體危機的陰暗陰影,聲稱計算機程式並不能真正解決它們要解決的問題,尤其是大型軟體系統。主要問題:這些系統對於我們有限的人類大腦來說太複雜了。

大多數開發人員都同意模組化是解決方案,這是許多人從計算開始就想到的一箇舊概念。模組化在當時受到關注,例如David Parnas (1971)的關於將系統分解為模組的標準,或Edsger Dijkstra (1974)的關於科學思想的作用的論文。

如何實現這種模組化?如何對系統進行分割槽?什麼放在同一個模組中,什麼放在外面?以什麼方式?什麼是好的模組?什麼是壞的?

一個模組只是一組東西,這使得這個概念非常模糊:
類是可能的模組之一,還有很多。
將某些知識隔離在定義的邊界內,建立“內部”和“外部”的所有東西都可以是一個模組。
具體來說,它可以是一個函式、一個類、一個名稱空間、一個包、一個完整的微服務,甚至是一個單體。
 

結構化設計
為了回答這些問題, 1974 年發表了一篇名為“結構化設計”的論文。一年後,也就是 1975 年,同一作者的一本書也被稱為結構化設計。
這兩個資源都試圖強調高層次的重要性。設計(我們現在稱之為軟體架構)。他們對軟體世界產生了重大影響,特別是因為他們定義了兩個重要概念:內聚和耦合。

從那時起,耦合和內聚被認為是優質軟體的重要概念和衡量標準。這是一個範圍:耦合和內聚都可以或多或少地被認為是“強”或“弱”。目標是建立指標,為學生建立一門新的“設計科學”;不多不少。

這本書本身對結構化設計的含義給出了更好的定義:

結構化設計是決定哪些元件以何種方式互連將解決一些明確指定的問題的過程

結構化設計的下一個重要里程碑是 Meil​​ir Page-Jones 在 1988 年出版的另一本書《結構化系統設計實用指南》。它試圖進一步定義結構化設計運動的重要首要原則。

然後,當不可阻擋的 OOP 浪潮席捲世界時,內聚和耦合僅被認為是類、介面和圍繞 OOP 正規化的所有概念。這種趨勢的一個很好的例子是在Steve McConnell 的名著Code Complete II中提到耦合和內聚,清楚地提到這些問題部分地由 OOP 解決。

從那時起,OOP 封裝通常被認為是減少耦合和增加內聚的實用方法。但是結構設計運動的原則仍然非常實際,特別是因為我們的語言越來越多正規化。

 

模組
在這篇文章中,我們將經常使用模組的一般概念。一個模組可以是一個函式,一個類,一個名稱空間(包),一個微服務,一個完整的單體,或任何結構,只要它有一個邊界分隔的內部和外部。這個邊界允許我們將一個模組的某些部分與其他模組隔離。

換句話說,這是一種劃分和封裝我們系統的一部分的方法。最終目標是繞過我們大腦的限制,正如我上面提到的。

為什麼不說說更具體的結構,比如類呢?只是為了說明耦合和內聚在你係統的不同抽象層的每個層面都是有用的概念,不管你用的是什麼程式語言。

你應該把什麼定義為 "模組",取決於你需要在抽象堆疊的哪個層次上工作,什麼是對你實現目標有用的考慮。例如,如果你為一個微服務建立一個新的API,你應該把 "模組 "的概念對映到微服務。如果你修改了一個獨立的類,你應該考慮把它的方法作為你選擇的模組。然後,你就可以開始考慮內聚力和耦合了。
 

耦合
那麼,什麼是耦合?它不是指模組本身,而是指模組之間的聯絡。當這種聯絡很強時,我們就說強耦合模組;當這種聯絡很弱時,我們就說鬆散耦合模組。這不是一個二元的故事,而是像通常一樣,更像是一個顏色光譜。

關於軟體架構的討論正好是關於耦合的94,82089%。如果我們發明一個喝酒的遊戲,當我們每次在這些討論中聽到 "耦合 "的時候,我們就喝一杯,我們的架構就會因此而大大改善。

結構化設計運動明確指出,耦合和內聚都不是絕對的真理:在設計中,一切都要權衡。
我們應該瞭解、體驗並記住我們提出的解決方案的好處,但也要記住缺點。這就是為什麼探索和實驗是如此重要。
這也是為什麼軟體開發是如此該死的困難的部分原因。

根據結構化設計運動,耦合的強度取決於。

  • 模組之間的連線型別。
  • 模組介面的複雜性。
  • 通過連線的資訊的型別。



讓我們來探討一下這些想法:
 

模組間的連線型別
如果不同的模組有許多不同的介面,那麼它們的耦合性就會更強,因為,它們可能有許多不同型別的連線。

我們在這裡說的是介面的一般概念,是一個模組跨越邊界向另一個模組傳送資訊的方式。例如,一個類的公共方法就是它的介面之一:它是一種與另一個類溝通的方式。一個函式的輸入和輸出也是它與其他函式的介面。

結構化設計書給出的定義很好。

任何這樣的引用元素都定義了一個介面,即模組邊界的一部分,資料或控制在其中流動。[你可以把它想象成一個插座,把由引用模組的連線所代表的插頭插入其中。一個模組中的每一個介面都代表著一個更多的東西,這些東西被系統中的其他模組所瞭解、理解和正確連線。
 

介面的複雜性
儘量減少介面的數量可以使模組之間的潛在耦合強度降到最低。
現在我們來看看介面本身:理想情況下,它們應該有最小的必要輸入和輸出量。

例如,如果你有一個模組,像一個函式,接受19個引數,你就增加了傳遞這19個引數的模組和接受這些引數的模組之間的連線強度(耦合)。

我們給介面的實體數量並不總是那麼明確。
例如,當你在傳遞物件時,可能很難知道這些物件包含什麼,以及它們包含多少。正如Joe Amstrong的名言。

因為物件導向的語言的問題是它們有所有這些隱含的環境,它們隨身攜帶。你想要一根香蕉,但你得到的卻是一隻抱著香蕉的大猩猩和整個叢林。

當你想從一個類的例項中得到什麼時,你不僅要傳遞這個例項,還要傳遞你能從這個例項中獲得的所有東西。這並不是類所特有的:
當你在像Go這樣的語言中匯入一個包時,你可能會比你想象的要有效地耦合得多。
 

不可見的介面
模組之間的連線是否清晰可能是一個問題。
如果每一個資料在通過介面時都有明確的說明,那麼在看程式碼的時候就更容易理解兩個模組之間的耦合。

為了使模組之間的耦合更加明顯,你也可以使用註釋,或者某種文件。
這些解決方案比較弱,因為它們不是程式碼,很容易忘記隨著程式碼的發展而更新它們。
 

修改一個模組的控制流
在有些情況下,不可能(或不希望)將公共邏輯提取到另一個模組。在這種情況下,對控制流採取行動可能是你最後的選擇。
 

共同環境上下文耦合
結構化設計將公共環境耦合定義為模組間共享的資源或變數。我們在這裡看到了軟體開發時的另一個首要原則:全域性狀態的問題,以及一般的範圍界定。

全域性共享的資源不一定是變數:它可以是另一個模組、公共庫、檔案,甚至是外部程式。

簡而言之,修改這些共享實體中的一個可能會在程式碼庫的未知部分產生影響,因為所有的東西和事物都可能依賴於它。

然而,共同的環境耦合對於某些特定的功能來說是很方便的。例如,一個記錄器是一個內聚的功能,它可以是整個應用程式的全域性。在這篇文章的下面有更多關於內聚的內容。
 

耦合的演變
到目前為止,我們談到的一切都直接來自結構化設計運動。即使他們提出的觀點在今天看來是完全有效的,但從70年代中期開始,他們已經對耦合的概念進行了一些補充。

本文通過對許多其他研究中的耦合概念的分析,談到了四個高水平的類別。

  • 結構耦合
  • 動態耦合
  • 語義耦合
  • 邏輯耦合


結構耦合主要是我們上面看到的,有一些有趣的細微差別。從強到弱的耦合。
  • 內容耦合 - 模組直接訪問對方的內容,不使用介面。
  • 共同耦合--模組在更大範圍內突變共同的變數(如全域性變數)。
  • 控制耦合 - 模組控制其他模組的邏輯(控制流)。
  • 外部耦合 - 模組使用外部手段交換資訊,如檔案。
  • 戳記耦合 - 模組交換元素,但接收端不對所有元素採取行動。例如,一個模組通過其介面接收一個陣列,但不使用其所有元素。
  • 資料耦合 - 模組交換元素,而接收端使用所有的元素。

 
這裡有一個簡短的總結。

動態耦合是指在執行時發生的耦合;例如,通過使用介面結構(引數化多型性)。

邏輯耦合發生在不同模組的部分同時發生變化時,在程式碼庫中它們之間沒有可見的聯絡。例如,當相同的行為在不同的模組中被重複時,它就會發生;換句話說,相同的知識在兩個不同的地方被編纂。當開發者在一個模組中改變了這種行為的表現形式,她就需要在所有重複的地方改變它。

語義耦合發生在一個模組使用另一個模組的知識時。例如,當一個模組假設另一個模組做一些特定的事情時。
 
 

與第三方的耦合
耦合不一定是在我們自己的程式碼中。與第三方的耦合可能是同樣的,甚至是更危險的,因為我們對這些依賴性的控制較少(或沒有)。

我有一個簡單的規則:如果實現是微不足道的,就應該避免使用一個庫。同樣,這真的取決於你想達到什麼目的:在光譜的一邊,如果我需要快速建立原型,我會使用庫甚至框架,如果它可以加速這個過程。如果我知道我正在開發的系統對我所從事的業務至關重要,我會避免使用框架,而且我會盡可能限制我使用的外部庫。

這種危險的一個好例子是幾年前發生的左鍵災難。簡而言之,許多Javascript專案都依賴於一個實現左鍵的庫,這是一個實現起來非常容易的機械功能。當這個庫的作者決定刪除它時,無數的專案都崩潰了。

第三方的危險並不侷限於庫,甚至是程式碼。例如,我們也可以將我們的軟體與雲供應商耦合。這種耦合是很難避免的;但應該仔細考慮。然而,我看到許多公司因為 "其他人都在使用它 "而把自己鎖定在一些供應商那裡,然後在公司發展壯大時後悔不已,賬單也一樣。

當你把耦合想成連線時,你會發現它們無處不在。例如,當我們寫程式碼的時候,我們把它和我們在這個特定時間內對這個領域的知識結合起來。這個領域,以及我們的知識,都會發生變化和發展;因此,程式碼也需要變化和發展。這就是為什麼軟體開發如此困難的原因之一:因為它與不斷變化的現實世界相耦合,而我們需要用我們不完整的感知力來儘可能準確地表達它。

一些運動,比如領域驅動設計,教會了我們一些重要的東西:如果你試圖幫助的業務在很大程度上依賴於你正在構建的應用程式,你應該儘量鬆散地耦合它,因為它很可能會改變。你要在隔離性和複雜性之間保持一個良好的平衡。

總而言之,和以往一樣,沒有什麼硬性原則是我們應該一直遵循的,即使有些人似乎有不同的意見。只有單純的指導方針。
  

DRY和耦合之間的關係
假設我們的應用程式中有兩個模組:訂單和發貨。兩者都需要一些邏輯來處理產品的概念。我們可以想象兩個解決方案。

  1. 建立第三個模組來處理這個邏輯,並將我們的兩個模組訂單和裝運耦合到這第三個模組。
  2. 在這兩個不同的模組中新增相同的邏輯。

如果我們嘗試遵循上面的指導方針,第一個解決方案可能是一個好的解決方案。
  1. 只使用最少的介面來連線我們的模組。
  2. 只通過介面傳遞所需的最小數量的引數。
  3. 只傳遞資料,避免改變我們模組的控制流。
  4. 不依賴另一個更全面的模組。

第二個解決方案意味著在兩個不同的地方複製相同的邏輯。這意味著如果這個邏輯發生變化,我們將需要修改這兩個地方;從這個意義上說,這也被認為是耦合,更確切地說,是邏輯耦合。

對我來說,問題不在於做什麼,而在於何時做。如果我對我需要實現的邏輯會在某個時間點上發生變化,哪怕是最小的懷疑,我也會在兩個地方複製它,看看它是如何演變的。
  1. 如果它從未改變,就沒有問題。
  2. 如果邏輯經常變化,以至於在兩個不同的地方維護同一段程式碼變得很煩人,或者最糟糕的是,如果開發人員開始忘記改變一個實現而不是另一個,我就會把它提取到自己的模組。
  3. 如果有更多的模組使用完全相同的程式碼,並且如果這些共同的程式碼似乎是在編纂相同的知識,我會將其提取到自己的模組中。


提取一段我們有充分理由歸納的程式碼,總比過早地歸納,建立一個被其他模組使用的模組,到時發現這個行為因使用模組的不同而變得明顯不同。

我們也不要忘記,有些型別的連線比其他型別的連線成本更高:函式之間的連線比類之間的連線更容易管理,而類比微服務之間的連線更容易管理。

對於最後一種情況,網路、連線可能具有的非同步性以及微服務之間的協調可能是一個嚴重的挑戰。如果它們是強耦合的,那就是一場噩夢了。這就是為什麼我認為從單體中建立微服務往往更好,因為已經對可能的模組、它們的邊界和它們的介面有了一些深入的瞭解。
 

待續”凝聚“見下面連結

 

相關文章