架构设计的五大原则-SOLID

1.背景

最近在读《架构整洁之道》这一本书,这本书的确写得不错,最近也没有更新文章,一方面再忙工作,另一方面也再啃一些书。当然文章还是得更新,《架构整洁之道》里面有些有意思的内容我会提取出来外加自己的思考。在这本书里面的第三章介绍了设计原则,这部分我觉得对于大家的平时工作都比较有用。

2. 设计原则

想必大家在学习面向对象的时候,都学习过下面几大原则:

  • SRP 单一职责:该设计原则是基于康威定律的推论,每个软件模块有且只有一个被更改的理由。

  • OCP 开闭原则:对扩展开放,对修改关闭。

  • LSP 里氏替换原则:任何基类可以出现的地方,子类一定可以出现。

  • ISP 接口隔离原则:在设计中需要避免不需要的依赖。

  • DIP 依赖反转原则:高层策略性代码不应该依赖底层细节的代码,而应该是底层细节代码依赖高层策略。

这五个原则也被称为,SOLID原则取的是他们的首字母。这个也是我们做一个好设计的基础,接下来会依次对其进行解释。

3.SRP:单一职责

SRP很容易被大家从字面意思无界,并不是每个模块只做一个事,而是每个模块的变化原因只有一个。在书中对于SRP最后的解释是:

任何一个软件模块都应该只对某一类行为者(有共同需求的人)负责。

这里的软件模块指的就是一个源代码文件或者一组紧密相关的函数和数据结构。SRP原则应该是大家运用得最多的原则之一。在书中举了一个例子,有一个Employee类其中有三个函数:

  • calculatePay():计算工资,由财务部门制定,需要向CFO汇报。

  • reportHours():计算工时,人力资源制定,向COO汇报

  • save():由DBA制定,向CTO汇报。

这里三个函数都放在了Employee类中,其实也就是把三个行为者的行为都耦合在了一起。一般来说计算工资,会获取正常工时,而计算工时也会获取工时,这两个函数都依赖了一个获取工时的方法,如果财务部门计算工资时,想修改逻辑,看大家辛苦了1个小时当1.1个小时发工资,这个时候修改了这个获取工时的方法,但是HR部门并不需要这个修改,这个时候就会导致reportHours()这个方法出现数据错误。所以这个时候就需要将不同行为者的代码就行拆分。

3.1 如何解决

在书中给出了第一个解决方法:

设计出三个类,每个类都只与一个行为者相关。这种问题的坏处是,程序员需要在程序里处理三个类,这里还介绍了使用门面模式的方法,让我们只需要在我们使用的地方使用一个类即可:

这样的话我们就不需要关心其他三个类,直接调用门面模式的方法即可。

3.2 实际场景

在实际场景中微服务可以算作是SRP的思想,虽然每一个微服务不止一个类,但是其整个服务也可以看做是一个模块,而每个一个模块基本也只于一个行为者相关。在我们的代码中可以使用3.1中所描述的方法来进行SRP的实现。

SRP的好处:

  • 修改代码容易,由于不需要考虑修改代码是否会影响其他业务所以是很容易的。

  • 更加容易维护,维护一个什么逻辑都有的代码明显比维护一个单一职责的代码难得多。

  • 容易发现问题,当出现问题的时候,由于职责清晰,可以比较容易的定位。

  • 松耦合,职责分离,耦合程度比较低。

4.OCP:开闭原则

在这本书中讲述OCP可能和大家从一些资料上面看的有点不同。一般大家所认为的开闭原则,应该将那些容易变化的部分进行抽象,利用对抽象的多个实现来进行对扩展开放,而不是直接在类中去修改。

这里我用吃饭的例子来列举:

每个人一天都会吃三餐,早餐,午餐,晚餐,但是随着时代的进步,又出现了下午茶,宵夜等,现在一天就不止三餐,那么其实我们就需要在这个类中去添加喝下午茶方法,吃宵夜方法,这样就导致我们没增加一个餐的种类就需要添加一个方法,在将SRP的时候我们有个例子,在同一个类中修改方法的时候容易修改其他业务逻辑,在我们这个例子中我们也会出现这个问题。怎么解决呢?那么我们就可以将变化的部分抽象出来:,后续如果还需要增加吃的方法那么只需要实现这个接口即可。

但是在这本书中,并没有去强调将变化的部分抽象出来,其认为修改是不可避免的,所以我们需要把控好修改的影响,所以提出了高层组件的修改不会影响底层组件,组件层次越低越稳定。对于J2EE的开发者来说,三层开发肯定并不陌生,controller,service,dao:

