1. 设计原则

设计原则思维导图

在这里插入图片描述

核心理论

基于接口编程
“基于接口而非实现编程” - “Program to an interface, not an implementation”

“接口”就是一组“协议”或者“约定”,是功能提供者提供给使用者的一个“功能列表”。“接口”在不同的应用场景下会有不同的解读,比如服务端与客户端之间的“接口”,类库提供的“接口”。“基于接口而非实现编程”这条原则中的“接口”,可以理解为编程语言中的接口或者抽象类。

实际上,“基于接口而非实现编程”这条原则的另一个表述方式,是“基于抽象而非实现编程”。在软件开发中,最大的挑战之一就是需求的不断变化,这也是考验代码设计好坏的一个标准。越抽象、越顶层、越脱离具体某一实现的设计,越能提高代码的灵活性,越能应对未来的需求变化。好的代码设计,不仅能应对当下的需求,而且在将来需求发生变化的时候,仍然能够在不破坏原有代码设计的情况下灵活应对。而抽象就是提高代码扩展性、灵活性、可维护性最有效的手段之一

举例:假设我们的系统中有很多涉及图片处理和存储的业务逻辑。图片经过处理之后被上传到阿里云上。为了代码复用,我们封装了图片存储相关的代码逻辑,提供了一个统一的 AliyunImageStore 类,供整个系统来使用。

package main

type AliyunImageStore struct {
   // ... 省略属性
}

func NewAliyunImageStore( /* 省略参数 */ ) *AliyunImageStore {
   return &AliyunImageStore{}
}

func (ais *AliyunImageStore) createBucketIfNotExisting(bucketName string) {
   // ...创建bucket代码逻辑...
   // ...失败会抛出异常..
}

func (ais *AliyunImageStore) generateAccessToken() (token string) {
   // ...根据accesskey/secrectkey等生成access token
   return
}

func (ais *AliyunImageStore) uploadToAliyun(image []byte, bucketName string, accessToken string) {
   //...上传图片到阿里云...
   //...返回图片存储在阿里云上的地址(url)...
}

func (ais *AliyunImageStore) downloadFromAliyun(url string, accessToken string) (image []byte) {
   //...从阿里云下载图片...
   return
}

func main() {
   const BucketName = "ai_images_bucket"

   var image = readImage() // 获取图片
   imageStore := NewAliyunImageStore( /* 省略参数 */ )
   imageStore.createBucketIfNotExisting(BucketName)
   accessToken := imageStore.generateAccessToken()
   imageStore.uploadToAliyun(image, BucketName, accessToken)
}

func readImage() (image []byte) {
   // ... 省略代码
   return
}

过了一段时间后,我们自建了私有云,不再将图片存储到阿里云了,而是将图片存储到自建私有云上。而对于私有云来说,上传流程和阿里云并不一致。为了满足这样一个需求的变化,我们该如何修改代码呢?

思路:

  • 函数的命名不能暴露任何实现细节。比如,前面 uploadToAliyun() 就不符合要求,应该改为去掉 aliyun 这样的字眼,改为更加抽象的命名方式,比如:upload()
  • 封装具体的实现细节。比如,跟阿里云相关的特殊上传(或下载)流程不应该暴露给调用者。我们对上传(或下载)流程进行封装,对外提供一个包裹所有上传(或下载)细节的方法,给调用者使用。
  • 为实现类定义抽象的接口。具体的实现类都依赖统一的接口定义,遵从一致的上传功能协议。使用者依赖接口,而不是具体的实现类来编程。
package main

import "fmt"

// 接口定义
type ImageStore interface {
   upload(image []byte, bucketName string) string
   download(url string) []byte
}

// 实现一:阿里云
type AliyunImageStore struct {
   // ... 省略属性
}

func NewAliyunImageStore() *AliyunImageStore {
   return &AliyunImageStore{}
}

func (ais *AliyunImageStore) upload(image []byte, bucketName string) (url string) {
   ais.createBucketIfNotExisting(bucketName)
   accessToken := ais.generateAccessToken()
   fmt.Println(accessToken)
   //...上传图片到阿里云...
   // ...返回图片在阿里云上的地址(url)...
   return
}

