Go 中的构建器模式

语言: CN / TW / HK

C T O

   

 

Go Rust Python Istio containerd CoreDNS Envoy etcd Fluentd Harbor Helm Jaeger Kubernetes Open Policy Agent Prometheus Rook TiKV TUF Vitess Arg o Buildpacks CloudEvents CNI Contour Cortex CRI-O Falco Flux gRPC KubeEdge Linkerd NATS Notary OpenTracing Operator  Framework SPIFFE SPIRE     Thanos

Go 中的构建器模式

Operator 条件更新上应用 Go 风格的构建器模式的实际示例

建议我们在某个“框架”内进行编码,即遵循一定的设计模式,这些模式是有效的、可复制的、被广泛认可的、更容易理解和应用的。

为什么要设计模式

为了不那么抽象,我们从实践中的一个例子开始。

通常,我们定义一个 struct ,然后在使用它时对其进行初始化。

type A struct { 
  name string 
} 
a := A { name: “abc”}

这是常见的用法,但不适用于 A 复杂的场景

  • 多层嵌套字段

  • 超过 5 个字段

  • 不同的字段需要不同的默认值

  • 多个可选字段

  • 以上四种的组合

例如在 Kubernetes operator 开发中,我们调用 SetStatusAndCondition 来更新资源信息,其中不仅包含了 metav1 的基本信息。条件,如状态,原因,观察生成等,但也传递回调函数,如 OnSuccess OnError 。围绕 ConditionAndStatus ,我们可以添加其他逻辑,比如发送事件、处理不同状态(成功或失败)的逻辑,等等,然后定义一个类似如下的结构。:point_down:

type ConditionAndStatus struct {
  Condition metav1.Condition
  EventType string  // event type
  Recorder record.EventRecorder, // K8s events recorder 
  Force bool, // is Force update
  OnError func, // err handler
  OnSucces func, // success handler
}

它可以通过通过 new 初始化这个 ConditionAndStatus 来工作,但是当有超过 5 个字段并且其中两个是嵌套的时候,它是累赘和复杂的,这是对非调用者友好的,并且在代码可读性上很差。除非 condition eventRecorder 被实例化,否则调用者不能实例化 ConditionAndStatus 。调用者需要知道所有的实现细节,例如,他们应该知道在错误处理中更新条件时传递 onSucc 方法,即使只有 nil 。此外,不同的用户在不同的地方执行初始化时,每次都需要传入相同的 onSucc onErr

