分享

细说几种耦合

 景昕的花园 2023-10-10 发布于北京

    高内聚和低耦合是很原则性、很“务虚”的概念。为了更好的讨论具体技术,我们有必要再多了解一些高内聚低耦合的度量标准。

这一篇与《细说几种内聚》是姊妹篇。可以对照着看。

花园的景昕,公众号:景昕的花园细说几种内聚

耦合

    耦合性讨论的是模块与模块之间的关系。同样参考维基百科,我们来看看耦合都有哪几种。

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了。除此之外,不直接使用代码、但是重复实现功能,也可以算作内容耦合。

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代码、重复实现一样,当这些“私密”内容发生变化时,与之耦合的代码必然也要发生变动。

不知道说啥好,给您拜个早年吧

    眼尖的同学可能已经发现了:上面这个例子就是二十三种设计模式中的“访问者模式”。但是,设计模式不是很高上大的吗?为什么也会有这样内容耦合这样的强耦合呢?这个事儿说来简单:任何设计——无论是架构设计、程序设计,还是建筑设计、平面设计——都是一个“取舍”的决策和过程:为了达到主要目标,常常要舍弃一些次要目标。“访问者模式”就是这样一种取舍的结果。

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、然后找上门来兴师问罪。

公共耦合的模块/系统之间,不知道什么时候就会爆发一场“私生子之战”。

    当然,不要直接访问其它系统的数据库基本上是程序员的共识、也是很少会再犯的错误了。但是,公共耦合还会出现在其它场景下,例如我们有过这样一段代码:

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;    // 字段,略}

    这种外部耦合就好像我必须有一个和你一模一样的钱包才能找你借钱,只要样式、尺寸、纹路、甚至新旧程度上有一点点不一样,我都借不到钱。它带来的问题也是显而易见的。当服务提供者要修改接口参数时,要么消费者全部随之升级;要么提供者维护多个版本——即使这次修改完全可以向下兼容。而这两种方案在绝大多数情况下都是在给自己挖坑。

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”了。

我也不知道我依赖的这个包是不是我依赖的你依赖的这个包

    处理过各种框架的版本匹配问题的话,一定不会忘记被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内存管理都有帮助。不过这个问题以后再说,这里按下不表。

    至于为什么要求传递的值就是要共享的值呢?简单的回答就是:如果传递了不需要使用的值,就会陷入特征耦合中。而特征耦合比数据耦合的耦合度更强。

别说一百块,多一个字段都不给

    不过话说回来,完全使用基本类型来做参数传递有时会降低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”才能使用继承;继承应尽量遵循里氏替换原则;尽量用组合取代继承;等等。

这次不放那个各种鸟的类图了……来出个戏,学习点鸟类学知识吧

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的形式暴露出来。曾经有位同学尝试用这种反射的方式来构建一个可扩展的功能模块,给我吓出一身冷汗……

我还找到了当时他画的设计图。上图中“统一处理类”就是用反射来做的。

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下标与正反面的对应关系)依赖于业务约束(用户先拍正面后拍反面),当业务约束发生变化时,系统逻辑很容易就被殃及池鱼了。

    诚然,系统逻辑多多少少都依赖于某些业务约束,也就是形式逻辑里所谓前置条件。但是,这类业务约束应当越少越好、越宽松越好。这样,当业务需求发生变化时,系统逻辑才能够以不变应万变、或至少以小变应大变。

I have a dream:如果需求万变系统不变、或者需求大变系统小变(这话怎么这么别扭呢),开发是不是就不用这么加班了

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多