购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

2.1 基于组件设计原则剖析代码结构

本节关注剖析代码结构的第一个核心主题,即组件设计原则。组件设计原则几乎是一切软件系统设计的基本原则,开发人员需要通过实践来加深对其的理解和应用。

2.1.1 为什么代码结构要这么设计

在与很多开发人员进行沟通和交流之后,笔者发现大家在学习开源框架的源码时普遍存在一个问题,即一不小心就扎进细节,没办法把握代码的整体结构。目前市面上主流的框架通常功能强大而完善,其代码结构也相对复杂。如果我们没有很好的方法来把握代码的整体结构,在阅读源码时很容易产生挫败感。因此,当我们面对一个框架的源码时,首先应该问如下问题:为什么这个框架的代码结构要这么设计?

为了探讨这一问题,让我们引入本书第一个框架:Dubbo。Dubbo是阿里巴巴开源的一款分布式服务框架,在互联网行业中的应用和扩展十分广泛及丰富。Dubbo通过其核心功能为分布式系统设计提供了两大方案,即高性能和透明化的RPC实现方案及服务治理方案。

Dubbo源代码可以从https://github.com/apache/dubbo下载,代码组织结构如图2-2所示。我们看到Dubbo在代码结构上一共包含common、remoting、rpc、cluster、registry、monitor、config和container等8大核心包。后文会详细介绍这些包,这里暂时不展开讲解。

图2-2 Dubbo框架代码的包结构图

让我们接着引入另一个非常主流的开源框架:MyBatis。MyBatis是一款优秀的持久层框架,它支持定制化SQL、存储过程以及高级映射。MyBatis避免了几乎所有的JDBC代码和手动设置参数以及获取结果集。MyBatis可以使用简单的XML或注解来配置和映射原生类型、接口和Java的POJO为数据库中的记录。

MyBatis的源码可以从https://github.com/mybatis/mybatis-3下载,其代码组织结构如图2-3所示。我们看到MyBatis的结构比较复杂,包含了session、mapping、binding等10余个核心包。

图2-3 MyBatis框架代码的包结构图

对MyBatis中各个核心包的详细介绍同样不是本节的重点,在这里我们关注的是从整体结构上把握这些框架的包组织结构。

在了解了Dubbo和MyBatis这两个框架的包结构之后,我们再从前文中“为什么这个框架的代码结构要这么设计”这个问题出发,延伸出以下问题。

□ 这些框架的开发人员是如何设计和规划其代码结构的?

□ 在这些框架的代码结构背后是否有一定的原则?

□ 如何评价这些框架的代码结构的优劣性?

□ 如何从这些框架的代码结构中获取经验从而做到学以致用?

源码阅读需要有突破点,而对以上问题的发散思考和总结就是我们进行源码解读的一个突破点。从上述问题可知,想要理解代码结构,我们需要从一些基本原理入手,例如组件设计原则。

2.1.2 组件设计原则与量化标准

组件设计原则有时候也被称为分包原则,可以用来设计和规划在上一节中提到的Dubbo、MyBatis等框架的代码结构。任何一个软件系统都可以看作一系列组件的集合,良好的设计能够把系统分解为一些大小恰到好处的组件,从而使每个开发团队都可以只关注单个的组件而无须关心整个系统。但在我们刚开始阅读某个框架的源码时,需要避免过多扎进细节导致只关注某一个具体组件,因此可以使用这些原则来把握框架的整体结构。

对组件设计而言,最核心的要点就是内聚和耦合。所谓内聚,是指在一个组件内各个元素彼此结合的紧密程度,而耦合指的是在一个软件的结构内不同组件之间的互连程度。基于这两个设计要点,组件设计原则也分为组件内聚原则和组件耦合原则两大类。组件内聚原则用于指导如何把类划分到包中,而组件耦合原则用来处理包与包之间的关系。

现在我们看到在Dubbo、MyBatis这些框架源码中有很多包结构。请注意,我们还没到要弄清楚为什么要将某些类放到同一个包中的时候(避免扎入细节)。从梳理代码结构的角度出发,我们首先应该关注的是组件之间的关系,即应用组件耦合原则来分析代码结构。具体来说,组件耦合原则包含以下3条设计原则。