那么我们该如何优化这段代码呢?`

Factory 模式

应用 Factory 模式可能是我们想到的第一个想法,但它不适用于这种情况。

通过 Factory 模式封装一些创建方法。

// Create no false, no default handlers 
func (c ConditionAndStatus) Create(cond metav1.Condition, eventType string, recorder record.EventRecorder) ConditionAndStatus{
  return create(cond, eventType, recorder, false, nil, nil)
}

// Create no default handlers 
func (c ConditionAndStatus) Create(cond metav1.Condition, eventType string, recorder record.EventRecorder, force bool) ConditionAndStatus{
  return create(cond, eventType, recorder, force, nil, nil)
}

// ... more create functions

func (c ConditionAndStatus) Create(cond metav1.Condition, eventType string, recorder record.EventRecorder, force bool, onErr func, onSucc func) ConditionAndStatus{
  return ConditionAndStatus {
    condtion: cond,
    eventType: eventType,
    recorder: recorder,
    force: force,
    onErr: onErr,
    onSucces: onSucc,
  }
}

api 应该易于使用且不易误用——来自 Josh Bloch

然而, Factory 模式实现的 api 并不是那么方便。

显然, create 不是一个选项,因为它需要提供所有参数,传入的参数越多,操作就越困难。此外,当多个参数为同一类型时,很容易出错。

尽管其他 Factory 方法可以通过提供一些默认值来减少传入的参数来降低复杂性,但它们仍然缺乏灵活性。添加参数后,需要修改所有 create * 方法。

Builder模式

Builder 模式是一种设计模式,旨在为面向对象编程中的各种对象创建问题提供灵活的解决方案

来自https://en.wikipedia.org/wiki/Builder_pattern。

构建器模式为灵活简化复杂对象的创建铺平了道路,同时也隐藏了嵌入式类型的一些初始化细节,大大提高了可读性。

Builder接口

builder 接口是两种 builder 模式实现之一, buildxxx 用接口实现各个字段的方法, Builder 通过多态性确定具体的 builder 。请参阅下面的 UML 流程图。

让我们“翻新”以前的 ConditionAndStatus .

type ConditionAndStatusBuilder interface {
  SetCondtion(cond metav1.Condition) ConditionAndStatusBuilder
  SetEventType(evnetType string) ConditionAndStatusBuilder
  SetRecorder(recorder record.EventRecorder) ConditionAndStatusBuilder
  SetForce(force bool) ConditionAndStatusBuilder
  SetOnErr(onErr func()) ConditionAndStatusBuilder
  SetOnSuccess(onSucc func()) ConditionAndStatusBuilder
  Build() ConditionAndStatus
}

type DefaultBuilder struct {
  condition metav1.Condition
  eventType string  // event type
  recorder record.EventRecorder, // K8s events recorder 
  force bool, // is Force update
  onError func, // err handler
  onSucces func, // success handler 
}

func (b *DefaultBuilder) SetCondtion(cond metav1.Condition) DefaultBuilder{
  b.condition = cond
  return b
}
// ... more set funcs

func (b *DefaultBuilder) Build() ConditionAndStatus {
    // set some default values
    b.force = true
  return ConditionAndStatus {
    condition: b.condtion,
    // ...
  }
}

要创建 ConditionAndStatus ,您可以使用注册方法组成所有构建器,然后通过 getByName 获得特定的构建器。

不难得出结论,该模式与 Factory 模式非常相似,因为每个构建器仍然需要创建所有字段或提供默认值。但它确实向前迈出了一步。

  • 当字段确定时,它可以灵活地添加新的生成器,而不需要修改旧的生成器。

  • 它可以控制创建不同字段的顺序。如果字段是相互依赖的,它可以隐藏细节并防止调用者犯错误。

然而,它与 Factory 模式有相同的缺点:一旦添加了字段(在 Builder 接口中添加方法),就需要修改所有构建器。

Pipeline建设者

另一种构建器模式是管道构建器,它更常见。通过上面的接口 builder ,你会发现多 builder 的设计是多余的,而让调用者控制相关字段的分配更合理:唯一的一个 builder 管理所有字段初始化,并通过返回 builder 本身来构建管道在每一步中,最后都组装成我们想要的。

通用调用代码的格式为 obj.Withxxx().Withyyy().Withzzz().build() . 更改 ConditionAndStatus 如下。

type Builder struct {
  condition metav1.Condition
  eventType string  // event type
  recorder record.EventRecorder, // K8s events recorder 
  force bool, // is Force update
  onError func, // err handler
  onSucces func, // success handler 
}

func (b *Builder) WithCondition(cond metav1.Condition) Builder{
  b.condition = cond
  return b
}
// ... more Withxxx funcs

func (b *Builder) Build() ConditionAndStatus {
    // set some default values
    b.force = true
  return ConditionAndStatus {
    condition: b.condtion,
    // ...
  }
}

Pipeline builder 巧妙地避免了添加新字段带来的麻烦。只有一个 builder ,它可以通过添加 With* 方法轻松处理字段添加。

它对现有的调用者绝对更友好。如果参数是可选的,则不需要修改其他调用者的代码。而你只有通过添加新的调用者并 With* 在调用时插入方法来完成它;但是,当需要新参数而没有默认值时,则需要修改所有调用者的代码。

当然,没有一种模式是没有缺陷的,管道构建器也不是。

  • Withxxx() 一旦要构建许多字段,堆积的方法会给调用者带来麻烦并降低可读性。
  • 无法控制字段的初始化顺序。如果存在依赖关系,则需要出色的错误控制和文档来避免错误。

  • 代码不是 Go 风格,而是更多 Java 风格。

可选的构建器模式

如果我们进一步优化管道构建器会怎样?正如 Dave Cheney 在他的 Practical Go 中提到的那样,我们应该以更多 Go 的方式尝试它。

首选 var args []T 参数

深入挖掘,我们看到这里的大部分字段都是可选的,并且可以 var args 自然地定义。如有传入,申报;如果没有,请忽略它。因此, builder/factory 当隐藏实现细节时,只需要一种方法来处理整个对象的创建。

让我们一步一步地把这个想法付诸实践。

将可选字段抽象到构建结构中,而不是将所有字段都放入。要将 ConditionAndStatus 转换为以下结构,其中配置包含所有可选字段。

type ConditionAndStatus struct {
  condition metav1.Condition
  eventType string  // event type
  recorder record.EventRecorder, // K8s events recorder 
  configs  configs // Optional configs
}

type configs struct {
  force bool, // is Force update
  onError func, // err handler
  onSucces func, // success handler 
}

对于配置,使用 func 选项接受一个 *configs 并返回自身以集成到管道中。需要使用以下方法。

type configs struct {
  force bool, // is Force update
  onError func, // err handler
  onSucces func, // success handler 
}

type Option func(*configs)

func ForceUpdate(force bool) Option { return func(c *configs) { c.force = force } }

func OnErr(onErr func()) Option { return func(c *configs) { c.onErr = onErr } }

func OnSuccess(onSucc func()) Option { return func(c *configs) { c.onSuccess = onSucc } }

然后是新的 create 方法,包括必要字段和可选配置的初始化。因为所有可选的配置都是用 func 类型的返回值初始化的,所以整个配置的赋值只能用一个循环来完成。超级简洁!

func Create(condition metav1.Condition, eventType string, recorder record.EventRecorder, os ...Option) error {
  opts := configs{
 force:         false,
 onSuccess:     func() {},
 onError:       nil,
  }
  // Apply all the optional configs
  for _, applyOption := range os {
 applyOption(&opts)
  }
  // check required fields
  
  // update conditions here

  // handle err
  if opts.err != nil {
    return opts.onError()
  }

  // eveutally call success func
  opts.onSuccess()
}

调用方可以根据场景选择可选配置,避免误用。

setCondition(
   metav1.Condition{
      Type:    apis.Ready,
      Status:  metav1.ConditionFalse,
      Reason:  apis.UpstreamUnavailable,
      Message: fmt.Sprintf("Failed to set resources %#v", resource),
   },
   "Update",
   nil,
   // only need onErr func from the optional configs.
   conditionAndStatus.ForOnErr(err),
)

Builder in Kubernetes

Kubernetes 源代码的几乎每个角落都可以看到这种 go 风格的代码。几乎所有的结构被*配置是建立在可选的建造者模式,如 PodApplyConfiguration EventApplyConfiguration 和配置文档你找到包裹。这些逐层嵌套配置获得最终值与一个或多个方法类似于 PodApplyConfiguration 提取。

最后

设计模式是经典的,尽管不是所有的模式都能在 Go 中完美实现。 Builder 无疑是其中最杰出的一个,我们应该最大限度地利用 Optional 管道生成器模式来构建一些配置对象,特别是在设计公共模块时。使用灵活、遵守代码标准和扩展友好的 api ,可以大大减轻升级压力。

感谢你的阅读!