软件系统的设计原则及解耦部署

一、背景

之所以写这篇文章,是因为我之前做了一段时间的后台开发工作,而且有些系统是从0开始做的。一开始的系统设计是没有遵循任何设计原则的,都是怎么快怎么来,导致后面有一段时间在做需求更改和软件变更时感到非常痛苦,想要加一个功能会需要改动很多地方,搞得自己战战兢兢。于是才找来一些系统设计相关的书来看一下,学习了设计原则和解耦部署的相关知识,并且尽可能用于实践。经过优化后的系统,虽然还是有很多问题亟待解决,但总体上来说好了很多,再做变更时心里也有把握了许多。经过一轮优化,我觉得有必要将这些原则汇总总结一下,一方面是梳理下自己学到的知识,另一方面是分享给有需要的人。限于篇幅,文章基本上是比较偏理论的,但每一点都可以套用在自己的系统中进行深入思考。

文章分为四个部分。第一部分讲SOLID设计原则,因为这是系统设计需要遵循的基本原则,这些原则在很多地方都有详细样例讲解,我这里就主要是提了一下基本概念。第二部分讲组件的设计原则,由于一个大型系统其实是由各个组件耦合组成的,因此有必要对组件应该如何设计进行系统总结。第三部分总结了软件系统解耦部署的几种方式,因为如果系统部署方式选择的正确,是会切实提升研发效率的。在文章最后,我也写了一些我个人对于系统设计原则实践的一点思考。

二、代码设计原则

前面提到了,一个大型软件系统其实就是由一系列组件耦合组成的。所谓组件,简单来说就是对数据与方法的封装。作为组件的使用者,无需关心它的实现细节,只需要简单的调用即可。当一个组件设计的比较合适合理时,是可以变得非常通用的,开发者可以重复利用,也就可以极大的提升研发效率。

既然组件是数据与方法的封装,本质上组件还是代码构成的,因此,组件的设计原则也就一定是基于代码的设计原则的。这里,我们先回顾一下代码设计原则。代码的主要设计原则可以简单记为SOLID原则。

SRP

所谓SRP,即单一职责原则,指的是一个类只能因为一个原因(一个行为者)而被修改。这个原则是为了防止两个不同的使用者如A、B,当A因为某个原因让开发修改类代码时,却影响到了B的使用功能。单一职责原则经常被人误解读为一个接口或者一个类只能做一件事,这样的设计也有道理,但这样理解这个原则是有偏差的。

OCP

OCP,指的是开闭原则。如果我们要添加一个新功能,最好是能够增加新代码来实现,而非变动之前的旧代码。该原则的目的是为了系统有良好的扩展性,而且,不会因为变动旧代码而出现问题。实现的方式是利用接口的方式将低层级代码插件化,可以将系统划分为一系列的组件,并且按层级划分好,让低层级的代码通过实现高层级的接口,从而实现插件化管理。

LSP

LSP,即里氏替换原则。这是一个针对继承的原则。指的是,一个父类的对象,要能够被它的子类的对象进行直接替换,并且相关程序还能够正常且正确的运行,那么就可以说是符合LSP原则的。继承要符合LSP原则的目的是,使继承不会增加复杂度,基类能真正被复用,而派生类也能够在基类的基础上增加新的行为。使得代码更容易被扩展,也能够更加健壮。LSP原则是对OCP原则的一种实践。

ISP

ISP是接口隔离原则。该原则指的是我们不应该依赖用不到的代码。任何层级的代码,如果是依赖了用不到的代码,那么很可能会在未来出现意想不到的麻烦。在实际中,我们可以做到以下几点来做到ISP原则:

  1. 一个类对另一个类的依赖应该建立在最小接口之上,接口中不应该有我们不需要的方法。
  2. 建立功能单一的接口,不要建立一个大而臃肿的接口。
  3. 尽量去细化接口,接口中的方法尽可能少。

DIP