□ 无环依赖原则

无环依赖原则(Acyclic Dependencies Principle,ADP)认为在组件之间不应该存在循环依赖关系。系统被划分为不同的可发布组件后,对某一个组件的修改所产生的影响不应该扩展到其他组件。

□ 稳定抽象原则

稳定抽象原则(Stable Abstractions Principle,SAP)认为组件的抽象程度应该与其稳定程度保持一致。即一个稳定的组件应该也是抽象的,这样该组件就不会因为系统无法扩展而变得不稳定。反之,一个不稳定的组件应该是具体的,因为它的不稳定性使其内部代码更易于修改。

□ 稳定依赖原则

稳定依赖原则(Stable Dependencies Principle,SDP)认为被依赖者应该比依赖者更稳定。在一个好的设计中,组件之间的依赖应该朝着稳定的方向进行。一个组件只应该依赖那些比自己更稳定的组件。

从这3条原则的命名上我们不难看出,组件耦合原则实际上关注的是系统的稳定性。那么什么是系统的稳定性?在现实生活中,如果某一个事物不容易被移动,就认为它是稳定的。而在软件开发过程中,如果某一个包被许多其他的软件包所依赖,也就是具有很多输入依赖关系的包就应该是稳定的,因为它的变化可能需要其他依赖它的包做相应的修改,而这种修改显然需要非常大的工作量。

我们来看几个具体的例子。在图2-4中我们看到一个X组件和其他三个组件,我们认为组件X是稳定的,因为组件X被很多其他组件依赖。

而在图2-5中存在一个Y组件,我们认为组件Y是不稳定的,因为组件Y没有被其他的组件依赖,且组件Y自身依赖很多别的组件。

图2-4 稳定组件示意图

图2-5 不稳定组件示意图

现在,我们已经明确了组件设计原则的相关定义,那么如何使用这些原则来指导开发工作呢?任何技术方案的落地都需要同时兼顾定性和定量的指标。现实代码结构中的包结构通常比较复杂,开发人员可能很难一眼就能判断组件的稳定性,这时候就需要借助一些量化标准来对包结构的稳定性进行衡量。幸运的是,业界已经存在了这样的量化标准以及对应的衡量工具。接下来,我们将介绍组件设计原则相关的量化标准及其测量工具。

(1)量化组件的稳定度

根据组件设计原则中的稳定抽象原则,组件的稳定度可以用以下公式来衡量:

I = Ce /( Ca + Ce )

其中 Ca 代表向心耦合,表示依赖该组件的外部组件数量。而 Ce 代表离心耦合,表示被该组件依赖的外部组件的数量。 I 代表Instability,即不稳定性,显然它的值处于[0, 1]之间。

针对上一节介绍的X和Y两个组件,我们可以使用该公式做一个简单计算。不难得出组件X的 Ce =0(因为它没有依赖任何外部组件),所以不稳定性 I =0,说明它非常稳定。相反,组件Y的 Ce =3, Ca = 0(因为没有任何组件依赖它),所以它的不稳定性 I =1,说明它非常不稳定。

图2-6 组件稳定性传递示意图

组件之间存在一个依赖链,稳定性在该依赖链上具有传递性。图2-6展示的是一种常见的场景,沿着依赖的方向,组件的不稳定性应该逐渐降低,稳定性应该逐渐升高。已经处于稳定状态的组件不应该依赖处于不稳定状态的组件。

(2)量化组件的抽象度

另一方面,组件的抽象度也存在类似的计算公式:

A = AC / CC

其中 A 代表抽象度, AC 表示组件中抽象类的数量,而 CC 表示组件中所有类的总和,这样通过对比 AC CC 就能简单得出该组件的抽象度。