func (ais *AliyunImageStore) download(url string) (image []byte) {
   accessToken := ais.generateAccessToken()
   fmt.Println(accessToken)
   //...从阿里云下载图片...
   return
}

func (ais *AliyunImageStore) createBucketIfNotExisting(bucketName string) {
   // ...创建bucket代码逻辑...
   // ...失败会抛出异常..
}

func (ais *AliyunImageStore) generateAccessToken() (token string) {
   // ...根据accesskey/secrectkey等生成access token
   return
}


// 实现二:私有云

type PrivateImageStore struct {
   // ... 省略属性
}

func NewPrivateImageStore() *PrivateImageStore {
   return &PrivateImageStore{}
}

func (pis *PrivateImageStore) upload(image []byte, bucketName string) (url string) {
   //...上传图片到私有云...
   // ...返回图片的url...
   return
}

func (pis *PrivateImageStore) download(url string) (image []byte) {
   //...从私有云下载图片...
   return
}

func main() {
   const BucketName = "ai_images_bucket"

   var image = readImage()              // 获取图片
   imageStore := NewPrivateImageStore() // imageStore := NewAliyunImageStore()
   imageStore.upload(image, BucketName)
}

func readImage() (image []byte) {
   // ... 省略代码
   return
}

问题:是否需要为每个类定义接口?

这条原则的设计初衷是,将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低代码间的耦合性,提高代码的扩展性。

从这个设计初衷上来看,如果在我们的业务场景中,某个功能只有一种实现方式,未来也不可能被其他实现方式替换,那我们就没有必要为其设计接口,也没有必要基于接口编程,直接使用实现类就可以了。

组合 vs 继承
继承是面向对象的四大特性之一,用来表示类之间的 is-a 关系(Apple is a Fruit),可以解决代码复用的问题。虽然继承有诸多作用,但继承层次过深、过复杂,也会影响到代码的可维护性。

举例:设计一个关于鸟的类

继承

package main

import "errors"

var (
   ErrCannotFly = errors.New("I can't fly.'")
)

// 父类
type Bird struct{}

func (b *Bird) fly() (err error) {
   return
}

// 子类 1 - 鹦鹉
type Parrot struct {
   Bird
}

// 子类 2 - 鸵鸟
type Ostrich struct {
   Bird
}

func (o *Ostrich) fly() (err error) {
   return ErrCannotFly
}

对于上面的代码,如果一种鸟不会飞,就需要重写 fly 方法。一方面会增加代码工作量;一方面也违反了最小知识原则,即暴露不该暴露的接口给外部,增加了类使用过程中被误用的概率。

你可能会说,那我们再派生两个类:会飞的鸟和不会飞的鸟

type Bird struct{}

// 会飞的鸟
type FlyableBird struct {
   Bird
}

func (fb *FlyableBird) fly() (err error) {
   return
}

// 不会飞的鸟
type UnFlyableBird struct {
    Bird
}

type Ostrich struct {
    UnFlyableBird
}

从图中我们可以看出,继承关系变成了三层。不过,整体上来讲,目前的继承关系还比较简单,层次比较浅,也算是一种可以接受的设计思路。我们再继续加点难度。在刚刚这个场景中,我们只关注“鸟会不会飞”,但如果我们还关注“鸟会不会叫”,那这个时候,我们又该如何设计类之间的继承关系呢?

如果我们还需要考虑“是否会下蛋”这样一个行为,那估计就要组合爆炸了。

类的继承层次会越来越深、继承关系会越来越复杂。而这种层次很深、很复杂的继承关系,一方面,会导致代码的可读性变差。因为我们要搞清楚某个类具有哪些方法、属性,必须阅读父类的代码、父类的父类的代码……一直追溯到最顶层父类的代码。另一方面,这也破坏了类的封装特性,将父类的实现细节暴露给了子类。子类的实现依赖父类的实现,两者高度耦合,一旦父类代码修改,就会影响所有子类的逻辑。

组合
组合表示类之前 has-a 的关系,比如 House has Bathroom,从例子就可以看出组合和继承的差异。

接下来我们看一个例子,来加深对组合的理解。前面讲到的接口表示具有某种行为特性。针对“会飞”这样一个行为特性,我们可以定义一个 Flyable 接口,只让会飞的鸟去实现这个接口。

package main

type Flyable interface {
   fly()
}