如果我们修改controller那么service其实是无感知的,不会受影响,如果我们修改service,dao是不会受影响的,但是我们的controller是会受影响。所以越底层的组件那么其实应该越稳定。通过这种方式我们可以控制修改范围的影响。总结起来就是通过将系统划分为一系列组件,并且将这些组件间的依赖关系按层次结构进行组织,使得高阶组件不会因低阶组件被修改而受到影响。

5.LSP:里氏替换

任何基类可以出现的地方,子类一定可以出现。大多数人认为LSP其实就是指导如何使用继承关系的一种方法,尤其是我们在开发的过程中用spring依赖注入的基本都是基类而非具体的实现类,这个的确也是LSP的一种实现手段。LSP也在逐渐演变成一种更广泛的,指导接口与其实现方式的设计原则。

这里用书中的一个反面例子来举例,假如我们现在在构建一个提供出租车调度服务的系统,我们提供restful进行调用,有这么一个司机,如果我们想调度他那么需要访问以下请求:

purplecab.com/driver/Bob/pickupAddress/24 Maple St./pickupTime/153/destination/ORD

每个公司想要接入我们的系统都得遵循上面的规矩,但是有一个公司Acme把destination写成了缩写dest,但是由于这个公司比较大,不想修改回来,所以调度系统只能写如下的if逻辑:

if(driver.getDispatchUri().startsWith("acme.com"))

这种逻辑一般的软件架构师都不会允许这样一条语句出现在系统中,如果又出现了一个公司违反了那么是否又需要增加一条if逻辑?软件架构师应该创建一个调度请求创建组件,并让该组件使用一个配置数据库来保存URI组装格式,如下:

URI调度格式
Acme.com /pickupAddress/%s/pickupTime/%s/dest/%s
. * . /pickupAddress/%s/pickupTime/%s/destination/%s

但是这样我们也需要增加一个组件来应对这个情况,也增加了复杂性。

6.ISP:接口隔离原则

首先大家看看下面这个例子:

我们这里的User1,User2,User3都是依赖OPS的,但是User1只需要用op1,User2用op2,User3用op3。在这种情况下,虽然User1不会和op2,op3产生直接的调用关系,但在源代码层次上也与他们形成依赖关系。这种依赖关系会导致两个问题:

  • 修改op2,op3的逻辑会导致op1的逻辑变化

  • 就算逻辑不变化,修改op2也会导致重新编译和部署User1。

我们通过下面这种方将不同的操作隔离成接口,运用第5节的LSP,我们将OPS类实现这三个接口,然后替换在User1中的U1Ops,由于依赖的是最小接口所以就不会出现上面的问题。

在书中对于ISP强调得比较多,在后面也讲了CRP原则,不要强迫一个组件的用户依赖他们不需要的东西,CRP是ISP的一个普适版。ISP是针对类来说,CRP是针对组件来说。所以我们总结起来就是:

不要依赖不需要的东西

7.DIP: 依赖反转

依赖反转其实总结起来就是多依赖抽象,少依赖具体实现。但是事事并没有那么绝对,我们的String类是一个具体的实现类但是在我们的代码中随处可见,那是不是我们就违反了DIP了呢?其实不是的,我们String已经非常稳定了,就算修改也会被严格的控制,所以我们不需要担心修改String类会发生一些意想不到的问题。所以对于我们稳定的东西,其实DIP原则就不适用了,而我们需要重点关注的应该经常变动的。这里我想要说的一点的是,大家在编码过程中写List的时候虽然大多数时候用的是ArrayList,但是其实很少写下面这句话ArrayList list = new ArrayList(),更多的是写List list = new ArrayList(),其实这个就是DIP的一个实现。

这里要说一下为什么叫反转?有反转就有正转,如下图所示:

这里的我们的serviceImpl是service的具体实现,在图中我们的依赖流向没有发生变化,所以叫正转。我们采用DIP来进行设计:

可以看见我们这里的最后的流向放生了变化,所以可以叫他依赖反转。

DIP还有几个编码规则需要注意:

  • 多使用抽象接口,尽量避免使用具体实现类。

  • 尽量不要在具体实现类上面创建子类。

  • 尽量不要覆盖继承的抽象类的方法:由于我们依赖的是抽象,有可能逻辑中已经对这些方法产生了依赖,如果覆盖有可能会造成问题。

8.总结

本文讲了一下设计的五大原则SOLID,SOLID在这《架构整洁之道》中一直贯穿,这五大原则能帮助我们在设计的时候做出更多优秀的架构设计,如果想了解更多的一些细节可以看看这本书。

posted @ 2020-04-27 21:26  小y  阅读(1876)  评论(0编辑  收藏  举报