DIP,依赖反转原则,指的是底层的代码应该依赖于核心代码。当核心代码需要依赖底层代码时,就使用多态的方式使依赖进行反转。也就是使控制流和依赖流相反。目的是使不稳定的底层代码依赖稳定的核心代码,从而减少更核心代码的变动。

举个我们常见的例子,比如我们在写一个功能时,很可能会用到缓存,经过调研,我们决定使用Redis来作为缓存使用。于是,我们就在核心代码里直接调用了Redis的操作方法。这是我们常用的方式。

但是这样的方式有个问题,就是我们以后如果想要将Redis替换掉,那我们就会比较痛苦,因为我们不得不去修改核心业务代码,并且我们要重新编译、测试、发布等。

img

假设我们要将Redis替换为Tendis,那么我们就不得不变更核心业务代码,如下:

img

但是,假如我们遵守DIP原则,将操作缓存的接口在核心业务代码中制定好,而由外部缓存如Redis的实际操作部分来实现我们的接口代码,那么Redis就变成了我们核心业务代码中的一个插件而已,我们在想要更换缓存选型时,也非常的轻松,只需要让新的缓存操作代码去实现核心业务代码中的操作接口即可,核心业务代码不需要做任何更改。这样就极大的降低了系统在做变更时的风险。

img

如果遵循DIP原则,那么,我们就可以保证核心业务代码持续稳定不用修改,让其他的组件都作为插件方便的进行替换。比如我们要将Redis替换为Tendis,那么写好Tendis操作代码后直接替换就行了。

img

三、组件设计原则

上面我们介绍到了,组件其实就是对数据与方法的封装。同时,组件也是软件开发与部署的最小单元。说到这里,那我们肯定产生的第一个疑问就是,什么样的代码应该被组织为一个组件?组件是代码量越小越好吗?下面就介绍一下代码组织为一个组件应该要遵守的设计原则。

1. 组件构成的设计原则

1.1 REP:复用发布等同原则

REP原则指的是软件复用的最小粒度应等同于其发布的最小粒度

由于一个组件是代码部署发布的最小单位,因此,一个组件的版本是可以由版本号以及版本信息来记录和追踪的。我们要将同时发布/变更的类放到同一个组件中,当我们的组件发布新版时,使用者就可以通过版本号以及版本信息来确定他们是否需要升级该组件。

比如说,如果我们将几个毫无关联的类或功能放在了同一个组件中,那么可能类A频繁变更发版,但类B却没有任何变化。那么类B的使用者一定会感到不合理,因为发现组件频繁的变更,却没有任何应该自己要关注的信息。

1.2 CCP:共同闭包原则

CCP原则指的是我们要将会同时修改并且会为相同目的而修改的类放在同一个组件中,而将不会同时修改并且不会为了相同目的而修改的类放在不同的组件中。

这个原则算是对上述代码设计原则SRP的一种组件上的延伸。目的也是为了避免变更范围大的情况,从而避免大量变更所导致的风险与问题。

1.3 CRP:共同复用原则

CRP原则指的是不要强迫使用者依赖他们不需要的东西。

CRP原则与之前讲述的ISP原则有相似之处。ISP简单说是我们不应该将用不到的方法放在使用者要使用的接口中。而CRP原则是说,我们不应该将用不到的类放在使用者要使用的组件中。

1.4 组件设计原则的关系

我们思考以下这些情况,CCP原则可能会让一些要同时修改的类放在一个组件中,但是,这些类又不是软件复用的最小粒度,因此,会导致REP原则的缺位。相反,如果我们强执行REP原则,就有可能导致某些本应放在一起的类被拆分开到不同组件,那也就可能会造成多次不必要的分布,也就造成了CCP原则的缺失。

再考虑一下,如果我们严格执行CRP原则,那么,我们就会使组件拆分的尽可能小,也可能会出现CCP原则的缺失。那么,也会造成频繁发布的问题。类似这样原则间互相冲突的情况有很多。

