引用
设计模式背后的思想就是SOLID原则!

单一职责原则、开闭原则、里氏替换原则、接口隔离原则和依赖倒置原则

一.单一职责原则(SRP)

单一职责原则(The Single Responsibility Principle,SRP)应该是SOLID原则中,最容易被理解的一个,但同时也是最容易被误解的一个。很多人会把“将大函数重构成一个个职责单一的小函数”这一重构手法等价为SRP, 这是不对的,小函数固然体现了职责单一,但这并不是SRP

函数职责单一并不等同于SRP,比如在一个模块有A和B两个函数,它们都是职责单一的,但是函数A的使用者是A类用户,函数B的使用者是B类用户,而且A类用户和B类用户变化的原因都是不一样的,那么这个模块就不满足SRP了。

A module should be responsible to one, and only one, actor.

也就是说,是否满足srp,并非是看函数,而是看函数对应的使用者;当函数面对的使用者不同时,他们产生变化的时间和方向都可能不同

因此,我们可以得出这样的结论:

  1. 如果一个模块面向的都是同一类用户(变化原因一致),那么就没必要进行拆分。
  2. 如果缺乏用户归类的判断,那么最好的拆分时机是变化发生时。

SRP是聚合和拆分的一个平衡,太过聚合会导致牵一发动全身,拆分过细又会提升复杂性,要从用户的视角来把握拆分的度,把面向不同用户的功能拆分开。如果实在无法判断/预测,那就等变化发生时再拆分,避免过度的设计。

二.开闭原则(OCP)

开闭原则(The Open-Close Principle,OCP)中,“开”指的是对扩展开放,“闭”指的是对修改封闭,它的完整解释为:

A software artifact should be open for extension but closed for modification.

通俗地讲就是,一个软件系统应该具备良好的可扩展性,新增功能应当通过扩展的方式实现,而不是在已有的代码基础上修改

2.1抽象

如何做到不修改已有代码,而是通过扩展新的功能来实现?关键点就在于抽象!

抽象就是不断忽略细节,找到事物间共同点的过程,抽象同时也是分层的,层次越高,细节也就越少

类比到数据库上,数据库的抽象层次从低至高可以是这样的:MySQL 8.0版本 -> MySQL -> 关系型数据库 -> 数据库。现在假设有一个需求,需要业务模块将业务数据保存到数据库上,那么就有以下几种设计方案:

方案一:把业务模块设计为直接依赖MySQL 8.0版本。因为版本总是经常变化的,如果哪天MySQL升级了版本,那么我们就得修改业务模块进行适配,所以方案一违反了OCP。
方案二:把业务模块设计为依赖MySQL。相比于方案一,方案二消除了MySQL版本升级带来的影响。现在考虑另一种场景,如果因为某些原因公司禁止使用MySQL,必须切换到PostgreSQL,这时我们还是得修改业务模块进行数据库的切换适配。因此,在这种场景下,方案二也违反了OCP。
方案三:把业务模块设计为依赖关系型数据库。到了这个方案,我们基本消除了关系型数据库切换的影响,可以随时在MySQL、PostgreSQL、Oracle等关系型数据库上进行切换,而无须修改业务模块。但是,熟悉业务的你预测未来随着用户量的迅速上涨,关系型数据库很有可能无法满足高并发写的业务场景,于是就有了下面的最终方案。
方案四:把业务模块设计为依赖数据库。这样,不管以后使用MySQL还是PostgreSQL,关系型数据库还是非关系型数据库,业务模块都不需要再改动。到这里,我们基本可以认为业务模块是稳定的,不会受到底层数据库变化带来的影响,满足了OCP。

在编程语言中,数据库的抽象表示就是接口!

  1. type Db interface {
  2. Query(tableName string, cond Condition) (*Record, error)
  3. Insert(tableName string, record *Record) error
  4. Update(tableName string, record *Record) error
  5. Delete(tableName string, record *Record) error
  6. }

无论是mysql,postgresql,还是其他,只要实现Db接口,都可以当作数据库使用,DB接口就是对拓展开放的;

2.2分离变化

满足OCP的另一个关键点就是分离变化,将变化点识别并分离出来,我们才能对其抽象化;
实际开发中,会经常遇到需求变化或者需求新增的情况,每次需求有变化时,就需要对模块进行修改,这很明显就违反了OCP,需要做的就是识别出变化点,将其抽象为接口,当有新的需求来时,实现这个接口即可,就实现了拓展而不改变原模块

