探究 Go 的高级特性之 【GO 选项模式构建灵活可配置的代码!】

807 阅读9分钟

函数选项模式(Functional Options Pattern)和建造者模式(Builder Pattern)都是在软件设计中常用的模式,用于解决对象构造过程的复杂性和灵活性问题。

函数选项模式(Functional Options Pattern)允许在构造对象时使用可变构造函数,这些构造函数可以接受零个或多个函数作为参数。这种模式的主要优势在于它能够轻松灵活地构建复杂结构,而不会导致构造函数参数列表的过度膨胀。通过将一些配置选项封装成函数,并将这些函数作为参数传递给构造函数,可以简化对象构造的过程,同时提供了更好的可读性和可维护性。

建造者模式(Builder Pattern)则是一种更为经典的对象构建模式,它将对象的构建过程抽象出来,使得不同的实现方法可以构造出不同属性的对象。通过建造者模式,可以将对象的构建细节与其表示分离,从而使得同样的构建过程可以得到不同的表示。这种模式在构建复杂对象时尤为有用,可以避免过多的构造器参数,并提供更好的可扩展性、可读性和可维护性。

为什么需要“选项模式“和”Builder 模式“

假设你需要基于某个 Redis 第三方包封装公司内部的 Redis 驱动包。你的老板定义了 RedisPoolConfig 类,希望你编写代码来实现该类,暂时包含两个变量,并根据这两个变量提供配置项以供外部传入。字段含义如下:

字段名称字段解释是否是必填是否有默认值
name名称
maxTotal最大连接数8

对于有经验的开发人员来说,实现这个需求并不难。最容易想到的思路肯定是编写 NewRedisPoolConfig() 函数,通过传入 name 和 maxTotal 即可完成。

const (
	MaxTotal = 8
)

type RedisPoolConfig struct {
	name     string // 名称
	maxTotal int    // 最大连接数
}

func NewRedisPoolConfig(name string, maxTotal int) (*RedisPoolConfig, error) {
	// 首先检查参数
	if len(name) == 0 {
		return nil, errors.New("Name为空")
	}
	if maxTotal == 0 {
		maxTotal = MaxTotal
	}

	return &RedisPoolConfig{
		name:     name,
		maxTotal: maxTotal,
	}, nil
}

然而,深入思考后会发现这段代码存在两个问题:

  1. 参数校验放 在 NewRedisPoolConfig 函数中可能不够合理。
  2. 如果 RedisConfig 类的字段越来越多,NewRedisPoolConfig 函数将不断增加参数,导致频繁修改。

针对这两个问题,有两个可能的解决方案。第一种是将 RedisPoolConfig 需要的字段放到 JSON、YAML 等文件中,然后通过一些第三方包将配置映射到 RedisPoolConfig。第二种是对代码进行改造,引入 WithXX 方法来解决新增字段和必传字段的问题,以提高代码的可读性和灵活性。

”Builder 模式“

首先创建一个"builder"对象,然后使用builder对象的WithXX方法来修改属性的值,在最后调用提供的build方法来构建RedisPoolConfig对象。以下是相应的代码示例:

const (
	MaxTotal = 8
)

// RedisPoolConfig 连接池配置
type RedisPoolConfig struct {
	name     string // 名称
	maxTotal int    // 最大连接数
}

type RedisPoolConfigBuilder struct {
	name     string // 名称
	maxTotal int    // 最大连接数
}

func NewBuilder() *RedisPoolConfigBuilder {
	return &RedisPoolConfigBuilder{}
}

func (b *RedisPoolConfigBuilder) WithName(name string) *RedisPoolConfigBuilder {
	b.name = name
	return b
}

func (b *RedisPoolConfigBuilder) WithMaxTotal(maxTotal int) *RedisPoolConfigBuilder {
	b.maxTotal = maxTotal
	return b
}

func (b *RedisPoolConfigBuilder) build() (*RedisPoolConfig, error) {
	if len(b.name) == 0 {
		return nil, errors.New("name为空")
	}
	if b.maxTotal == 0 {
		b.maxTotal = MaxTotal
	}
	// TODO: 可以增加其他的参数校验和设置

	return &RedisPoolConfig{
		name:     b.name,
		maxTotal: b.maxTotal,
	}, nil
}

func TestBuilder(t *testing.T) {
	c, err := NewBuilder().
		WithName("test").
		WithMaxTotal(10).
		build()
	if err != nil {
		panic(err)
	}

	fmt.Printf("c=%v\n", *c)
}

上述代码的输出如下:

c={test 10}

这段代码非常简单,RedisPoolConfigBuilder类提供了WithXX方法来设置字段值,build方法用于校验参数并构建RedisPoolConfig对象。在使用时,按照上面的代码编写即可。

