Go 项目开发常用设计模式

设计模式就某些编码场景下的最佳实践,用来解决常见的软件设计问题。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{}
}

NewHTTPClientNewMockHTTPClient 都返回了同一个接口类型 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,还定义了 addreduce 这两种策略,最后定义了一个策略执行者,可以设置不同的策略并执行,比如:

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常用设计模式

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值