Golang技巧之默认值设置的高阶玩法

最近使用 GRPC 发现一个设计特别好的地方,非常值得借鉴。

我们在日常写方法的时候,希望给某个字段设置一个默认值,不需要定制化的场景就不传这个参数,但是 Golang 却没有提供像 PHPPython 这种动态语言设置方法参数默认值的能力。

低阶玩家应对默认值问题

以一个购物车举例。比如我有下面这样一个购物车的结构体,其中 CartExts 是扩展属性,它有自己的默认值,使用者希望如果不改变默认值时就不传该参数。但是由于 Golang 无法在参数中设置默认值,只有以下几个选择:

  1. 提供一个初始化函数,所有的 ext 字段都做为参数,如果不需要的时候传该类型的零值,这把复杂度暴露给调用者;
  2. ext 这个结构体做为一个参数在初始化函数中,与 1 一样,复杂度在于调用者;
  3. 提供多个初始化函数,针对每个场景都进行内部默认值设置。

下面看下代码具体会怎么做

const (
    CommonCart = "common"
    BuyNowCart = "buyNow"
)

type CartExts struct {
    CartType string
    TTL      time.Duration
}

type DemoCart struct {
    UserID string
    ItemID string
    Sku    int64
    Ext    CartExts
}

var DefaultExt = CartExts{
    CartType: CommonCart,       // 默认是普通购物车类型
    TTL:      time.Minute * 60, // 默认 60min 过期
}

// 方式一:每个扩展数据都做为参数
func NewCart(userID string, Sku int64, TTL time.Duration, cartType string) *DemoCart {
    ext := DefaultExt
    if TTL > 0 {
        ext.TTL = TTL
    }
    if cartType == BuyNowCart {
        ext.CartType = cartType
    }

    return &DemoCart{
        UserID: userID,
        Sku:    Sku,
        Ext:    ext,
    }
}

// 方式二:多个场景的独立初始化函数;方式二会依赖一个基础的函数
func NewCartScenes01(userID string, Sku int64, cartType string) *DemoCart {
    return NewCart(userID, Sku, time.Minute*60, cartType)
}

func NewCartScenes02(userID string, Sku int64, TTL time.Duration) *DemoCart {
    return NewCart(userID, Sku, TTL, "")
}

上面的代码看起来没什么问题,但是我们设计代码最重要的考虑就是稳定与变化,我们需要做到 对扩展开放,对修改关闭 以及代码的 高内聚。那么如果是上面的代码,你在 CartExts 增加了一个字段或者减少了一个字段。是不是每个地方都需要进行修改呢?又或者 CartExts 如果有非常多的字段,这个不同场景的构造函数是不是得写非常多个?所以简要概述一下上面的办法存在的问题。

  1. 不方便对 CartExts 字段进行扩展;
  2. 如果 CartExts 字段非常多,构造函数参数很长,难看、难维护;
  3. 所有的字段构造逻辑冗余在 NewCart 中,面条代码不优雅;
  4. 如果采用 CartExts 做为参数的方式,那么就将过多的细节暴露给了调用者。

接下来我们来看看 GRPC 是怎么做的,学习优秀的范例,提升自我的代码能力。

从这你也可以体会到代码功底牛逼的人,代码就是写的美!

GRPC 之高阶玩家设置默认值

源码来自:grpc@v1.28.1 版本。为了突出主要目标,对代码进行了必要的删减。

// dialOptions 详细定义在 google.golang.org/grpc/dialoptions.go
type dialOptions struct {
    // ... ...
    insecure    bool
    timeout     time.Duration
    // ... ...
}

// ClientConn 详细定义在 google.golang.org/grpc/clientconn.go
type ClientConn struct {
    // ... ...
    authority    string
    dopts        dialOptions // 这是我们关注的重点,所有可选项字段都在这里
    csMgr        *connectivityStateManager
    
    // ... ...
}

