再读整洁架构之道(四)组件构建原则

Tommy Cheese | Jul 6, 2024 min read

再读整洁架构之道(四)组件构建原则

第三篇主要叙述如何设计模块和类,在本篇将会更近一步,叙述如何进行组件的设计。

组件是软件的部署单元,是整个软件系统在部署过程中可以独立完成部署的最小实体,组件可以被单独开发,组件化的插件式架构已经成为我们习以为常的软件构建形式了。

组件聚合

组件聚合告诉我们哪些模块和类应该组合在一起形成组件。主要有三个原则:

  • 复用/发布等同原则;
  • 共同闭包原则;
  • 共同复用原则;

复用/发布等同原则REP

REP指出软件复用的最小粒度应等同于其发布的最小粒度。

REP是从代码复用角度考虑的,它规定了具备相同主题和功能的代码可以进行组合形成组件。REP建议软件复用应该按照组件来进行的。

通俗来讲,某个组件打包发布后可能会形成版本号或唯一标识,我们通过引入现成代码库的方式复用代码,引入的单位就是组件打包的结果,引入的凭据就是版本号。

共同闭包CCP

CCP指出我们应该将那些会同时修改,并且为相同目的而修改的类放到同一个组件中,而将不会同时修改,并且不会为了相同目的而修改的那些类放到不同的组件中。

CCP是从代码维护角度考虑的,对于由于同一原因/修改目的代码应该组合形成组件,这样就可以有效地降低因软件发布、验证及部署所带来的工作压力。

前面说过,CCP是SRP的组件版,SRP和CCP都可以用以下的语言概括:

将由于相同原因而修改,并且需要同时修改的“东西”放在一起。将由于不同原因而修改,并且不同时修改的东西分开。

  • 在SRP中,东西是指函数和其他组成类或模块的成分;
  • 在CCP中,东西是指构成组件的类和模块。

共同复用CRP

CRP指出不要强迫一个组件的用户依赖他们不需要的东西。

CRP规定了对于使用场景不同、使用频次不同的代码需要进行拆分到不同的组件中。CRP 是为了避免不必要的切分,是ISP接口隔离原则的普适版。

CRP是接口隔离的普适版,ISP和CRP都可以用以下的语言概括:

不要依赖不需要用到的“东西”。

  • 在ISP中,东西是指包含不需要的方法的函数/类;
  • 在CCP中,东西是指包含不需要的函数的类/模块;

思考

那么REP、CCP、CRP三者之间有什么关系呢?

实际上三者存在着竞争关系

REP和CCP是黏合性原则,它们指导我们应该把哪些类/模块放在一起形成组件,而CRP属于排他性原则,它知道我们哪些类/模块不应该放在一起。

image-20240706143525187

  • 如果架构师只关注REP和CCP,那么会发现软件依赖了的组件中包含了很多并不需要的部分,这样会导致太多不必要的发布;
  • 如果架构师只关注CRP和CCP,那么会发现软件的复用会变得非常困难;
  • 如果架构师只关注REP和CRP,那么会发现如果部分类/模块需要变更,很多相关模块也不可避免的要跟随变更;

软件架构师的任务就是要在这三个原则中间进行取舍,组件的构成安排应随着项目重心的不同,以及研发性与复用性的不同而不断演化,因此确定项目朝着哪个方向演进是要动态判断的。

组件耦合

组件耦合告诉我们如何安排组件之间的关系。它包含三个原则:

  • 无依赖环原则ADP;
  • 稳定依赖原则SDP;
  • 稳定抽象原则SAP;

无依赖环原则ADP

ADP告诉我们组件依赖关系图中不应该出现环。

版本号控制机制解决了一觉醒来综合征的问题,而此机制需要遵循ADP,首先让我们读一下作者是怎么介绍一觉醒来综合征的:

当你花了一整天的时间,好不容易搞定了一段代码,第二天上班时却发现这段代码莫名其妙地又不能工作了。这大概率是其他工作人员修改了你项目所依赖的组件。

要想解决这个问题一般有两种方案:

  • 每周构建
  • 版本号控制机制

每周构建:每个人先在自己的代码仓工作,每周固定时间(比如周五)再进行项目构建并处理可能的冲突问题。

这种方式的局限性很明显:

  • 随着项目越来越大,集成工作会越来越难以按时完成
  • 整个项目会变得越来越难以构建与测试,团队反馈周期会越来越长,研发质量自然也会越来越差

因此版本号控制机制需要被引入。