type Tweetableyd interface {
   tweet()
}

type EggLayable interface {
   layEgg()
}

// 接口 TweetEggLayable has Tweetableyd & EggLayable
type TweetEggLayable interface {
   Tweetableyd
   EggLayable
}

type Ostrich struct {}

func (o *Ostrich) tweet() {}
func (o *Ostrich) layEgg() {}

不过,我们知道,接口只声明方法,不定义实现。也就是说,每个会下蛋的鸟都要实现一遍 layEgg() 方法,并且实现逻辑是一样的,这就会导致代码重复的问题。那这个问题又该如何解决呢?

我们可以针对每个接口再定义各自的实现类:

type TweetAbility struct{} // 类似java中的抽象类
func (f TweetAbility) tweet() {}

type EggLayAbility struct{}
func (f EggLayAbility) layEgg() {}

// 实现类 OstrichV2 has TweetAbility & EggLayAbility
type OstrichV2 struct {
   tweetAbility TweetAbility
   eggLayAbility EggLayAbility
}

func (o *OstrichV2) tweet() {
   o.tweetAbility.tweet()
}

可以看到,基于 Go 的 interface 及 struct,我们能很轻松得实现组合。Go 语言本身很多地方也使用到了组合,比如 io 包的 ReadWriteCloser、WriteCloser 等

// ReadWriter is the interface that groups the basic Read and Write methods.
type ReadWriter interface {
   Reader
   Writer
}

// ReadCloser is the interface that groups the basic Read and Close methods.
type ReadCloser interface {
   Reader
   Closer
}

// WriteCloser is the interface that groups the basic Write and Close methods.
type WriteCloser interface {
   Writer
   Closer
}

// ReadWriteCloser is the interface that groups the basic Read, Write and Close methods.
type ReadWriteCloser interface {
   Reader
   Writer
   Closer
}

问题:如何判断该用组合还是继承?
尽管我们鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。从上面的例子来看,继承改写成组合意味着要做更细粒度的类的拆分。这也就意味着,我们要定义更多的类和接口。类和接口的增多也就或多或少地增加代码的复杂程度和维护成本。所以,在实际的项目开发中,我们还是要根据具体的情况,来具体选择该用继承还是组合。

如果类之间的继承结构稳定(不会轻易改变),继承层次比较浅(比如,最多有两层继承关系),继承关系不复杂,我们就可以大胆地使用继承。反之,系统越不稳定,继承层次很深,继承关系复杂,我们就尽量使用组合来替代继承。

除此之外,还有一些设计模式会固定使用继承或者组合。比如,装饰者模式(decorator pattern)、策略模式(strategy pattern)、组合模式(composite pattern)等都使用了组合关系,而模板模式(template pattern)使用了继承关系。

SOLID

单一职责

单一职责原则的英文是 Single Responsibility Principle,缩写为 SRP。这个原则的英文描述是这样的:A class or module should have a single responsibility。如果我们把它翻译成中文,那就是:一个类或者模块只负责完成一个职责(或者功能)。

注意,这个原则描述的对象包含两个,一个是类(class),一个是模块(module)。通常可以把模块看做比类更抽象的概念,比如函数、类、由类组合的功能单元都可以是一个模块。

  1. 如何理解单一职责原则(SRP)?
    一个类(或模块)只负责完成一个职责或者功能。不要设计大而全的类,要设计粒度小、功能单一的类。单一职责原则是为了实现代码高内聚、低耦合,提高代码的复用性、可读性、可维护性。

  2. 如何判断类的职责是否足够单一?
    不同的应用场景、不同阶段的需求背景、不同的业务层面,对同一个类的职责是否单一,可能会有不同的判定结果。实际上,一些侧面的判断指标更具有指导意义和可执行性,比如,出现下面这些情况就有可能说明这类的设计不满足单一职责原则:

  • 类中的代码行数、函数或者属性过多
  • 类依赖的其他类过多,或者依赖类的其他类过多
  • 私有方法过多
  • 比较难给类起一个合适的名字
  • 类中大量的方法都是集中操作类中的某几个属性
  • 代码中存在大量注释
  1. 类的职责是否设计得越单一越好?
    单一职责原则通过避免设计大而全的类,避免将不相关的功能耦合在一起,来提高类的内聚性。同时,类职责单一,类依赖的和被依赖的其他类也会变少,减少了代码的耦合性,以此来实现代码的高内聚、低耦合。但是,如果拆分得过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性。