// 创建一个 grpc 链接
func DialContext(ctx context.Context, target string, opts ...DialOption) (conn *ClientConn, err error) {
    cc := &ClientConn{
        target:            target,
        csMgr:             &connectivityStateManager{},
        conns:             make(map[*addrConn]struct{}),
        dopts:             defaultDialOptions(), // 默认值选项
        blockingpicker:    newPickerWrapper(),
        czData:            new(channelzData),
        firstResolveEvent: grpcsync.NewEvent(),
    }
    // ... ...

    // 修改改选为用户的默认值
    for _, opt := range opts {
        opt.apply(&cc.dopts)
    }
    // ... ...
}

上面代码的含义非常明确,可以认为 DialContext 函数是一个 grpc 链接的创建函数,它内部主要是构建 ClientConn 这个结构体,并做为返回值。defaultDialOptions 函数返回的是系统提供给 dopts 字段的默认值,如果用户想要自定义可选属性,可以通过可变参数 opts 来控制。

经过上面的改进,我们惊奇的发现,这个构造函数非常的优美,无论 dopts 字段如何增减,构造函数不需要改动;defaultDialOptions 也可以从一个公有字段变为一个私有字段,更加对内聚,对调用者友好。

那么这一切是怎么实现的?下面我们一起学习这个实现思路。

DialOption 的封装

首先,这里的第一个技术点是,DialOption 这个参数类型。我们通过可选参数方式优化了可选项字段修改时就要增加构造函数参数的尴尬,但是要做到这一点就需要确保可选字段的类型一致,实际工作中这是不可能的。所以又使出了程序界最高手段,一层实现不了,就加一层。

通过这个接口类型,实现了对各个不同字段类型的统一,让构造函数入参简化。来看一下这个接口。

type DialOption interface {
    apply(*dialOptions)
}

这个接口有一个方法,其参数是 *dialOptions 类型,我们通过上面 for 循环处的代码也可以看到,传入的是 &cc.dopts。简单说就是把要修改的对象传入进来。apply 方法内部实现了具体的修改逻辑。

那么,这既然是一个接口,必然有具体的实现。来看一下实现。

// 空实现,什么也不做
type EmptyDialOption struct{}

func (EmptyDialOption) apply(*dialOptions) {}

// 用到最多的地方,重点讲
type funcDialOption struct {
    f func(*dialOptions)
}

func (fdo *funcDialOption) apply(do *dialOptions) {
    fdo.f(do)
}

func newFuncDialOption(f func(*dialOptions)) *funcDialOption {
    return &funcDialOption{
        f: f,
    }
}

我们重点说 funcDialOption 这个实现。这算是一个高级用法,体现了在 Golang 里边函数是 一等公民。它有一个构造函数,以及实现了 DialOption 接口。

newFuncDialOption 构造函数接收一个函数做为唯一参数,然后把传入的函数保存到 funcDialOption 的字段 f 上。再来看看这个参数函数的参数类型是 *dialOptions ,与 apply 方法的参数是一致的,这是设计的第二个重点。

现在该看 apply 方法的实现了。它非常简单,其实就是调用构造 funcDialOption 时传入的方法。可以理解为相当于做了一个代理。把 apply 要修改的对象丢到 f 这个方法中。所以重要的逻辑都是我们传入到 newFuncDialOption 这个函数的参数方法实现的。

现在来看看 grpc 内部有哪些地方调用了 newFuncDialOption 这个构造方法。

newFuncDialOption 的调用

由于 newFuncDialOption 返回的 *funcDialOption 实现了 DialOption 接口,因此关注哪些地方调用了它,就可以顺藤摸瓜的找到我们最初 grpc.DialContext 构造函数 opts 可以传入的参数。

调用了该方法的地方非常多,我们只关注文章中列出的两个字段对应的方法:insecuretimeout

// 以下方法详细定义在 google.golang.org/grpc/dialoptions.go
// 开启不安全传输
func WithInsecure() DialOption {
    return newFuncDialOption(func(o *dialOptions) {
        o.insecure = true
    })
}

// 设置 timeout
func WithTimeout(d time.Duration) DialOption {
    return newFuncDialOption(func(o *dialOptions) {
        o.timeout = d
    })
}

来体验一下这里的精妙设计:

  1. 首先对于每一个字段,提供一个方法来设置其对应的值。由于每个方法返回的类型都是 DialOption ,从而确保了 grpc.DialContext 方法可用可选参数,因为类型都是一致的;
  2. 返回的真实类型是 *funcDialOption ,但是它实现了接口 DialOption,这增加了扩展性。

