Golang中如何将公共对象传递给嵌套结构体

Golang中如何将公共对象传递给嵌套结构体 我有以下设置

type Foo struct {
    a int
}

func NewFoo(a int) *Foo {
    return &Foo{a: a}
}

type Bar struct {
    foo *Foo
    b bool
}

func NewBar(a int, b bool) *Bar {
    return &Bar{foo: NewFoo(a), b: b}
}

基本上,Foo 是服务的底层接口,而 Bar 在其之上提供了高级接口。

目前,FooBar 都使用一个全局记录器进行日志记录,这使得为两者配置记录器变得很容易。然而,我想给用户提供指定自己记录器的选项。无论如何,这两种类型应该使用同一个记录器。

我尝试了几种方法,但大多数感觉都不太对。我最好的解决方案如下:

var globalLogger := Logger{}

type Foo struct {
    a int
    logger Logger
}

func NewFoo(a int) *Foo {
    return NewFooWithLogger(a, globalLogger)
}

func NewFooWithLogger(a int, logger Logger) *Foo {
    return &Foo{a: a, logger: logger}
}

type Bar struct {
    foo *Foo
    b bool
    logger Logger
}

func NewBar(a int, b bool) *Bar {
    return NewBarWithLogger(a, b, globalLogger)
}

func NewBarWithLogger(a int, b bool, logger Logger) *Bar {
    return &Bar{foo: NewFooWithLogger(a, logger), b, logger: logger}
}

之所以添加这两个额外函数,是因为设置记录器不是常规用例,因此我不想强迫普通用户为此做任何事情。

我对 Go 还比较陌生。对于更有经验的人来说,我的方法是否可行?


更多关于Golang中如何将公共对象传递给嵌套结构体的实战教程也可以访问 https://www.itying.com/category-94-b0.html

12 回复

但是在这里,你必须检查 Options 结构体中定义的每个选项的值,如果未设置则使用默认值。

有道理。感谢你的意见!

更多关于Golang中如何将公共对象传递给嵌套结构体的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


FWIW:我通常建议通过上下文将日志记录器传递给方法,因为您可以在日志记录器中构建或柯里化值以获取额外的上下文信息。

为什么不试试像这样的方法:Go Playground - The Go Programming Language

依我之见:如果 Foo 实现了一个“底层接口”,那么 Bar 的构造函数就不应该去构造它。

这就是为什么这里的最佳解决方案是使用函数式选项模式,正如 @iignat 所建议的那样。你可以向用户隐藏默认选项的实现,但同时允许在需要时配置这些选项。函数定义中的 ... 模式意味着,你可以传递这些设置,也可以不传递。

感谢您的建议。我喜欢使用带有默认值的选项结构体这个想法。但我不理解的是,为什么要使用多个函数来修改默认值,例如 options ...func(*BarOptions),而不是直接提供一个像 NewBarOptions 这样的新函数来返回默认值,并将调整的责任交给用户在调用 NewBar 之前完成。

这看起来会像这样:

func main() {
    _ = NewBar(2, true)

    o := NewBarOptions()
    o.Logger = Logger{}
    _ = NewBar(2, true, o)
}

这就是为什么这里的最佳解决方案是使用函数式选项模式。

你能澄清一下你所说的“那一点”指的是什么吗?正如上面所述,我确实看到了可选传递额外配置项的好处,但我不理解为什么它们应该作为函数传递。对我来说

NewFoo(1, func(ops *FooOptions) {ops.logger = logger})

比下面的方式更复杂:

ops := FooOptions{logger: logger}
NewFoo(1, ops)

感谢你的建议。我有两点意见:

  1. 我不喜欢在默认的创建函数中设置一个 logger Logger 参数,因为在大多数情况下,默认值就能很好地工作。因此,我不得不让普通用户总是传递 nil,这使得调用变得更加复杂。目前这可能还不算太糟,但我宁愿最终不会出现像 NewFoo(3, nil, nil, nil) 这样的情况。
  2. NewBar 不构造 Foo 似乎是合理的。我的初衷是为普通用户隐藏复杂性。在大多数情况下,他们不需要知道,也不需要关心是否存在 Foo 对象。因此,将其添加到 NewBar 中会使事情对他们来说变得不必要的复杂。我需要再仔细考虑一下这个问题。

我现在的实现看起来像这样。其中的

c := NewFooConfig()
c.Logger = localLogger

目前来看有些过度设计,因为配置中只有一个字段,可以简化为:

c := FooConfig{Logger: localLogger}

然而,如果 FooConfig 将来增加了更多字段,并且这些字段由 NewFooConfig 初始化,那么简化版本就不具备前瞻性了。

除此之外,我认为我已经融合了 @freeformz@iignat 的主要观点:

  1. NewBar 不应该构造 Foo,而应该将其作为输入参数。
  2. NewFooNewBar 都不应该强制用户提供那些有合理默认值的可选参数,但应该保留提供这些参数的选项。