开放封闭

开闭原则的英文全称是 Open Closed Principle,简写为 OCP。它的英文描述是:software entities (modules, classes, functions, etc.) should be open for extension , but closed for modification。我们把它翻译成中文就是:软件实体(模块、类、方法等)应该“对扩展开放、对修改关闭”

开放封闭原则可以说是 SOLID 中最有用的原则。之所以说这条原则最有用,那是因为,扩展性是代码质量最重要的衡量标准之一。在 23 种经典设计模式中,大部分设计模式都是为了解决代码的扩展性问题而存在的,主要遵从的设计原则就是开闭原则。

举例:比如基于接口编程部分举的 ImageStore 的例子,当我们要引入一种新的云存储时,只需要新增一个实现类(扩展),然后替换调用的实现类即可,业务调用逻辑和原有类并没有发生修改。

  1. 如何理解“对扩展开放、对修改关闭”?
    添加一个新的功能,应该是通过在已有代码基础上扩展代码(新增模块、类、方法、属性等),而非修改已有代码(修改模块、类、方法、属性等)的方式来完成。关于定义,我们有两点要注意。
    第一点是,开闭原则并不是说完全杜绝修改,而是以最小的修改代码的代价来完成新功能的开发。
    第二点是,同样的代码改动,在粗代码粒度下,可能被认定为“修改”;在细代码粒度下,可能又被认定为“扩展”。
    第二点举例:我们对一个类添加新的方法。添加方法相当于修改类,在类这个层面,这个代码改动可以被认定为“修改”;但这个代码改动并没有修改已有的属性和方法,在方法(及其属性)这一层面,它又可以被认定为“扩展”。

  2. 如何做到“对扩展开放、修改关闭”?
    我们要时刻具备扩展意识、抽象意识、封装意识。在写代码的时候,我们要多花点时间思考一下,这段代码未来可能有哪些需求变更,如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,在不改动代码整体结构、做到最小代码改动的情况下,将新的代码灵活地插入到扩展点上。很多设计原则、设计思想、设计模式,都是以提高代码的扩展性为最终目的的。特别是 23 种经典设计模式,大部分都是为了解决代码的扩展性问题而总结出来的,都是以开闭原则为指导原则的。最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态)

里式替换

里式替换原则的英文翻译是:Liskov Substitution Principle,缩写为 LSP。这个原则最早是在 1986 年由 Barbara Liskov 提出,他是这么描述这条原则的:

If S is a subtype of T, then objects of type T may be replaced with objects of type S, without breaking the program。

1996 年,Robert Martin 在他的 SOLID 原则中,重新描述了这个原则,英文原话是这样的:

Functions that use pointers of references to base classes must be able to use objects of derived classes without knowing it。

我们综合两者的描述,将这条原则用中文描述出来,是这样的:子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。

package main

import "net/http"

//  class: Request
type Request struct{}

func NewRequest() Request {
   return Request{}
}

// 网络传输接口 I开头表示interface
type ITransporter interface {
   sendRequest(request Request)
}

// class: Transporter
type Transporter struct {
   httpClient *http.Client
}

func NewTransporter(httpClient *http.Client) *Transporter {
   return &Transporter{httpClient: httpClient}
}

func (t *Transporter) sendRequest(request Request) {
   // ...use httpClient to send request
}

// class: SecurityTransporter 继承了Transporter,是其子类
type SecurityTransporter struct {
   Transporter
   appID    string
   appToken string
}

func NewSecurityTransporter(httpClient *http.Client, appID, appToken string) *SecurityTransporter {
   return &SecurityTransporter{Transporter{httpClient: httpClient}, appID, appToken}
}

func (st *SecurityTransporter) sendRequest(request Request) {
   // ... 重写 sendRequest,加入对 appID, appToken 的处理
}

// 里式替换原则
func demoFunction(transporter ITransporter) {
   request := NewRequest()
   //...省略设置request中数据值的代码...
   transporter.sendRequest(request)
}