grpc.DialContext 的调用

完成了上面的程序构建,现在我们来站在使用的角度,感受一下这无限的风情。


opts := []grpc.DialOption{
    grpc.WithTimeout(1000),
    grpc.WithInsecure(),
}

conn, err := grpc.DialContext(context.Background(), target, opts...)
// ... ...

当然这里要介绍的重点就是 opts 这个 slice ,它的元素就是实现了 DialOption 接口的对象。而上面的两个方法经过包装后都是 *funcDialOption 对象,它实现了 DialOption 接口,因此这些函数调用后的返回值就是这个 slice 的元素。

现在我们可以进入到 grpc.DialContext 这个方法内部,看到它内部是如何调用的。遍历 opts,然后依次调用 apply 方法完成设置。

// 修改改选为用户的默认值
for _, opt := range opts {
    opt.apply(&cc.dopts)
}

经过这样一层层的包装,虽然增加了不少代码量,但是明显能够感受到整个代码的美感、可扩展性都得到了改善。接下来看一下,我们自己的 demo 要如何来改善呢?

改善 DEMO 代码

首先我们需要对结构体进行改造,将 CartExts 变成 cartExts, 并且需要设计一个封装类型来包裹所有的扩展字段,并将这个封装类型做为构造函数的可选参数。


const (
    CommonCart = "common"
    BuyNowCart = "buyNow"
)

type cartExts struct {
    CartType string
    TTL      time.Duration
}

type CartExt interface {
    apply(*cartExts)
}

// 这里新增了类型,标记这个函数。相关技巧后面介绍
type tempFunc func(*cartExts)

// 实现 CartExt 接口
type funcCartExt struct {
    f tempFunc
}

// 实现的接口
func (fdo *funcCartExt) apply(e *cartExts) {
    fdo.f(e)
}

func newFuncCartExt(f tempFunc) *funcCartExt {
    return &funcCartExt{f: f}
}

type DemoCart struct {
    UserID string
    ItemID string
    Sku    int64
    Ext    cartExts
}

var DefaultExt = cartExts{
    CartType: CommonCart,       // 默认是普通购物车类型
    TTL:      time.Minute * 60, // 默认 60min 过期
}

func NewCart(userID string, Sku int64, exts ...CartExt) *DemoCart {
    c := &DemoCart{
        UserID: userID,
        Sku:    Sku,
        Ext:    DefaultExt, // 设置默认值
    }
    
    // 遍历进行设置
    for _, ext := range exts {
        ext.apply(&c.Ext)
    }

    return c
}

经过这一番折腾,我们的代码看起来是不是非常像 grpc 的代码了?还差最后一步,需要对 cartExts 的每个字段包装一个函数。


func WithCartType(cartType string) CartExt {
    return newFuncCartExt(func(exts *cartExts) {
        exts.CartType = cartType
    })
}

func WithTTL(d time.Duration) CartExt {
    return newFuncCartExt(func(exts *cartExts) {
        exts.TTL = d
    })
}

对于使用者来说,只需如下处理:

exts := []CartExt{
    WithCartType(CommonCart),
    WithTTL(1000),
}

NewCart("dayu", 888, exts...)

总结

是不是非常简单?我们再一起来总结一下这里代码的构建技巧:

  1. 把可选项收敛到一个统一的结构体中;并且将该字段私有化;
  2. 定义一个接口类型,这个接口提供一个方法,方法的参数应该是可选属性集合的结构体的指针类型,因为我们要修改其内部值,所以一定要指针类型;
  3. 定义一个函数类型,该函数应该跟接口类型中的方法保持一致的参数,都使用可选项收敛的这个结构体指针作为参数;(非常重要)
  4. 定义一个结构体,并实现 2 中的接口类型;(这一步并非必须,但这是一种良好的编程风格)
  5. 利用实现了接口的类型,封装可选字段对应的方法;命令建议用 With + 字段名 的方式。

按照上面的五步大法,你就能够实现设置默认值的高阶玩法。

如果你喜欢这个类型的文章,欢迎留言点赞!

阅读 2.3k

推荐阅读