谢谢!

相反,将选项结构体暴露给用户,你会得到类似这样的代码:

你的包:

type Options struct {
  FooLogger *log.Logger
  BarLogger *log.Logger
}

var defaultOptions = &Options{
  FooLogger: globalLogger,
  BarLogger: globalLogger,
}

type foo struct {
  logger *log.Logger
}

type Bar struct {
  foo *foo
  data int
  logger *log.Logger
}

func NewBar(data int, opts *Options) *Bar {
  if opts == nil {
    return &Bar{
        foo: &foo{logger: defaultOptions.FooLogger},
        data: data,
        logger: defaultOptions.BarLogger,
    }

  // 但在这里,你将不得不检查 `Options` 结构体中定义的每个选项的值
  // 如果未设置,则使用默认值。

  if opts.FooLogger != nil {
    defaultOptions.FooLogger = opts.FooLogger
  }

  if opts.BarLogger != nil {
    defaultOptions.BarLogger = opts.BarLogger
  }

  return &Bar{
        foo: &foo{logger: defaultOptions.FooLogger},
        data: data,
        logger: defaultOptions.BarLogger,
    }
}
  • 使用指针看起来不错,因为你可以只检查它们是否为 nil,但一些额外的参数会让人头疼。

而在我的代码中,我不得不这样做,而不是简单地将函数传递给构造函数:

opts := &Options{
  // 设置我想要的。
}

b := NewBar(data, opts)

// 或者不使用选项。

b := NewBar(data, nil)

总而言之,两种方式都可以。这只是不同的模式,你可以使用更适合你目标的方式。

在我看来,函数看起来是更灵活、更清晰的方法。

我会考虑函数选项模式。其优点在于,它允许将多个构造函数合并为一个,并且可以在不破坏客户端代码的情况下进行后续扩展,同时为客户端提供合理的默认值(例如你示例中的 globalLogger)。缺点是代码看起来有点复杂,直到你掌握它为止。

首先,你需要为每个结构体添加两个选项结构体,其中包含所有可设置默认值的选项。

type FooOptions struct {
	logger Logger
}
...
type BarOptions struct {
	logger Logger
	Foo    *Foo
}

然后,只保留 NewFooNewBar 构造函数,如下所示。

// 选项之前的参数 - 本例中的 'a',
// 是那些服务无法工作所必需的参数,
// 并且你作为实现者无法知道其默认值
// (例如,API密钥)
// options - 用于可以设置默认值的参数(如Logger)
func NewFoo(a int, options ...func(*FooOptions)) *Foo {
	// 定义默认值
	ops := &FooOptions{}
	ops.logger = globalLogger

	// 用提供的选项覆盖配置
	for _, option := range options {
		option(ops)
	}

	// 基于最终的选项集配置Foo
	return &Foo{a: a, logger: ops.logger}
}

...

func NewBar(a int, b bool, options ...func(*BarOptions)) *Bar {
	// 定义默认值
	ops := &BarOptions{}
	ops.logger = globalLogger
	ops.Foo = NewFoo(a) // 使用默认Logger的Foo

	// 用提供的选项覆盖配置
	for _, option := range options {
		option(ops)
	}

	// 基于最终的选项集配置Foo
	return &Bar{b: b, logger: ops.logger, foo: ops.Foo}
}

现在,为了方便起见,你可以按如下方式定义选项函数。

func FooWithLogger(logger Logger) func(*FooOptions) {
	return func(ops *FooOptions) {
		ops.logger = logger
	}
}

...
func BarWithLogger(logger Logger) func(*BarOptions) {
	return func(ops *BarOptions) {
		ops.logger = logger
	}
}

func BarWithFoo(foo *Foo) func(*BarOptions) {
	return func(ops *BarOptions) {
		ops.Foo = foo
	}
}

现在你的客户端可以像下面这样使用它。

func main() {
	// 现在你可以使用默认logger和默认Foo创建bar
	_ = NewBar(2, true)

	// 或者提供所有参数
	_ = NewBar(2, false, BarWithLogger(Logger{}), BarWithFoo(NewFoo(3, FooWithLogger(Logger{}))))
}

希望这能有所帮助。

type options {
    logger *log.Logger
}

type optFunc func(*options)

func defaultOptions() *options {
  return &options{
    logger: defaultGlobalLogger,
  }
}

func WithLogger(l *log.Logger) optFunc {
  return func(o *options) {
    o.logger = l
  }
}

type foo struct {
  // something
  logger *log.Logger
}

type Bar struct {
  foo *foo
  data int
  logger *log.Logger
}

func NewBar(data int, opts ... optFunc) *Bar {
  o := defaultOptions()
  for _, fn := range opts {
    fn(o)
  }

  f := &foo{logger: o.logger}
  return &Bar{foo: f, data: data, logger: o.logger}
}

在这种情况下,你可以通过添加导出的函数来实现任意多的默认选项,以允许用户配置行为。但同时,用户也可以简单地调用 NewBar 而不提供任何 optFunc 来使用默认选项,例如:

b := NewBar(1) // 使用默认的全局日志记录器。

// 或者

b := NewBar(1, WithLogger(myCustomLogger)) // 设置自定义日志记录器。

或者,你可以为 fooBar 分别设置日志记录器,例如:

type options struct {
  fooLogger *log.Logger
  barLogger *log.Logger
}

func defaultOptions() *options {
  return &options{
    fooLogger: defaultGlobalLogger,
    barLogger: defaultGlobalLogger,
  }
}

func WithFooLogger(l *log.Logger) optFunc {
  return func(o *options) {
    o.fooLogger = l
  }
}

func WithBarLogger(l *log.Logger) optFunc {
  return func(o *options) {
    o.barLogger = l
  }
}

// 现在我可以这样做:

b := NewBar(1) // foo 和 Bar 都使用默认设置。
b := NewBar(1, WithFooLogger(myCustomFooLogger)) // foo 使用自定义,Bar 使用全局(同样可以仅使用 WithBarLogger 实现)。
b := NewBar(1, WithFooLogger(myCustomFooLogger), WithBarLogger(myCustomBarLogger)) // foo 和 Bar 都使用自定义日志记录器。

最终,你可以添加带有默认参数的新选项,只暴露一个导出的函数,这样任何人都可以使用他们需要的功能。这向用户隐藏了实现细节,提供了更简洁、更灵活的方式来定制他们想要的功能。

你那个向用户暴露设置结构体的例子,也是允许自定义选项的另一种方式。但在那种情况下,每次添加选项时,你都需要在代码中添加检查,以确保用户确实提供了某些内容。这比简单的函数选项模式可读性要差,后者你已经知道默认值,但允许用户自定义它们。

你的方法完全可行,这是Go中处理依赖注入的常见模式。通过提供默认构造函数和可配置构造函数,既保持了API的简洁性,又提供了灵活性。

让我展示一个更完整的示例,包括日志接口定义和使用:

// 定义日志接口
type Logger interface {
    Info(msg string)
    Error(msg string)
}

// 默认实现
type DefaultLogger struct{}

func (l DefaultLogger) Info(msg string)  { fmt.Println("[INFO]", msg) }
func (l DefaultLogger) Error(msg string) { fmt.Println("[ERROR]", msg) }

var globalLogger Logger = DefaultLogger{}

type Foo struct {
    a      int
    logger Logger
}

func NewFoo(a int) *Foo {
    return &Foo{a: a, logger: globalLogger}
}

func NewFooWithLogger(a int, logger Logger) *Foo {
    return &Foo{a: a, logger: logger}
}

func (f *Foo) DoSomething() {
    f.logger.Info("Foo doing something")
}

type Bar struct {
    foo    *Foo
    b      bool
    logger Logger
}

func NewBar(a int, b bool) *Bar {
    return &Bar{
        foo:    NewFoo(a),
        b:      b,
        logger: globalLogger,
    }
}

func NewBarWithLogger(a int, b bool, logger Logger) *Bar {
    return &Bar{
        foo:    NewFooWithLogger(a, logger),
        b:      b,
        logger: logger,
    }
}

func (b *Bar) Process() {
    b.logger.Info("Bar processing")
    b.foo.DoSomething()
}

// 使用示例
func main() {
    // 默认使用全局日志器
    bar1 := NewBar(42, true)
    bar1.Process()

    // 使用自定义日志器
    customLogger := &CustomLogger{}
    bar2 := NewBarWithLogger(100, false, customLogger)
    bar2.Process()
}

// 自定义日志器实现
type CustomLogger struct{}

func (l *CustomLogger) Info(msg string)  { fmt.Println("CUSTOM INFO:", msg) }
func (l *CustomLogger) Error(msg string) { fmt.Println("CUSTOM ERROR:", msg) }

这种模式的优势:

  1. 向后兼容:现有代码无需修改
  2. 可测试性:可以注入模拟日志器进行单元测试
  3. 灵活性:用户可以根据需要选择不同的日志实现

另一种常见的变体是使用选项模式,这在需要配置多个参数时更优雅:

type BarOption func(*Bar)

func WithLogger(logger Logger) BarOption {
    return func(b *Bar) {
        b.logger = logger
        b.foo.logger = logger
    }
}

func NewBarWithOptions(a int, b bool, opts ...BarOption) *Bar {
    bar := &Bar{
        foo:    NewFoo(a),
        b:      b,
        logger: globalLogger,
    }
    
    for _, opt := range opts {
        opt(bar)
    }
    return bar
}

// 使用
bar := NewBarWithOptions(42, true, WithLogger(customLogger))

你的实现已经很好,特别是对于Go新手来说。随着经验积累,你可以根据具体需求选择最适合的模式。

回到顶部