总而言之,这三个原则其实是很难被同时满足的,三个原则其实是存在竞争关系的。

如图:

img

因此,如何权衡这三个原则的关系就非常重要。一般来说,我们在软件开发的初期都是更在意研发效率,而复用可以暂时放一放。所以,一般在软件初期,我们的组件设计会更偏重于CCP原则,减少变更与发布次数从而提升研发效率。到软件开发中后期,我们会逐步将原则由右边转移至左边,也就是会更偏重于REP原则,从而提升组件的复用性,使研发变得更具有长期性。

当然,实际使用情况还是要看项目的具体情况来灵活调整,不用拘泥于某一个原则。

2. 组件耦合的设计原则

上面的组件构成指的是组件内部由什么构成,需要遵循的原则。而组件耦合指的是组件和组件之间关系,这需要遵循的原则如下。

2.1 无依赖环原则

什么是无依赖环原则

要理解无依赖环原则,首先得定义清楚什么是依赖环?

我们都知道,从一个结构中的任意一个节点开始,都不能沿着结构的边最终走回到起始点,那么就可以说这个结构中不存在环。如果这个结构是一个图,那么,这个图就是有向无环图。如果这个结构是组件的依赖关系图,那么就可以说这些组件是不存在依赖环的,也就是说,满足了无依赖环原则。

如下图组件耦合图就满足了无依赖环原则:

img

而下图组件耦合图不满足无依赖环原则:

img

为什么不能出现依赖环

定义清楚后,我们思考一下,为什么组件耦合不可以出现依赖环呢?

我们先看下没有依赖环的情况。假如上图几个组件都是由不同的开发者负责的,在没有依赖环的情况下,如果组件C做了调整修改,并且打上了新的Tag并进行了代码发布。那么,依赖组件C的其他开发者都要考虑是否需要升级该组件,若升级该组件,则其余的开发者都可能要进行相应调整、测试、发布。重新发布完成就到此结束了。

但是,如果是有依赖环的情况,如果组件C做了调整修改,并且还是打上了新的Tag并进行了代码发布。那么,依赖C的组件,包括D和E组件都需要调整、测试、发布。但是,重新发布完成还没有结束,因为D、E做了变更,而C又是依赖于D、E,因此,在D、E重新发布完成后,C又必须进行调整和重新发布。此时就进入了一个尴尬的死循环。要想破除这个循环,就必须要C、D、E三个组件的开发者同时进行联调并发布上线。这就使几个组件变得强依赖了,开发成本就变高了,这样的设计显然是不合理的。

可能会有开发者觉得,如果组件形成了依赖环,那么我们只需要确认有没有对我们所依赖的类或接口进行变更再来判断需不需要进行重新开发和发布不就好了么?这样的想法是不对的。假如是一个大型项目,组件数量繁多,难道每次开发者依赖的组件在变更后,开发者都要去确认我们需不需要变更吗?这样的成本是非常高的,而且出错的可能性也非常高。

如何优化不符合无依赖环原则的组件耦合

现在,我们知道了一个项目中的组件是不应该出现依赖环的,那么假如依赖环是业务导致形成的,是无法避免的呢?或者说,是历史遗留问题呢?这个时候如何去解决依赖环的问题呢?

一般是有两种解决方案:使用接口进行依赖反转以及将共同依赖抽离出一个新的组件。

先说一下依赖反转。其实依赖反转就是根据前面所叙述的DIP原则,将用于实现的接口放在需要被依赖的组件中,让另一个组件来实现这个接口,这样就可以达到让依赖反转的目的。具体来说,我们可以让上图中的组件D、E的依赖进行反转,组件E中做一个用于组件D实现的接口。这样就可以让组件D反而依赖组件E了。

img

另一种解决方案是将共同依赖抽离出一个新的组件。这个很好理解,其实就是将组件D、E互相依赖的类抽离出来,单独做成一个新的组件,如图。