版本号控制机制:每当一个组件发布新版本时,其他依赖这个组件的团队都可以自主决定是否立即采用新版本。

版本号控制机制不允许组件结构依赖关系图中出现环,也就是要遵守无依赖换原则ADP,否则一觉醒来综合征是不可避免的。为什么呢?

这是因为如果存在依赖环路,环路里的所有组件被组合成了一个更大的组件,这就要求他们都必须使用相同的版本,这样其他的组件才能成功的完成依赖。同样,存在依赖换的系统在测试时也会成为一个棘手的问题,虽然打桩mock技术已经被广泛应用,但同时为环路中的组件重复打桩也已经十分的不优雅了。

那么如何消除循环依赖呢?方案有两个:

  • 使用依赖反转创建接口;
  • 创建新组件。

应用DIP解决循环依赖很好理解,下面讲述一个在工作中遇到的案例来说明如何使用创建新组件来打破循环依赖。

现在有这样的一个场景,团队正在使用领域驱动设计的方法设计系统架构,现在实体Entity Dog需要依赖Dog Repo完成Save存储操作。此时Entity模块依赖了Repo模块,很不幸的是在Entity模块中我们还定义了Dog PO(简直糟透了),Repo的Save又需要使用PO来完成对象的转换并存储。此时Entity和Repo之间就发生了循环依赖,幸运的是,在Go中模块之间的相互依赖是不允许的,检查起会提醒“循环导入”错误,怎么处理这个问题呢?

答案是我们需要新创建一个PO模块,并把Entity中的PO移动到模块,Repo中依赖PO的函数也要划分到PO中,这样就消除了依赖环。使用依赖注入方法也是一个选择,如果你使用SprintBoot,@AutoWired即可解决此问题,但其本质也是创建了一个新组件——依赖池,消除了依赖环。

类似的例子还有很多很多,是否还记得我们使用anaconda下载Python库时遇到的库版本冲突问题……

在这里多插一句,笔者在设计组件结构的时候很自然的就想到要和系统功能对应,也就是说,组件应该是和系统功能一一对应的,这样组件依赖图就和系统功能模块划分也一一对应起来。想当然认为在系统设计的一开始组件依赖结构图也就产出了。

但作者特别提到,自顶向下的设计是不可能的。让我们看看书中是怎么描述的:

[!NOTE]

组件结构图必须随着软件系统的变化而变化和扩张,而不可能在系统构建的最初就被完美设计出。因为组件依赖结构图并不和功能一一对应,它更像是应用程序在构建性与维护性方面的一张地图。

被设计并实现出来的模块越来越多,项目中就逐渐出现了要对组件依赖关系进行管理的需求,我们希望将项目变更所影响的范围被限制得越小越好,因此需要应用单一职责原则(SRP)和共同闭包原则(CCP)来将经常同时被变更的类聚合在一起。

组件结构图中的一个重要目标是指导如何隔离频繁的变更。我们不希望那些频繁变更的组件影响到其他本来应该很稳定的组件。

另外,随着应用程序的增长,创建可重用组件的需要也会逐渐重要起来。这时CRP又会开始影响组件的组成。最后当循环依赖出现时,随着无循环依赖原则(ADP)的应用,组件依赖关系会产生相应的抖动和扩张。

稳定依赖原则SDP

SDP告诉我们依赖关系必须要指向更稳定的方向。通常来说,要做到软件架构的越底层越稳定。

任何一个我们预期会经常变更的组件都不应该被一个难于修改的组件所依赖,否则这个多变的组件也将会变得非常难以被修改。这就是软件开发的困难之处,我们精心设计的一个容易被修改的组件很可能会由于别人的一条简单依赖而变得非常难以被修改,通过遵循SDP,这样的问题就能够被避免。

判断组件是否稳定,可以使用稳定性指标。作者定义稳定性指标可以通过如下公式计算: $$ I=\frac{Fan-out}{Fan-in+Fan-out} $$ 其中Fan-in为组件的入依赖数量,Fan-out为出依赖数量,这意味着一个组件的出依赖越少越稳定,因为如果出依赖为 0 意味着没有因素可以让组件改变。

稳定依赖原则(SDP)的要求就是要让每个组件的I指标都必须大于其所依赖组件的I指标(即被指向的组件更稳定)。

SDP并不是要求所有组件都应该是稳定的,我们设计组件架构图的目的就是要决定应该让哪些组件稳定,让哪些组件不稳定,所有组件都稳定的架构是不灵活的,不灵活的架构没有足够的架构价值。

稳定抽象原则SAP