func main() {
   httpClient := &http.Client{}
   
   transporter1 := NewTransporter(httpClient)
   demoFunction(transporter1)

   transporter2 := NewSecurityTransporter(httpClient, "loki", "test")
   demoFunction(transporter2)
}

在上面的代码中,子类 SecurityTransporter 的设计完全符合里式替换原则,可以替换父类出现的任何位置,并且原来代码的逻辑行为不变且正确性也没有被破坏。

问题:刚刚的代码设计不就是简单利用了面向对象的多态特性吗?多态和里式替换原则说的是不是一回事呢?

我们对子类的 sendRequest 做如下更改:

func (st *SecurityTransporter) sendRequest(request Request) {
   // ... 重写 sendRequest,加入对 appID, appToken 的处理
   if st.appID == "" || st.appToken == "" {
       panic("invalid params") // 子类SecurityTransporter在这里会抛出异常
   }
   // ... 后续发送请求逻辑
}

在改造之后的代码中,如果传递进 demoFunction() 函数的是父类 Transporter 对象,那 demoFunction() 函数并不会有异常抛出,但如果传递给 demoFunction() 函数的是子类 SecurityTransporter 对象,那 demoFunction() 有可能会有异常抛出。可以说,子类替换父类传递进 demoFunction 函数之后,整个程序的逻辑行为有了改变。从设计思路上来讲,SecurityTransporter 的设计是不符合里式替换原则的。

多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。而里式替换是一种设计原则,是用来指导继承关系中子类该如何设计的,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性。

问题:哪些代码明显违背了 LSP?

  1. 子类违背父类声明要实现的功能
    父类中提供的 sortOrdersByAmount() 订单排序函数,是按照金额从小到大来给订单排序的,而子类重写这个 sortOrdersByAmount() 订单排序函数之后,是按照创建日期来给订单排序的。那子类的设计就违背里式替换原则。

  2. 子类违背父类对输入、输出、异常的约定
    在父类中,某个函数约定(假设在 Go 中返回的是 interface{}):运行出错的时候返回 nil;获取数据为空的时候返回空集合(empty slice)。而子类重载函数之后,实现变了,运行出错返回错误(error),获取不到数据返回 nil。那子类的设计就违背里式替换原则。
    在父类中,某个函数约定,输入数据可以是任意整数,但子类实现的时候,只允许输入数据是正整数,负数就抛出,也就是说,子类对输入的数据的校验比父类更加严格,那子类的设计就违背了里式替换原则。
    在父类中,某个函数约定,只会抛出 ArgumentNullException 异常(错误),那子类的设计实现中只允许抛出 ArgumentNullException 异常(错误),任何其他异常的抛出,都会导致子类违背里式替换原则。

  3. 子类违背父类注释中所罗列的任何特殊说明
    父类中定义的 withdraw() 提现函数的注释是这么写的:“用户的提现金额不得超过账户余额……”,而子类重写 withdraw() 函数之后,针对 VIP 账号实现了透支提现的功能,也就是提现金额可以大于账户余额,那这个子类的设计也是不符合里式替换原则的。

接口隔离

接口隔离原则的英文翻译是“ Interface Segregation Principle”,缩写为 ISPRobert MartinSOLID 原则中是这样定义它的:“Clients should not be forced to depend upon interfaces that they do not use。”直译成中文的话就是:客户端不应该被强迫依赖它不需要的接口。其中的“客户端”,可以理解为接口的调用者或者使用者。

  1. 如何理解“接口隔离原则”?
    理解“接口隔离原则”的重点是理解其中的“接口”二字。这里有三种不同的理解。
    如果把“接口”理解为一组接口集合,可以是某个微服务的接口,也可以是某个类库的接口等。如果部分接口只被部分调用者使用,我们就需要将这部分接口隔离出来,单独给这部分调用者使用,而不强迫其他调用者也依赖这部分不会被用到的接口。(比如将获取用户敏感数据的相关接口和获取普通信息的接口做隔离)

如果把“接口”理解为单个 API 接口或函数,部分调用者只需要函数中的部分功能,那我们就需要把函数拆分成粒度更细的多个函数,让调用者只依赖它需要的那个细粒度函数。

如果把“接口”理解为 OOP 中的接口,也可以理解为面向对象编程语言中的接口语法。那接口的设计要尽量单一,不要让接口的实现类和调用者,依赖不需要的接口函数。Go 语言 io 包中的 Reader、Writer 接口实现,就是“接口隔离原则”的最佳实践。