如果你不需要传入maxTotal,只需不调用WithMaxTotal方法即可。Builder模式的好处在于按需设置字段值,最后一次性创建对象。然而,它引入了额外的对象作为构建器,可能会带来一些额外的开销。

选项模式

const (
	MaxTotal = 8
)

// RedisPoolConfig 连接池配置
type RedisPoolConfig struct {
	name     string // 名称
	maxTotal int    // 最大连接数
}

func (c *RedisPoolConfig) check() error {
	if c.maxTotal == 0 {
		c.maxTotal = MaxTotal
	}
	if len(c.name) == 0 {
		return errors.New("name为空")
	}

	return nil
}

type Option func(option *RedisPoolConfig)

func WithMaxTotal(maxTotal int) Option {
	return func(options *RedisPoolConfig) {
		options.maxTotal = maxTotal
	}
}

func WithName(name string) Option {
	return func(options *RedisPoolConfig) {
		options.name = name
	}
}

func NewConfig(opts ...Option) (*RedisPoolConfig, error) {
	c := &RedisPoolConfig{}
	for _, opt := range opts {
		opt(c)
	}

	return c, c.check()
}

func TestOption(t *testing.T) {
	c, err := NewConfig(WithName("test"), WithMaxTotal(8))
	if err != nil {
		panic(err)
	}

	fmt.Println(c)
}

上述代码的输出如下:

&{test 8}

对于上述代码的解释:

  1. 首先定义了Option类型,是一个函数类型func(option *RedisPoolConfig)。
  2. 定义了几个高阶函数,比如WithName和WithMaxTotal,它们的返回类型都是Option。
  3. 返回的Option作为NewConfig函数的参数,NewConfig函数遍历opts,并依次调用WithXX方法来设置RedisPoolConfig的字段值。

"选项模式"的优点是无需引入额外的类,并且支持自定义传参。如果不需要设置maxTotal,只需在NewConfig函数中不传入WithMaxTotal选项即可。使用起来也非常灵活。

”选项模式“优化 Update 方法

通过使用"选项模式",我们可以对Update方法进行优化,减少方法重复和提高代码复用性。以下是对应的代码示例:

type UserTaskRepo interface {
	Update(ctx context.Context, query *UserTaskQueryOption, opts ...UpdateOption) (int64, error) // 更新数据
}

type UserTaskRepoImpl struct{}

func NewUserTaskRepoImpl() *UserTaskRepoImpl {
	return &UserTaskRepoImpl{}
}

func (d *UserTaskRepoImpl) Update(ctx context.Context, query *UserTaskQueryOption, opts ...UpdateOption) (int64, error) {
	if len(opts) == 0 {
		return 0, errors.New("opts为空,暂无数据更新")
	}

	m := make(map[string]interface{})
	for _, v := range opts {
		v(m) // 调用 opt 为 map 设置值
	}

	// 省略数据库更新操作,query 对象字段用于更新操作的检索条件,比如:where id = query.ID
	return 10, nil
}

type UpdateOption func(op map[string]interface{})

// WithFinishTime 更新完成时间
func WithFinishTime() UpdateOption {
	return func(opt map[string]interface{}) {
		opt["finish_time"] = time.Now().UnixMilli()
	}
}

// WithStatus 更新状态
func WithStatus(status int) UpdateOption {
	return func(opt map[string]interface{}) {
		opt["status"] = status
	}
}

type UserTaskQuery struct {
	ID  string
	IDs []string
}

func TestUserTaskRepo(t *testing.T) {
	row, err := NewUserTaskRepoImpl().Update(context.TODO(), UserTaskQuery{ID: "xxx"}, WithFinishTime(), WithStatus(1))
	if err != nil {
		panic(err)
	}
	fmt.Printf("本次更新行数 row = %d", row)
}

通过将不同的更新选项抽象为为UpdateOption函数,可以传递不同的选项来调用Update方法。例如,假设员工A完成了任务需要更新完成时间和状态,可以使用WithFinishTime()WithStatus(1)选项;如果只需要更新状态,可以只使用WithStatus(1)选项。这种方法非常灵活,并且可以满足95%的业务场景。

通过使用"选项模式"来对代码进行收敛,可以减少重复的代码,提高代码复用性。"选项模式"在许多业务场景中都得到了广泛使用。

对 UserTaskRepo 类的思考

Repo 方法多了开发者的使用和维护成本也会增加。

package options

import (
	"context"
	"errors"
	"time"
)