SAP指出一个组件的抽象化程度应该与其稳定性保持一致。

高层策略等应该存在稳定的组件中,但这样做会导致高层策略很难修改,幸运的是开闭原则 OCP 告诉我们稳定的组件可以被设计成容易扩展的,抽象类具有稳定抽象能力,可以设计一个抽象类具有较好的稳定性并且易于扩展和修改。

抽象类是接口和类之间的缓冲地带。

稳定抽象原则(SAP)为组件的稳定性与它的抽象化程度建立了一种关联。原则要求稳定的组件同时应该是抽象的,这样它的稳定性就不会影响到扩展性。同时一个不稳定的组件应该包含具体的实现代码,这样它的不稳定性就可以通过具体的代码被轻易修改。因此,如果一个组件想要成为稳定组件,那么它就应该由接口和抽象类组成,以便将来做扩展。

和稳定性类似,我们也可以使用抽象程度指标来衡量组件的抽象程度: $$ A=\frac{N_c}{N_a} $$ 其中Nc是指组件中类的数量,而Na是组件中抽象类和接口的数量。A指标的取值范围是从0到1,值为0代表组件中没有任何抽象类,值为1就意味着组件中只有抽象类。

思考🤔

SDP和SAP有什么关联吗?

第一点,从SDP、SAP与DIP上说

实际上,SDP+SAP=组件DIP。因为SDP要求的是让依赖关系指向更稳定的方向,而SAP则告诉我们稳定性本身就隐含了对抽象化的要求,即依赖关系应该指向更抽象的方向。

怎么理解这句话呢?

可以想到,DIP在类上的作用是为了保证灵活性,SDP+SAP实际上就是为了保证组件在稳定的基础上足够灵活,并且 DIP 和 SDP+SAP 在软件架构上的影响都是具体实现类指向抽象类/接口,因此从作用上看SDP+SAP=组件DIP。

但是DIP和SDP+SAP还是有不同之处,对类来说,设计是没有灰色地带的,一个类要么是抽象类,要么就不是。SDP与SAP这对原则是应用在组件层面上的,我们要允许一个组件部分抽象,部分稳定。

其次,从SAP和SDP的目的达成来说

此外,SDP指出依赖应该指向稳定的位置,SAP要求我们在设计组件时,高层的策略应该设计在抽象的组件,以便我们在架构设计时将策略与细节分开,同时便于我们进行插件式开发,SDP和SAP他们在目的上是一致的。

看到了吧,这里又提到了插件式开发这个词,上次是在哪里提到的?对,依赖反转DIP,这也说明了SAP+SDP确实类似于组件DIP。

主序列

把组件稳定性指标I与组件抽象程度指标A结合在一起,画出一张图,a=-i+1这条直线就叫做主序列线。

image-20240706171725797

整个区域可以分为三个区域:

  • 痛苦区:靠近(0,0)的区域,在这个区域内的组件十分稳定但同时又十分的具体,因此不易改变,如数据库表;工具库也是处于痛苦区的组件,虽然其 I 指标为 1(工具库依赖非常多的组件,因此其不稳定),但不能被修改,否则很多代码会出现问题;
  • 无用区:处于(1,1)附近的组件,该位置上的组件通常是无限抽象的,但是没有被其他组件依赖,这样的组件往往无法使用,对于这个区域中的软件组件来说,其源码或者类中的设计问题通常是由于历史原因造成的,比如忘记清除的旧代码;
  • 主序列线区:在整条主序列线上的区域,组件所能处于最优的位置是线的两端(0, 1)与(1, 0)。一个优秀的软件架构师应该争取将自己设计的大部分组件尽可能地推向这两个位置。

怎么衡量组件的综合性能呢?答案是计算它与主序列线的距离,来量化一个系统设计与主序列的契合程度,这个值为D值,值为0意味着组件是直接位于主序列线上的,值为1则意味着组件在距离主序列最远的位置。 $$ D=|A+I-1| $$ 这样我们就可以用D指标大于0多少来指导组件的重构与重新设计。

除此以外,D还可以有更多的作用,比如计算设计中所有组件的D指标的平均值和方差,用统计学的方法来量化分析一个系统设计:

  • 对于一个良好的系统设计来说,D指标的平均值和方差都应该接近于0;
  • 方差作为组件的“达标红线”来使用,我们可以通过它找出系统设计中那些不合常规的组件;
  • 还可以按照时间追踪 D 的方差值,来观察随着时间的变化,软件系统架构的稳定和抽象变化。

(第四篇完)

comments powered by Disqus