細說幾種耦合

    高內聚和低耦合是很原則性、很“務虛”的概念。爲了更好的討論具體技術,我們有必要再多瞭解一些高內聚低耦合的度量標準。

這一篇與《細說幾種內聚》是姊妹篇。可以對照着看。

花園的景昕,公衆號:景昕的花園細說幾種內聚


耦合

    耦合性討論的是模塊與模塊之間的關係。同樣參考維基百科,我們來看看耦合都有哪幾種。

640?wx_fmt=jpeg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1



Content coupling:內容耦合

Content coupling is said to occur when one module uses the code of other module, for instance a branch. This violates information hiding - a basic design concept.

內容耦合是指一個模塊直接使用另一個模塊的代碼。這種耦合違反了信息隱藏這一基本的設計概念。


    內容耦合是耦合度最高的一種耦合。最常見、大概也是最可惡的內容耦合,無疑就是Ctrl+C/Ctrl+V了。除此之外,不直接使用代碼、但是重複實現功能,也可以算作內容耦合。

640?wx_fmt=jpeg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1


ctrl+c/ctrl+v是面向搜(wa)索(keng)引(bu)擎(tian)編程的基本技能


    例如,在我們系統中有一個很重要的閾值,用戶的某項分數必須達到這個閾值才能通過。而且這個閾值在多個系統中都要使用和判斷處理。結果,這一個簡單的獲取閾值的操作就在三個系統中、用不同的方式被重寫了三次:系統A把閾值直接寫死在代碼中;系統B把閾值配置在本地數據庫中;系統C把閾值配置在公共的配置系統中。問題是顯而易見的:當產品要調整這個閾值時,他需要通知三個系統一起調整。這與我們把一套代碼Copy到N個地方的後果一樣:一個變化,N處修改。


    有些文章會把“通過非正常入口而轉入另一個模塊內部”也歸入內容耦合中。什麼叫“轉入一個模塊內部”呢?我們可以參考下面這個例子:



// 這個接口有諸多實現:Mother、Father、Sister、Brother、Uncle等,略public interface Relative {    // 接納一個拜年的人    default void accept(HappNewYearVisitor visitor){        visitor.visit(this);    }}
// 這個接口定義了拜年的人public interface HappNewYearVisitor {   void visit(Mother mother);   void visit(Father father);   void visit(Sister sister);   void visit(Brother brother);   void visit(Uncle uncle);}// 我是這麼拜年的public class Me implements HappNewYearVisitor{   public void visit(Mother mother){       // 給老媽發個打麻將紅包   }   public void visit(Father father){       // 詐一下老爸的私房錢   }   public void visit(Sister sister){       // 妹兒~不給紅包我就把你男朋友捅到爸媽那兒了哦   }   public void visit(Brother brother){       // 哥,來打一局遊戲啊,誰輸誰發紅包   }   public void visit(Uncle uncle){       // 叔叔過年好   }}// 我堂妹是這麼拜年的public class Cousin implements HappNewYearVisitor{   public void visit(Mother mother){       // 姆姆過年好   }   public void visit(Father father){       // 伯伯過年好   }   public void visit(Sister sister){       // 姐姐過年好,你的口紅在哪買的好好看多少錢有代購嗎……   }   public void visit(Brother brother){       // 哥哥過年好,紅包呢紅包呢紅包呢紅包呢紅包呢紅包呢紅包呢紅包呢   }   public void visit(Uncle uncle){       // 把把~~倫家今年想去蘇州玩~~~~給發個旅遊紅包好不~~~~~~~~~~~~   }}



    上面這個例子中,Mother/Father/Sister/Brother/Uncle這些類,都處在Relative這個模塊的內部。原則上,模塊外部的任何類都只能通過接口來訪問它們。但是,HappNewYearVisitor及其實現類卻打破了這層封裝,直接訪問到了模塊內部的具體類,並根據不同的類做了不同處理:這些不同的處理很有可能要使用不同類的“私密”數據或者行爲,例如我必須知道我姐有個沒公開的男朋友才能“要挾”她、還得知道我哥雖然打遊戲特別菜卻偏偏不服輸纔會向他挑戰,等等。這就是我把這種情況稱爲“內容耦合”的原因。與Copy代碼、重複實現一樣,當這些“私密”內容發生變化時,與之耦合的代碼必然也要發生變動。

