高内聚和低耦合是很原則性、很“務虛”的概念。為了更好的讨論具體技術,我們有必要再多了解一些高内聚低耦合的度量标準。
這裡先說說幾種内聚。
内聚
達到什麼樣的程度算高内聚?什麼樣的情況算低内聚?wiki上有一個内聚性的分類(https://en.wikipedia.org/wiki/Cohesion_(computer_science)#Types_of_cohesion),我們可以看看内聚都有哪些類型。
Coincidental cohesion:偶然内聚
Coincidental cohesion is when parts of a module are grouped arbitrarily; the only relationship between the parts is that they have been grouped together (e.g., a “Utilities” class) 偶然内聚是指一個子產品内的各個部分是很任性地組合到一起。偶然内聚的各個部分之間,除了“恰好放在同一個子產品内”之外,沒有任何關系。最典型例子就是“Utilities”類。 https://en.wikipedia.org/wiki/Cohesion_(computer_science)#Types_of_cohesion
這是内聚性最弱、也是最差的一種情況。這種情況下,應該盡量把這個子產品拆分成幾個獨立子產品——即使現在不拆分,以後也遲早要拆。前陣子我就遇到了一個類似的問題。在我們的系統中,有這樣一個處理類:
乍一看,這個接口似乎挺“高内聚”的。但是實際上,UserBean是從本地資料庫中擷取的、記錄使用者在目前業務線中的資料的類;而UserInfo是從使用者中心擷取的、記錄使用者注冊資訊資料的類:它們除了名字相似之外,基本沒有相關性。把這兩個資料的相關功能放在同一個子產品中 ,就是一種“偶然内聚”。雖然在初期的使用中,這裡并沒有什麼問題。但是在後續擴充時,這種“偶然内聚”導緻了循環依賴,我們不得不把它們拆分成兩個不同的子產品。
Logical cohesion:邏輯内聚
Logical cohesion is when parts of a module are grouped because they are logically categorized to do the same thing even though they are different by nature . 邏輯内聚是指一個子產品内的幾個元件僅僅因為“邏輯相似”而被放到了一起——即使這幾個元件本質上完全不同。
很多文章裡會特别指出,用戶端每次調用邏輯内聚的子產品時,需要給這個子產品傳遞一個參數來确定該子產品應完成哪一種功能 。這是因為邏輯内聚的幾個元件之間并沒有什麼本質上的相似之處,因而從入參提供的業務資料中無法判斷應該按哪種邏輯處理,而隻好要求調用方額外傳入一個參數來指定要使用哪種邏輯。
我早期做“可擴充”的設計時,經常會産生這種内聚。例如,有一個計算還款計劃的接口,我是這樣設計的:
除了借款申請和必要的計算參數(本金、期數、利率等)之外,這個接口還要求調用方傳入一個計息方式字段,用以決定是使用等額本息、等額本金還是其它公式計算利息。如果某天要增加一種計息方式,比如先息後本,也很好辦:增加一種CalculateMethond就行。
看起來一切都好,直到有一天業務要求停用等額本金方式,統一采用等額本息方式計算還款計劃表。這時候我們隻有兩種選擇:要麼讓所有的調用方排查一遍自己調用這個接口時傳入的參數,保證入參calculateMethod隻傳入了等額本息方式;或者,在接口内部做一個轉換:調用方傳入了等額本金方式,那麼按等額本息方式處理。顯然,第一種方式會把原本很小的一個需求變化擴散到整個系統中。這就好像隻是被蚊子盯了一口卻全身都長了大包一樣。如果某一個調用方改漏了,那麼它得到的還款計劃表就是錯的。如果這份錯誤的還款計劃表到了使用者手裡,那麼投訴扯皮事故複盤就少不了了。第二種方式則容易讓調用方産生誤解——明明指定了等額本金方式,為什麼計算結果是等額本息的?這就好比下單點了一份蝦滑上菜給了一份黃瓜。如果這種誤解一路傳遞給了使用者——例如某個調用方的開發、産品一看參數支援等額本金,于是向使用者宣傳“我們的産品支援等額本金”——那麼投訴扯皮事故複盤就又要出現了。
邏輯内聚也是一種“低内聚”,它把接口内部的邏輯處理暴露給了接口之外。這樣,當這部分邏輯發生變更時,原本無辜的調用方就要受到牽連了。
Temporal cohesion:時間内聚
Temporal cohesion is when parts of a module are grouped by when they are processed - the parts are processed at a particular time in program execution 時間内聚是指一個子產品内的多個元件除了要在程式執行到同一個時間點時做處理之外、沒有其它關系。
概念有點晦澀,舉個例子就簡單了:當Controller處理Http請求之前,用Filter統一做解密、驗簽、認證、鑒權、接口日志、異常處理等操作,那麼,解密/驗簽/認證/鑒權/接口日志/異常吹了這些功能之間就産生了時間内聚。這些功能之間原本沒有什麼關系,但是考慮到這種時間内聚,我們一般會把它們放到同一個包下、或者繼承同一個父類。
這些操作、功能之間并沒有必然的聯系——從這一點上來看,時間内聚也是一種弱内聚。但它多少還是比偶然内聚和邏輯内聚要更強一些的:畢竟它們聚在一起是有正當理由的。就好比哪怕你都叫不全大學同班同學的名字,但畢業十周年的時候聚一聚也是合情合理的。
Procedural cohesion:過程内聚
Procedural cohesion is when parts of a module are grouped because they always follow a certain sequence of execution. 過程内聚是指一個子產品内的多個元件之間必須遵循一定的執行順序才能完成一個完整功能。
顯然,過程内聚已經是一種比較強的内聚了。存在過程内聚的幾個功能元件應該盡可能地放在一個子產品内,否則在後續的維護、擴充中一定要吃苦頭。
在前面提到的那個金額計算的子產品中,存在下面這種情況:
InstallmentServiceFeeCalculator是用來計算分期服務費的一個類。從分期服務費的計算公式可以看出:在計算分期服務費之前,必須先計算出分期本金。這樣,InstallmentServiceFeeCalculator與InstallmentPricipalCalculator之間就有了過程耦合。應對這種情況,我們有兩種選擇:一是讓調用方在計算分期服務費之前,先自己計算一遍分期本金,然後把計算結果傳給分期服務費電腦;二是讓分期服務費電腦在必要的時候自己調用一次分期本金電腦。
顯然,第二種方式比第一種更好:分期服務費電腦和分期本金電腦之間存在過程耦合,第二種方式把它們放到了同一個子產品内部。這樣,無論哪個電腦發生變化——修改公式、變更取值來源等——都可以隻修改這個子產品,而不會影響到調用方。
Communicational/informational cohesion:通信内聚
Communicational cohesion is when parts of a module are grouped because they operate on the same data (e.g., a module which operates on the same record of information). 通信内聚是指一個子產品内的幾個元件要操作同一個資料(例如同一個Dto、同一個檔案、或者同一張表等)。
對設計模式熟悉的同學一定不會對通信内聚感到陌生:責任鍊/代理等模式就是很典型的通信内聚。例如,我們曾有一個子產品應該是這樣的:
上面是一個典型的責任鍊模式。責任鍊上每一環都需要向Data中寫入一部分資料,最終得到一個完整的Data。很顯然,DataCollectorFromDb和DataCollectorFromRpc、DataCollectorFromHttp之間存在着通信内聚,它們應該被放到同一個子產品内。
然而在我們的系統中,這一條完整的責任鍊被徹底拆散,零零碎碎地分布在業務流程的各個角落裡;有些字段甚至被分散在了分布部署的好幾個服務上。于是乎,我們要查找某個字段取值問題時,總要翻遍整個流程才能确定它到底在哪兒指派、要如何修改;如果要增加字段、或者修改某些字段的資料來源,甚至要修改好幾個系統的代碼。這就是打破通信内聚造成的惡果。
Sequential cohesion:順序内聚
Sequential cohesion is when parts of a module are grouped because the output from one part is the input to another part like an assembly line. 順序内聚是指在一個子產品内的多個元件之間存在“一個元件的輸出是下一個元件的輸入”這種“流水線”的關系。
如果熟悉Java8的Lambda表達式的話,應該很容易想到:Java8中的Stream就是一個順序内聚的子產品。例如下面這段代碼中,從bankcCardList.stream()開啟一個Stream之後,filter/map/map每一步操作的輸出都是下一個操作的輸入,而且它們必須按順序執行,這正是标準的順序内聚:
除了Stream之外,設計模式中的裝飾者/模闆/擴充卡等模式也是很典型的順序内聚……等等。例如,我們來看這段代碼:
在上面的裝飾者——當然也可以叫模闆——類中,這兩個步驟的順序是固定的:必須先由被裝飾者執行基礎的查詢操作、再由裝飾者做一次增強操作;而且被裝飾者的查詢結果也恰恰就是裝飾操作的一個入參。可以說,這段代碼很完美的解釋了什麼叫“順序内聚”。
這段代碼是我們重構優化後的成果。在重構之前,我們隻有FlwoQueryServiceFromDbImpl。調用方需要自己判斷和處理資料庫中沒有資料的情況,加上不同業務場景下對沒有資料的處理方式不同,相似但不完全相同的代碼重複出現了好幾次。是以,當處理邏輯發生變化——例如庫表結構變了、或者字段取值邏輯變了時——我們需要把所有引用的地方都檢查一遍、然後再修改好幾處代碼。而在重構之後,所有處理邏輯都集中到了這個裝飾者子產品内,我們可以很輕松地确定影響範圍、然後統一地修改代碼。
Functional cohesion (best):功能内聚(最強内聚)
Functional cohesion is when parts of a module are grouped because they all contribute to a single well-defined task of the module . 功能内聚是指一個子產品内所有元件共同完成一個功能、缺一不可。
功能内聚是最強的一種内聚。其它内聚更多的是在讨論把哪些元件組合成一個子產品;而功能内聚的意義在于:它讨論的是把哪些元件提出目前子產品。即使某個元件與子產品内元件存在順序内聚、通信内聚、過程内聚,但隻要這個元件與這個子產品的功能無關,那這個元件就應該另謀高就。
例如,我們系統中有一個調用規則引擎的子產品:
無論是校驗、建構請求、調用引擎還是解析結果,這個子產品中所有的代碼都是為了實作一個功能:調用規則引擎并解析結果。但是,随着業務發展、需求變更,這個子產品中出現了越來越多的“噪音”:把調用規則引擎的request和response入庫、在封裝資料時把某個資料同步給某個系統、在得到響應後把某個字段發送給另一個系統……諸如此類,不一而足。這些業務需求并不直接與“調用規則引擎”這個核心功能,相關元件與“調用核心規則”也隻是順序内聚(需要使用調用規則引擎的傳回結果)、通信内聚(需要使用調用規則引擎的入參/出參)甚至隻是時間内聚(需要在調用規則引擎時同步資料)。從“功能内聚”的角度來看,這些新增代碼就不應該放到這個子產品中來。
但是,由于一些曆史原因,這些代碼、元件、需求全都被塞到了這個子產品中。結果,這個子產品不僅代碼非常臃腫,而且性能也十分低下:一次使用者請求常常要20多秒才能完成,可是由于子產品可維護和可擴充性差,重構優化也非常困難。如果當初能遵循“功能内聚”的要求,把不必要的功能放到别的子產品下,我們也不會像現在這樣望洋興歎、無從下手了。
練習
我在《高内聚與低耦合》文中舉過一個這樣的例子:

這個子產品中的元件屬于哪種内聚呢?
嚴格一點說,右側那些元件——從“送出資訊”到“發送短信驗證碼”或“判斷短信驗證碼是否正确”——屬于功能内聚。它們全都是為了完成“短信簽約”這個操作而組合到目前子產品下的。
但是,左側這些元件——從“後續業務分發器”到“後續業務處理A”等——之間,隻能算時間内聚。各種後續業務處理之間并沒有直接的、或者本質上的關聯,它們被放在這個子產品中的原因僅僅是他們都要在短信簽約完成之後做一些處理。這可以說是标準的時間内聚。
左側和右側元件之間呢?從上面的分析也能看出來:這兩大部分之間是順序内聚。這個子產品必須先調用右側元件,在它們處理完成後才能去調用左側元件進行處理。
在《抽象》一文中,還有這樣一個例子:
在這個元件中,用于處理DEDUCT/UN_BIND/BIND等各種邏輯的元件之間是什麼内聚關系呢?我認為是通信内聚:它們都要針對入參userId和scene做處理,并傳回同樣的List<Card>。