设计模式就某些编码场景下的最佳实践,用来解决常见的软件设计问题。Go 语言不是面向对象语言,但是可以使用结构体、接口等类型实现面向对象语言的特性,想要弄懂设计模式,要熟练的使用 Go 语言接口类型 和结构体类型
设计模式总体上分为创建型、结构型和行为型 3 类、共 25 种经典设计方案,在 Go 项目开发中常见的有 6 种
下面详细说
创建型
创建型设计模式提供了一种 在创建对象的同时隐藏创建逻辑 的方式,而不是使用 new 运算符直接实例化对象
这种类型的设计模式有单例模式和工厂模式,工厂模式包括简单工厂模式、抽象工厂模式和工厂方法模式三种,这种设计模式在 Go 项目开发种比较常用
单例模式
单例模式是最简单的一个模式。在 Go 中,单例模式指的就是全局只有一个实例,而且只被初始化一次,比较适合 全局共享一个示例,并且只需要被初始化一次 的场景,比如数据库实例、全局配置、全局任务池等
单例模式又分为饿汉方式和懒汉方式,饿汉方式是全局的单例实例在包被加载时创建,懒汉方式是全局的单例实例在第一次被使用时创建
饿汉方式的单例模式:
type singleton struct{} var ins *singleton = &singleton{} func GetInsOr() *singleton { return ins }
在包被导入时,实例会直接初始化,如果初始化耗时,会导致程序加载时间变长
懒汉方式是开源项目中使用最多的,它的缺点是非并发安全,在实际使用时需要加锁,一个简单的实现:
type singleton struct{} var ins *singleton func GetInsOr() *singleton { if ins == nil { ins = &singleton{} } return ins }
可以看到,在调用 GetInsOr()
函数时,如果 ins 为 nil,就会创建一个 ins 实例,如果不加锁,就会有多个实例创建
可以对实例加锁,保证并发安全:
import "sync" type singleton struct{} var ins *singleton var mu sync.Mutex func GetInsOr() *singleton { if ins == nil { mu.Lock() if ins == nil { ins = &singleton{} } mu.Unlock() } return ins }
要注意加锁后需要再判断是否已经创建好实例。这样就保证了并发安全
除了饿汉方式和懒汉方式,在 Go 开发中还有一种更优雅的实现方式,比较推荐使用:
import "sync" type singleton struct{} var ins *singleton var once sync.Once func GetInsOr() *singleton { once.Do(func() { ins = &singleton{} }) return ins }
sync.Once
是一个结构体,它提供的 Do
方法可以确保 ins 实例全局只被创建一次,还可以确保在并发场景下,只有一个线程能执行这个函数,Do
方法的参数只能是一个没有参数和返回值的匿名函数,用于做一些初始化操作
工厂模式
工厂模式是面向对象编程中的常用模式,在 Go 中,可以把结构体理解为类,比如:
type Person struct { Name string Age int } func (p Person) Greet() { fmt.Println("111") }
Person 结构体实现了 Greet 方法,有了 Person 结构体,就可以通过简单工厂模式、抽象工厂模式、工厂方法模式这三种方式来创建一个 Person 实例
简单工厂模式是最常用、最简单的,它就是接收一些参数,然后返回 Person 实例:
type Person struct { Name string Age int } func (p Person) Greet() { fmt.Println("111") } func NewPerson(name string, age int) *Person { return &Person { Name: name, Age: age, } }
和 p := &Person{}
这种创建方式相比,简单工厂模式可以确保创建的实例具有需要的参数,进而保证实例的方法可以按预期执行,比如通过 NewPerson
方法创建的 Person 实例,可以确保实例的 name 和 age 属性被设置
抽象工厂模式和简单工厂模式的唯一区别,就是返回的是接口而不是结构体
通过返回接口,可以在 不公开内部实现的情况下,让调用者使用提供好的各种功能,比如:
type Person interface { Greet() } type person struct { name string age int } func (p person) Greet() { fmt.Println("111") } func NewPerson(name string, age int) Person { return person { name: name, age: age, } }
注意接口名是开头大写的 Person
,而结构体是开头小写的 person
,在 Go 中,开头小写的结构体是不能被导出的,在上面的例子中,只能通过 NewPerson
函数去生成接口类型的实例,这样就隐藏了 person
结构体的内部实现细节
通过返回接口类型,还可以实现多个工厂函数,来实现返回不同的接口实现:
type Doer interface { Do(req *http.Request) (*http.Response, error) } func NewHTTPClient() Doer { return &http.Client{} } // mock 的 HTTP 连接 用于模拟外部连接 type mockHTTPClient struct{} func (*mockHTTPClient) Do(req *http.Request) (*http.Response, error) { // 假设 httptest.NewRecorder 是实现好的方法 // 用于返回一个新的 request 实例 res := httptest.NewRecorder() return res.Result, nil } func NewMockHTTPClient() Doer { return &mockHTTPClient{} }
NewHTTPClient
和 NewMockHTTPClient
都返回了同一个接口类型 Doer,这使得两者可以互相使用,如果想测试一段调用了 Doer 接口的 Do 方法的代码时,就可以使用 Mock 出来的 HTTP 客户端,避免调用外部接口带来的失败,只专注于测试想测试的代码片段
比如现在想测试下面这段代码:
func QueryUser(doer Doer) error { req, err := http.NewRequest("Get", "http://iam.api.marmotedu.com:8080/v1/secrets", nil) if err != nil { return err } _, err := doer.Do(req) if err != nil { return err } // 处理一些其他逻辑 // ... return nil }
给这段代码编写测试用例为:
func TestQueryUser(t *testing.T) { doer := NewMockHTTPClient() if err := QueryUser(doer); err != nil { t.Errof(QueryUser failed, err: %v", err) } }
这个测试用例忽略了请求外部的 http://iam.api.marmotedu.com:8080/v1/secrets
带来的错误,只专注于核心业务逻辑
另外,在使用简单工厂模式和抽象工厂模式返回实例对象时,都可以返回指针,比如:
简单工厂模式:
return &Person { Name: name, Age: age }
抽象工厂模式:
return &person { Name: name, Age: age }
但是在实际开发中,推荐使用非指针的实例,因为使用工厂模式是想通过创建实例,来调用其提供的方法,而不是对实例做更改,如果要对实例进行更改,可以给实例实现 SetXXX
方法,返回非指针的实例,可以避免属性被意外修改
在简单工厂模式中,依赖于唯一的工厂对象,如果需要创建一个实例,就要向工厂中传入一个参数,如果工厂函数要根据传入的参数值返回不同类型的实例,如果要创建一种新的实例,就需要在工厂中修改函数,这会导致耦合度过高,这时候就可以使用 工厂方法模式
在工厂方法模式中,依赖工厂函数,通过工厂函数来创建多种工厂,把实例创建从 由一个对象负责所有具体实例的实例化,变成一群子实例负责对具体实例的实例化,从而将过程解耦
比如:
type Person struct { name string age int } func NewPersonFactory(age int) func(name string) Person { return func(name string) Person { return Person { name: name, age: age, } } }
NewPersonFactory
函数返回了一个闭包函数,使用时可以创建具有默认年龄的工厂:
newBaby := NewPersonFactory(1) baby := newBaby("john") newTeenager := NewPersonFactory(16) teen := newTeenager("jill")
结构型模式
结构型模式关注 类和对象的组合,这一类型中有策略模式和模板模式
策略模式
策略模式定义了一组算法,将每个算法封装起来,并且使它们之间可以互换
在项目开发中,经常要根据不同的场景,采取不同的措施,也就是不同的策略。比如要对 a、b 这两个整数进行运算,根据条件的不同,需要执行不同的计算方式,就可以把所有操作封装在同一个函数中,通过 if ... else ...
来调用不同的计算方式,这种方式称之为硬编码
在实际应用中,随着功能和体验的不断增长,经常需要增加/修改策略,这样就需要不断修改已有的代码,这不仅会让这个函数越来越难维护,还可能因为修改带来一些 bug,为了解耦,就需要使用策略模式,定义一些独立的类来封装不同的算法,每一个类封装一个具体的算法
比如:
// 策略类 type IStrategy interface { do(int, int) int } // 策略实现:加 type add struct{} func (*add) do(a, b int) int { return a + b } // 策略实现:减 type reduce struct{} func (*reduce) do(a, b int) int { return a - b } // 具体策略的执行者 type Operator struct { strategy IStrategy } // 设置策略 func (op *Operator) setStrategy(strategy IStrategy) { op.strategy = strategy } // 调用策略中的方法 func (op *Operator) calculate(a, b int) int { return op.strategy.do(a, b) }
在这段代码中,定义了策略接口 ISstrategy
,还定义了 add
和 reduce
这两种策略,最后定义了一个策略执行者,可以设置不同的策略并执行,比如:
func TestStrategy(t *testing.T) { op := Operator{} // 设置策略为 加 op.setStrategy(&add{}) result := op.calculate(1, 2) fmt.Println("add:", result) op.setStrategy(&reduce{}) result = operator.calculate(2, 1) fmt.Println("reduce:", result) }
这样就可以随意更换策略,而不影响 Operator
的所有实现
模板模式
模板模式定义一个操作中算法的骨架,将一些步骤延迟到子类中,这种方法可以让子类在不改变一个算法结构的情况下,能重新定义该算法的某些特定步骤
实现上,模板模式将一个类中的公用方法放在抽象类中实现,不能公共使用的方法作为抽象方法,强制子类去实现。这样就做到了将一个类作为一个模板,让开发者去填充需要填充的地方
比如:
type Cooker interface { fire() cooke() outfire() } // 类似于一个抽象类 type CookMenu struct{} func (CookMenu) fire() { fmt.Println("开火") } // 做菜,交给具体的子类实现 func (CookMenu) cooke() { } func (CookMenu) outfire() { fmt.Println("关火") } // 封装具体步骤 func doCook(cook Cooker) { cook.fire() cook.cooke() cook.outfire() } type XiHongShi struct { CookMenu } func (*XiHongShi) cooke() { fmt.Println("做西红柿") } type ChaoJiDan struct { CookMenu } func (ChaoJiDan) cooke() { fmt.Println("做炒鸡蛋") }
在上面这段代码中,把通用的开火和关火交给了抽象父类实现,子类通过结构体嵌套的方式继承了通用方法,再自己实现对应的 cooke() 方法。对应的测试用例为:
func TestTemplate(t *testing.T) { // 做西红柿 xihongshi := &XiHongShi{} doCook(xihongshi) // 做炒鸡蛋 chaojidan := &ChaoJiDan{} doCook(chaojidan) }
行为型模式
行为型模式关注 对象之间的通信,这一类的设计模式中,有代理模式和选项模式
代理模式
代理模式可以为另一个对象提供一个替身或占位符,用来控制对这个对象的访问
比如:
type Seller interface { sell(name string) } // 火车站 type Station struct { stock int // 库存 } func (station *Station) sell(name string) { if station.stock > 0 { station.stock-- fmt.Printf("代理点中:%s买了一张票,剩余:%d \n", name, station.stock) } else { fmt.Println("票已售空") } } // 火车代理点 type StationProxy struct { station *Station // 持有一个火车站对象 } func (proxy *StationProxy) sell(name string) { // 增加一些其他逻辑,比如权限校验 if proxy.station.stock > 0 { proxy.station.stock-- fmt.Printf("代理点中:%s买了一张票,剩余:%d \n", name, proxy.station.stock) } else { fmt.Println("票已售空") } }
在这段代码中,StationProxy
代理了 Station
,代理类中持有被代理类对象,且和被代理类实现了统一接口。代理类主要是为了增加一种控制机制,在 StationProxy
实现的 sell
方法中,可以增加一些其他的逻辑
选项模式
选项模式是 Go 项目开发中经常用到的模式,比如 grpc/grpc-go的 NewServer
函数,uber-go/zap 包的 New
函数,都用到了选项模式
使用选项模式可以创建一个带有默认值的 struct 变量,并选择性的修改其中一些参数的值
Go 语言中不支持给参数设置默认值,为了既能够创建带默认值的实例,又能创建自定义参数的实例,不使用选项模式,一般有两种写法。
第一种方法是,分别创建两个用来创建实例的函数,一个可以创建带默认值的实例,一个可以定制化参数创建实例
const ( defaultTimeout = 10 defaultCaching = false ) type Connection struct { addr string cache bool timeout time.Duration } // 创建一个连接对象 需要路径参数 func NewConnect(addr string) (*Connection, error) { return &Connection{ addr: addr, cache: defaultCaching, timeout: defaultTimeout, }, nil } // 创建一个连接对象,需要路径参数和一些配置参数 func NewConnectWithOptions(addr string, cache bool, timeout time.Duration) (*Connection, error) { return &Connection{ addr: addr, cache: cache, timeout: timeout, }, nil }
这种写法创建一个 Connection
实例,却要实现两个不同的函数,很麻烦,如果 Connection
结构体又增加了新属性,那么也要再编写一个带有这个新属性的构造方法
另一种写法是创建一个带默认值的选项,并用该选项创建实例:
const ( defaultTimeout = 10 defaultCaching = false ) type Connection struct { addr string cache bool timeout time.Duration } type ConnectionOptions struct { Caching bool Timeout time.Duration } // 默认选项 func NewDefaultOptions() *ConnectionOptions { return &ConnectionOptions{ Caching: defaultCaching, Timeout: defaultTimeout, } } // 传入选项结构体和地址 func NewConnect(addr string, opts *ConnectionOptions) (*Connection, error) { return &Connection{ addr: addr, cache: opts.Caching, timeout: opts.Timeout, }, nil }
使用这种方式,虽然只需要一个函数来创建实例,但是调用 NewConnect
函数创建实例时,每次都要先创建一个 ConnectionOptions
结构体,操作起来比较麻烦
上面两种都有各自的缺点,使用选项模式可以更优雅的解决:
const ( defaultTimeout = 10 defaultCaching = false ) type Connection struct { addr string cache bool timeout time.Duration } // 配置选项 type options struct { timeout time.Duration caching bool } // 接口类型 要实现 apply 方法 type Option interface { apply(*options) } // 函数类型起别名 类型是参数为 *options 返回为空的函数 type optionFunc func(*options) func (f optionFunc) apply(o *options) { f(o) } func WithTimeout(t time.Duration) Option { // 把参数为 *options 返回为空的函数类型转换为 optionFunc 类型 return optionFunc(func(o *options) { o.timeout = t }) } func WithCaching(cache bool) Option { return optionFunc(func(o *options) { o.caching = cache }) } // 创建一个连接对象 ... 表示可以有多个 Option 接口类型的参数 func NewConnect(addr string, ops ...Option) (*Connection, error) { options := options { timeout: defaultTimeout, caching: defaultCaching } for _, o := range ops { o.apply(&options) } return &Connection{ addr: addr, cache: options.caching, timeout: options.timeout, }, nil }
在这段代码中,首先定义了 options
结构体,它带有 timeout、caching 两个属性。接下来通过 NewConnect
创建连接,NewConnect
函数首先创建了一个带有默认值的 options
结构体,然后通过传入的 Option
参数,去修改 options
结构体,最后完成创建
在调用时,传入 WithXXX
格式的函数即可完成配置,因为函数返回值是 optionFunc
类型,而 optionFunc
类型又实现了 Option
接口,这就实现了动态设置 options
结构体变量的属性
选项模式有很多有点,例如:支持传递多个参数,在参数发生变化时保持兼容性,支持任意顺序传递参数,支持默认值,方便扩展等等
但是,为了实现选项模式,要增加很多代码,在开发中,要根据实际场景选择是否使用选项模式
选项模式适用的场景有:
-
结构体参数很多,创建结构体时期望创建一个携带默认值的结构体变量,并选择性修改其中一些参数值
-
结构体参数经常变动,变动时又不想修改创建实例的函数,比如:结构体新增一个 retry 参数,但是又不想在 NewConnect 入参列表中添加
retry int
这样的参数声明
如果结构体参数比较少,要慎重考虑要不要采用选项模式
总结
设计模式,是业界沉淀下来的针对特定场景的最佳解决方案,Go 项目常见的有 6 种设计模式,每种设计模式解决某一类场景
汇总成一张表:
参考:
设计模式 | 菜鸟教程 (runoob.com)
Go 语言项目开发实战 -11.Go常用设计模式