640?wx_fmt=jpeg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1

不知道說啥好,給您拜個早年吧


    眼尖的同學可能已經發現了:上面這個例子就是二十三種設計模式中的“訪問者模式”。但是,設計模式不是很高上大的嗎?爲什麼也會有這樣內容耦合這樣的強耦合呢?這個事兒說來簡單:任何設計——無論是架構設計、程序設計,還是建築設計、平面設計——都是一個“取捨”的決策和過程:爲了達到主要目標,常常要捨棄一些次要目標。“訪問者模式”就是這樣一種取捨的結果。


Common coupling:公共耦合

Common coupling is said to occur when several modules have access to the same global data. But it can lead to uncontrolled error propagation and unforeseen side-effects when changes are made.

公共耦合是指多個模塊訪問同一個全局數據。當(某個模塊或全局數據)發生變化時,這種耦合可能會導致不受控制的錯誤傳播以及無法預見的副作用。


    公共耦合也叫共享耦合、全局耦合。這個定義很容易讓人聯想到一些併發場景下的同步控制,例如信號量、生產者-消費者等:畢竟Java中的同步控制就是通過共享數據來實現的。不過,同步控制的組件一般都會放到同一個模塊下,所以他們之間即使有公共耦合,問題也不大。


    容易出問題的是模塊與模塊之間、甚至是系統與系統之間的公共耦合。最常見的恐怕是系統A直接訪問系統B的數據庫表。我們的系統目前就面臨這樣的問題:由於歷史原因,很多個外部系統直接訪問了我們的數據庫表;尤其可怕的是,現在已經統計不清楚哪些系統訪問了哪些表了。結果,雖然我們正在大刀闊斧的對自己的系統進行重構優化,但是不僅無法變更數據庫表結構,甚至重構後的代碼還得往已廢棄的表裏寫入數據。否則,說不定哪個系統就要出bug、然後找上門來興師問罪。

640?wx_fmt=jpeg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1

公共耦合的模塊/系統之間,不知道什麼時候就會爆發一場“私生子之戰”。


    當然,不要直接訪問其它系統的數據庫基本上是程序員的共識、也是很少會再犯的錯誤了。但是,公共耦合還會出現在其它場景下,例如我們有過這樣一段代碼:


public class XxxService{    private static final Bean BEAN = new Bean();    static{        // 初始化BEAN。Bean中所有get/set都是public的。BEAN.setA("a");        BEAN.setB("b");    }    public List<Bean> queryBeanList(){        // 先從數據庫查一批數據       List<Bean> list = ...;       // 如果數據庫麼有數據,那麼給個默認列表       if(Collections.isEmpty(list)){           list = Collections.singletonList(BEAN);       }       return list;    }}// 使用上面這個方法的類public class YyyService{    public void doSomething(){        List<Bean> beanList = xxxService.queryBeanList();        // 其它邏輯,略    }}public class ZzzService{    public void querySomeList(){        List<Bean> beanList = xxxService.queryBeanList();        // 其他邏輯,略    }}



