细说几种内聚
高内聚和低耦合是很原则性、很“务虚”的概念。为了更好的讨论具体技术,我们有必要再多了解一些高内聚低耦合的度量标准。
内聚
内聚达到什么样的程度算高?什么样的情况算低?
wiki上有一个内聚性的分类,我们可以看看内聚都有哪些类型。
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
例如,我们有一个这样的接口:
乍一看,这个接口包含的功能都是用来查用户信息的,似乎挺“高内聚”的。
但是实际上,UserBean是记录用户在当前业务线中的数据的类,queryUsrBean是从本地数据库中查询用户数据。而UserInfo是从用户中心获取的、记录用户注册信息数据的类,queryUserInfo则是从第三方服务接口中查询用户数据。
它们除了名字相似之外,基本没有相关性。把这两个数据的相关功能放在同一个模块中 ,就是一种“偶然内聚”。
在初期的使用中,这种“偶然内聚”并没有造成什么问题。但是,当功能发生扩展时,这种“偶然内聚”导致了循环依赖,我们不得不把它们拆分成两个不同的模块。
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
我早期做“可扩展”的设计时,经常会引入逻辑内聚。例如,有一个计算还款计划的接口,我是这样设计的:
这是一个计算还款计划表的接口。入参中,LendApply是借款申请;CalculateParam是计算所需金额参数;CalcuateMethod是计息方式——如等额本金、等额本息、先息后本,等等。
“开始总是分分钟都妙不可言”。这个接口一直运行得很好,并且被系统群广泛地使用。直到有一天业务要求停用等额本金方式,统一采用等额本息方式计算还款计划表。
这时候我们只有两种选择:
让所有的调用方排查一遍自己调用这个接口时传入的参数,保证入参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
时间内聚的概念有点晦涩,举个例子就简单了。
假定我们在Servlet处理Http请求之前,使用了一套Filter来做解密、验签、认证、鉴权、接口日志、异常处理等操作。在这个场景中,解密、验签、认证、鉴权、接口日志、异常处理这些功能之间就产生了时间内聚。因为它们之间原本没有什么功能上的联系,唯一的联系就是恰好都发生在“Servlet处理Http请求之前”这个时间点上。
时间内聚也不算一种强内聚。所以,我们在修改其中一项功能时,很少能考虑到对其它功能的影响。出于这个原因,虽然我们会把时间内聚的功能代码放到同一个包下、或者继承同一个父类,但是,不应该让这些代码之间再产生其它关联。
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
过程内聚已经是一种比较强的内聚了。存在过程内聚的几个功能组件应该尽可能地放在一个模块内,否则在后续的维护、扩展中一定要吃苦头。
例如,在前面提到的那个金额计算的模块中,存在下面这种情况:
InstallmentServiceFeeCalculator是用来计算分期服务费的一个类。从分期服务费的计算公式可以看出:在计算分期服务费之前,必须先计算出分期本金。此时,InstallmentServiceFeeCalculator与InstallmentPricipalCalculator之间就有了过程耦合。
应对这种情况,我们有两种选择:一是让调用方在计算分期服务费之前,先自己计算一遍分期本金,然后把计算结果传给分期服务费计算器;二是让分期服务费计算器在必要的时候自己调用一次分期本金计算器。
很明显,第二种方式比第一种更好。既然分期服务费计算器和分期本金计算器之间存在过程耦合,那就应该把它们放到同一个模块内部。这样,无论哪个计算器发生变化——修改公式、变更取值来源等——都可以只修改这个模块,而不会影响到调用方。就像前面的例子中所说的那样:不影响到调用方,不仅仅能降低工作量,而且还能减少隐患和bug。
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
对设计模式熟悉的同学一定不会对通信内聚感到陌生:责任链/代理等模式就是很典型的通信内聚。这些类要操作同一个数据(如入参、出参),必然要共享这份数据。因此,通信内聚也叫信息内聚。
举个例子来说,我们曾有一个模块应该是这样的:
这是一个典型的责任链模式。链条上的每一环都从Data中读取一些数据、并写入一些数据。它们之间构成了非常明显的通信内聚关系。
然而在我们的系统中,这一条完整的责任链被彻底拆散,零零碎碎地分布在业务流程的各个角落里。于是乎,我们要查找某个字段取值问题时,总要翻遍整个流程才能确定它到底在哪儿赋值、要如何修改。如果要增加字段、或者修改某些字段的数据来源,甚至要修改好几个系统的代码。这就是打破通信内聚造成的恶果。
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就是一种顺序内聚。例如下面这段代码中,从bankCardList.stream()开启一个Stream之后,filter/map/map每一步操作的输出都是下一个操作的输入,而且它们必须按顺序执行,这正是标准的顺序内聚:
除了Stream之外,设计模式中的装饰者/模板/适配器等模式也是很典型的顺序内聚……等等。例如,我们来看这段代码:
这段代码是我们重构优化后的成果。
在重构之前,我们只有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
功能内聚堪称最强内聚。在这种内聚中,每个组件单拿出来都不构成一个独立的业务功能;只有组合成一个整体,才能形成完整的功能。
例如,我们系统中有一个调用规则引擎的模块:
本质上看,这个模块包含校验、构建请求、调用规则引擎和解析结果这四个组件。无论是校验、构建请求、调用引擎还是解析结果,这个模块中所有的代码都是为了实现一个功能:调用规则引擎并解析结果。所以,这些组件之间是一种功能内聚的关系。
但是,随着业务发展、需求变更,这个模块中出现了越来越多的“噪音”:把调用规则引擎的request和response入库、在封装数据时把某个数据同步给某个系统、在得到响应后把某个字段发送给另一个系统……诸如此类,不一而足。
这些业务需求并不直接参与“调用规则引擎”这个核心功能,相关组件与“调用核心规则”也只是顺序内聚(需要使用调用规则引擎的返回结果)、通信内聚(需要使用调用规则引擎的入参/出参)甚至只是时间内聚(需要在调用规则引擎时同步数据)。从“功能内聚”的角度来看,这些新增代码就不应该放到这个模块中来。
但是,由于一些历史原因,这些代码、组件、需求全都被塞到了这个模块中。结果,这个模块不仅代码非常臃肿,而且性能也十分低下:一次用户请求常常要20多秒才能完成,可是由于模块可维护和可扩展性差,重构优化也非常困难。
如果当初能遵循“功能内聚”的要求,把不必要的功能放到别的模块下,后续的维护和扩展也不会像现在这样望洋兴叹、无从下手了。
练习
例子一
我在《高内聚与低耦合》文中举过一个这样的例子:
这个模块中的组件属于哪种内聚呢?
严格一点说,右侧那些组件——从“提交信息”到“发送短信验证码”或“判断短信验证码是否正确”——属于功能内聚。它们全都是为了完成“短信签约”这个操作而组合到当前模块下的。
但是,左侧这些组件——从“后续业务分发器”到“后续业务处理A”等——之间,只能算时间内聚。各种后续业务处理之间并没有直接的、或者本质上的关联,它们被放在这个模块中的原因仅仅是他们都要在短信签约完成之后做一些处理。这可以说是标准的时间内聚。
左侧和右侧组件之间呢?从上面的分析也能看出来:这两大部分之间是顺序内聚。这个模块必须先调用右侧组件,在它们处理完成后才能去调用左侧组件进行处理。
例子二
在《抽象》一文中,还有这样一个例子:
在这个组件中,用于处理DEDUCT/UN_BIND/BIND等各种逻辑的组件之间是什么内聚关系呢?我认为是通信内聚:它们都要针对入参userId和scene做处理,并返回同样的List<Card>。
版权声明: 本文为 InfoQ 作者【落英亭郎】的原创文章。
原文链接:【http://xie.infoq.cn/article/1b6637ccee22f8abb90a6c966】。文章转载请联系作者。
评论