避免过度耦合的设计
下图展示了几种不同情况下的依赖示例。
图中的每一个圆圈都代表一个设计单元,它可能是类,也可能是模块或者系统。箭头代表设计单元之间的某种依赖关系,如A使用了B的方法或者数据,甚至A只是简单地共享了B的知识,那么A就依赖于B。(不要将依赖局限于调用,若B改变则A也必须改变。那么A就依赖B)下面分别介绍这四种依赖形态对耦合的影响。
(1) 循环依赖造成紧耦合。图中的①表示A和B之间存在循环依赖。循环依赖是一种非常紧的耦合。因为A的变化会引起B的变化,B的变化也会引起A的变化,所以A和B本质上是一个整体,而不是两个不同的设计单元。
循环依赖往往意味着设计不合理,或者依赖粒度过大。下图是一个真实场景的案例,其中包domain和包infrastructure之间存在循环依赖。如果仔细分析,就会发现只是类InvoiceRateService依赖了StringUtils,类InvoiceRepoImpl实现了InvoiceRepo定义的接口。只要重新调整包infrastructure的粒度,把它划分为包lang和包database,循环依赖就消失了。调整后的结果如新图所示。
(2) 依赖层级越深,耦合越紧。图中的②表示C依赖D,D依赖E。当E变化时,不仅D会受到影响,C也会。所以,在这种依赖中,C和E也存在耦合关系。依赖链越长,耦合影响的范围就越广。尽管链式依赖在设计中无可避免,但是存在许多能够减少依赖链长度的方法。下图展示的是一种链式依赖,AccountService要用到数据库封装DBWrapper,DBWrapper又依赖于第三方代码库ThirdPartyLibrary。当ThirdPartyLibrary更新时,AccountService也可能受到影响。
让我们分析如何解决这个问题。AccountService真正关心的并不是DBWrapper如何实现,而只是需要它提供的数据库访问服务。那把这种服务封装成接口AccountRepository,然后让DBWrapper实现这个接口,ThirdPartyLibrary的更新就不会影响AccountService了,如下图所示。这种通过接口解耦依赖的方式,就是著名的依赖倒置。(我觉得本质就是加个过度带)
(3) 依赖范围越广,耦合越严重。图中的③表示F依赖于G、H、I。在这种情况下,只要G、H、I中的任何一个发生变化,F就会发生变化。所以,和依赖范围更小的设计单元相比,F的稳定性相对较弱。此时如果能想办法降低F依赖的设计单元的数量,它的稳定性就可以得到增强。依赖范围过大,往往也和设计单元承担的职责过多有关。下面是类AccountService的部分声明。
public class AccountService {
AccountRepository repo;
SesssionManager sessionManager;
StudentService studentService;
ConsumptionService consumptionService;
// 其他
public LoginResultDTO login(String accountName, String securedPassword)
{...}
}
代码清单中的StudentService和ConsumptionService的依赖显然是有问题的。造成这种问题的原因,是原来login方法的职责不够内聚,增加了不必要的功能。通过职责分解调整了login方法的职责,把和消费记录相关的内容移到了别的类中,自然也就消除了AccountService对StudentService和ConsumptionService的依赖。本质就是分层,相邻层之间不能相差太多元素。但是这样也往往会带来层级过高,梯度损失的问题。
(4) 全局依赖和隐式依赖让耦合难以管理。图中的④代表J和K同时依赖L。根据L类型的不同,会导致J和K之间出现不同类型的耦合。.当L是一个全局变量时,这类耦合在传统设计耦合理论中被称为共同耦合(Common Coupling)。J和K会建立一种严重的耦合。因为J和K的状态会在缺乏设计可见性的情况下互相影响,所以要尽量避免这种耦合。.当L是一个外部模块时,这类耦合被称为外部耦合(External Coupling)。J和K会同时受到L变化的影响,形成共同变更的耦合关系。当J和K对L的外部耦合可能发生变化时,更好的处理方案是建立一个L的封装层,让J和K依赖于该封装层。.当L是一个隐含的知识,J和K都与L没有实际的代码联系时,J和K会产生隐式依赖。最典型的情况是,J和K为两段重复的代码,一旦这份代码背后的逻辑发生变化,J和K往往需要同步修改。.当L是一个稳定的接口时,J和K之间没有耦合关系。(这么说有一个前提 假设,接口相对于具体的实现更加稳定,不易变动)
(5) 对内部状态和数据的依赖是严重的耦合。对内部状态和数据的依赖在传统设计耦合理论中被称为内容耦合(Content Coupling)。虽然内容耦合不能简单地用图形表达,但是内容耦合会破坏封装性,是严重的耦合。下面是一个内容耦合的例子。
public double getArea(Sector s){
double result = s.getAngle() * s.getRadius() / 360;
return result;
}
这个例子虽然看起来比较正常,但是它隐含地让getArea方法指定了扇形对象Sector的内部结构表示方式。(扇形中圆心角的表示方式一定是角度吗?可不可以是弧度呢?)良好的面向对象设计原则可以消除内容耦合。更合理的做法是把getArea方法的职责赋予Sector对象。
public double getArea(Sector s){
double result = s.getArea();
return result;
}
在实际项目中常见的设计反模式,如Smart UI和贫血模型,都是封装不足导致的内容耦合的结果。针对这种问题,最行之有效的做法是:除了纯粹的数据类,都应该尽量少暴露getter/setter方法。当一个对象对外暴露了getter/setter方法时,很容易引入不必要的对内部状态和数据的依赖。Smart UI指把一切业务逻辑都写到界面层(或相当于界面层的地方,如接口层)的实现中。贫血模型是一种面向对象的反模式。所谓的模型层没有任何逻辑,仅仅是对数据的写入(setter)和读取(getter),所有的逻辑都在上层通过对数据的操作实现。
耦合不局限于代码层面,它可以发生在设计的任何粒度上。例如,如果两个系统之间是通过一组API定义进行通信,那这两个系统之间就基于这组API形成了耦合,当你开发的系统使用了某种第三方框架,或者使用了某种消息通信的基础设施时,同时也在你开发的系统和该第三方框架以及消息通信基础设施之间建立了耦合。框架、通信基础设施或者API的变化都会影响依赖这些内容的模块。
没有重复
重复是一种特殊的耦合。有经验的开发者会对代码中的重复特别敏感,因为重复代码不仅会影响到代码的易于理解、易于维护特征,往往也意味着拥有改善设计的机会。从设计角度看,重复代码具有以下特点。
.重复代码增加了理解难度。重复代码必然会增加代码量,也就增加了阅读代码的工作量。而且,如果这些重复代码存在细微的不同,那这些不同很容易被忽视,从而导致重复代码更加难以理解,甚至因此引入错误。.重复代码加大了维护难度。如果重复代码中存在缺陷,那么很可能需要逐一修复每个重复实例。如果不了解系统中存在哪些重复实例,就很容易造成遗漏。更进一步,由于重复代码自身存在的差异以及所处的上下文环境有所不同,往往需要分别分析每个重复实例,这又加大了维护的工作量。.重复代码往往隐含着改善设计的空间。重复代码本质上是一种重复的概念。如果以重复代码为表象,对重复概念加以识别和抽象,就有希望通过消除重复来改善设计。
DRY原则
对于代码中的重复问题,Andy Hunt和Dave Thomas提出了著名的DRY(Don’t Repeat Yourself)原则,也就是“不要重复你自己”。DRY原则背后的逻辑是:在一个系统中,每一块知识的表达,都应该是唯一、无歧义和权威的。(仔细思量也是奥卡姆剃刀原理的泛化)这句话和基于知识的隐式依赖是同一个着眼点。只要在两个地方存在对同一个知识的表达,那么一旦这个知识改变了,这两个地方就需要一起改变。而且,既然这个知识可以被单独改变,就意味着这是一个单独的关注点,应该被分离出来,成为一个内聚的模块。所谓关注点就是容易变化需要 重点关注的点。
造成重复的原因
重复代码的引入,可能有多种原因。我们从一个最常见的场景开始分析,假如我们已经实现了列出指定目录下的Java文件名的代码),现在有了一个新的业务需求:打印指定目录下的所有文本文件的内容。这是两个看起来有点类似,但是不尽相同的需求。如果项目进度恰好紧张,需要尽快实现该功能,那么对于正在完成这项工作的程序员而言,他下意识的反应很可能是复用。于是,复制并修改就产生了以下代码。
这几种重复分别被称为I~IV类代码克隆。.完全相同的代码。.模式一致的代码。.模式一致,夹杂一些差异的代码。.功能相同,实现方式不同的代码。产生重复的原因有很多。有时候是时间压力导致的复制 - 粘贴式编程方式,有时候是程序员担心在既有的方案上直接改动可能会破坏原有的功能,有时候是原来的代码关注点分离得不好,还有时候是需要改动的代码的所有权属于其他开发者或组织,自己没有办法直接修改。更多时候,重复是上述多种原因综合作用的结果。
一旦不正确地接受了代码的重复,(简单的复制张贴和修改)就给未来的维护者带来了不好的范例,代码的腐化速度会逐渐变快。例如,当出现了一个新的需求—统计指定目录下的所有文件的个数时,会以更快的速度创造出一个新的代码重复副本。有句话叫“习惯成自然”,一旦某种编码风格形成习惯,久而久之,也就没人觉得这种重复是一种问题了。
通过消除重复改善设计
大多数时候,重复可能会引起维护问题,但是并不一定有害。例如,如果一段重复的代码从来都没有需要修复的缺陷,也从来没有演进的需求,那么在代码中保留这些重复,也很难说有根本性的问题。但是,无论重复是否真正有害,关注代码中的重复都能带来有价值的收益。最大的收益就是启发程序员注意关注点分离。我们分析一下之前的关注点,可以很容易得到下图所示的结果。
根据上图,容易把通用职责从两段代码中分离出来,得到代码如下。它把职责拆分为两个关注点:(1) 遍历目录文件;(2) 判断遍历到的每个文件的类型,并输出文件名。
public class FileTraversal {
private FileVisitor visitor;
public FileTraversal(FileVisitor visitor){
this.visitor = visitor;
}
public void travers(String path) {
File dir = new File(path);
travers(dir);
}
public void travers(File root) {
File[] files = root.listFiles();
Arrays.asList(files).forEach(f->visitor.visit(f));
}
}
public interface FileVisitor {
void visit(File file);
}
public class JavaFileNamePrinter implements FileVisitor {
@Override
public void visit(File file) {
if (isJavaFile(file))
System.out.println(file.getAbsolutePath());
}
private boolean isJavaFile(File file) {
if (!file.isFile()) return false;
return FilenameUtils.getExtension(file.getName()).endsWith("java");
}
public void printJavaFiles(String rootPath) {
FileTraversal fileTransversal = new FileTraversal(this);
fileTransversal.travers(rootPath);
}
}
其中,FileTraversal和FileVisitor是一个设计单元,它们负责完成目录文件的遍历,并对外部用户提供扩展点FileVisitor。JavaFileNamePrinter是一个设计单元,基于FileTraversal提供的遍历能力完成需求中的打印Java文件名的功能。下图是代码对应的类图。
通过分离关注点,我们还提升了代码的可复用性。利用新代码的设计,在遇到新的需求,如输出某个目录下的所有文本文件的内容时,我们就没有必要再像之前那样冗余,可以直接非常简洁地写出如下代码。
public class TextContentPrinter implements FileVisitor {
@Override
public void visit(File file) {
if (isTextFile(file)) {
Files.readAllLines(file.toPath()).forEach(line -> System.out.println(line));
}
}
private boolean isTextFile(File node) {
if (!node.isFile()) return false;
return FilenameUtils.getExtension(node.getName()).endsWith("txt");
}
public void printTextFileContents(String rootPath) {
FileTraversal fileTransversal = new FileTraversal(this);
fileTransversal.travers(rootPath);
}
}
这段代码更加,更重要的是我们无须知道如何实现遍历目录文件,只需要关心在遍历到具体的文件时做什么事情。顺便提一下,这是一个已经被实践检验过的设计。从Java 7开始,Java的nio类库已经内建了名为Files.walkFileTree的方案,应用的就是刚才提及的关注点分离原则。在实际的工程项目中,程序员已经无须编写FileTraversal,直接应用nio类库的Files.walkFileTree方案即可。实际 开发中,软件工程师往往是短视的,大家为了完成当下的KPI,往往会直接复制张贴,简单修改。因为要做到关注点分离往往意味这要抽取公共重复的代码。这也意味着额外的成本。那这个成本有谁来承当呢?这又是个问题。我们在实际开发中能做到的是将自己责任田内的重复消除。如果能有优质稳定的平台代码或者模块能够复用就尽量复用。但是注意不要引入过大的模块。否则会带来巨大的维护成本。