type Reader interface {
   Read(p []byte) (n int, err error)
}

type Writer interface {
   Write(p []byte) (n int, err error)
}
  1. 接口隔离原则与单一职责原则的区别
    单一职责原则针对的是模块、类、接口的设计。接口隔离原则相对于单一职责原则,一方面更侧重于接口的设计,另一方面它的思考角度也是不同的。接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。

依赖反转

依赖反转、控制反转等思想主要用来指导框架设计,内容比较多,这里仅简单介绍下概念

  1. 控制反转
    实际上,控制反转是一个比较笼统的设计思想,并不是一种具体的实现方法,一般用来指导框架层面的设计。这里所说的“控制”指的是对程序执行流程的控制,而“反转”指的是在没有使用框架之前,程序员自己控制整个程序的执行。在使用框架之后,整个程序的执行流程通过框架来控制。流程的控制权从程序员“反转”给了框架。实现控制反转的方式有很多,比如下面要说的依赖注入,就是实现控制反转的方式之一。

  2. 依赖注入
    依赖注入和控制反转恰恰相反,它是一种具体的编码技巧。我们不通过 new 的方式在类内部创建依赖类的对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类来使用。依赖注入也是实现组合最简单的方式。

type A struct {}

func NewA() A {
    return A{}
}

type B struct {
    a A
}

func NewB(a A) B {
    return B{a:a}
}

func main() {
    a := NewA()
    // 依赖注入
    // 将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类来使用
    b := NewB(a)
}

通过依赖注入的方式来将依赖的类对象传递进来,这样就提高了代码的扩展性,我们可以灵活地替换依赖的类(此时依赖的是一个接口而非一个具体的类)。

type interface ITest {}

type A struct {} // implement ITest

type B struct {
    test ITest
}

// 传入的 test,可以是 A 的对象,也可以是其他实现了 ITest 的对象
func NewB(test ITest) B {}
  1. 依赖注入框架
    我们通过依赖注入框架提供的扩展点,简单配置一下所有需要的类及其类与类之间依赖关系,就可以实现由框架来自动创建对象、管理对象的生命周期、依赖注入等原本需要程序员来做的事情。

  2. 依赖反转
    原则依赖反转原则也叫作依赖倒置原则。这条原则跟控制反转有点类似,主要用来指导框架层面的设计。高层模块不依赖低层模块,它们共同依赖同一个抽象。抽象不要依赖具体实现细节,具体实现细节依赖抽象。

KISS

KISS 原则的英文描述有好几个版本,比如下面这几个。

  • Keep It Simple and Stupid.
  • Keep It Short and Simple.
  • Keep It Simple and Straightforward.

不过,仔细看你就会发现,它们要表达的意思其实差不多,翻译成中文就是:尽量保持简单。

KISS 原则是保证代码可读性和可维护性的重要手段。KISS 原则中的“简单”并不是以代码行数来考量的。代码行数越少并不代表代码越简单,我们还要考虑逻辑复杂度、实现难度、代码的可读性等。而且,本身就复杂的问题,用复杂的方法解决,并不违背 KISS 原则。除此之外,同样的代码,在某个业务场景下满足 KISS 原则,换一个应用场景可能就不满足了。

对于如何写出满足 KISS 原则的代码,有下面几条指导原则:

  • 不要使用同事可能不懂的技术来实现代码
  • 不要重复造轮子,要善于使用已经有的工具类库
  • 不要过度优化

DRY

DRY 原则(Don’t Repeat Yourself)几乎人尽皆知。你可能会觉得,这条原则非常简单、非常容易应用。只要两段代码长得一样,那就是违反 DRY 原则了。真的是这样吗?答案是否定的。这是很多人对这条原则存在的误解。实际上,重复的代码不一定违反 DRY 原则,而且有些看似不重复的代码也有可能违反 DRY 原则。

通常存在三种典型的代码重复情况,它们分别是:实现逻辑重复、功能语义重复和代码执行重复。这三种代码重复,有的看似违反 DRY,实际上并不违反;有的看似不违反,实际上却违反了。

实现逻辑重复

type UserAuthenticator struct {}