在一个系统中,多数组件位于依赖链的中间,既具备一定的稳定性也具备一定的抽象度。如果一个组件的稳定度和抽象度都是1,意味着该组件内部全是抽象类且没有任何组件依赖它,那么这个组件就没有任何用处。相反,如果一个组件稳定度和抽象度都是0,那么意味着这个组件在不断变化,不易维护,这也不是我们想设计的组件。所以,在稳定度和抽象度之间应该保持一种平衡,图2-7中的那条斜线就是平衡线。在有些资料中,这条平衡线有一个专业的名称,即主序列(main sequence)。

我们引入距离 D 的概念来量化这种平衡,距离的计算公式如下所示:

D =abs(1 -I-A )×sin(45)

距离的图形化表示如图2-8所示。

图2-7 组件稳定性主序列示意图

图2-8 组件稳定性距离示意图

使用这个量化标准,我们就可以全面分析代码结构的设计。当阅读某一个框架的代码时,这种分析非常有助于我们理解框架的设计者如何确定哪些包更容易维护,哪些包对变化不那么敏感。

(3)量化稳定度和抽象度的测量工具

有了量化标准,我们就需要进一步引入相关的工具来使用这些量化标准。这里介绍一款分析组件依赖关系的利器:JDepend。JDepend是用来评价Java代码质量的优秀工具,它遍历Java类的文件目录,以Java包为单位,为每一个包自动生成关于包的依赖程度、稳定性、可靠度等方面的评价报告。根据这些报告,我们可以得到包之间的依赖关系,并分析出包的稳定程度、抽象程度、是否存在循环依赖关系等。这些报告中的各项指标与上一节中介绍的组件设计量化标准保持一致。

