大部分内容来自于文章,有改动和自己思考time.geekbang.org/column/arti…
why?
在软件开发中,经常会遇到各种各样的编码场景,这些场景往往重复发生,因此具有典型性。针对这些典型场景,我们可以自己编码解决,也可以采取更为省时省力的方式:直接采用设计模式。
设计模式是啥呢?简单来说,就是将软件开发中需要重复性解决的编码场景,按最佳实践的方式抽象成一个模型,模型描述的解决方法就是设计模式。使用设计模式,可以使代码更易于理解,保证代码的重用性和可靠性。
what?
在软件领域,GoF(四人帮,全拼 Gang of Four)首次系统化提出了 3 大类、共 25 种可复用的经典设计方案,来解决常见的软件设计问题,为可复用软件设计奠定了一定的理论基础。
从总体上说,这些设计模式可以分为创建型模式、结构型模式、行为型模式 3 大类,用来完成不同的场景。这一讲,介绍几个在 Go 项目开发中比较常用的设计模式,帮助你用更加简单快捷的方法应对不同的编码场景。
创建型模式
首先来看创建型模式(Creational Patterns),它提供了一种在创建对象的同时隐藏创建逻辑的方式,而不是使用 new 运算符直接实例化对象。
这种类型的设计模式里,单例模式和工厂模式(具体包括简单工厂模式、抽象工厂模式和工厂方法模式三种)在 Go 项目开发中比较常用。我们先来看单例模式。
单例模式
因为单例模式保证了实例的全局唯一性,而且只被初始化一次,所以比较适合全局共享一个实例,且只需要被初始化一次的场景,例如数据库实例、全局配置、全局任务池等。
单例模式又分为饿汉方式和懒汉方式。饿汉方式指全局的单例实例在包被加载时创建,而懒汉方式指全局的单例实例在第一次被使用时创建。你可以看到,这种命名方式非常形象地体现了它们不同的特点。
下面是一个饿汉方式的单例模式代码:
package singleton
type singleton struct{
}
var ins *singleton = &singleton{}
func GetInsOr() *singleton{
return ins
}
需要注意,因为实例是在包被导入时初始化的,所以如果初始化耗时,会导致程序加载时间比较长
懒汉模式的比较优雅的实现,使用once.Do可以确保 ins 实例全局只被创建一次,once.Do 函数还可以确保当同时有多个创建动作时,只有一个创建动作在被执行。
package singleton
import "sync"
type singleton struct {
}
//懒汉模式
var ins *singleton
var once sync.Once
func GetInsOr() *singleton{
once.Do(func(){
ins = &singleton{}
})
return ins
}
工厂模式
工厂模式(Factory Pattern)是面向对象编程中的常用模式。在 Go 项目开发中,你可以通过使用多种不同的工厂模式,来使代码更简洁明了。
简单工厂模式
是最常用、最简单的。它就是一个接受一些参数,然后返回 实例的函数
type Person struct{
Name string
Age int
}
func NewPerson(name string,age int)*Person{
return &Person{
Name: name,
Age: age,
}
}
通过NewPerson创建 Person 实例时,可以确保实例的 name 和 age 属性被设置\
抽象工厂模式
它和简单工厂模式的唯一区别,就是它返回的是接口而不是结构体
通过返回接口,我们还可以实现多个工厂函数,来返回不同的接口实现,例如
type Doer interface {
Do(req *http.Request) (*http.Response, error)
}
type mockHTTPClient struct{}
func (*mockHTTPClient) Do(req *http.Request) (*http.Response, error) {
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.Errorf("QueryUser failed, err: %v", err)
}
}
在简单工厂模式中,依赖于唯一的工厂对象,如果我们需要实例化一个产品,就要向工厂中传入一个参数,获取对应的对象;如果要增加一种产品,就要在工厂中修改创建产品的函数。这会导致耦合性过高,这时我们就可以使用工厂方法模式
工厂方法模式
依赖工厂函数,我们可以通过实现工厂函数来创建多种工厂,将对象创建从由一个对象负责所有具体类的实例化,变成由一群子类来负责对具体类的实例化,从而将过程解耦。(工厂方法模式在golang中讲的不是很清晰,看了几个文章讲的不是很清楚,有些示例跟策略模式傻傻分不清楚,这里不展示示例了)
看java示例里面,特点就是在客户端new对象的时候要知道具体哪个一个工厂,实例化具体的工厂类型
结构型模式
它的特点是关注类和对象的组合
策略模式
策略模式(Strategy Pattern)定义一组算法,将每个算法都封装起来,并且使它们之间可以互换。
在项目开发中,我们经常要根据不同的场景,采取不同的措施,也就是不同的策略。比如,假设我们需要对 a、b 这两个整数进行计算,根据条件的不同,需要执行不同的计算方式。我们可以把所有的操作都封装在同一个函数中,然后通过 if ... else ... 的形式来调用不同的计算方式,这种方式称之为硬编码
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 (operator *Operator) setStrategy(strategy IStrategy){
operator.strategy = strategy
}
func (operator *Operator) calculate(a,b int)int{
return operator.strategy.do(a,b)
}
在上述代码中,我们定义了策略接口 IStrategy,还定义了 add 和 reduce 两种策略。最后定义了一个策略执行者,可以设置不同的策略,并执行,例如:
func TestStrategy(t *testing.T) {
operator := Operator{}
operator.setStrategy(&add{})
res := operator.calculate(1, 2)
fmt.Println("add", res)
operator.setStrategy(&reduce{})
res = operator.calculate(2, 1)
fmt.Println("reduce:", res)
}
可以看到,我们可以随意更换策略,而不影响 Operator 的所有实现。
模版模式
模版模式 (Template Pattern) 定义一个操作中算法的骨架,而将一些步骤延迟到子类中。这种方法让子类在不改变一个算法结构的情况下,就能重新定义该算法的某些特定步骤。
简单来说,模板模式就是将一个类中能够公共使用的方法放置在抽象类中实现,将不能公共使用的方法作为抽象方法,强制子类去实现,这样就做到了将一个类作为一个模板,让开发者去填充需要填充的地方。
//模版模式
type Cooker interface {
fire()
cook()
outfire()
}
type CookMenu struct {
}
func (CookMenu) fire() {
fmt.Println("开火")
}
func (CookMenu) cook() {
}
func (CookMenu) outfire() {
fmt.Println("熄火")
}
func doCook(cooker Cooker) {
cooker.fire()
cooker.cook()
cooker.outfire()
}
type xihongshi struct {
CookMenu
}
func (xihongshi) cook() {
fmt.Println("炒西红柿")
}
type tudou struct {
CookMenu
}
func (tudou) cook() {
fmt.Println("炒土豆")
}
这里来看下测试用例:
func TestTemplate(t *testing.T) {
xihongshi := &xihongshi{}
doCook(xihongshi)
tudou := &tudou{}
doCook(tudou)
}
行为型模式
它的特点是关注对象之间的通信。这一类别的设计模式中,我们会讲到代理模式和选项模式。
代理模式
代理模式 (Proxy Pattern),可以为另一个对象提供一个替身或者占位符,以控制对这个对象的访问。
package proxy
import "fmt"
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,代理类中持有被代理类对象,并且和被代理类对象实现了同一接口。
选项模式
选项模式(Options Pattern)也是 Go 项目开发中经常使用到的模式,例如,grpc/grpc-go 的NewServer函数,uber-go/zap 包的New函数都用到了选项模式。使用选项模式,我们可以创建一个带有默认值的 struct 变量,并选择性地修改其中一些参数的值
import "time"
type Connection struct{
addr string
cache bool
timeout time.Duration
}
const(
defaultTimeout = 10
defaultCaching = false
)
//可修改的选项
type options struct{
timeout time.Duration
caching bool
}
type Option interface {
apply(*options)
}
type optionFunc func( *options)
func (f optionFunc)apply(o *options){
f(o)
}
func WithTimeout(t time.Duration)Option{
return optionFunc(func(o *options) {
o.timeout = t
})
}
func WithCaching(cache bool) Option{
return optionFunc(func(o *options) {
o.caching = cache
})
}
func NewConnect(addr string,opts ...Option)(*Connection,error){
//首先创建默认配置
options := options{
timeout: defaultTimeout,
caching: defaultCaching,
}
for _,o := range opts{
o.apply(&options)
}
return &Connection{
addr: addr,
cache: options.caching,
timeout: options.timeout,
},nil
}
在上面的代码中,首先我们定义了options结构体,它携带了 timeout、caching 两个属性。接下来,我们通过NewConnect创建了一个连接,NewConnect函数中先创建了一个带有默认值的options结构体变量,并通过调用for _, o := range opts { o.apply(&options)}来修改所创建的options结构体变量。
选项模式通常适用于以下场景:
- 结构体参数很多,创建结构体时,我们期望创建一个携带默认值的结构体变量,并选择性修改其中一些参数的值。
- 结构体参数经常变动,变动时我们又不想修改创建实例的函数。例如:结构体新增一个 retry 参数,但是又不想在 NewConnect 入参列表中添加retry int这样的参数声明。
自己总结:各种第三方连接比如数据库,缓存连接用单例模式;不同的对象有相同的属性方法用工厂;同一个对象有不同的属性方法用策略;不同对象大多数属性方法雷同,个别不同用模版模式;结构体参数很多需要灵活修改用选项模式