5 组件构建原则
大型软件系统的架构过程与建筑物修建很类似,都是由一个个小组件组成的。所以,如果说SOLID原则是用于指导我们如何将砖块砌成墙与房间的,那么组件构建原则就是用来指导我们如何将这些房间组合成房子的。
5.1 组件
组件是软件的部署单元,是整个软件系统在部署过程中可以独立完成部署的最小实体。例如,对于Java来说,它的最小组件是Jar文件。在编译运行语言中,组件是一组二进制文件的集合,而在解释运行语言中,组件则是一组源代码文件的集合。无论采用什么编程语言来开发软件,组件都是该软件在部署过程中的最小单元。
在早期的软件开发中,程序员可以完全掌握自己编写的程序所处的内存地址和存放格式,在那里,程序中的第一条语句被称为起源(origin)语句,它的作用是声明该程序应该被加载到的内存位置。由于那时候没有重定位(relocate)技术,因此设计程序加载的内存地址是早期程序员在编程初期就要做的一个重要决策。而为了调用库函数,程序员们必须将所有要调用的库函数的源代码包含到自己的程序代码中, 然后再进行整体编译。在那个年代,存储设备十分缓慢,而内存则非常昂贵,也非常有限,编译器在编译程序的过程中需要数次遍历整个源代码,由于内存非常有限,驻留所有的源代码是不现实的,编译器只能多次从缓慢的存储设备中读取源代码,而这样做是十分耗时的——库函数越多,编译就越慢。大型程序的编译过程经常需要几个小时。
为了缩短编译时间,程序员们改将库函数的源代码单独编译,库函数的源代码单独编译后被加载到一个指定的内存位置,然后编译器会针对该库文件创建一个符号表(symbol table),并将其和应用程序代码编译在一起,当程序运行时,它会先加载二进制形式的库文件,再加载编译过的应用程序,其内存布局如下所示:
当然,只要应用程序的代码能够完全存放在地址0000~1777(八进制)内,这种组织方式完全没有问题,但是,当应用程序的代码超过这个范围时,程序员则不得不将应用程序切分成两个不同的地址段,以跳过库函数存放的内存范围:
很显然,这种方案也是不可持续的,因为随着函数库中函数的增加,它的大小也随之增加,我们同样也需要为函数库划分新的区域,这样一来,程序和函数库的碎片化程度会随着计算机内存的增加而不断增加。
程序员们提出的解决方案是——生成可重定位的二进制文件,其原理是:程序员修改编译器输出文件的二进制格式,使其可以由一个智能加载器加载到任意内存位置。
重定位技术要求我们在加载器启动时为这些文件指定要加载到的内存地址,而且可重定位的代码中还包含了一些记号,加载器将其加载到指定位置时会修改这些记号对应的地址值。一般来说,这个过程就是将二进制文件中包含的内存地址都按照其加载到的内存基础位置进行递增。
这样一来,程序员们就可以用加载器来调整函数库及应用程序的位置了,事实上,这种加载器还可以接受多个二进制文件的输入,并顺序在内存中加载它们,再逐个进行重定位。这样,程序员们就可以只加载他们实际会用到的函数了。
除此之外,程序员们还对编译器做了另外一个修改,就是在可重定位二进制文件中将函数名输出为元数据并存储起来,这样一来,如果一段程序调用了某个库函数,编译器就会将这个函数的名字输出为外部引用(external reference),而将库函数的定义输出为外部定义(external definition)。加载器在加载完程序后,会将外部引用和外部定义链接(link)起来。这就是链接加载器的由来。
链接加载器让程序员们可以将程序切分成多个可被分别编译、加载的程序段。在程序规模较小、外部链接也较少的情况下,这个方案一直很好用。然而,在20世纪60年代末期到70年代初期,程序的规模突然有了大幅度的增长,导致链接加载器的处理过程变得很慢。
于是,程序员们将加载过程和链接过程也进行了分离,将耗时较长的部分——链接部分——放到了一个单独的程序中去进行,这个程序就是所谓的链接器(linker)。链接器的输出是一个已经完成了外部链接的、可以重定位的二进制文件,这个文件可以由一个支持重定位的加载器迅速加载到内存中,这使得程序员可以用缓慢的链接器生产出可以很快进行多次加载的可执行文件。
而随着时间的推移,到了20世纪80年代,程序的规模进一步扩大,链接器的生产效率也变得很慢,直到20世纪90年代,随着磁盘物理尺寸不断缩小、速度不断提高、内存造价的不断降低,以及计算机时钟频率的不断提升,链接器的生产速度也不断提升,并远远超过了程序规模的增长速度。
与此同时,编译领域中还诞生了Active-X、共享库、jar文件等组件形式,由于计算与存储速度的大幅提升,我们又可以在加载过程中进行实时链接了,链接几个jar文件或是共享库文件通常只需要几秒种时间,由此,插件化架构也随之诞生了。
5.2 组件聚合
组件聚合是一个非常重要的设计决策,通常我们会遵循三个与构建组件相关的基本原则,即复用/发布等同原则(REP)、共同闭包原则(CCP)和共同复用原则(CRP)。
5.2.1 复用/发布等同原则(Reuse/Release Equivalence Principle)
核心定义:软件复用的最小粒度应等同于其发布的最小粒度。
该原则强调了在软件设计中,一个组件或模块的修改只应该影响到那些需要同时被复用和发布的部分,这意味着当组件发生变化时,只有那些相关联且同样需要更新的组件才应当一起重新发布,这一原则有助于降低组件间的耦合度,并确保系统的稳定性和可维护性。
5.2.2 共同闭包原则(Common Closure Principle)
核心定义:我们应该将那些会同时修改,并且为相同目的而修改的类放到同一个组件中,而将不会同时修改,并且不会为了相同目的而修改的那些类放到不同组件中。
这其实是SRP(Single Responsibility Principle,单一职责原则)原则在组件层面的再度阐述。CCP的主要作用是提示我们将所有可能会被一起修改的类集中在一处,也就是说,如果两个类紧密相关,不管是源代码层面还是抽象理论层面,永远都会一起被修改,那么它们就应该归属为同一个组件。
CCP和SRP的共同点是:将由于相同原因而修改,并且需要同时修改的东西放在一起。将由于不同原因而修改,并且不同时修改的东西分开。
5.2.3 共同复用原则(Common Reuse Principle)
核心定义:不要强迫一个组件的用户依赖他们不需要的东西。
CRP建议我们将经常共同复用的类和模块放在同一个组件中,更重要的是,不是紧密相连的在不应该被放在同一个组件中。
当我们决定要依赖某个组件时,最好是实际需要依赖该组件中的每个类,而不是只依赖其中的某些类,但不依赖其他类的情况。
CRP原则实际上是ISP(Interface Segregation Principle,接口隔离原则)原则的一个普适版,ISP原则建议我们不要依赖带有不需要的函数的类,而CRP原则则是建议我们不要依赖带有不需要的类的组件。它们的共同点是:不要依赖不需要用到的东西。
5.2.4 组件聚合张力图
组件构建的这三个原则实际上是存在彼此竞争关系的,REP和CCP是黏合性原则,它们会上组件变更更大,而CRP原则则是排除性原则,它会尽量让组件变小。软件架构师的任务就是要在这三个原则中进行取舍。
下图是组件聚合三大原则的张力图,图的边线所描述的是忽视对应原则的后果:
只关注REP和CRP的软件架构师会发现,即使是简单的变更也会同时影响到许多组件,相反,如果架构师过于关注CCP和REP,则会导致很多不必要的发布。
通常情况下,在项目早期,CCP原则会比REP原则更重要,因为在这一阶段研发速度比复用性更重要。
一般来说,一个软件项目的重心会从该三角区域的右侧开始,先期主要牺牲的是复用性,然后随着项目逐渐成熟,其他项目会逐渐开始对其产生依赖,项目重心就会逐渐向该三角区域的左侧滑动。
换句话说,一个项目在组件结构设计上的重心是根据该项目的开发时间和成熟度不断变动的,我们对组件结构的安排主要与项目开发的进度和它被使用的方式有关,与项目本身功能的关系其实很小。
5.3 组件耦合
接下来要讨论的三条原则主要关注的是组件之间的关系,在这些原则中,我们同样会面临着研发能力和逻辑设计之间的冲突。这三条原则是无依赖环原则、稳定依赖原则和稳定抽象原则。
5.3.1 无依赖环原则(Acyclic Dependencies Principle)
组件依赖关系图中不应该出现环。
如下是一个典型的应用程序的组件结构:
在这个组件结构图中,不管我们从该图中的哪个节点开始,都不能沿着这些代表了依赖关系的边最终走回到起点。也就是说,这种结构中不存在环,这种结构被称为有向无环图(Directed Acyclic Graph,简称为DAG),这是符合无依赖环原则的。
再看下面的组件结构图:
可以明显看到,Interactors组件、Authorizer组件和Entities组件间形成了一个环,这就是循环依赖。
在这种循环依赖的场景下,如果我们要修改Interactors组件,由于Authorizer组件依赖它,因此要考虑Interactors组件对Authorizer组件的影响而可能修改Authorizer组件,而由于Entities组件依赖Authorizer组件,因此要考虑Authorizer组件对Eneities组件的影响而可能修改Entities组件,由于Interactors组件依赖Entities组件,那么Entities组件的修改又可能影响Interactors组件导致其修改,出现了无限循环。
而要打破这些组件中的循环依赖,将依赖图转化为DAG,则可以通过两种机制做到:
(1) 应用依赖反转原则(DIP):在上图中,假设是Entities组件中的User类依赖了Authorizer中的Permissions类,那么我们可以在Entities中创建一个Permission接口,并在Authorizer组件中实现它,那么依赖关系就倒转过来了,如图5.3.1-1;
(2) 创建一个新组件:在上图中,我们创建一个新组件Permissions,然后让Entities组件和Authorizer组件都依赖它,就可以打破循环依赖,如图5.3.1-2;
5.3.1-1 依赖反转
5.3.1-2 创建新组件
当然,采用第二种解决方案也意味着在需求变更时,项目的组件结构也要随之变更,因此,我们必须要持续地监控项目中的循环依赖关系,当循环依赖出现时,必须以某种方式消除它们。为此,我们有时候不可避免地需要创建新的组件,而使整个组件结构变得更大。
根据上述讨论,我们可以得出一个无法逃避的结论:组件结构图是不可能自上而下设计出来的。它必须随着软件系统的变化而变化和扩张,而不可能在系统构建的最初就被完美设计出来。
这与我们通常了解的信息不一致,同样不一致的还有另一个概念:项目粗粒度的组件分组规则所产生的就是组件的依赖结构,也应该在某种程度上与项目的系统功能分解的结果相互对应,但是很明显,组件依赖关系图其实不具备这样的属性。
事实上,组件依赖结构图并不是用来描述应用程序功能的,它更像是应用程序在构建性与维护性方面的一张地图。这就是组件的依赖结构图不能在项目的开始阶段被设计出来的原因——这时候该项目还没有任何被构建和维护的需要,自然也不需要一张地图来指引。然而,随着早期被设计并实现出来的模块越来越多,项目中就逐渐出现了要对组件依赖关系进行管理的需求,以此来预防“一觉醒来综合征”的爆发。除此之外,我们还希望将项目变更所影响的范围被限制得越小越好,因此需要应用单一职责原则(SRP)和共同闭包原则(CCP)来将经常同时被变更的类聚合在一起。
组件结构图中的一个重要目标是指导如何隔离频繁的变更,因此软件架构师才有必要设计并且铸造出一套组件依赖关系图来,以便将稳定的高价值组件与常变的组件隔离开,从而起到保护作用。另外,随着应用程序的增长,创建可重用组件的需要也会逐渐重要起来,这时,CRP又会开始影响组件的组成。最后,当循环依赖出现时,随着无循环依赖原则(ADP)的应用,组件依赖关系会产生相应的抖动和扩张。组件依赖关系是必须要随着项目的逻辑设计一起扩张和演进的。
5.3.2 稳定依赖原则(Stable Dependencies Principle)
依赖关系必须要指向更稳定的方向。
设计这件事不可能是完全静止的,如果我们要让一个设计是可维护的,那么其中某些部分就必须是可变的。通过遵守共同闭包原则(CCP),我们可以创造出对某些变更敏感,对其他变更不敏感的组件,这其中的一些组件在设计上就已经是考虑了易变性,预期它们会经常发生变更的。
任何一个我们预期会经常变更的组件都不应该被一个难以修改的组件所依赖,否则这个多变的组件也将会变得非常难以被修改。
下面是一个稳定的组件的例子,虽然非常理想化:
X是一个稳定的组件,因为有三个组件依赖着X,所以X有三个不应该被修改的原因,即X需要对三个组件负责,另一方面,X不依赖于任何组件,所以不会有任何原因导致它需要被变更,我们称它为“独立组件”。
而下图中的Y则是一个不稳定组件,它不被任何组件依赖,因此Y不需要对任何组件负责,但因为Y同时依赖于三个组件,所以它的变更就可能由三个不同的源产生:
组件的稳定性是可以通过计算所有入和出的依赖关系来量化的,这里涉及到几个概念:
(1) Fan-in:入向依赖,表示组件外部类依赖于组件内部类的数量;
(2) Fan-out:出向依赖,表示组件内部类依赖于组件外部类的数量;
(3) I:不稳定性,
I
=
F
a
n
−
o
u
t
F
a
n
−
i
n
+
F
a
n
−
o
u
t
I=\frac{Fan-out}{Fan-in + Fan-out}
I=Fan−in+Fan−outFan−out,该指标的范围是[0,1],I=0意味着组件是最稳定的,I=1意味着组件是最不稳定的;
以下图为例:
对于组件Cc来说,有三个组件外部类依赖于组件内部类,即Ca的q类依赖于Cc的t类、Ca的r类依赖于Cc的u类、Cb的s类依赖于Cc的u类,有一个组件内部类依赖于组件外部类,即Cc的u类依赖于Cd的v类,那么,Fan-in=3,Fan-out=1,于是
I
=
F
a
n
−
o
u
t
F
a
n
−
i
n
+
F
a
n
−
o
u
t
=
1
3
+
1
=
0.25
I=\frac{Fan-out}{Fan-in + Fan-out}=\frac{1}{3 + 1}=0.25
I=Fan−in+Fan−outFan−out=3+11=0.25。
稳定依赖原则(SDP)的要求是让每个组件的I指标都必须大于其所依赖的组件的I指标。也就是说,组件结构依赖图中各组件的I指标必须要按其依赖关系方向递减。
由此可见,遵守稳定依赖原则(SDP)的组件结构是:可变更的组件位于顶层,而稳定组件位于底层。
5.3.3 稳定抽象原则(Stable Abstractions Principle)
一个组件的抽象化程度应该与其稳定性保持一致。
稳定抽象原则(SAP)为组件的稳定性与它的抽象化程度建立了一种关联:
(1) 该原则要求稳定的组件同时应该是抽象的,这样它的稳定性就不会影响到扩展性;
(2) 该原则要求一个不稳定的组件应该包含具体的实现代码,这样它的不稳定性就可以通过具体的代码被轻易修改;
因此,如果一个组件想要成为稳定组件,那么它就应该由接口和抽象类组成,以便将来做扩展。如此,这些既稳定又便于扩展的组件可以被组合成既灵活又不会受到过度限制的架构。
将SAP(稳定抽象原则)和SDP(稳定依赖原则)这两个原则结合起来,就等于组件层次上的DIP(依赖反转原则)。因为SDP(稳定依赖原则)要求的是让依赖关系指向更稳定的方向,而SAP(稳定抽象原则)则告诉我们稳定性本身就隐含了对抽象化的要求,即 依赖关系应该指向更抽象的方向。
抽象化程序同样可以量化:
(1) Nc表示组件中类的数量(包括抽象类和接口);
(2) Na表示组件中抽象类和接口的数量;
(3) 抽象程度
A
=
N
a
N
c
A=\frac{Na}{Nc}
A=NcNa;
A指标的取值范围从0到1,值为0代表组件不没有任何抽象类,值为1则意味着组件中只有抽象类。
现在我们可以来定义组件的稳定性I与其抽象化程度A之间的关系了,如下图所示:
我们无法要求所有组件都是最稳定的且包含了无限抽象类的(I=0,A=1)或者最不稳定的且无任何抽象的(I=1,A=0),因为组件通常都有各自的稳定程度和抽象化程度,因此我们会认为A/I图上存在着一个合理组件的区间,也存在不合理组件的区间,以下是两个不合理组件的区间,而这个区间外的区间,则可认为是合理组件区间:
(1) 痛苦区:如上图所示,假设某个组件处于(0,0)的位置,那么它应该是一个非常稳定但也非常具体的组件,这样的组件在设计上是最不佳的,因为它很难被修改,这意味着该组件不能被扩展,当然,如果这个组件是不可变组件的话,它在这一区域是无害的,例如一些工具型类库,更具体的如String类;而如果一个多变的组件落在这个区域则会非常麻烦,因为它的任何修改都可能造成依赖它的组件的变更;
(2) 无用区:如上图所示,当某个组件处于(1,1)的位置时,它是无限抽象的,但没有被任何组件所依赖,因此这个区域通常包含了大量的无用代码;
很明显,最多变的组件应该离上述两个区域越远越好。
距离两个区域最远的点连成的一条线,即从(0,1)连接到(1,0)的线,被称为 主序列线(main sequence) ,位于主序列线上的组件不会为了追求稳定性而被设计得“太过抽象”,也不会为了避免抽象化而被设计得“太过不稳定”,这样的组件既不会特别难以被修改,又可以实现足够多的功能,对于这些组件来说,通常会有足够多的组件依赖于它们,这使得它们会具有一定程度的抽象,同时它们也依赖了足够多的其他组件,这又使得它一定会包含很多具体实现。
在整条主序列线上,组件所处的最优位置是线的两端,我们应该争取将大部分组件尽可能地推向这两个位置,但这很难,因此实践中,我们通常会将组件设计成在主序列线上,或者贴近主序列线。
衡量一个组件距离最佳位置的距离也是可以量化衡量的:D指标,距离 D = ∣ A + I − 1 ∣ D=|A+I-1| D=∣A+I−1∣,该指标的取值范围是[0,1],值为0意味着组件是直接位于主序列线上的,值为1则意味着组件在距离主序列最远的位置。
通过计算每个组件的D指标,就可以量化一个系统设计与主序列的契合程度了,另外,我们也可以用D指标大于0多少来指导组件的重构与重新设计。
对于一个良好的系统设计来说,D指标的平均值和方差都应该接近于0,其中,方差还可以被当作组件的“达标红线”来使用,我们可以通过它找出系统中那些不合常规的组件。