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

从别人的代码中吸收营养!让本身成长git

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

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

低阶玩家应对默认值问题

以一个购物车举例。好比我有下面这样一个购物车的结构体,其中 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 若是有很是多的字段,这个不一样场景的构造函数是否是得写很是多个?因此简要概述一下上面的办法存在的问题。markdown

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

接下来咱们来看看 GRPC 是怎么作的,学习优秀的范例,提高自个人代码能力。app

从这你也能够体会到代码功底牛逼的人,代码就是写的美!函数

GRPC 之高阶玩家设置默认值

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

// 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 + 字段名 的方式。

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

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

我的公众号:dayuTalk

GitHub:github.com/helei112g

相关文章
相关标签/搜索