在讨论如何稳定系统内各模块的分层设计前, 本文先介绍一下目前判断各模块间耦合度强弱的度量方式。这些度量方式,在实际工作中读者应该都涉及过,只是可能没有去做详细的划分归类。
1、模块间耦合强度度量
模块间的耦合强度分为以下几种(耦合强度从高到底),耦合强度越高越应该重新进行模块间耦合方式的设计,付出的设计成本也相对越低,但效果却不一定很显著。但要对耦合强度已经较低的设计进行修改,要求的业务抽象能力和模块设计能力就越高,设计成本也越高,普遍来看效果也会越显著。
本节内容我们将通过理论讲解和代码示例演示相结合的方式,为各位读者详细介绍这些耦合强度在我们平时的工作中是如何体现的。先从模块间耦合度最高的耦合方式开始,讲解他们的代码效果和存在的问题。
另外需要说明的是,虽然前文介绍过解除循环依赖是模块解耦设计的关键,但不代表较高的模块耦合度一定存在循环依赖,仅代表是否更容易产生循环依赖。
1.1、内容耦合/逻辑耦合
初看这种耦合描述,有的读者就会问,内容不就是数据吗?那么内容耦合和数据耦合不就是一回事吗?既然是一回事,为什么两种叫法却有不同的耦合强度呢?那就只可能有一种情况,就是这里所说的“内容”和“数据”不是一回事。实际上它们确实不是一回事,内容耦合主要是指模块内部的具体业务处理过程和模块内的数据结构模型。
也就是说,这种耦合方式下如果被调用的模块,其内部行为过程或者数据模型发生了变化,那么这种变化就会被传导到调用方。例如,直接通过调用模块的数据表(持久层)功能,进行数据操作。这样一来,如果被调用模块的数据表发生了变化(增加/减少/修改了某个字段、新增了一张关联的数据表),那么调用方的业务逻辑就不得不进行变化。
内容耦合一般会出现一个调用现象,就是调用方对所调用的模块,其调用位置并不是一个固定的层面,而是可能对被依赖模块的不同层面进行调用。如下图所示,就是一种常见的内容耦合。
这可能由于A业务模块并没有为调用者提供统一的调用层,也可能由于调用者并没有按照调用规范进行调用,导致B业务模块针对A业务模块的调用分散在A业务模块的多个设计结构层面。内容耦合是典型的需要避免的耦合方式,它的缺点包括:
-
很容易产生循环依赖:由于调用分散,开发人员并不明确知道该如何调用模块,也不清楚模块的分层定位,基本上就是随心所欲(怎么方便怎么来)地进行功能的调用。在这个过程中模块和模块的循环依赖就很容易产生了。
-
在被调用模块的内容发生变化的情况下,很难控制涟漪:特别是当调用者直接调用了A业务模块的具体逻辑实现时,例如A业务模块的数据库设计实现层,那么就意味着B业务模块依赖了A业务模块的具体实现进行自己业务逻辑的实现。当A业务模块的处理逻辑、处理逻辑发生变化时,B业务系统的逻辑就不得不做出调整。如果还有其它调用者又调用了B业务模块的具体实现,那么这种逻辑调整就不得不进行传递。
-
无法适应变化,技术债务积累速度很快:技术债务会以惊人的速度被积累,是显而易见的事情。特别是在大型系统中进行这样的模块设计,并且不在研发规范上对开发人员进行限制。那么5个调用者对同一个模块进行调用时,可能就会有5中调用方式,而且这些调用方式的逻辑可能还是重复的或者冲突的。以下代码是一个典型的内容耦合/逻辑耦合的常见示例:
// 以下是调用方代码
// 为了简化理解,这里使用了一些spring的注解
@Component
public class Invoker {
// 其他模块的数据持久层功能
@Autowired
private TargetRepository targetRepository;
// 直接调用目标模块的数据持久层方法
// 注意,这里的数据持久层并不是本模块的,而是其它模块的
public void findSomething() {
// 查询到其它模块的数据
TargetEntity entity = targetRepository.findSomething();
// ...... 再依据这个entity对象做后续的处理
}
}
以上代码可以看到当前调用者,引入了目标模块数据持久层的一个方法,然后得到了一个目标业务模块的具体数据结构。那么一旦对方数据表发生了变化,必然导致调用者的处理过程修改。
1.2、公共耦合/外部耦合
看到这个耦合的描述,有的读者又会有疑问:难道我在多个模块中,都调用一种工具方法,就是公共耦合了?我们一定要说明清楚,本系列文章都在讨论一个核心内容,是好的应用系统应该怎么进行设计,或者这么说:要怎么将业务需求设计成层次分明、功能稳定、性能充沛的应用系统。所以我们讨论的问题,一定都绕不开针对业务的讨论。
也就是说这里的公共耦合并不是指多个模块共同调用了某个工具方法,而是指多个模块由于业务边界设计不清楚,所以只能将本来属于多个模块的某种业务过程联合在一起,形成一个底层模块。然后多个模块都将这个过程视为一个公共的业务过程进行调用。如下图所示:
读者实际上可以看到,这种公共耦合就是本系列文章中《软件设计不是CRUD(2):降低模块间耦合性——需求场景》提到的一种所谓解决两个(或多个)模块出现循环依赖的“办法”,显然这种“办法”并不是好的办法。还有一种和公共耦合和相似的耦合强度,叫做外部耦合。这两种耦合强度的区别在于,需不需要将复杂的业务结构暴露给调用者。如果需要暴露,就是一种公共耦合;如果不需要暴露,就是一种外部耦合。写两种模块间耦合强度的设计缺点是:
-
业务边界不明确,且调用者必须知道模块内部的工作逻辑,才能确定在哪个模块去调用相应的业务功能。这主要是因为多个模块中都包含了相同业务领域的处理逻辑,所以调用者必须首先翻阅内部逻辑,确认要调用A业务对外提供的某个功能,是应该调用A业务模块本身,还是需要调用其他模块中的功能。另外,这样的设计方式,也很容易堆积技术债务。
-
公共耦合/外部耦合的内部,很容易出现循环依赖,且这个模块内部的业务边界更难进行拆分。之所以会有这样的公共模块,很多时候是由于A模块和B模块(甚至更多的模块)存在循环依赖的情况。为了尽快简化A模块和B模块的依赖关系,设计人员在没有更好调整办法的情况下,又下沉创建了一个新的模块并将A模块和B模块存在循环依赖的功能部分移动到这个新模块中。这样产生的模块既没有真正解决问题,又没有明确业务边界,而且很难适应后续更多的业务调整诉求。
-
这种公共耦合/外部耦合很难控制涟漪。这主要还是因为这种耦合强度并没有去除调用者对调用模块内部实现逻辑的关注。一旦调用模块的内部实现发生了变化,就会导致调用者的业务逻辑发生修改。
1.3、控制耦合
从控制耦合开始,调用者就不再需要详细关注模块内部的实现逻辑,只需要保证从被模块提供的一个专门的调用层(通常是设计人员所称的服务层)进行模块功能的调用即可。控制耦合本身并没有规定在进行模块功能调用时,传递的控制信息是一个对象还是一个简单的数据类型,控制耦合关注的核心要点是:
-
调用者不再需要关注模块内部实现逻辑,但是需要根据被调用模块要求来传输数据结构,以便进行被调用模块内部逻辑过程的控制。这个控制数据可能是一个普通的数值,也可能是传递结构中某一个(或多个)属性的值。也就是说,如果定义调用时传递的数据结构,话语权是在被调用方。
-
调用者通过一个规范的、专用的调用层进行模块功能的调用。这就意味着,模块间的业务边界需要尽可能清晰,否则很难形成这样的调用层。举个例子,在基于Java语言进行应用程序的设计的过程中,如果设计人员发现一个业务模块很难设计出一个只存在interface的调用层,那么最可能的原因是业务需求在转变成具体设计的过程中,并没有划分好业务边界。
-
这里有一个特定情况需要说明,如果当前模块并不存在业务过程,而是单纯工具性质的处理过程(例如计算日期的功能、字符串拆分功能、中文字符判定功能等等),且这些处理过程并不涉及对其它模块的调用。那么这些对于这些工具性质模块的调用,其耦合强度都属于控制耦合或更弱的耦合。
, -
控制耦合不能完全确保不存在模块间的循环依赖,但可以减少出现循环依赖的可能性。这主要是因为控制耦合的评判标准要求业务边界清晰,而除非你设计的应用系统足够小,否则清晰的业务边界就是能设计出专门的调用层的前提。
-
其次,控制耦合也不能保证外部调用者完全不需要了解模块内部的工作过程,因为外部调用者至少需要知道模块内部有哪几种工作逻辑,这些工作逻辑分别被传入结构或者传入参数的什么属性、什么值所控制。也就是说一旦被调用模块的内部的逻辑分支发生了变化(例如增加了一种处理场景、减少了一种处理场景),那么调用方的逻辑也可能需要修改。
注意:如果模块存在一个专门的调用层,那也不能说明这个模块的耦合度已经降低到控制耦合。但是,一个模块如果没有提供专门的调用层,而任由其他模块无规则的调用,那么这个模块的耦合强度一定没有降低到控制耦合。另外,所谓的只存在interface定义的调用层,可不是专指service这样的接口定义,后文会进行详细介绍。
1.4、标记耦合
在模块间耦合强度已经降低至控制耦合的基础上,如果被调用的模块要求调用者传入一个具体的、符合规定的模型结构,才能完成模块功能的调用,那么这种耦合程度被称为标记耦合。标记耦合的特点是:
-
标记耦合一定首先满足控制耦合的特点,也就是说标记耦合一定是在模块具备了一个统一的、专门的调用层的情况下来进行讨论、识别的。另外由于标记耦合只是在描述调用时传入的模型特点,所以标记耦合并不会避免控制耦合存在的问题,例如调用者仍然需要清楚模块功能中的逻辑分支,仍然需要准备在被调用的模块分支变化时,改变自己的逻辑过程。
-
标记耦合在调用时传入的结构(例如Java中进行调用时传入User对象)是一个具体的结构而不是一个抽象的结构。如果传入的是一种抽象结构,例如传入的是一个被向上转型的对象那么就不认为是一种标记耦合。
以下是一种标记耦合的代码示例:
// 这里定义了一个具体的用户对象.
public class UserInfo {
private String account;
private Date createTime;
private Integer age;
// ......
// 这里省略了读者都是到的get/set方法
}
// ======== 接着在专门的服务层定义调用接口UserService,并在UserService的实现中书写具体的业务
// 专门的用户信息服务接口,交给外部模块进行调用
public interface UserInfoService {
/**
* 创建用户信息
* @param userInfo 具体的用户对象
*/
public void create(UserInfo userInfo);
}
// ======== 以下是具体的业务实现,写在UserInfoService的实现中
@Service
public class UserInfoServiceImpl implements UserInfoService {
public void create(UserInfo userInfo) {
// 这里书写了具体的业务过程
}
}
如果说控制耦合关注点在于是不是有统一的、专门的调用层,调用者是否需要关注模块内的全部逻辑细节。那么标记耦合的关注点就是调用时传入的数据结构是否和业务特点有强相关。但是无论是控制耦合还是标记耦合,都不能完全屏蔽调用者对被调用模块内部实现的关注,无非是关注程度不一样而已。
那么有没有一种模块间的耦合方式,可以让调用者不用关注被调用模块的任何工作细节,无论被调用的模块内部工作过程、逻辑分支如何变化,都可以控制涟漪效果。下面我们试着做一些深入探讨。
// ======== 接下文