func (ua * UserAuthenticator) authenticate(username, password string) {
    if !ua.isValidUsername(username) {
        // ... code block 1
    }
    if !ua.isValidPassword(username) {
        // ... code block 1
    }
    // ...省略其他代码...
}

func (ua * UserAuthenticator) isValidUsername(username string) bool {
}

func (ua * UserAuthenticator) isValidPassword(password string) bool {
}

假设 isValidUserName() 函数和 isValidPassword() 函数代码重复,看起来明显违反 DRY 原则。为了移除重复的代码,我们对上面的代码做下重构,将 isValidUserName() 函数和 isValidPassword() 函数,合并为一个更通用的函数 isValidUserNameOrPassword()

经过重构之后,代码行数减少了,也没有重复的代码了,是不是更好了呢?答案是否定的。单从名字上看,我们就能发现,合并之后的 isValidUserNameOrPassword() 函数,负责两件事情:验证用户名和验证密码,违反了“单一职责原则”和“接口隔离原则”。

实际上,即便将两个函数合并成 isValidUserNameOrPassword(),代码仍然存在问题。因为 isValidUserName() isValidPassword() 两个函数,虽然从代码实现逻辑上看起来是重复的,但是从语义上并不重复。所谓“语义不重复”指的是:从功能上来看,这两个函数干的是完全不重复的两件事情,一个是校验用户名,另一个是校验密码。尽管在目前的设计中,两个校验逻辑是完全一样的,但如果按照第二种写法,将两个函数的合并,那就会存在潜在的问题。在未来的某一天,如果我们修改了密码的校验逻辑,那这个时候,isValidUserName() isValidPassword()的实现逻辑就会不相同。我们就要把合并后的函数,重新拆成合并前的那两个函数。

对于包含重复代码的问题,我们可以通过抽象成更细粒度函数的方式来解决。

功能语义重复
在同一个项目代码中有下面两个函数:isValidIp()checkIfIpValid()。尽管两个函数的命名不同,实现逻辑不同,但功能是相同的,都是用来判定 IP 地址是否合法的。

func isValidIp(ipAddress string) bool {
    // ... 正则表达式判断
}

func checkIfIpValid(ipAddress string) bool {
    // ... 字符串方式判断
}

在这个例子中,尽管两段代码的实现逻辑不重复,但语义重复,也就是功能重复,我们认为它违反了 DRY 原则。我们应该在项目中,统一一种实现思路,所有用到判断 IP 地址是否合法的地方,都统一调用同一个函数。

代码执行重复

type UserService struct {
    userRepo UserRepo
}

func (us *UserService) login(email, password string) {
    existed := us.userRepo.checkIfUserExisted(email, password)
    if (!existed) {
        // ...
    }
    user := us.userRepo.getUserByEmail(email)
}

type UserRepo struct {}

func (ur *UserRepo) checkIfUserExisted(email, password string) bool {
    if !ur.isValidEmail(email) {
        // ...
    }
}

func (ur *UserRepo) getUserByEmail(email string) User {
    if !ur.isValidEmail(email) {
        // ...
    }
}

上面这段代码,既没有逻辑重复,也没有语义重复,但仍然违反了 DRY 原则。这是因为代码中存在“执行重复”。这个问题解决起来比较简单,我们只需要将校验逻辑从 UserRepo 中移除,统一放到 UserService 中就可以了。

问题:如何提高代码复用性?

  • 减少代码耦合
  • 满足单一职责原则
  • 模块化业务与非业务逻辑分离
  • 通用代码下沉
  • 继承、多态、抽象、封装
  • 应用模板等设计模式

LOD

迪米特法则的英文翻译是:Law of Demeter,缩写是 LOD。单从这个名字上来看,我们完全猜不出这个原则讲的是什么。不过,它还有另外一个更加达意的名字,叫作最小知识原则,英文翻译为:The Least Knowledge Principle

迪米特法则法则强调不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口。迪米特法则是希望减少类之间的耦合,让类越独立越好。每个类都应该少了解系统的其他部分。一旦发生变化,需要了解这一变化的类就会比较少。

前面提到的“单一职责原则”、“接口隔离原则”以及“最小知识原则”,都是实现高内聚低耦合的有效指导思想。“最小知识原则”更强调类与类之间的关系。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值