img

上面有一点可能不太好理解。组件E依赖于组件D,因此将组件D中被依赖的类抽离出一个新的组件F,这个很直观。但是为什么改造后的组件D也要依赖于组件F呢?是因为组件D可能会通过组件C间接的依赖于组件E的某个类,这样的话,我们也应该将组件E中被依赖的类抽离到组件F中。当然,如果组件D并没有依赖于组件E的某个类,那么组件D对于组件F的依赖就是没有必要的了。

2.2 稳定依赖原则

什么是稳定依赖原则

所谓稳定依赖原则,指的是,被依赖的组件的稳定性应该高于产生依赖的组件的稳定性。

img

如图所示,组件B的稳定性应该是要高于组件A的稳定性。否则,我们就说该组件耦合不满足稳定依赖原则。

组件的稳定性如何评估

既然我们已经知道了稳定依赖原则指的是被依赖的组件的稳定性应该高于产生依赖的组件的稳定性。那么,重点就在于,我们如何来评估一个组件的稳定性呢?什么样的组件可以被称为更为稳定的组件呢?

组件与组件之间都是有依赖关系的,一个组件会依赖于别的组件,也可能会被别的组件所依赖。我们用于评估组件稳定性的公式是:I(A) = o(A) / (o(A) + i(A)

其中,I(A)表示组件A的稳定性,o(A)表示组件A依赖于别的组件的数量,i(A)表示组件被别的组件所依赖的数量。I(A)越小,则表示组件A越稳定。比如下面这两个组件A、B,分别对应的稳定性值为I(A)=1/3,I(B)=0。

img

上图的组件耦合就是一个满足了稳定依赖原则的耦合。

另外,稳定性有两个极端情况,一种是组件只被依赖,而不依赖于其他组件,如组件B,这是最稳定的组件,这样的组件非常难以变更。还有一种组件是只依赖别的组件,而自身却不被依赖,如组件C、E、F,这种组件是最不稳定的,很容易被变更。

如何优化不符合稳定依赖原则的组件耦合

比如我们现有的组件耦合方式如下图所示:

img

可以看到,组件A的I(A)=1/4,组件B的I(B)=3/4,因此,组件A的稳定性是大于组件B的。根据我们前面说的稳定依赖原则,被依赖的组件的稳定性应该大于产生依赖的组件,所以,上图中组件A、B的耦合方式是不满足稳定依赖原则的。

对于这种情况,我们依然可以用DIP原则进行依赖反转,具体方式前面已经多次叙述,这里不再赘述。

优化后的组件耦合如下图所示:

img

2.3 稳定抽象原则

什么是稳定抽象原则

所谓稳定抽象原则,指的是一个组件的抽象化程度应该与其稳定性保持一致。

换句话说,就是越稳定的组件就应该抽象化程度越高,越不稳定的组件就应该有越多的具体实现。

组件的抽象程度如何评估

我们知道了什么是稳定抽象原则,也已经通过上一节的叙述知道了稳定性的评估方法。接下来就该了解一下如何评估一个组件的抽象程度。

衡量一个组件抽象程度的公式如下:$$
A = Na / Nc
$$

其中,A表示组件的抽象程度。Na表示该组件中抽象类与接口的数量。Nc表示该组件中类的数量。

A的取值范围为[0, 1],当A为0时,则表示组件中没有抽象类与接口,该组件全是具体实现。当A为1时,则表示该组件中全是抽象类与接口,那么该组件就是完全抽象的组件。

稳定抽象原则的实际应用

我们对稳定抽象原则加以思考就会发现,该原则是较为“模糊”的。因为现实中,不可能所有的组件都是完全抽象化且完全稳定,或者完全具体实现且完全不稳定的。现实中的情况是绝大多数组件都是处于中间状态的,那么,怎么样才算是抽象化程度与稳定性保持了一致呢?

(1)最理想的情况

我们先来看下理想情况,如图所示:

img

当一个组件的稳定性指标为0,抽象化程度为1时,即在(0,1)点;或者一个组件的稳定性指标为1,抽象化程度为0时,即在(1,0)点。这两个点上的组件是最理想的情况。也毫无疑问是遵循了稳定抽象原则的了。

(2)最不理想的情况

然后我们看下最不理想的情况,最不理想的情况有两个区域,一个是痛苦区、一个是无用区。怎么理解这两个区域呢?

首先来看痛苦区。

痛苦区指的是一些组件的稳定性非常高,但是抽象程度却很低。这些组件由于稳定性高,被其他组件依赖非常多,抽象程度又很低,因此一旦这样的组件要被更改就会非常痛苦,因为其他依赖他的组件都要重新检查测试并发布。

但反过来看,如果一个组件本身就无需被变更,那么这个组件落在这个区域也就显得没有那么痛苦了。典型的情况就是一些公共工具库如String库,这样的公共库都是基本不会做变更的,因此这样的库就算是落在这个区间也没关系。

再来看下无用区。

无用区指的是一些组件的稳定性非常低,但是抽象程度却很高。为什么说这样的组件是无用的呢?这是因为,这样的组件基本不会被别的组件所依赖,但是它们又都是抽象的,没有具体实现,这样的代码一般是历史原因造成的,放着都是多余的。

(3)现实情况

如果用“小黑点”来表示组件落在坐标中的情况的话,现实情况一般如上图所示。

(4)D指标

看到了现实情况的图示后,我们还要引入一个新的指标D,用于描述“小黑点”到主线的距离(并非垂直距离),用这个指标可以描述一个组件对稳定依赖原则的遵循程度。公式如下:D=|A+I-1|

当D=0时,则表示组件就位于主线上,而当D=1时,则表示组件位于离主线最远的地方。

当然,我们的组件的D指标越小就越好。

另外,我们还可以将系统所有的组件的D指标计算出统计量如平均值以及方差。用以衡量一个系统的设计是否优良。最理想的情况当然是平均值以及方差都趋近于0,我们在做系统设计时,也应该尽可能往这方面考虑。对于单个组件,我们也可以用D指标来衡量该组件针对稳定抽象原则是否能够达标。总之,D指标可以帮助我们量化我们的系统设计。

四、合理的解耦部署

前面我们讲到的都是代码以及组件在开发前的设计原则,遵循这些设计原则,我们可以得到一个更健壮更易于扩展的系统。接下来,我们讲一下在开发后,应该怎样来合理的部署系统。这里不会讲具体的部署步骤,只是叙述一下代码的解耦部署方式。

源码层次解耦部署

源码层次解耦部署其实就是我们常说的单体系统,该系统内通过划分不同的模块以及管理好模块间的依赖关系来进行解耦,使得不同模块可以被独立开发,从而在一个模块进行变更后,可以尽量不影响到其他模块,避免其他模块也要变更发布。

单体系统的所有模块一般都是在同一个进程地址空间中执行,通过函数来进行功能调用。单体系统是理解上较为简单的系统,但并不等于单体系统就是比较低端的系统,由于单体系统易于开发、易于部署以及函数间调用时延低的特点,其实很多场景下更适合用单体系统。

库层次解耦部署

库层次解耦部署其实依然算是一个单体系统,只是该系统可以通过管理好不同代码库之间的依赖关系来进行解耦,使得不同模块可以独立开发以及发布,不同的库可以有不同的版本,我们可以依据实际情况来决定是否进行升级所依赖的库组件。

这种部署方式一般来说也是所有模块都在同一进程地址空间中执行且通过函数进行交互调用。但是也存在一些组件会运行在其他进程中,组件间就会通过进程间通信方式来进行交互调用,比如共享内存以及socket。

服务层次解耦部署

所谓服务层次解耦部署,指的是让不同的组件运行在不同的服务中。组件间只能通过网络包来进行数据交互,现在比较流行的服务间通信的方式是gRPC。按服务层次解耦部署,则其中一个服务的开发部署完全影响不到另外一个服务,这个层次解耦部署的方式是隔离性最强的,常见的SOA和微服务就是这样的部署方式。

部署方式的选择

前面所述的三种部署方式其实没有标准答案,要看具体业务场景来进行选择。现在有许多开发者都喜欢鼓吹SOA和微服务,但这是 不合理的,假如业务场景还没有到必须拆分服务的时候,而去过早的拆分成微服务进行部署,那么很可能会出现一些负面效果,因为服务层次解耦部署有一些难以绕开的问题,比如服务器资源成本更高、增加了不必要的网络时延、开发维护成本高等等。如果不是选用后的优势很大,让人可以忍受这些无法避免的问题,那么就不建议一开始就去拆微服务。

一般来说,在软件开发初始阶段,系统都会选用源码层次解耦部署,随着业务场景以及请求规模的变化,可能会进行进一步的演进(也可能一直维持单体系统),比如把通用的组件拆出来解耦部署,比如将各个模块拆分成独立的服务部署。无论演进的情况如何,有一点很重要,就是要保持系统的整体架构边界清晰,在进行部署方式变更时可以不至于有很高的开发成本,这也就要求在系统设计时就要尽可能遵循上面讲到的设计原则。

五、个人思考

我觉得各种设计原则都算是一些指导性的建议,一般都是经过长期实践、不断总结凝练出来的很难过时的经验,但并不是说每个系统都非得严格按照这些原则来做,况且,有时候有些设计原则在某些场景下相互之间也会有矛盾,也会有需要取舍的时候。我觉得我们在工程实践时,要尽可能的在心里对这些原则有所把握,在可以实践时一定要按照这些设计原则进行实践。但反过来说,如果遵循某个原则会导致很大的成本投入,比如可能因为时间花费较多而引起需求延误。又或者我们做的是一个持续时间不长的小型活动系统(国内很多这样的临时产品),那就需要自己去权衡利弊了。

另外,我觉得需要长期维护的系统是最应该实践设计原则的,绝对是一劳永逸的好事,哪怕是跟产品砍一些功能需求来做都是非常值得的。只要尽力说服,产品和老板是会给一定的时间让开发去做这些优化事项的,毕竟产品和老板也希望自己之后的需求总能够快速被实现,而不是项目越往后做一个新需求或者变更一个旧功能越困难。

本文我总结的可能会有些理论化,建议文后可以找相关书籍进行系统了解,另一方面还可以在实践中去实际感受。当理论知识被应用于实践中,而且还有一点点成效时,那是非常有成就感的。

六、Reference

[1] 《Clean Architecture》 https://github.com/sdcuike/Clean-Code-Collection-Books/blob/master/Clean%20Architecture%20A%20Craftsman‘s%20Guide%20to%20Software%20Structure%20and%20Design.pdf

[2] 《object-oriented software construction》 chrome-extension://ikhdkkncnoglghljlkmcimlnlhkeamad/pdf-viewer/web/viewer.html?file=https%3A%2F%2Fweb.uettaxila.edu.pk%2FCMS%2FAUT2011%2FseSCbs%2Ftutorial%2FObject%2520Oriented%2520Software%2520Construction.pdf

[3] 让里氏替换原则为你效力 https://yuanshenjian.cn/make-lsp-working-for-you/

[4] 极客教程 里氏替换原则 https://geek-docs.com/design-pattern/design-principle/liskov-substitution-principle.html

[5] 通过实例讲解Go的七大设计原则 https://km.woa.com/group/22373/articles/show/482249?kmref=search&from_page=1&no=5

[6] 设计模式概念和七大原则 https://cloud.tencent.com/developer/article/1650116