OCP是软件设计的终极目标,我们都希望能设计出可以新增功能却不用动老代码的软件。但是100%的对修改封闭肯定是做不到的,另外,遵循OCP的代价也是巨大的。它需要软件设计人员能够根据具体的业务场景识别出那些最有可能变化的点,然后分离出去,抽象成稳定的接口。这要求设计人员必须具备丰富的实战经验,以及非常熟悉该领域的业务场景。否则,盲目地分离变化点、过度地抽象,都会导致软件系统变得更加复杂。

三.里氏替换原则(LSP)

OCP的一个关键点就是抽象,而如何判断一个抽象是否合理,这是里氏替换原则(The Liskov Substitution Principle,LSP)需要回答的问题。

简单地讲就是,子类型必须能够替换掉它们的基类型,也即基类中的所有性质,在子类中仍能成立。一个简单的例子:假设有一个函数f,它的入参类型是基类B。同时,基类B有一个派生类D,如果把D的实例传递给函数f,那么函数f的行为功能应该是不变的。

下面,我们总结一下在继承体系(IS-A)下,要想设计出符合LSP的模型所需要遵循的一些约束:

  1. 基类应该设计为一个抽象类(不能直接实例化,只能被继承)。
  2. 子类应该实现基类的抽象接口,而不是重写基类已经实现的具体方法。
  3. 子类可以新增功能,但不能改变基类的功能。
  4. 子类不能新增约束,包括抛出基类没有声明的异常。

因为在go中实现多态只能通过接口的方式,因此,在go中,上述的1-3已经满足了:

  1. 接口本身就无法实例化,可以嵌套实现继承
  2. 接口没有具体的实现方法,所以不会被重写
  3. 接口本身只约定了行为,没有规定实际的功能,所以也不存在改变这一说,但是嵌套了接口的新接口可以新增方法

四.接口隔离原则(ISP)

Client should not be forced to depend on methods it does not use.

一个模块不应该强迫客户程序依赖它们不想使用的接口,模块间的关系应该建立在最小的接口集上。

比如一个接口提供fun1 fun2 fun3 三个方法,3个客户端分别依赖其中一个方法,那么就违反了接口隔离原则

违反ISP主要会带来如下2个问题:

  1. 增加模块与客户端程序的依赖,比如在上述例子中,虽然Client2和Client3都没有调用func1,但是当Class1修改func1还是必须通知Client1~3,因为Class1并不知道它们是否使用了func1。
  2. 产生接口污染,假设开发Client1的程序员,在写代码时不小心把func1打成了func2,那么就会带来Client1的行为异常。也即Client1被func2给污染了。

解决方法就是在之间再拆分一个小接口,比如引入一个interface1只包含func1,这样client1就只依赖interface1,实现了接口隔离

关键点就在于将大接口拆分为小接口,需要对接口的使用场景了如指掌

接口隔离可以减少模块间耦合,提升系统稳定性,但是过度地细化和拆分接口,也会导致系统的接口数量的上涨,从而产生更大的维护成本。
接口的粒度需要根据具体的业务场景来定,可以参考单一职责原则,将那些为同一类客户端程序提供服务的接口合并在一起。

五.依赖倒置原则(DIP)

如果要模块A免于模块B变化的影响,那么就要模块B依赖于模块A。这句话貌似是矛盾的,模块A需要使用模块B的功能,怎么会让模块B反过来依赖模块A呢?
这就是依赖倒置原则(The Dependency Inversion Principle,DIP)所要解答的问题。

定义:

1、高层模块不应该依赖低层模块,两者都应该依赖抽象
2、抽象不应该依赖细节,细节应该依赖抽象

(1)高层模块和低层模块

一般地,我们认为高层模块是包含了应用程序核心业务逻辑、策略的模块,是整个应用程序的灵魂所在;低层模块通常是一些基础设施,比如数据库、Web框架等,它们主要为了辅助高层模块完成业务而存在。

(2)抽象和细节

在前文“OCP:开闭原则”一节中,我们可以知道,抽象就是众多细节中的共同点,抽象就是不断忽略细节的出来的。

现在再来看DIP的定义,对于第2点我们不难理解,从抽象的定义来看,抽象是不会依赖细节的,否则那就不是抽象了;而细节依赖抽象往往都是成立的。

理解DIP的关键在于第1点,按照我们正向的思维,高层模块要借助低层模块来完成业务,这必然会导致高层模块依赖低层模块。但是在软件领域里,我们可以把这个依赖关系倒置过来,这其中的关键就是抽象。我们可以忽略掉低层模块的细节,抽象出一个稳定的接口,然后让高层模块依赖该接口,同时让低层模块实现该接口,从而实现了依赖关系的倒置

依赖倒置是OCP的基础