type UserTaskRepo interface {
	Create(tasks ...*UserTask) (int64, error)                                                       // 插入数据
	Delete(ids []string) (int64, error)                                                             // 删除数据
	UpdateMap(ctx context.Context, query *UserTaskQueryOption, opts ...UpdateOption) (int64, error) // 更新数据
	FindOne(ctx context.Context, query *UserTaskQueryOption) (*UserTask, bool, error)               // byid 查询、by其他字段查询
	FindMany(ctx context.Context, query *UserTaskQueryOption) ([]*UserTask, error)                  // byids 查询...
	FilterUserTask(ctx context.Context, query *UserTaskFilterOption) ([]*UserTask, int64, error)    // 过滤,针对 web 端口复杂的业务查询,比如排序、过滤、分页、权限等
}

// UserTask ..
type UserTask struct {
	ID         int64 `gorm:"column:id;primarykey"` // 主键id
	Status     int   `gorm:"column:status"`        // 状态
	FinishTime int64 `gorm:"column:finish_time"`   // 完成时间
}

type UserTaskQueryOption struct {
	ID  string
	IDs []string
}

type UserTaskFilterOption struct {
	IDs            []string
	Sort           string
	Page, PageSize int
}

type UpdateOption func(op map[string]interface{})

// WithFinishTime 更新完成时间
func WithFinishTime() UpdateOption {
	return func(opt map[string]interface{}) {
		opt["finish_time"] = time.Now().UnixMilli()
	}
}

// WithStatus 更新状态
func WithStatus(status int) UpdateOption {
	return func(opt map[string]interface{}) {
		opt["status"] = status
	}
}

type UserTaskRepoImpl struct {
}

func NewUserTaskRepoImpl() *UserTaskRepoImpl {
	return &UserTaskRepoImpl{}
}

UserTaskRepoImpl

// UpdateMap 用于更新数据
func (u *UserTaskRepoImpl) UpdateMap(ctx context.Context, query *UserTaskQueryOption, opts ...UpdateOption) (int64, error) {
	// 构建更新操作的选项
	updateOpts := make(map[string]interface{})
	for _, opt := range opts {
		opt(updateOpts)
	}

	// 在这里编写具体的更新数据的逻辑
	// 根据 query 条件查询数据,并将 updateOpts 中的字段进行更新

	// 返回更新数据的结果
	return 0, nil // 返回更新后的记录数和可能的错误
}

UserTaskFilterOption 和 UserTaskQueryOption 是两个结构体,用于封装不同类型的查询条件,以供在数据存储层进行查询、过滤和排序等操作时使用。它们的作用如下:

  1. UserTaskFilterOption:用于复杂的 web 端业务查询操作。当需要进行排序、分页和其他复杂的条件筛选时,可以使用 UserTaskFilterOption 封装查询条件。例如,可以通过 Sort 字段指定排序方式,Page 和 PageSize 字段指定要查询的页码和每页的记录数,以及其他可能需要的字段用于过滤和筛选数据。
  2. UserTaskQueryOption:用于简单的查询操作。当只需要根据几个简单的条件进行查询时,可以使用 UserTaskQueryOption 封装查询条件。例如,可以通过 ID 字段查询特定的记录,或者通过 IDs 字段查询多个指定的记录。

这两个结构体的设计目的是为了减少方法参数以及避免方法签名的频繁变更。通过将查询条件封装在结构体中,可以更灵活地处理不同类型的查询需求,同时在需要修改或添加查询条件时,无需修改方法签名,减少了对调用方的影响。

func (d *UserTaskDao) query(query *UserTaskQueryOption) *gorm.DB {
	tx := &gorm.DB{}
	if len(query.IDs) != 0 {
		tx = tx.Where("id in ?", query.IDs)
	}
	if query.ID != 0 {
		tx = tx.Where("id = ?", query.ID)
	}
	// TODO 可以增加其他的条件判断
	
	return tx
}

总结

总结起来,“选项模式”和“Builder模式”的区别主要体现在以下几个方面:

  1. 使用方式:在“选项模式”中,可以通过传入一个或多个可选参数函数,并在下游进行适当的调用来修改对象的选项。而“Builder模式”通过调用一个或多个方法逐步设置对象的属性。
  2. 适用场景:对于选项较少且可能有默认值的情况,适合使用“选项模式”。而对于属性较多或需要复杂逻辑的情况,适合使用“Builder模式”。
  3. 创建对象的方式:在“选项模式”中,可以在创建对象时一次性传入所有的选项,并在对象内部进行处理。而“Builder模式”需要先创建一个构建者对象,然后逐步调用方法设置属性,最后调用一个build方法来校验和生成对象。

需要注意的是,根据具体情况选择适合的模式是很重要的。如果选项较少且简单,使用“选项模式”可以使代码更简洁和灵活。如果属性较多或需要复杂逻辑,而且需要确保对象的完整性和一致性,使用“Builder模式”可以提供更好的可读性和可维护性。