高內聚和低耦合是很原則性、很“務虛”的概念。爲了更好的討論具體技術,我們有必要再多瞭解一些高內聚低耦合的度量標準。
這裏先說說幾種內聚。
內聚
達到什麼樣的程度算高內聚?什麼樣的情況算低內聚?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
這是內聚性最弱、也是最差的一種情況。這種情況下,應該儘量把這個模塊拆分成幾個獨立模塊——即使現在不拆分,以後也遲早要拆。前陣子我就遇到了一個類似的問題。在我們的系統中,有這樣一個處理類:
public interface UserBiz UserBean queryUserBean(long userId); UserInfo queryUserInfo(long userInfoId); }
乍一看,這個接口似乎挺“高內聚”的。但是實際上,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 .
邏輯內聚是指一個模塊內的幾個組件僅僅因爲“邏輯相似”而被放到了一起——即使這幾個組件本質上完全不同。
https://en.wikipedia.org/wiki/Cohesion_(computer_science)#Types_of_cohesion
很多文章裏會特別指出,客戶端每次調用邏輯內聚的模塊時,需要給這個模塊傳遞一個參數來確定該模塊應完成哪一種功能 。這是因爲邏輯內聚的幾個組件之間並沒有什麼本質上的相似之處,因而從入參提供的業務數據中無法判斷應該按哪種邏輯處理,而只好要求調用方額外傳入一個參數來指定要使用哪種邏輯。
我早期做“可擴展”的設計時,經常會產生這種內聚。例如,有一個計算還款計劃的接口,我是這樣設計的:
public interface RepayPlanCalculator{ List<RepayPlan> calculate(LendApply apply, CalculateParam param, CalculateMethond calculateMethod);}
除了借款申請和必要的計算參數(本金、期數、利率等)之外,這個接口還要求調用方傳入一個計息方式字段,用以決定是使用等額本息、等額本金還是其它公式計算利息。如果某天要增加一種計息方式,比如先息後本,也很好辦:增加一種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
時間內聚是指一個模塊內的多個組件除了要在程序執行到同一個時間點時做處理之外、沒有其它關係。
https://en.wikipedia.org/wiki/Cohesion_(computer_science)#Types_of_cohesion
概念有點晦澀,舉個例子就簡單了:當Controller處理Http請求之前,用Filter統一做解密、驗籤、認證、鑑權、接口日誌、異常處理等操作,那麼,解密/驗籤/認證/鑑權/接口日誌/異常吹了這些功能之間就產生了時間內聚。這些功能之間原本沒有什麼關係,但是考慮到這種時間內聚,我們一般會把它們放到同一個包下、或者繼承同一個父類。
/*** 入參解密*/class DecodeFilter extends HttpFilter{ // 略}/*** 入參驗籤*/class SignFilter extends HttpFilter{ // 略}/*** 登錄認證*/class CertificationFilter extends HttpFilter{ // 略}// 其它類似,略
這些操作、功能之間並沒有必然的聯繫——從這一點上來看,時間內聚也是一種弱內聚。但它多少還是比偶然內聚和邏輯內聚要更強一些的:畢竟它們聚在一起是有正當理由的。就好比哪怕你都叫不全大學同班同學的名字,但畢業十週年的時候聚一聚也是合情合理的。
Procedural cohesion:過程內聚
Procedural cohesion is when parts of a module are grouped because they always follow a certain sequence of execution.
過程內聚是指一個模塊內的多個組件之間必須遵循一定的執行順序才能完成一個完整功能。
https://en.wikipedia.org/wiki/Cohesion_(computer_science)#Types_of_cohesion
顯然,過程內聚已經是一種比較強的內聚了。存在過程內聚的幾個功能組件應該儘可能地放在一個模塊內,否則在後續的維護、擴展中一定要吃苦頭。
在前面提到的那個金額計算的模塊中,存在下面這種情況:
/** 計算器基類 */
public abstract class Calculator{
private String formula;
protected Calculator(String formula){
super();
this.formula=fomrula;
}
public abstract CalculateResult calculate(CalculateParam money);
}
/** 分期服務費計算器 */
public class InstallmentServiceFeeCalculator exstends Calculator{
public ServiceFeeCalculator(){
// 分期服務費公式:分期本金*服務費費率
super("installmentPrincipal*serviceFeeRate");
}
/** 計算分期服務費 */
CalculateResult calculate(CalculateParam money){
// 注意:這裏必須保證已經調用過InstallmentPricipalCalculator
// 並已經計算出了分期本金
}
}
/** 分期本金計算器 */
public class InstallmentPricipalCalculator extends Calculator{
// 略
}
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、同一個文件、或者同一張表等)。
https://en.wikipedia.org/wiki/Cohesion_(computer_science)#Types_of_cohesion
對設計模式熟悉的同學一定不會對通信內聚感到陌生:責任鏈/代理等模式就是很典型的通信內聚。例如,我們曾有一個模塊應該是這樣的:
public interface DataCollector{
void collect(Data data);
}
class DataCollectorAsChain implements DataCollector{
private List<DataCollector> chain;
@Override
public void collect(Data data){
chain.foreach(collector-> collector.collect(data));
}
}
class DataCollectorFromServerA implements DataCollector{
@Override
public void collect(Data data){
// 從數據庫裏查到一堆數據
data.setDataA(xxx);
}
}
// 此外還有類似的從ServerB/ServerC的接口獲取數據的幾個類;
// 這些類最終都會組合到DataCollectorAsChain的chain裏面去。
上面是一個典型的責任鏈模式。責任鏈上每一環都需要向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.
順序內聚是指在一個模塊內的多個組件之間存在“一個組件的輸出是下一個組件的輸入”這種“流水線”的關係。
https://en.wikipedia.org/wiki/Cohesion_(computer_science)#Types_of_cohesion
如果熟悉Java8的Lambda表達式的話,應該很容易想到:Java8中的Stream就是一個順序內聚的模塊。例如下面這段代碼中,從bankcCardList.stream()開啓一個Stream之後,filter/map/map每一步操作的輸出都是下一個操作的輸入,而且它們必須按順序執行,這正是標準的順序內聚:
List<BankCard> bankCardList = ...;
User u = ...;
String bankCardPhone =
bankcCardList.stream()
.filter(card->card.no().equals(u.getBankCardNo())))
.map(BankCard::getPhone())
.map(phone -> "*******" + phone.subString(phone.lengh()-4)))
.orElse(StringUtils.EMPTY);
除了Stream之外,設計模式中的裝飾者/模板/適配器等模式也是很典型的順序內聚……等等。例如,我們來看這段代碼:
public interface FlwoQueryService{
Optional<Flow> queryFlow(Queryer queryer);
}
class FlwoQueryServiceFromDbImpl{
public Optional<Flow> queryFlow(Queryer queryer){
// 從數據庫裏查詢用戶流程,略
}
}
abstract class FlowQueryServiceAsDecorator implements FlowQueryService{
private FlwoQueryService decorated;
public Optional<Flow> queryFlow(Queryer queryer){
// 裝飾者,在decorated查詢結果的基礎上,做一次裝飾處理
return decorated(queryer).map(flow-> decorate(flow, queryer));
}
/** 增強方法 */
protected abstract Flow decorate(Flow flow, Queryer queryer);
}
class FlowQueryServiceNotNullImpl extends FlowQueryServiceAsDecorator{
protected Flow decorate(Flow flow, Queryer queryer){
// 如果flow爲null,則創建一個新數據
}
}
在上面的裝飾者——當然也可以叫模板——類中,這兩個步驟的順序是固定的:必須先由被裝飾者執行基礎的查詢操作、再由裝飾者做一次增強操作;而且被裝飾者的查詢結果也恰恰就是裝飾操作的一個入參。可以說,這段代碼很完美的解釋了什麼叫“順序內聚”。
這段代碼是我們重構優化後的成果。在重構之前,我們只有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 .
功能內聚是指一個模塊內所有組件共同完成一個功能、缺一不可。
https://en.wikipedia.org/wiki/Cohesion_(computer_science)#Types_of_cohesion
功能內聚是最強的一種內聚。其它內聚更多的是在討論把哪些組件組合成一個模塊;而功能內聚的意義在於:它討論的是把哪些組件提出當前模塊。即使某個組件與模塊內組件存在順序內聚、通信內聚、過程內聚,但只要這個組件與這個模塊的功能無關,那這個組件就應該另謀高就。
例如,我們系統中有一個調用規則引擎的模塊:
public interface CallRuleService{ RuleResult callRule(RuleData data);}class CallRuleService implements CallRuleService{ public RuleResult callRule(RuleData data){ validate(data); RuleRequest request = transToRequest(data); RuleResponse response = callRuleEngin(request); return transToResult(response); }}
無論是校驗、構建請求、調用引擎還是解析結果,這個模塊中所有的代碼都是爲了實現一個功能:調用規則引擎並解析結果。但是,隨着業務發展、需求變更,這個模塊中出現了越來越多的“噪音”:把調用規則引擎的request和response入庫、在封裝數據時把某個數據同步給某個系統、在得到響應後把某個字段發送給另一個系統……諸如此類,不一而足。這些業務需求並不直接與“調用規則引擎”這個核心功能,相關組件與“調用核心規則”也只是順序內聚(需要使用調用規則引擎的返回結果)、通信內聚(需要使用調用規則引擎的入參/出參)甚至只是時間內聚(需要在調用規則引擎時同步數據)。從“功能內聚”的角度來看,這些新增代碼就不應該放到這個模塊中來。
但是,由於一些歷史原因,這些代碼、組件、需求全都被塞到了這個模塊中。結果,這個模塊不僅代碼非常臃腫,而且性能也十分低下:一次用戶請求常常要20多秒才能完成,可是由於模塊可維護和可擴展性差,重構優化也非常困難。如果當初能遵循“功能內聚”的要求,把不必要的功能放到別的模塊下,我們也不會像現在這樣望洋興嘆、無從下手了。
練習
我在《高內聚與低耦合》文中舉過一個這樣的例子:
這個模塊中的組件屬於哪種內聚呢?
嚴格一點說,右側那些組件——從“提交信息”到“發送短信驗證碼”或“判斷短信驗證碼是否正確”——屬於功能內聚。它們全都是爲了完成“短信簽約”這個操作而組合到當前模塊下的。
但是,左側這些組件——從“後續業務分發器”到“後續業務處理A”等——之間,只能算時間內聚。各種後續業務處理之間並沒有直接的、或者本質上的關聯,它們被放在這個模塊中的原因僅僅是他們都要在短信簽約完成之後做一些處理。這可以說是標準的時間內聚。
左側和右側組件之間呢?從上面的分析也能看出來:這兩大部分之間是順序內聚。這個模塊必須先調用右側組件,在它們處理完成後才能去調用左側組件進行處理。
在《抽象》一文中,還有這樣一個例子:
public interface CardListService{ List<Card> query(long userId, Scene scene);}//核心實現是這樣的public class CardListServiceImpl{ private Map<Scene, CardListService> serviceMap; public List<Card> query(long userId, Scene scene){ return serviceMap.get(scene).query(userId, scene); }}// 返回字段是這樣的public class Card{ // 客戶端根據這個字段的值來判斷當前銀行卡是展示還是置灰 private boolean enabled; // 其它卡號、銀行名等字段,和accessor略去}// 入參是這樣的public enum Scene{ DEDUCT, UN_BIND, BIND;}
在這個組件中,用於處理DEDUCT/UN_BIND/BIND等各種邏輯的組件之間是什麼內聚關係呢?我認爲是通信內聚:它們都要針對入參userId和scene做處理,並返回同樣的List<Card>。