高内聚和低耦合是很原则性、很“务虚”的概念。为了更好地将它们落地实践,我们有必要再多了解一些高内聚低耦合的度量标准。
这里先说说几种内聚。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”类。
偶然内聚的内聚性最弱,因而也是最糟糕的一种内聚。发现模块中存在偶然内聚后,我们应该尽量把这个模块拆分成几个独立模块。反正迟早也要拆,越晚动手,要面对的历史遗留问题就越多,不如早早开工的好。
前阵子我就遇到了一个类似的问题。在我们的系统中,有这样一个处理类:
public interface UserBiz{
UserBean queryUserBean(long userId);
UserInfo queryUserInfo(long userInfoId);
}
乍一看,这个接口似乎挺“高内聚”的嘛!实际上可不是这样。UserBean是从本地数据库中获取的、记录用户在当前业务线中的数据的类;而UserInfo是从用户中心获取的、记录用户注册信息数据的类。一句话:它们除了名字相似之外,基本没有相关性。
把这两个毫不相关的数据及其功能代码放在同一个模块中,这就是典型的“偶然内聚”。这些功能代码除了“恰好放在同一个模块内”之外,在业务上没有任何关系。
在初期的使用中,这里的确并没有什么问题。但是在后续扩展时,这种“偶然内聚”导致了循环依赖。另外一个类(OtherService)需要依赖UserBiz#queryUserBean(long)
方法,而UserBiz#queryUserInfo(long)
方法又依赖于OtherService中的某个方法。当时的Spring无法处理循环依赖,只会抛出异常。这使得我们不得不把这个UserBiz拆分成两个不同的实现类——当然,拆了是好事。纵然不能拆迁致富,起码不再受危房困扰嘛。
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 .
逻辑内聚是指一个模块内的几个组件仅仅因为“逻辑相似”而被放到了一起——即使这几个组件本质上完全不同。
很多文章里会特别指出,客户端每次调用逻辑内聚的模块时,需要给这个模块传递一个参数来确定该模块应完成哪一种功能 。这是因为逻辑内聚的几个组件之间并没有什么本质上的相似之处,因而从入参提供的业务数据中无法判断应该按哪种逻辑处理,而只好要求调用方额外传入一个参数来指定要使用哪种逻辑。
我早期做“可扩展”的设计时,经常会产生这种内聚。例如,有一个计算还款计划的接口,我是这样设计的:
public interface RepayPlanCalculator{
List<RepayPlan> calculate(LendApply apply,
CalculateParam param,
CalculateMethond calculateMethod);
}
除了借款申请和必要的计算参数(本金、期数、利率等)之外,这个接口还要求调用方传入一个计息方式字段,用以决定是使用等额本息、等额本金还是其它公式计算利息。如果某天要增加一种计息方式,比如先息后本,也很好办:增加一种CalculateMethond就行。
看起来一切都好,直到有一天业务要求停用等额本金方式,统一采用等额本息方式计算还款计划表。
我们有三种方案:第一,让所有的调用方排查一遍自己调用这个接口时传入的参数,保证入参calculateMethod只传入了等额本息方式;第二,在接口内部做一个转换:调用方传入了等额本金方式,那么按等额本息方式处理;第三,在接口内部做处理,如果调用方传入了等额本金方式,那么直接抛出异常。
显然,第一种方案会把原本很小的一个需求变化扩散到整个系统中。这就好像只是被蚊子盯了一口却全身都长了大包一样。如果某一个调用方改漏了,那么它得到的还款计划表就是错的。如果这份错误的还款计划表到了用户手里,那么投诉扯皮事故复盘就少不了了。
第二种方案则容易让调用方产生误解——明明指定了等额本金方式,为什么计算结果是等额本息的?这就好比下单点了一份虾滑上菜给了一份黄瓜。如果这种误解一路传递给了用户——例如某个调用方的开发、产品一看参数支持等额本金,于是向用户宣传“我们的产品支持等额本金”——那么投诉扯皮事故复盘就又要出现了。
第三种方案借助“fast-fail”,避免了前面两种方案的潜在问题。只不过这样处理,太过生硬了一些,对用户不太友好。
逻辑内聚也是一种“低内聚”,它把接口内部的逻辑处理暴露给了接口之外。这样,当这部分逻辑发生变更时,原本无辜的调用方就要受到牵连了。
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
时间内聚是指一个模块内的多个组件除了要在程序执行到同一个时间点时做处理之外、没有其它关系。
概念有点晦涩,举个例子就简单了:当Controller处理Http请求之前,我们常常会用Filter统一做解密、验签、认证、鉴权、接口日志、异常处理等操作。在这一场景中,解密/验签/认证/鉴权/接口日志/异常处理这些功能之间就产生了时间内聚。这些功能之间原本没有什么关系,但是考虑到这种时间内聚,我们一般会把它们放到同一个包下、或者继承同一个父类。
/**
* 入参解密
*/
class DecodeFilter extends HttpFilter{
// 略
}
/**
* 入参验签
*/
class SignFilter extends HttpFilter{
// 略
}
/**
* 登录认证
*/
class CertificationFilter extends HttpFilter{
// 略
}
// 其它类似,略
这些操作、功能之间并没有必然的联系——从这一点上来看,时间内聚也是一种弱内聚。但它多少还是比偶然内聚和逻辑内聚要更强一些的:毕竟它们聚在一起是有正当理由的。就好比哪怕你都叫不全大学同班同学的名字,但毕业十周年的时候聚一聚也是合情合理的。
Procedural cohesion:过程内聚
Procedural cohesion is when parts of a module are grouped because they always follow a certain sequence of execution.
过程内聚是指一个模块内的多个组件之间必须遵循一定的执行顺序才能完成一个完整功能。
显然,过程内聚已经是一种比较强的内聚了。存在过程内聚的几个功能组件应该尽可能地放在一个模块内,否则在后续的维护、扩展中一定要吃苦头。
在前面提到的那个金额计算的模块中,存在下面这种情况:
/** 计算器基类 */
public abstract class Calculator{
private String formula;
protected Calculator(String formula){
super();
this.formula=fomrula;
}
public abstract CalculateResult calculate(CalculateParam money);
}
/** 分期服务费计算器 */
public class InstallmentServiceFeeCalculator exstends Calculator{
public ServiceFeeCalculator(){
// 分期服务费公式:分期本金*服务费费率
super("installmentPrincipal*serviceFeeRate");
}
/** 计算分期服务费 */
CalculateResult calculate(CalculateParam money){
// 注意:这里必须保证已经调用过InstallmentPricipalCalculator
// 并已经计算出了分期本金
}
}
/** 分期本金计算器 */
public class InstallmentPricipalCalculator extends Calculator{
// 略
}
InstallmentServiceFeeCalculator是用来计算分期服务费的一个类。从分期服务费的计算公式可以看出:在计算分期服务费之前,必须先计算出分期本金。这样一来,InstallmentServiceFeeCalculator与InstallmentPricipalCalculator之间就有了过程耦合。
面对这种情况,我们有两种选择:一是让调用方在计算分期服务费之前,先自己计算一遍分期本金,然后把计算结果传给分期服务费计算器;二是让分期服务费计算器在必要的时候自己调用一次分期本金计算器。
显然,第二种方式比第一种更好:分期服务费计算器和分期本金计算器之间存在过程耦合,第二种方式把它们放到了同一个模块内部。这样,无论哪个计算器发生变化——修改公式、变更取值来源等——都可以只修改这个模块,而不会影响到调用方。
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、同一个文件、或者同一张表等)。
熟悉设计模式的同学一定不会对通信内聚感到陌生:责任链/代理等模式就是很典型的通信内聚。例如,我们曾有一个模块应该是这样的:
public interface DataCollector{
void collect(Data data);
}
class DataCollectorAsChain implements DataCollector{
private List<DataCollector> chain;
@Override
public void collect(Data data){
chain.foreach(collector-> collector.collect(data));
}
}
class DataCollectorFromServerA implements DataCollector{
@Override
public void collect(Data data){
// 从数据库里查到一堆数据
data.setDataA(xxx);
}
}
// 此外还有类似的从ServerB/ServerC的接口获取数据的几个类;
// 这些类最终都会组合到DataCollectorAsChain的chain里面去。
上面是一个典型的责任链模式。责任链上每一环都需要向Data中写入一部分数据,最终得到一个完整的Data。很显然,DataCollectorFromDb和DataCollectorFromRpc、DataCollectorFromHttp之间存在着通信内聚,它们应该被放到同一个模块内。
遗憾的是,在我们的系统中,这一条完整的责任链被拆得七零八碎,各个功能东一鳞西一爪地分布在业务流程的各个角落里;有些字段甚至被分散在了分布部署的好几个服务上。于是乎,我们要查找某个字段取值问题时,总要翻遍整个代码库才能确定它到底在哪儿赋值、要如何修改。如果要增加字段、或者修改某些字段的数据来源,甚至要修改好几个系统的代码。
这就是打破通信内聚造成的恶果。
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.
顺序内聚是指在一个模块内的多个组件之间存在“一个组件的输出是下一个组件的输入”这种“流水线”的关系。
如果熟悉Java8的Lambda表达式的话,应该很容易想到:Java8中的Stream就是一个顺序内聚的模块。例如下面这段代码中,从bankcCardList.stream()开启一个Stream之后,filter/map/map每一步操作的输出都是下一个操作的输入,而且它们必须按顺序执行。这正是标准的顺序内聚:
List<BankCard> bankCardList = ...;
User u = ...;
String bankCardPhone =
bankcCardList.stream()
.filter(card->card.no().equals(u.getBankCardNo())))
.map(BankCard::getPhone())
.map(phone -> "*******" + phone.subString(phone.lengh()-4)))
.orElse(StringUtils.EMPTY);
除了Stream之外,设计模式中的装饰者/模板/适配器等模式也是很典型的顺序内聚……等等。例如,我们来看这段代码:
public interface FlwoQueryService{
Optional<Flow> queryFlow(Queryer queryer);
}
class FlwoQueryServiceFromDbImpl{
public Optional<Flow> queryFlow(Queryer queryer){
// 从数据库里查询用户流程,略
}
}
abstract class FlowQueryServiceAsDecorator implements FlowQueryService{
private FlwoQueryService decorated;
public Optional<Flow> queryFlow(Queryer queryer){
// 装饰者,在decorated查询结果的基础上,做一次装饰处理
return decorated(queryer).map(flow-> decorate(flow, queryer));
}
/** 增强方法 */
protected abstract Flow decorate(Flow flow, Queryer queryer);
}
class FlowQueryServiceNotNullImpl extends FlowQueryServiceAsDecorator{
protected Flow decorate(Flow flow, Queryer queryer){
// 如果flow为null,则创建一个新数据
}
}
在上面的装饰者——当然也可以叫模板——类中,这两个步骤的顺序是固定的:必须先由被装饰者执行基础的查询操作、再由装饰者做一次增强操作;而且被装饰者的查询结果也恰恰就是装饰操作的一个入参。可以说,这段代码很完美的解释了什么叫“顺序内聚”。
这段代码是我们重构优化的成果。在重构之前,我们只有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 .
功能内聚是指一个模块内所有组件共同完成一个功能、缺一不可。
功能内聚是最强的一种内聚。其它内聚更多的是在讨论“应该把哪些组件组合成一个模块”;而功能内聚讨论的是“应该把哪些组件踢出当前模块”。从功能内聚的角度来看,即使某个组件与模块内其它组件存在顺序内聚、通信内聚、过程内聚,但只要它与这个模块的功能无关,那它就应该另谋高就。
例如,我们系统中有一个调用规则引擎的模块。无论是校验、构建请求、调用引擎还是解析结果,这个模块中所有的代码都是为了实现一个功能:调用规则引擎并解析结果。
public interface CallRuleService{
RuleResult callRule(RuleData data);
}
class CallRuleService implements CallRuleService{
public RuleResult callRule(RuleData data){
validate(data);
RuleRequest request = transToRequest(data);
RuleResponse response = callRuleEngin(request);
return transToResult(response);
}
}
随着业务发展、需求变更,这个模块中出现了越来越多的“噪音”:把调用规则引擎的request和response入库、在封装数据时把某个数据同步给某个系统、在得到响应后把某个字段发送给另一个系统……诸如此类,不一而足。这些业务需求与“调用规则引擎”这个核心功能没有直接关系,相关组件与“调用核心规则”也只是顺序内聚(需要使用调用规则引擎的返回结果)、通信内聚(需要使用调用规则引擎的入参/出参)甚至只是时间内聚(需要在调用规则引擎时同步数据)。从“功能内聚”的角度来看,这些新增代码就不应该放到这个模块中来。
但是,由于一些历史原因,这些代码、组件、需求全都被塞到了这个模块中。结果,这个模块不仅代码非常臃肿,而且性能也十分低下:一次用户请求常常要20多秒才能完成,可是由于模块可维护和可扩展性差,重构优化也非常困难。如果当初能遵循“功能内聚”的要求,把不必要的功能放到别的模块下,我们也不会像现在这样望洋兴叹、无从下手了。
练习
例子一
我在《高内聚与低耦合》文中举过一个这样的例子:
这个模块中的组件属于哪种内聚呢?
作为参考,我认为右侧那些组件——从“提交信息”、“是否完成签约”,到“发送短信验证码”或“判断短信验证码是否正确”——属于功能内聚。它们全都是为了完成“短信签约”这个操作而组合到当前模块下的。
但是,左侧这些组件——从“后续业务分发器”到“后续业务处理A”等——之间,只能算时间内聚。各种后续业务处理之间并没有直接的、或者本质上的关联,它们被放在这个模块中的原因仅仅是他们都要在短信签约完成之后做一些处理。这可以说是标准的时间内聚。
如果我们把代码包视作功能模块的基本单位,那么左侧组件应该放到它们自己的业务代码包下,而不应放在短信签约这个包下。
这与《关注代码质量——包结构》、和《聊聊系统中的包结构》中的想法不谋而合,也算温故而知新了。
左侧和右侧组件之间呢?从上面的分析也能看出来:这两大部分之间是顺序内聚。这个模块必须先调用右侧组件,在它们处理完成后才能去调用左侧组件进行处理。
例子二
在《抽象》一文中,有这样一个例子:
public interface CardListService{
List<Card> query(long userId, Scene scene);
}
//核心实现是这样的
public class CardListServiceImpl{
private Map<Scene, CardListService> serviceMap;
public List<Card> query(long userId, Scene scene){
return serviceMap.get(scene).query(userId, scene);
}
}
// 返回字段是这样的
public class Card{
// 客户端根据这个字段的值来判断当前银行卡是展示还是置灰
private boolean enabled;
// 其它卡号、银行名等字段,和accessor略去
}
// 入参是这样的
public enum Scene{
DEDUCT,
UN_BIND,
BIND;
}
在这个组件中,用于处理DEDUCT/UN_BIND/BIND等各种逻辑的组件之间是什么内聚关系呢?我认为是通信内聚:它们都要针对入参userId和scene做处理,并返回同样的List<Card>
。
当然,说它是逻辑内聚也不无道理。这段代码有逻辑内聚最典型的特征:调用方通过传入一个参数,来控制服务方的流程。在这段代码中,入参Scene
就起到了这样的作用。
不过,这一特征也有争议:重点在于Scene
是否是正常的业务数据。如果认为它是业务数据、而非专门的控制字段,那恐怕就算不上逻辑内聚。
例如,我们有一个登录模块,根据用户提交的数据来做不同的登录判断。如果提交了用户名、密码,就直接比对密码;如果只提交了手机号,就发送验证码;如果提交了手机号和验证码,那就比对验证码。这种情况下,登录模块也是根据调用方传入参数来执行不同的处理流程。但我相信,大家应该不会把它划入“逻辑内聚”这一类吧。
往期索引
《面向对象是什么》
从具体的语言和实现中抽离出来,面向对象思想究竟是什么?
《抽象》
抽象这个东西,说起来很抽象,其实很简单。
《高内聚与低耦合》
“高内聚”与“低耦合”是软件设计和开发中经常出现的一对概念。它们既是做好设计的方法论,也是评价设计好坏的标准。