在使用JDepend时,我们一般加载它所提供的Eclipse插件(可从http://andrei.gmxhome.de/jdepend4eclipse/links.html下载),也可以在Eclipse市场中直接搜索JDepend插件进行安装。

安装完JDepend插件之后,在待分析项目的src图标上单击右键,会看到新增了Run JDepend Analysis命令,如图2-9所示。

图2-9 JDepend插件在Eclipse中的使用效果

直接执行该命令,就可以在打开的JDepend视图中看到分析结果。我们在接下来的内容中会结合Dubbo和MyBatis框架来详细讨论这些分析结果。

2.1.3 组件设计原则与代码结构:Dubbo与MyBatis

在上一节的基础上,本节将使用JDepend来分析Dubbo和MyBatis这两个框架的代码结构。

1.Dubbo框架的稳定度和抽象度

我们首先来看Dubbo框架,Dubbo在设计过程中同样遵循稳定抽象和稳定依赖等原则。我们来回顾Dubbo的各个核心包,它们的名称和简要描述如下所示。

□ dubbo.common:公共逻辑模块,包括Util类和通用模型。

□ dubbo.remoting:远程通信模块,内部包含了对自定义Dubbo协议的实现。

□ dubbo.rpc:远程调用模块,抽象了各种协议并实现了动态代理,针对普通的一对一RPC调用,不包含集群的管理。

□ dubbo.cluster:集群模块,负责将多个服务提供方伪装成一个提供方,包括负载均衡、集群容错和路由等功能。

□ dubbo.registry:注册中心模块,提供对各种注册中心的抽象。

□ dubbo.monitor:监控模块,统计服务调用次数和调用时间,并提供调用链跟踪的服务。

□ dubbo.config:配置模块,提供Dubbo对外的API,开发人员通过配置模块隐藏了Dubbo内部的所有细节。

□ dubbo.container:容器模块,以简单的Main方法加载并启动Spring。

在前面的内容中,我们已经给出了Dubbo中各个包之间的依赖关系,除了dubbo.common这个通用工具包之外,处于依赖关系底层的dubbo.remoting包和dubbo.rpc包在整个框架中也是高层抽象。接下来我们尝试通过JDepend来对Dubbo中的包结构进行量化分析。

我们先来关注位于整个依赖关系中心位置的dubbo.rpc包,可以看到如图2-10所示的分析结果。JDepend给出了四个子页面,分别是所选中的对象、存在循环依赖关系的包、依赖的包和被依赖的包。我们可以看到具体类(CC)、抽象类(AC)、向心耦合(Ca)、离心耦合(Ec)、不稳定性(I)、抽象度(A)和距离(D)等组件设计原则相关的指标,同时该结果还使用“Cycle!”标志位来标识包结构是否存在循环依赖。

图2-10 Dubbo中rpc包基于JDepend的分析结果图

通过可视化界面,JDepend提供了完整的结果描述。由于内容比较多,这里以com.apache.dubbo.rpc.protocol包为例,截取部分数据供大家参考,如代码清单2-1所示。

代码清单2-1 Dubbo中protocol包基于JDepend的分析结果

最后,JDepend还自动生成了主序列图以及各个包在该图中的分布情况。单击某个点,可以看到该点所代表的包结构中的不稳定性、抽象度和主序列之间的距离值,图2-11就是com.apache.dubbo.rpc包的主序列图。com.apache.dubbo.rpc包内部包含7个子包,所以在该图上一共有7个点。

图2-11 com.apache.dubbo.rpc包的主序列图

实际上,图中的分布点应分为三种颜色,绿色点集中在主序列线附近,代表包结构在不稳定性和抽象度之间达成了一种比较好的平衡。黑色点则表示平衡性相对差一些。而如果出现了红色点,则表示设计上出现了问题,需要注意。

2.MyBatis框架的稳定度和抽象度

接下来我们分析MyBatis框架。MyBatis的核心包比较多,我们同样对位于依赖关系中间位置的session包做相同的分析,得到的量化指标结果如代码清单2-2所示。

代码清单2-2 MyBatis中session包基于JDepend的分析结果

从指标结果上讲,MyBatis中的org.apache.ibatis.session包与Dubbo中的org.apache.dubbo.rpc.protocol包相差不多。读者可以自行尝试分析MyBatis中其他的包。

2.1.4 循环依赖及其消除方法

我们前面在介绍Dubbo和MyBatis的代码结构时,主要关注的是稳定抽象和稳定依赖原则,而组件耦合原则还包括一条很重要的原则,即无环依赖原则。根据无环依赖原则,系统设计中不应该存在循环依赖。借助JDepend,我们也可以发现代码结构中存在的循环依赖关系。

如果系统中只存在类A和类B,那么它们之间的依赖关系就非常容易被识别。但如果再引入一个类C,那么这3个类之间关系的组合就有很多种情况,如图2-12所示。

图2-12 循环依赖的类关系示例

可以想象一下,如果在一个系统中存在几十个类,那么它们之间的依赖关系就很难通过简单的关系图一一列举。而一般系统中类的个数显然不止几十个,且类之间的依赖关系存在传递性,所以循环依赖有时候并不像图2-12描述的那么容易识别,产生循环依赖的多个组件之间可能同时存在多种直接和间接的依赖关系。

1.循环依赖示例

上述描述比较抽象,我们看一下具体示例代码,就比较容易理解循环依赖的产生过程。这个代码示例描述了医疗健康类系统中的一个常见场景,每个用户都有一份健康档案,存储着代表用户当前健康状况的健康等级以及一系列的健康任务。用户每天可以通过完成医生所指定的任务来获取一定的健康积分,而这个积分的计算过程取决于该用户当前的等级。也就是说,在不同等级下的用户完成同一个任务所能获取的积分也是不一样的。反过来,等级的计算也取决于该用户当前需要完成的任务数量,任务越多说明越不健康,等级也就越低,如图2-13所示。

图2-13 循环依赖的具体示例

针对这个场景,我们可以抽象出两个类,一个是代表健康档案的HealthRecord类,一个是代表健康任务的HealthTask类。我们先来看HealthRecord类,这个类包含着一个HealthTask列表以及添加HealthTask的方法,同样也包含一个获取等级的方法,这个方法根据任务数量来判断等级,如代码清单2-3所示。

代码清单2-3 HealthRecord类代码

对应的HealthTask显然应该包含对HealthRecord的引用,同时它也实现了一个方法来计算用户完成该任务所能获取的积分,这时就需要使用HealthRecord中的等级信息,如代码清单2-4所示。

代码清单2-4 HealthTask类代码

从代码中,我们不难看出HealthRecord和HealthTask之间存在明显的相互依赖关系。我们可以使用JDepend来对包含HealthRecord和HealthTask类的包结构进行分析,会得到系统中存在循环依赖代码的提示,如图2-14所示。

现在,我们已经解决了如何有效识别代码中的循环依赖这个重要问题。接下来,我们将讨论如何消除这些循环依赖。

2.消除循环依赖的方法

如何消除这种循环依赖?软件行业有一句经典名言:当我们无从下手时,不妨考虑一下是否可以通过“加一层”的方法解决问题。消除循环依赖的基本思路也是这样,就是通过在两个相互循环依赖的组件之间添加中间层,变循环依赖为间接依赖。有3种策略可以做到这一点,分别是关系上移、关系下移和回调。

图2-14 HealthRecord和HealthTask类的包结构依赖关系分析效果图

(1)关系上移

关系上移意味着把两个相互依赖的组件中的交互部分抽象出来形成一个新的组件,而新组件同时包含着对原有两个组件的引用,这样就把循环依赖关系剥离出来并置于一个更高层次的组件中,如图2-15所示。

图2-15 关系上移效果图

图2-15中的HealthPointMediator类的实现也非常简单,通过提供一个计算积分的方法来对循环依赖进行剥离,该方法同时依赖HealthRecord和HealthTask对象,并实现了原来HealthTask根据HealthRecord的等级信息进行积分计算的业务逻辑,如代码清单2-5所示。

代码清单2-5 HealthPointMediator类代码

这时HealthTask就变得非常简单,已经不包含任何有关HealthRecord的依赖信息,如代码清单2-6所示。

代码清单2-6 调整后的HealthTask类代码

(2)关系下移

关系下移策略与上移策略的切入点刚好相反。这种方法的实现思路是提取一个专门的业务组件来完成等级计算过程。这样,HealthTask原有的对HealthRecord的依赖就转变为对这个业务组件的依赖,而这个业务组件本身不需要依赖任何对象,如图2-16所示。

HealthLevelHandler这个业务组件的实现过程同样非常简单,包含了等级计算过程,如代码清单2-7所示。

代码清单2-7 HealthLevelHandler类代码

图2-16 关系下移效果图

为了提取业务组件,HealthRecord需要做相应的改造,在其代码中封装对HealthLevel-Handler的创建过程,如代码清单2-8所示。

代码清单2-8 关系下移策略中的HealthRecord类代码

同样,对应的HealthTask也需要进行改造,在其代码中添加了对HealthLevelHandler的使用过程,如代码清单2-9所示。

代码清单2-9 关系下移策略中的HealthTask类代码

(3)回调

所谓回调,本质上就是一种双向调用模式,也就是说,被调用方在被调用的同时也会调用对方。在实现上,我们可以提取一个用于计算等级的业务接口,然后让HealthRecord去实现这个接口。这样,HealthTask在计算积分时只需要依赖这个业务接口,而不需要关心这个接口的具体实现类,如图2-17所示。

图2-17 回调效果示意图

我们同样将这个接口命名为HealthLevelHandler,其代码包含一个计算等级的方法定义,如代码清单2-10所示。

代码清单2-10 HealthLevelHandler接口代码

有了这个接口,在HealthTask中就不再存在对HealthRecord的任何依赖,而是在其构造函数中注入Health Level Handler接口。在计算积分时,我们也只会使用这个接口所提供的方法,如代码清单2-11所示。

代码清单2-11 回调中的HealthTask类代码

而现在HealthRecord就需要实现该接口,并提供计算等级的具体业务逻辑。同时,在创建HealthTask时,HealthRecord需要把自己作为一个参数传入HealthTask的构造函数中,如代码清单2-12所示。

代码清单2-12 回调中的HealthRecord类代码

关系上移、关系下移以及回调这三种消除循环依赖关系的方法都非常实用,可以直接应用到日常开发过程中。 abajhkypBzlLMcLSKVkh8VYCDb92fHZrGW8IpCxDj892GllszeJKSiMjARplXTh3

点击中间区域
呼出菜单
上一章
目录
下一章
×