Go 语言设计模式系列之二——设计模式简介

从这篇开始将开始讨论设计模式,我们在日常开发代码的时候总是希望开发的代码可以被充分的复用,设计模式就是为了解决代码复用问题而提出的。在GoF的经典著作Design Patterns: Elements of Reusable Object-Oriented Softwar 中提出了23中设计模式,这23中设计模式描述了一些常见的问题并且提出了解决问题的模板即为设计模式,这些模板可以用在不同的上下文中。通过研究设计模式可以很好将解决客户需求转化为一个稳定的可复用的软件架构,因为设计模式是软件架构师必备的技能。

设计模式大致可分为三大类:

  1. 创建型(Creational)主要特点是将对象的创建和使用分离GoF中提供了单例, 原型, 工厂, 抽象工厂, 建造者等5中创建型模式
  2. 行为型(Behavioral)用于描述如何将类或者对象按照某种布局组成更大的结构, GoF提供了代理, 适配器, 桥接, 外观, 享元, 组合等6中结构性模式.
  3. 结构型(Structural)用于描述类或对象之间如何通过写作共同完成单个对象无法完成的任务, 以及怎样分配职责. GoF中提供了模板方法, 策略, 命令, 职责链, 状态, 观察者, 中介者, 迭代者, 访问者, 备忘录, 解释器.

设计原则

在low-level design中需要注意两个关键方面:

  1. 责任分配:每个类的责任是什么?
  2. 依赖管理: 这个类应该依赖什么其他类?

Robert C Martin曾经提出了SOLID(每一个字母对应一个特定的原则)原则,虽然这是Bob大叔在很早之前提出的理论,但在今天仍然适用。

  • 单一职责原则 Single Responsibility Principle (S)

一个类有且只有一个职责(One class should have one, and only one, responsibility.)在Go语言编程中除了了类遵守这个原则外,包(package)也要尽量遵守这个原则。比如我在一些代码中经常看到有一个叫utils的包,点进去一看简直跟垃圾场一样啥都往里面塞,所以这种包是要尽量去避免的。Go标准库中一些好的包名的例子,这些例子清楚地表明了其目的。例如net/http:这提供了http客户端和服务器。 encoding/JSON:这实现了JSON序列化/反序列化。

  • 开闭原则 Open/Closed Principle (O):

开闭原则就是说对扩展开放,对修改关闭("You should be able to extend a class's behavior without modifying it.")。在程序需要进行拓展的时候,不能去修改原有的代码,实现一个热插拔的效果。简单来说:就是为了使程序的扩展性好,易于维护和升级。

  • .里氏代换原则 Liskov Substitution Principle (L):

原则的关键是派生类必须可以通过基类接口使用,而不需要客户端知道特定的派生类(Derived types must be substitutable for their base types.)go语言的接口可以很好的实现此原则。

  • 接口隔离原则 Interface Segregation Principle (I):

Clients should not be forced to depend on methods they do not use。客户端不应该依赖它不需要的接口,使用多个专门的接口比使用单一的总接口要好。

  •  依赖倒置原则(D) D ependency Inversion Principle (D)

传统的过程性系统的设计方法倾向于使高层次的模块依赖于低层次的模块,抽象层次依赖于具体层次。倒置原则就是要把这个错误的依赖关系倒转过来。简单的说就是要面向接口编程。Depend on abstractions, not on concretions.

实战例子

下面我们通过一个真实的案例来看一下在go语言编程中怎么去很好的应用这几项原则。案例的use case很简单我们需要设计一个小存储系统去存储一些数据(注意代码是解释概念用的伪代码不能直接运行)。

当我们编写Go程序时,一些包被main()函数调用,这些可以认为是顶层(high-level)模块。相反的,一些和外部资源交互的模块,比如数据库,典型的不由main()调用而是由逻辑层调用,这些就被称作low-level。

首先我们定义基础的high-level的接口和结构体

type Fooler interface {

	Save(data interface{}) bool
}

type foo struct{

}
func (f *foo) Save(bar interface{}) bool{
	return true
}

上面定义了一个接口Fooler包含了一个Save()函数,结构体foo实现了此接口。这里仅仅包含了Save操作,但是数据怎么存和存到哪里并没有具体的实现,这些就是low-level的模块,我们定义一个结构体并实现,假设是使用Amazon的S3作为后台存储。代码如下

type S3Storage struct{

}

func (s *S3Storage) Store(ctx context.Context,data interface{}) (int,error){
    .......
    return 1,nil

}

func (s *S3Storage) Fetch(ctx context.Context,id int) (int,error){

    .......
    return 0,nil

}

有了这个便可以具体的去具体实现上面的high-level的模块了

type Fooler interface {

	Save(data interface{}) bool
}

type foo struct{
   storage S3Storage

}
func (f *foo) Save(bar interface{}) bool{
    ......
    foo.storage.Store(ctx,bar)    
    return true
}

