今天跟大家一起探讨在日常开发过程中经常会碰到的一个问题,这个问题跟代码的维护工作有很大关系。我们知道任何系统在开发了一段时间之后,随着业务功能和代码数量的不断增加,代码之间的调用和被调用场景也会逐渐变的越来越复杂。各个类或组件之间可能会存在出乎开发人员想象的复杂关系。
代码之间复杂关系的一种常见形式就是类与类之间存在循环依赖关系。所谓循环依赖,简单来说就是一个类A会引用类B中的方法,而反过来类B同样也会引用类A中的方法,从而导致类A和类B之间存在相互引用,从而形成一种循环依赖。
合理的系统架构以及持续的重构优化工作能够减轻这种复杂关系,但如何有效识别系统中存在的循环依赖仍然是开发人员面临的一个很大的挑战。主要原因在于类之间的依赖关系存在传递性。如果系统中只存在类A和类B,那么它们之间的依赖关系就非常容易识别。但如果再引入一个类C,那么这三个类之间关系的组合就有很多种情况。可以想象一下,如果一个系统中存在几十个类,那么它们之间的依赖关系就很难通过简单的关系图进行一一列举。而一般的系统中的类的个数显然不止几十个。
更宽泛的讲,类之间的这种循环依赖关系同样也可以扩展到组件级别。组件之间的循环依赖关系的产生原因在于组件1中的类与组件2中的类之间存在依赖关系,从而导致组件与组件之间产生一种循环依赖。
在软件设计领域,存在一条公认的设计原则,这就是无环依赖原则。无环依赖原则认为在组件之间不应该存在循环依赖关系。通过将系统划分为不同的可发布组件,对某一个组件的修改所产生的影响可以不扩展到其他组件。
<<<
ADP(The Acyclic Dependencies Principle,无环依赖原则)
The dependencies between packages must not form cycles.
在包的依赖关系图中不允许存在环。
The dependency structure between packages must be a directed acyclic graph (DAG). That is, there must be no cycles in the dependency structure.
包之间的依赖结构必须是一个直接的无环图形(DAG)。也就是说,在依赖结构中不允许出现环(循环依赖)。
>>>
下面,我们将通过一个具体的代码示例来介绍组件之间循环依赖的产生过程。这个代码示例描述了医疗健康类系统中的一个常见场景,每个用户都有一份健康档案,存储着代表用户当前健康状况的健康等级,以及一系列的健康任务。用户每天可以通过完成医生所指定的任务来获取一定的健康积分,而这个积分的计算过程取决于该用户当前的等级。也就是说,不同的等级下同一个任务所能获取的积分也是不一样的。反过来,等级的计算也取决于该用户当前需要完成的任务数量,任务越多说明越不健康,等级也就越低。
针对这个场景,我们可以抽象出两个类,一个是代表健康档案的HealthRecord类,一个是代表健康任务的HealthTask类。我们先来看HealthRecord类,这个类里面包含着一个HealthTask列表以及添加HealthTask的方法,同样也包含一个获取等级的方法,这个方法根据任务数量来判断等级。
public class HealthRecord {
private List<HealthTask> tasks = new ArrayList<HealthTask>();
public Integer getHealthLevel() {
//根据健康任务数量来判断健康等级
//任务越多说明越不健康,健康等级就越低
if(tasks.size() > 5) {
return 1;
}
if(tasks.size() < 2) {
return 3;
}
return 2;
}
public void addTask(String taskName, Integer initialHealthPoint) {
HealthTask task = new HealthTask(this, taskName, initialHealthPoint);
tasks.add(task);
}
public List<HealthTask> getTasks() {
return tasks;
}
}
对应的HealthTask中显然应该包含对HealthRecord的引用,同时也实现了一个方法来计算该任务所能获取的积分,这个就需要使用到HealthRecord中的等级信息。
public class HealthTask {
private HealthRecord record;
private String taskName;
private Integer initialHealthPoint;
public HealthTask(HealthRecord record, String taskName, Integer initialHealthPoint) {
this.record = record;
this.taskName = taskName;
this.initialHealthPoint = initialHealthPoint;
}
public Integer calculateHealthPointForTask() {
//计算该任务所能获取的积分需要等级信息
//等级越低积分越高,以鼓励多做任务
Integer healthPointFromHealthLevel = 12 / record.getHealthLevel();
//最终积分为初始积分加上与等级相关的积分
return initialHealthPoint + healthPointFromHealthLevel;
}
public String getTaskName() {
return taskName;
}
public int getInitialHealthPoint() {
return initialHealthPoint;
}
}
从代码中,我们不难看出HealthRecord和HealthTask之间存在明显的相互依赖关系。那么,有没有工具能够自动化的帮助我们来识别它们之间存在的这种循环依赖关系呢?答案是肯定的。业界存在一款优秀的开源工具专门用于量化代码的各种度量标准,其中就包括了代码循环依赖分析,这款工具就是JDepend。我们可以使用JDepend来对包含HealthRecord和HealthTask类的包结构进行分析,得到系统中存在循环依赖代码的提示。
对于循环依赖,JDepend给出了四个子页面,分别是所选中的包、存在循环依赖关系的包、所依赖的包和被依赖的包。我们可以使用JDepend来自动分析各个开源框架中存在的依赖关系。例如,在我们熟悉的Dubbo框架dubbo-rpc-api代码工程中,实际上也存在循环依赖的代码。
现在,我们已经解决了如何有效识别代码中存在的循环依赖这个重要问题。接下来,我们就将讨论如何消除代码中的这些循环依赖。软件行业有一句很经典的话,即当我们碰到问题无从下手时,不妨考虑一下是否可以通过“加一层”的方法进行解决。消除循环依赖的基本思路也是一样,我们有三种常见的方法,分别是提取中介者、转移业务逻辑和采用回调接口。
我们先来看第一种方法提取中介者。提取中介者的核心思想是把两个相互依赖的组件中的交互部分抽象出来形成一个新的组件,而新组件同时包含着原有两个组件的引用,这样就把循环依赖关系剥离出来并提取到一个专门的中介者组件中。
这个中介者组件的实现也非常简单,通过提供一个计算积分的方法来对循环依赖进行了剥离,该方法同时依赖于HealthRecord和HealthTask对象,并实现了原有HealthTask中根据HealthRecord的等级信息进行积分计算的业务逻辑。
public class HealthPointMediator {
private HealthRecord record;
public HealthPointMediator(HealthRecord record) {
this.record = record;
}
public Integer calculateHealthPointForTask(HealthTask task) {
Integer healthLevel = record.getHealthLevel();
Integer initialHealthPoint = task.getInitialHealthPoint();
Integer healthPoint = 12 / healthLevel + initialHealthPoint;
return healthPoint;
}
}
这个时候HealthTask就变得非常简单,已经不包含任何有关HealthRecord的依赖信息。
public class HealthTask {
private String taskName;
private Integer initialHealthPoint;
public HealthTask(String taskName, Integer initialHealthPoint) {
this.taskName = taskName;
this.initialHealthPoint = initialHealthPoint;
}
public String getTaskName() {
return taskName;
}
public Integer getInitialHealthPoint() {
return initialHealthPoint;
}
}
然后,我们来针对“提取中介者”这种消除循环依赖的实现方法来编写测试用例。我们在HealthRecord中创建了6个HealthTask并赋予不同的积分初始值,然后通过HealthPointMediator这个中介者来分别对每个Task计算积分。
public class HealthPointTest {
public static void main(String[] args) {
HealthRecord record = new HealthRecord();
record.addTask("忌烟酒", 5);
record.addTask("一周慢跑三次", 4);
record.addTask("一天喝两升水", 2);
record.addTask("坐1小时起来活动5分钟", 2);
record.addTask("晚上10点按时睡觉", 3);
record.addTask("晚上8点之后不再饮食", 1);
HealthPointMediator mediator = new HealthPointMediator(record);
List<HealthTask> tasks = record.getTasks();
for(HealthTask task : tasks) {
Integer healthPoint = mediator.calculateHealthPointForTask(task);
System.out.print(healthPoint);
}
}
}
最后,我们同样可以再次运行JDepend来获取当前的代码依赖关系。这次,我们发现代码中已经不存在任何依赖环了。
我们继续介绍第二种消息循环依赖的方法,这就是转移业务逻辑。这种方法的实现思路在于提取一个专门的业务组件来完成对等级的计算过程。这样,HealthTask原有的对HealthRecord的依赖就转移到了对这个业务组件的依赖,而这个业务组件本身不需要依赖任何对象。
HealthLevelHandler这个业务组件的实现过程同样非常简单,包含了对等级的计算过程。
public class HealthLevelHandler {
private Integer taskCount;
public HealthLevelHandler(Integer taskCount) {
this.taskCount = taskCount;
}
public Integer getHealthLevel() {
if(taskCount > 5) {
return 1;
}
if(taskCount < 2) {
return 3;
}
return 2;
}
}
随着业务组件的提取,HealthRecord需要做相应的改造,这里封装了对HealthLevelHandler的创建过程。
public class HealthRecord {
private List<HealthTask> tasks = new ArrayList<HealthTask>();
public void addTask(String taskName, Integer initialHealthPoint) {
HealthTask task = new HealthTask(taskName, initialHealthPoint);
tasks.add(task);
}
public HealthLevelHandler getHealthPointHandler() {
return new HealthLevelHandler(new Integer(tasks.size()));
}
public List<HealthTask> getTasks() {
return tasks;
}
}
同样,对应的HealthTask也需要进行改造,添加了对HealthLevelHandler的使用过程。
public class HealthTask {
private String taskName;
private Integer initialHealthPoint;
public HealthTask(String taskName, Integer initialHealthPoint) {
this.taskName = taskName;
this.initialHealthPoint = initialHealthPoint;
}
public Integer calculateHealthPointForTask(HealthLevelHandler handler) {
Integer healthPointFromHealthLevel = 12 / handler.getHealthLevel();
return initialHealthPoint + healthPointFromHealthLevel;
}
public String getTaskName() {
return taskName;
}
}
最后,我们需要完成对测试类的改造。现在,HealthTask和HealthRecord都已经只剩下对HealthLevelHandler的依赖。如果我们执行JDepend,同样也会发现系统中不存在任何循环依赖。
public class HealthPointTest {
public static void main(String[] args) {
HealthRecord record = new HealthRecord();
record.addTask("忌烟酒", 5);
record.addTask("一周慢跑三次", 4);
record.addTask("一天喝两升水", 2);
record.addTask("坐1小时起来活动5分钟", 2);
record.addTask("晚上10点按时睡觉", 3);
record.addTask("晚上8点之后不再饮食", 1);
HealthLevelHandler handler = record.getHealthPointHandler();
List<HealthTask> tasks = record.getTasks();
for(HealthTask task : tasks) {
Integer healthPoint = task.calculateHealthPointForTask(handler);
System.out.print(healthPoint);
}
}
}
介绍完了提取中介者和转移业务逻辑之后,我们来看最后一种消除循环依赖的方法,这种方法会采用回调接口。所谓回调本质上就是一种双向调用模式,也就是说,被调用方在被调用的同时也会调用对方。在实现上,我们可以提取一个用于计算等级的业务接口,然后让HealthRecord去实现这个接口。这样,HealthTask在计算积分时只需要依赖这个业务接口,而不需要关心这个接口的具体实现类。
我们同样将这个接口命名为HealthLevelHandler,包含一个计算等级的方法定义。
public interface HealthLevelHandler {
Integer getHealthLevel();
}
有了这个接口,HealthTask中就不再存在对HealthRecord的任何依赖,而是在构造函数中注入这个Handler接口。在计算积分时,我们也只会使用这个接口所提供的方法。
public class HealthTask {
private String taskName;
private Integer initialHealthPoint;
private HealthLevelHandler handler;
public HealthTask(String taskName, Integer initialHealthPoint, HealthLevelHandler handler) {
this.taskName = taskName;
this.initialHealthPoint = initialHealthPoint;
this.handler = handler;
}
public Integer calculateHealthPointForTask() {
Integer healthPointFromHealthLevel = 12 / handler.getHealthLevel();
return initialHealthPoint + healthPointFromHealthLevel;
}
public String getTaskName() {
return taskName;
}
}
而现在的HealthRecord就需要实现该接口,并提供计算等级的具体业务逻辑。同时,在创建HealthTask时,HealthRecord需要把自己作为一个参数传入到HealthTask的构造函数中。
public class HealthRecord implements HealthLevelHandler {
private List<HealthTask> tasks = new ArrayList<HealthTask>();
@Override
public Integer getHealthLevel() {
if(tasks.size() > 5) {
return 1;
}
if(tasks.size() < 2) {
return 3;
}
return 2;
}
public void addTask(String taskName, Integer initialHealthPoint) {
HealthTask task = new HealthTask(taskName, initialHealthPoint, this);
tasks.add(task);
}
public List<HealthTask> getTasks() {
return tasks;
}
}
就这样,我们通过回调方法完成了对系统的改造。采用这样方法,测试用例的代码也会变得更加简洁,我们没有发现除HealthRecord和HealthTask之外的任何第三方对象。我们同样可以通过使用JDepend来验证当前系统中是否还存在循环依赖关系。
public class HealthPointTest {
public static void main(String[] args) {
HealthRecord record = new HealthRecord();
record.addTask("忌烟酒", 5);
record.addTask("一周慢跑三次", 4);
record.addTask("一天喝两升水", 2);
record.addTask("坐1小时起来活动5分钟", 2);
record.addTask("晚上10点按时睡觉", 3);
record.addTask("晚上8点之后不再饮食", 1);
List<HealthTask> tasks = record.getTasks();
for(HealthTask task : tasks) {
Integer healthPoint = task.calculateHealthPointForTask();
System.out.print(healthPoint);
}
}
}
在日常开发过程中,前面介绍的三种消除循环依赖的方法都可以根据具体场景进行灵活应用。作为总结,我们来梳理一下与循环依赖相关的各个知识点。我们介绍了循环依赖的定义以及无环依赖设计原则。然后我们给出了如何自动识别系统中存在的循环依赖的方法和工具。最后,我们基于代码示例详细阐述了消除循环依赖的三种方法。