前言
最近一直在关注电动车,其中多数电动车企业都提出了从平台化到模块化的转变。模块化的意义在于最大化的设计重用,以最少的模块、零部件,更快速的满足更多的个性化需求。模块化不单单是设计的模块化,还包括工艺的模块化技术,制造的模块化技术,交付、服务的模块化技术。对于企业的意义在于,降低零件数量,减少变形设计,接更多的订单,提升企业的核心竞争力。
“模块化”在软件工程中也是很常见的,分析软件架构的许多工具(度量指标、适应度函数、可视化)都依赖于这些模块化的概念,模块化是一种组织原则,如果在设计系统时没有注意各个部分是如何连接在一起的,那么最终构建的系统必将导致众多问题。在《代码大全》和《代码之道》等书中,都一再强调“高内聚、低耦合”的代码实践的重要性。
1.模块化的定义
所谓的“模块”是用于构建更复杂结构的一组标准化零件或者独立单元中的一个,使用模块化来描述代码的逻辑分组,该逻辑分组可以是面向对象语言中的一组类,也可以是结构化语言和函数式语言中的函数,多数语言都提供了模块化的机制中的包,开发人员通常使用模块作为相关代码分组在一起的一种方式。
大多数语言提供了模块化的机制中的包,如java中的包 (package),例如com.xxx.customer包应包含与客户相关的内容。在设计系统中,需要了解是如何规划包的,在架构设计中有重要的意义,如果几个包紧密地耦合在一起,那么将其中一个复用于其他模块将变得更加困难。
在讨论架构时,一般适用模块化作为通用术语来表示代码分组,类、函数或其他分组方式,这并不意味着进行物理上的分离,而只是逻辑上的分离,例如将大量的类在单体应用中很方便,但需要重新设计架构时,由松散区间而产生的耦合成了分割单体应用的障碍,因此,将模块化作为特定平台上强制或隐含的物理分离的概念进行讨论很有必要。
2.模块化的度量
通常,模块化从内聚、耦合和共生性三个维度进行分析与度量。
2.1 内聚性
内聚性是指模块中各部分的关联程度,它度量模块中各部分的管理程度高低。理想情况下,一个内聚的模块会将所有部分包装在一起,如果将它们分成更小的部分,就需要模块之间的调用耦合在一起。尝试分割内聚模块会导致耦合度增加和可读性下降。其中内聚包括了以下几种常见的内聚:
2.1.1 内聚的分类
功能内聚: 模块的每个部分都彼此相关,并且每个模块包含了功能所必需的所有内容,比如Math.max()方法就是子程序执行的操作与名字相符合,如果它还做了其他操作的事情,那它的内聚性是不够高。
public class Math {
public static int max(int a, int b) {
return (a >= b) ? a : b;
}
}
顺序内聚: 两个模块进行交互,其中一个模块的输出作为另一个模块的输入,根据ID取用户信息,再获取员工信息,如果将这两个过程拆散,在调用方就需要调用两次并组合起来,降低调用方可读性。
public Employ getEmployInfo(String userId) {
User user = userService.getUserById(userId);
if (user == null || StringUtils.isEmpty(user.getWorkNo()) {
return null;
}
return employService.getEmployByWorkNo(user.getWorkNo());
}
联系内聚性(信息内聚/通信内聚): 两个模块构成一个通信链,其每个模块都基于信息进行操作或者有助于输出,例如添加一条记录到数据库和基于该信息生成电子邮件。
public void handleUpdate(User user) {
User user = userSevice.updateById(user);
if (user == NULL || StringUtils.isEmpty(user.getEmail())) {
return;
}
mailService.sendEmail(user.getEmail(), "xxxx");
}
时间内聚性: 模块基于时序的依赖,例如许多系统有一些看起来不相关的东西,必须在系统启动时对其进行初始化,这些不同的任务具有时间内聚性,例如Spring框架的启动过程,Bean的容器模块先要启动初始化后再做其他模块的初始化。
@Override
public void refresh() throws BeansException, IllegalStateException {
prepareRefresh();
ConfigurableListableBeanFactory beanFactory = obtainFreshBeanFactory();
prepareBeanFactory(beanFactory);
postProcessBeanFactory(beanFactory);
invokeBeanFactoryPostProcessors(beanFactory);
registerBeanPostProcessors(beanFactory);
initMessageSource();
initApplicationEventMulticaster();
onRefresh();
registerListeners();
finishBeanFactoryInitialization(beanFactory);
finishRefresh();
}
逻辑内聚性: 模块内的数据在逻辑上相似,但在功能上无关,如在常见的消息处理中,根据消息内容的类型不同采用不同的逻辑处理。
void handleMessage(MessageBody body) {
switch(body.getType()) {
case TYPE1:
handle1(body);
break;
case TYPE2:
handle2(body);
break;
default:
...
}
临时内聚性: 某个程序含有一些需要的执行的时候才放到一起的操作。例初始化函数,里面并没有
public void init() {
以下操作之间没有联系,但是程序启动时需要都执行:
initIdlist();//使用子程序完成操作,而不是将繁琐的初始化代码写在这里
initErrorList();
...
}
偶然内聚: 除了位于相同的源文件中,模块中的元素互不相关,这是内聚性的最差形式,应该尽量避免出现这样的内聚。
特定模块的内聚程度需要考虑特殊情况。如下模块定义客户维护模块,也可以拆分为两个单独的模块如客户模块、订单模块。这里其实需要有些争论点,因为每一次的改动都会影响到开发实现成本。
-
订单模块只有两项操作的话,如果这样是不是就放在客户维护模块中可以,不必大动干戈。
-
是否期望客户模块包含更多的功能,鼓励开发人员提炼更多的业务行为。
-
订单模块是否需要掌握大量的客户信息,以至于将两个模块分开后依然会有高度耦合。
2.1.2 内聚的度量指标
计算机领域开发出一种结构化度量内聚性的方法,C&K类内聚缺乏度用于度量模块或组件的结构内聚性,计算方法的方式如下所示。
LCOM定义: 未通过共享字段来实现分享的方法的总和。如下图所示,字段显示为单个字母,方法显示为块。在X类中,LCOM得分较低,LCOM得分较低,表明其结构内聚性良好,而Y类中每个字段或方法的组合都可以出现在其自己的类中,缺乏内内聚性,不会对其他类产生影响。Z类展示了混合的内聚性,开发人员可以将最后一个字段或方法的组合重构成一个单独的类。
通过LCOM可以科学地评估能否将一种架构风格切换成另一种架构风格,修改架构时的一个常见问题就是共享工具类,使用LCOM可以帮助找到平时不小心造成的耦合类。
2.2 耦合
耦合主要包括了传入与传出耦合,传入耦合是指测量代码工件(组件、类、函数等)的输入连接数,传出耦合是指测量用于连接到其他代码工件的输出连接,这在重组、迁移和理解代码库上非常有作用。
2.3 抽象性、不稳定性以及主序列的距离
抽象性是抽象工件(抽象类、接口等)与具象工件(实现)的比率。它代表了抽象性对比实现的一个指标。比如,一个没有任何抽象代码的代码库,只包含了一个巨大、单一的方法(在单个 main()方法中完成所有实现),相反的是有太多抽象代码的代码库,使开发人员难以理解事物之间是如何绑定在一起的。抽象性可以用如下方程表示:
在该方程中, 表示模块的抽象元素(接口或抽象类),而表示模块的具象元素(非抽象类)。举个例子:对于一个包含了10000行代码的应用程序,所有代码都在一个main()方法中, 所以抽象为1,而分母为10000,得出的抽象性则几乎为0。
另一个衍生的度量指标是不稳定性,定义为传出耦合和传入耦合总和之比,其中表示传出(或输出)耦合,而表示传入(或输入)耦合。如下方程所示:
不稳定度量指标表示代码的易变性,由于高度耦合,当代码库被修改时,高度不稳定性的代码更容易遭到破坏。例如,如果一个类调用许多其他类来委派工作,则当一个或多个被调用方法发生改变时,被调用的类就很容易出现问题。
距主序列的距离的表示方程如下:
在该方程中,A表示抽象性,I表示不稳定性,其中抽象性和不稳定性都是比率,所以D始终在0-1之间。可以用二维坐标来表示两者的关系,其中距离指标设想了抽象性和不稳定性之间的理想关系,接近理想线的类表示这两个相互矛盾的关注点完美融合,平衡做的很好。在图中标示某个类的抽象性和不稳定性可以帮助开发人员计算其与主序列的距离如下所示。
在下图中标示某个类的点,然后测量该点与理想线的距离。距离线越近,类的平衡性越好。落入右上角的类代表代码抽象过度难以使用,落入左下角的代码代表实现过多但抽象性不足,代码变得脆弱且难以维护。
这些方程、方法都在迁移代码或者技术债务评估进行分析非常具有指导作用,用量化的指标去替代以前的主观感觉。
2.4 共生性
定义如果一个组件的变更需要另一个组件才能保持系统的整体正确性,则两个组件是共生的,可以分为以下两种:
- 静态共生性: 指代码级的耦合
- 动态共生性: 组件执行期的耦合,主要考虑执行顺序
2.4.1 静态共生性
-
名称共生性: 多个组件必须在实体名称上达成一致,方法名称是代码最常见的耦合方式,也最容易被修改,尤其是如今现代化的重构工具,使系统范围的修改变得更加轻松和容易。
-
类型共生性(COT): 多个组件必须在实体类型达成一致,这种类型的共生性是指,通常在许多静态语言中存在的变量和参数限制为特定类型的做法。但是,这并不是静态类型语言的特权,一些动态类型语言也提供了可选类型。
-
意义共生性或公约共生性: 多个组件必须在实体类型上一致,这种类型的共生性是指,通常在许多静态语言类中存在的将变量和参数限制为特定类型的做法,在代码库中这种类型的共生性最常见和明显的例子不是常量,而是硬编码数字,例如,在一些语言中,通常定义int TRUE = 1; int FALSE = 0。
-
位置共生性(CoP): 多个组件必须在特定值上的含义达成一致,这是一个方法和函数调用的参数值问题,即使在具有静态类型的语言中也是如此,如果创建了一个方法updateUser(String uid, String name)并使用值updateUser("123", "test", 12)对其进行调用,即便类型正确,语义也是不正确的。
-
算法共生性(CoA): 多个组件必须在特定算法上达成一致。 这个类型的一个通用类型是,当开发人员定义了一种安全哈希算法,该算法必须分别在服务器和客户端上运行,通过判断是否产生了相同结果类认证用户,此时就发生了这种类型的共生性。显然,这代表了一种高级的耦合形式,如果一边更改了任何算法细节,握手将不再有效。
2.4.2 动态共生性
-
执行共生性:需要考虑多个组件的执行顺序。由于必须按照必要的顺序来初始化,因此代码的结果是不正确的。
email = new Email(); email.setRecipient(""); email.sentSender(""); emain.send(); email.setSubject("test");
-
时机共生性:考虑多个组件执行的时机,在多线程实现中常见,两个线程同时执行导致的竞态,从而影响联合操作的结果。
-
值共生性:当多个值相互关联时,更改必定会一起进行,尤其是在分布式系统中,有些事务可能会横跨所有数据库来更新某个值。
-
身份共生性:当多个值相互关联时,这类共生性的一个常见例子是两个独立的组件必须共享和更新同一个数据结构,比如分布式队列。
2.4.3 共生性的属性
- 强度: 多使用静态共生性而不是动态共生性,通过简单的源代码分析来判断静态共生性,使用含有名称的常量来替换魔术从而将代码重构成名称共生性,从而改进代码。
-
位置: 共生性的位置衡量各个模块在代码库中彼此之间的距离,分割的代码和近邻的代码相比,通常近邻代码具有更多和更高级别形式的共生性,彼此距离越远,不同共生性代表的耦合就越差,例如同一组件的两个类具有意义共生性与两个组件之间有意义的共生性相比,两个类具有意义共生性对代码库的破坏性小点。
-
程度: 共生性的程度与其影响范围有关-它影响几个类还是会影响很多类?较小程度的共生性对代码损失破坏性也较少。
提升系统模块化水平与降低共生性的几点建议:
-
将系统分解为封装元素来最小化整体共生性
-
最小化跨越封装边界的任何剩余共生性
-
最大化封装边界内的共生性
耦合和共生性是来自不同时代和不同的目标的度量方法,这两个概念是重叠的。结构化编程关心输入或输出,而共生性关心的是事物如何耦合在一起。结构化编程的耦合概念在左边,共生性特征在右侧,静态共生性在结构化编程中被称为数据耦合(方法调用)。
3.小结
通过对系统模块化的定义说明,阐述了内聚/耦合/共生三个维度的分析与度量角度的相关定义与场景,尽量使用公式或数学方法来对三个维度进行量化分析,提供了较为科学的决策依据,在我们提高系统模块化水平、代码质量等方面上有较好的指导意义。
参考:《软件架构-架构模式、特征以及实践指南》