到这里也许敏感的你已经发现我的这个high-level 的模块已经强依赖于S3Storage,违背了D原则,这样写代码的复用性便的很差了,比如如果我要添加新的存储方式比如是本地存储,阿里云OSS存储等等foo的结构就需要发生变化,增加了代码维护的难度。

怎么改呢其实很简单就是让high-level 依赖于接口,更改如下

//增加一个抽象的接口
type DataStorage interface{
    Store(ctx context.Context,data interface{}) (int,error)
    Fetch(ctx context.Context,id int) (int,error)
    
}

foo 更改

type Fooler interface {

	Save(data interface{}) bool
}

type foo struct{
   
   storage DataStorage  // 属性更改为接口

}
func (f *foo) Save(bar interface{}) bool{
    ......
    foo.storage.Store(ctx,bar)    
    return true
}

更改之后还缺少了将DataStorage的具体实例传给Save的方式,可以使用一个函数NewFoo来做这个事情

最终代码的实现方式如下:

func NewDataStorage() DataStorage{
	......
	return S3Storage{}
}
func NewFoo(s DataStorage) Fooler{
    ......
    return {
        foo{
           storage:s,
        }
    }
}
main(){
	storage := NewDataStorage()
	fooler = NewFoo(storage)
	fooler.Save("Some Data")
}

另外我们可以发现,在定义foo结构体的时候是直接使用的DataStorage 类型,在最终使用的时候赋值给storage是一个S3Storage的实例,并不需要做任何改动就可以使用,因此也符合L原则。

在实际运行中我们的老板可能觉得用Amazon的S3需要他每天都掏钱给贝索斯他觉得不爽,并且请求云端太依赖于公网,因此让我们找一个开源的对象存储工具。开源的对象存储比较多这里推荐一下go语言实现的Minio, 为了符合S原则我们定义一个新的struct继承于S3storage 即可。

type MinioStorage struct{

    S3Storage

}

如上使用golang里面的Embedding 实现了继承这样就可以使用了。Minio 的操作方式和Amazon S3会有一些区别这些区别可以通过定义新的方法来实现比如

func (s *MinioStorage) Store(ctx context.Context,data interface{}) (int,error){
    .......
    minoSpecificStore(data)
    return 1,nil

}

func (s *MinioStorage) Fetch(ctx context.Context,id int) (int,error){

    .......
    minoSpecificFetch(id)
    return 0,nil

}

上面这些改变由于receiver(func和函数名之间的参数)的存在是改变不了S3Storage里面的函数的因此这里可以说实现了Open/Close原则。

继续扩展上面的这个例子,存储的原数据来源问题。

在实际使用过程中我们需要将源数据读进来,比如读取一个存储在硬盘上的文件的数据

我们需要定义一个函数

func Read(f *os.file, data interface{}) error{
    
}

这个函数将文件f中的内容读进来并赋值给data,data 是个inteface可以接收任何数据。通过这种方式这个函数看起来很强大。但是仔细思考可以发现,数据来源可能有很多种比如 网络,标准输入输出等。这样f *os.file 就不适合这种情况了。并且os.file 中定义了很多别的方法(接口)而我们这个仅仅是需要一个Read操作,因此不符合接口隔离原则I。为了避免这个问题一个很容易想到的方法就是想输入参数f也定义为接口,而强大的golang语言也给我们提供了这样一个接口io.Reader.

func Read(f io.Reader, data interface{}) error{
    
}

围绕io.Reader/Writer,有几个常用的实现:

  • net.Conn, os.Stdin, os.File: 网络、标准输入输出、文件的流读取
  • strings.Reader: 把字符串抽象成Reader
  • bytes.Reader: 把[]byte抽象成Reader
  • bytes.Buffer: 把[]byte抽象成Reader和Writer
  • bufio.Reader/Writer: 抽象成带缓冲的流读取(比如按行读写)

这样这个函数就可以处理多种类型的输入数据了,并且也摆脱了一些干扰。

总结

本篇主要介绍了设计模式的基本原则,并通过一个例子详细讨论了go语言开发过程中如何遵守这些原则。总结一下几个基本的点有

1. 顶层模块不应该依赖底层模块,应该依赖于接口

2. 结构体不应该依赖于结构体

3. 接口不应该依赖于结构体

4. A great rule of thumb for Go is accept interfaces, return structs

通过本篇我们看到了go语言中接口interface的重要作用,通过接口Go语言实现了很多编程理念。go语言其实很简单,借用一位同事的话就是go语言就是不往里添加类的概念,这样就断了写复杂业务逻辑的念想。我们在日常使用go语言编程中要牢记面向接口编程这个基本的思想。这样写出的代码就会特别高端大气上档次。

下一篇计划写创建模式里面的工厂模式,敬请期待

你可能感兴趣的:(Go 语言设计模式系列之二——设计模式简介)