    上面這段代碼看起來沒問題。但是,仔細梳理一下就能發現:XxxService和YyyService/ZzzService(以及任何調用了XxxService#queryBeanList()方法的模塊)之間,在BEAN這個全局變量上產生了公共耦合。這種耦合會導致什麼問題呢?一方面,如果XxxService變更了BEAN中的數據——也就是變更了默認列表的數據——那麼YyyService/ZzzService等模塊就有可能受到不必要的牽連。另一方面,如果YyyService模塊修改了queryBeanList()的返回數據,那就有可能修改BEAN中的數據,從而在悄無聲息間改變了queryBeanList()的邏輯、並導致ZzzService模塊出現莫名其妙的bug。


    可見,公共耦合的耦合度也比較高,系統中應當儘量避免出現這種耦合。


External coupling:外部耦合

External coupling occurs when two modules share an externally imposed data format, communication protocol, or device interface. This is basically related to the communication to external tools and devices.

外部耦合是指兩個模塊共享一個外部強加的數據結構、通信協議或者設備接口。外部耦合基本上與外部工具和設備的通信有關。


    說到外部耦合,我就想吐槽一下大名鼎鼎的Dubbo了。在Dubbo中,服務提供者與消費者通信時使用的數據結構必須完全一致:包名、類名、字段名、字段類型、乃至字段個數以及序列化版本號都必須完全一致。這就是一種典型的外部耦合:提供者與消費者共享一個外部強加的數據結構。



public interface DubboFacade{    // 服務者與消費者使用的Request和Response必須完全一致    Response call(Request req);}public class Request implements Serializable{    // 序列化版本號    private static final long serialVersionUID = -45245298751L;    // 字段,略}public class Response implements Serializable{    // 序列化版本號    private static final long serialVersionUID = -98639823124L;    // 字段,略}



    這種外部耦合就好像我必須有一個和你一模一樣的錢包才能找你借錢,只要樣式、尺寸、紋路、甚至新舊程度上有一點點不一樣,我都借不到錢。它帶來的問題也是顯而易見的。當服務提供者要修改接口參數時,要麼消費者全部隨之升級;要麼提供者維護多個版本——即使這次修改完全可以向下兼容。而這兩種方案在絕大多數情況下都是在給自己挖坑。

640?wx_fmt=jpeg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1

java.lang.IllegalStateException: Serialized class Money must implement java.io.Serializable


    類似的問題在我們自己的代碼中也出現過。我在《抽象》一文中就舉過這樣一個例子。我參與設計過一套Java操作Excel文件的工具,底層用的是POI組件。


// 這是這個工具模塊提供的接口public interface ExcelService<T>{    public List<T> read(HSSFWorkbook workBook);}// 調用方是這樣的使用的public class DataService{    private ExcelService<Data> excelService;    public void parseData(){        // 讀取一個excel文件       HSSFWorkbook workBook = ....;       // 把excel解析爲數據封裝類       List<Data> dataList = excelService.read(workBook);       // 後續處理,略    }}



    ExcelService這個工具的問題,在《抽象》一文中也已經提到過:它把Excel2003格式的組件HSSFWorkbook暴給了調用方,導致無法平滑升級更高版本的Excel。而導致這個問題的原因,正是外部耦合:ExcelService和DataService這兩個模塊共享了一個外部的數據結構HSSFWorkbook。


Control coupling:控制耦合

Control coupling is one module controlling the flow of another, by passing it information on what to do (e.g., passing a what-to-do flag).

控制耦合是指一個模塊通過傳入一個“做什麼”的數據來控制另一個模塊的流程。


    結合內聚類型來看,控制耦合對應的就是邏輯內聚。邏輯內聚的問題在前面已經討論過,這裏就不再贅述了。



Stamp coupling:特徵耦合

Stamp coupling occurs when modules share a composite data structure and use only parts of it, possibly different parts .

特徵耦合是指多個模塊共享一個數據結構、但是隻使用了這個數據結構的一部分——可能各自使用了不同的部分。


    特徵耦合也叫數據結構耦合(data-structured coupling)。衆所周知,面向對象編程很容易引發“類爆炸”問題,一段簡簡單單的邏輯中可能就要定義七八個類。要避免類爆炸問題,複用代碼是一個不錯的法子。但是,無腦地複用代碼就有可能造成特徵耦合,爲後續的維護和擴展埋下隱患。


    例如,我們有一個api包中的數據結構是這樣定義的:


package com.abc.api.model;

import com.def.data.XxxInfoVO;import com.def.api.data.YyyVO;import com.def.api.data.ZzzInfoVO;
import java.io.Serializable;import java.util.List;

public class AbcVo implements Serializable {    private XxxInfoVO xxxInfo;    private YyyVO yyyInfo;    private ZzzInfoVO zzzInfo;}



    這裏的問題比較隱蔽:AbcVo在com.abc的子包下;但是其成員變量xxxInfo/yyyInfo/zzzInfo卻是在com.def的子包下定義的。而在實際中,com.abc和com.def是由兩個不同的項目定義的兩套api——假定分別是abc-api.jar和def-api.jar吧。這意味着什麼呢?首先,某個系統如果要使用AbcVo,那不僅需要引入abc-api.jar,還需要引入def-api.jar,並且這兩個jar包的版本還必須能匹配上。如果兩個包的版本號沒匹配上,就是熟悉的“NoSuchClassError/NoSuchMethodError”了。

640?wx_fmt=jpeg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1

我也不知道我依賴的這個包是不是我依賴的你依賴的這個包


    處理過各種框架的版本匹配問題的話,一定不會忘記被NoSuchClassError/NoSuchMethodError支配的恐懼。萬萬沒想到我們的業務代碼中也埋着如此高級的雷。我該欣慰呢還是難過呢。


    但是,也不要爲了避免特徵耦合而走向另一個極端。例如,信用卡和借記卡都是銀行卡,不用爲它們倆分別定義一個數據結構:



public class BankCardController{    public CardInfo queryCardInfo(Long userId){        // 分別查出放款卡和還款卡,略    }}public class CardInfo{    private CreditCardInfo creditCardInfo;    private DebitCardInfo debitCardInfo;}public class CreditCardInfo{    private String cardNo;    private String bankName;    private String userName;}public class DebitCardInfo{    private String cardNo;    private String bankName;    private String userName;}



Data coupling:數據耦合

Data coupling occurs when modules share data through, for example, parameters. Each datum is an elementary piece, and these are the only data shared (e.g., passing an integer to a function that computes a square root).

數據耦合是指模塊間通過傳遞數值來共享數據。傳遞的每個值都是基本數據,而且傳遞的值是就是要共享的值。


    數據耦合的耦合度非常低,模塊間只通過數值傳遞耦合在一起。更何況這種數值傳遞還有兩個附加條件:傳遞的每個值都是基本數據;而且傳遞值就是要共享的值。爲什麼要有這兩個附加條件呢?


    首先,爲什麼要求傳遞基本數據呢?一般來說,與“基本數據”對應的是“指針”、“引用”、或者“複雜對象”這種數據。後者可能導致模塊功能產生一些副作用。例如這種:


public class SomeService{    public void doSomething(Map<String, String> param){        param.put("a","abc");    }}



    上面這段代碼看起來人畜無害。但是,假如某個調用方在調用doSomething方法時,傳入的param中就已經有"a"="111"這個鍵值對了呢?在調用完這個方法後,param.get("a")不知不覺就編程了"abc"。這就有可能讓調用方出現bug。如果doSomething方法把入參改爲簡單類型的值、並且"abc"作爲返回值傳遞給調用方,就不會出現這個問題了。


    不過,“Java到底是值傳遞還是引用傳遞”這個問題也經常出現。這個問題其實很有趣,對理解Java中的對象、引用甚至JVM內存管理都有幫助。不過這個問題以後再說,這裏按下不表。


    至於爲什麼要求傳遞的值就是要共享的值呢?簡單的回答就是:如果傳遞了不需要使用的值,就會陷入特徵耦合中。而特徵耦合比數據耦合的耦合度更強。

640?wx_fmt=jpeg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1

別說一百塊,多一個字段都不給


    不過話說回來,完全使用基本類型來做參數傳遞有時會降低API的可擴展性和可維護性。例如,考慮下面這個接口:


public interface IdCardService{    boolean checkIdNo(String idNo);}



    最初版本中,這個服務只需要檢查身份證號是否合法,並且返回結果就只有合法、不合法兩種。但是,隨着業務需求的發展,這個服務還要檢查身份證號與姓名是否匹配;還要區分幾種錯誤類型(身份證號錯誤,身份證號與姓名不匹配,等等)。這時,我們只有兩個辦法:要麼修改原有接口,要麼增加多個方法。而這兩種辦法,都會像前面吐槽Dubbo時所說的那樣各有弊端。


    但是,如果我們一開始就用複雜對象來傳遞數據呢?像這樣:


public interface IdCardService{    CheckResult checkIdNo(IdCard card);}public class IdCard{    // 最初版本的字段    private String idNo;    // 隨着需求發展而增加的字段    private String name;    private String address;   private Date startDate;   private Date endDate;}public class CheckResult{    // 最初版本的字段    private boolean checkPass;    // 隨着需求發展而增加的字段    private IdCardError error;}public enum IdCardError{    NO_ERROR,    ID_NUMBER_ILLEGAL,    NUMBER_NOT_MATCH_NAME,}



    這樣定義接口可能產生特徵耦合,但是其擴展性和維護性會更好一些。何去何從?這也是一種取捨。


Subclass coupling:子類耦合

Describes the relationship between a child and its parent. The child is connected to its parent, but the parent is not connected to the child.

子類耦合描述的是子類與父類之間的關係:子類鏈接到父類,但是父類並沒有鏈接到子類。


    子類耦合的耦合度非常高,我認爲我們可以把它看做是面向對象中的內容耦合:子類非常深的侵入到了父類的內部,並且可以通過重寫來改變父類的行爲。這也是爲什麼雖然繼承是面向對象的基本特性,但是面向對象設計並不提倡使用繼承的一個原因。


    使用繼承帶來的問題中,最典型的就是修改一個父類、影響所有子類。除此之外,子類對父類變量、方法的重寫和覆蓋也很容易帶來問題——這類問題在各種面試題中都屢見不鮮;在我們的系統中也偶有出現。例如,我曾經遇到過一段這樣的代碼:


/** 通用的返回結果定義 */public class CommonResult {    private boolean success;    private String message;    public boolean isSuccess() {        return this.success;    }    public void setSuccess(boolean success) {        this.success = success;    }}/** 某個接口自定義的返回結果 */public class SpecialResult extends CommonResult {    private Boolean success;    // 其它字段,略    public Boolean getSuccess() {        return this.success;    }    public void setSuccess(Boolean success) {        this.success = success;    }}



    用一個對象來封裝所有API接口都要返回的公共字段、用它的子類來封裝各接口特定的字段,這是一種比較通用的做法。上面的CommonResult和SpecialResult也是遵循這個思路來定義的。但是,SpecialResult作爲子類,卻錯誤地重寫了父類中的成員變量和方法,導致這個接口在JSON序列化與反序列化時出了問題:


CommonResult o = new SpecialResult();o.setSuccess(true);// 猜猜這裏的序列化結果是什麼?System.out.println(new ObjectMapper().writeValueAsString(o));
String json = "{\"success\":true}";SpecialResult bo = new ObjectMapper().readValue(json, SpecialResult.class);// 猜猜這裏的反序列化結果是什麼?System.out.println(bo.isSuccess() + "," + bo.getSuccess());



    由於父子類之間的耦合度是如此之高,所以在使用繼承時有諸多的約束:必須是“is-a”才能使用繼承;繼承應儘量遵循里氏替換原則;儘量用組合取代繼承;等等。

640?wx_fmt=jpeg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1

這次不放那個各種鳥的類圖了……來出個戲,學習點鳥類學知識吧


Dynamic coupling:動態耦合


The goal of this type of coupling is to provide a run-time evaluation of a software system. It has been argued that static coupling metrics lose precision when dealing with an intensive use of dynamic binding or inheritance [4]. In the attempt to solve this issue, dynamic coupling measures have been taken into account.

動態耦合是用來衡量系統運行時的耦合情況的。有人認爲,對於大量使用了動態綁定和繼承的系統來說,靜態耦合不能很好地度量出模塊間的耦合度。動態耦合就是爲了解決這方面問題而提出的。



    動態綁定,例如繼承、多態、反射、甚至序列化/反序列化等機制,確實給編碼帶來了很大的便利。但是動態一時爽……哈哈哈。動態耦合最大的問題在於:如果耦合的一方發生了變化,通常很難評估另一方會受到什麼影響——我們甚至很難評估出哪些功能會受到影響,因爲從靜態的代碼中很難監測到動態耦合的各方。例如下面這種代碼:


SomeClass.getMethod("methondName").invoke("abc",String.class);
BeanUtils.copyProperties(dto, vo);
JsonUtils.fromJson(JsonUtils.toJson(dto), Vo.class);
Glass glass = (Glass)context.getBean("maybeGlassImpl");
<bean class="SomeService>    <property name="someDao" ref="someDaoImpl"/></bean>



    以第一種情況爲例:如果SomeClass#methondName(String)方法的方法簽名變了——例如擴展爲SomeClass#methondName(String, int),這行代碼可能不會有任何的錯誤提示。如果測試用例沒有覆蓋到這一行,那麼這個問題就會被忽視掉,最後以線上bug的形式暴露出來。曾經有位同學嘗試用這種反射的方式來構建一個可擴展的功能模塊,給我嚇出一身冷汗……

640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1

我還找到了當時他畫的設計圖。上圖中“統一處理類”就是用反射來做的。


Semantic coupling:語義耦合

This kind of coupling considers the conceptual similarities between software entities using, for example, comments and identifiers and relying on techniques such as Latent Semantic Indexing (LSI).

語義耦合是指兩個軟件實體使用了相似的概念——例如註釋、標識符等等。(自動檢測)語義耦合依賴於潛在語義索引(LIS)這類技術。


    語義耦合、潛在語義索引這些概念聽着很高上大。個人理解,語義耦合說的就是系統邏輯依賴於業務約束。例如這種:


public interface IdCardService{    void dealIdCardPhoto(List<byte[]> photoList);}



    IdCardService這個接口的功能是對身份證正反面照片做處理。但是它的入參卻是一個List<byte[]>。這就帶來一個問題:在這個List中,哪個元素是身份證正面、哪個是身份證反面?在我們的系統中,photoList.get(0)是身份證正面,而photoList.get(1)是身份證反面。爲什麼這樣定義?因爲按照業務需求,用戶會先拍攝身份證正面照片、再拍攝身份證反面照片。


    顯然地,這個接口會帶來一個新的問題:如果業務需求變化,要求用戶先拍攝身份證反面、後拍攝身份證正面呢?或者APP不強制要求順序,用戶可以自己決定先拍哪一面呢?或者哪怕需求沒有發生變化,就是開發在後來的代碼維護中修改了List中的順序呢?由於這個接口內的系統邏輯(List下標與正反面的對應關係)依賴於業務約束(用戶先拍正面後拍反面),當業務約束髮生變化時,系統邏輯很容易就被殃及池魚了。


    誠然,系統邏輯多多少少都依賴於某些業務約束,也就是形式邏輯裏所謂前置條件。但是,這類業務約束應當越少越好、越寬鬆越好。這樣,當業務需求發生變化時,系統邏輯才能夠以不變應萬變、或至少以小變應大變。

640?wx_fmt=jpeg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1

I have a dream:如果需求萬變系統不變、或者需求大變系統小變(這話怎麼這麼彆扭呢),開發是不是就不用這麼加班了


qrcode?scene=10000004&size=102&__biz=MzUzNzk0NjI1NQ==&mid=2247484307&idx=1&sn=92c2952fe0a655a7fb36a287ca1dd619&send_time=

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章