深入 GO 选项模式「附详细案例」

5,044 阅读10分钟

我列一些我写过的设计模式文章,若有兴趣可以点链接看看。

深入设计模式之适配器模式「附详细案例」 - 掘金

深入设计模式之工厂模式「附详细案例」 - 掘金

设计模式学习之路-策略模式「附详细案例」 - 掘金

责任链模式,一看就会「GO版本框架讲解+详细案例」 - 掘金

写作背景

为什么会写“选项模式”?网上很多文章都在写,看了一圈都没有我想要的文章,能把“选项模式”融入到日常开发中(后面会举开发中遇到的实际问题,如何解决了我在开发中遇到的问题)。另外写“选项模式”不得不提“Builder模式”,后文会对比这 2 个模式的优缺点。

基本概念

函数选项模式(Functional Options Pattern),也称为选项模式(Options Pattern),并不是设计模式中的一种,它允许你使用可变构造函数构建复杂结构,该构造函数可以接受零个或多个函数作为参数。

Builder 模式(英:Builder Pattern)又名:建造模式,是一种对象构建模式。它可以将复杂对象的建造过程抽象出来,使这个抽象过程的不同实现方法可以构造出不同表现(属性)的对象。

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

假设你准备基于某 Redis 三方包封装公司内部 redis driver 包。你老大定义了 RedisPoolConfig 类,请你编写代码实现 RedisPoolConfig 类,该类暂时包含 2 个变量,根据这 2 个变量做配置项提供给外部传入。含义如下:

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

对于稍微有经验的研发同学,要实现需求并不难。最容易想到的思路肯定是下面这段代码,提供 NewRedisPoolConfig() 函数传入 name 和 maxTotal 即可。

const (
	MaxTotal = 8
)

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

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

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

如果你深入思考这段代码会有 2 个问题

1、  参数校验在 NewRedisPoolConfig 函数 是否合理。

2、  如果 RedisConfig 类 字段越来越多,NewRedisPoolConfig 函数 不断增加参数会被频繁修改。

解决这 2 个问题的方案你可能也会很快想到。

方案一:

把 RedisPoolConfig 需要的字段放到 json、ymal...文件,直接通过网上的一些三方包把配置映射到 RedisPoolConfig 这个问题不就解决了?暂不考虑这个方案。

方案二:

把代码稍微改造下,引入 WithXX 方法,以后有新增字段 RedisPoolConfig 增加 WithXX 方法即可,另外把必传字段放到 NewRedisPoolConfig 函数代码可读性会好很多,毕竟这类场景必须传字段会比较少。经过一波改造代码如下:

const (
	MaxTotal = 8
)

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

func (c *RedisPoolConfig) WithMaxTotal(maxTotal int) {
	if maxTotal == 0 {
		maxTotal = MaxTotal
	}

	c.maxTotal = maxTotal
}

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

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

这段代码看上去没啥问题很完美,如果你把代码给你同事 review,你同事可能会再给你提 2 个需求:

1、  不希望外部能直接操作 RedisPoolConfig 对象字段,比如修改字段值,但是现在提供 WithXX 方法是可以直接修改字段值的。

2、  参数之间的校验若有依赖关系 ,比如后续增加一个字段 MaxIdle ,它的值要小于 MaxTotal ,他们有依赖关系校验。

经过你不断的 google、百度、思考 你可能会有 2 种较好解决方案。

第一种方案 ”Builder 模式“

第一个方案引入 “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)
}

上段代码的输出如下:

=== RUN   TestBuilder
c={test 10}
--- PASS: TestBuilder (0.00s)
PASS

这段代码非常简单 RedisPoolConfigBuilder 类提供 WithXX 方法设置字段值,build 方法校验参数、构造 RedisPoolConfig 对象。使用的时候按照下面代码写即可:

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

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

如果你不需要传入 maxTotal ,不调 WithMaxTotal 方法即可,它的好处就是按需设置字段值,最后一次性创建对象。但它也有缺点需要引入额外的对象。

第二种方案 ”选项模式“

好了我们讲完 “Builder模式” 实现的 RedisPoolConfig 类需求,下面该研究下如何用 “选项模式” 实现,直接上代码

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)
}

上段代码打印如下:

=== RUN   TestOption
&{test 8}
--- PASS: TestOption (0.00s)
PASS

解释下上面这段代码:

1、  首先定义 Option 变量,类型是func(option *RedisPoolConfig)。

2、  定义若干个高阶函数 WithName(name string) 、WithMaxTotal(maxTotal int)...,返回值都是 Option。

3、  高阶函数返回值 Option 作为 NewConfig(opts ...Option) 函数参数,该函数遍历 opts 分别调用 WithXX 方法给 RedisPoolConfig 设置字段值。

这里会用高阶函数和闭包概念,若不了解请自己百度。

“选项模式” 优点是无需引入额外的类,也支持自定义传参,假设不需要设置 maxTotal 那 NewConfig  不传 WithMaxTotal(8) 即可,使用也是相当灵活。

实用场景

我总结了几个实用场景:

1 、结构体字段较多,创建对象时需要携带默认值,并且支持修改某些参数的值。

2、 函数参数较多,为了满足业务开发同学在函数、方法中不断扩展字段、或不断新增方法、函数导致代码难以维护。

3、函数或方法期望自定义传参,只有使用的参数能传入。

问题 2 在开发中很常见,给大家举一个案例;

image.png 这段代码有几类操作:

1、  insert 数据到 DB;

2、  更新 DB 数据;

3、  删除 DB 数据;

4、  ByID 和 ByIDs 查询;

5、  Filter 复杂场景检索数据;

但你看代码 interface 定义的方法却有 10 多个,随着业务增加可能会更多。

”选项模式“优化 Update 方法

interface 有 3 个 Update 方法,若这个模型新增需求,对于使用者来说很难分清楚是新增方法,还是仔细研究应该用哪一个,如果三个方法都类似仅有细微差异无法复用就只能新增了(我截图中的例子三个方法都只有细微差异的)。用选项模式先把方法抽象下,看代码:

type UserRepo interface {
	UpdateMap(ctx context.Context, query *UserQueryOption, opts ...UpdateOption) (int64, error) // 更新数据
}

type UserRepoImpl struct {
}

func NewUserRepoImpl() *UserRepoImpl {
	return &UserRepoImpl{}
}

func (d *UserRepoImpl) UpdateMap(ctx context.Context, query *UserQueryOption, 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 UserQuery struct {
	ID  string
	IDs []string
}

UserRepo 只提供 UpdateMap  入参是 context、UserQuery(查询相关字段)、UpdateOption 更新的可选参数,如果不知道 UpdateOption 为啥是 map?学习下gorm。

下面是收敛后 UpdateMap 使用姿势,假设员工A完成了任务需要更新完成时间和状态,opts 传  WithFinishTime(), WithStatus(1) 即可,假设不需要更新完成时间只需要更新状态 opts 传 WithStatus(1) 即可,非常的灵活。按照我的经验收敛成 UpdateMap 方法已经能满足 95% 的业务场景了,剩下的场景针对需求定制一些接口就可以了。

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

到这里 Update 方法的优化就讲完了,当然“选项模式”在我们业务中也在其他场景广泛使用,这里就不啰嗦啦。

对 UserRepo 类的思考

你遇到过类似于 XXRepo 类?真的需要这么多方法吗?方法多了开发者的使用和维护成本也会增加。我进行一些抽象

package options

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

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

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

type UserQueryOption struct {
	ID  string
	IDs []string
}

type UserFilterOption 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 UserRepoImpl struct {
}

func NewUserRepoImpl() *UserRepoImpl {
	return &UserRepoImpl{}
}

// TODO UserRepoImpl 方法留给大家去思考

另外:细心的研发同学可能发现我用了 UserQuery 、UserFilterOption 类,我解释下这两个类主要用于 SQL where 后的条件拼接,对于 Update、Select  简单查询我会把参数封装在 UserQuery 中,比如: id、ids 等。对于复杂的 web 端业务查询操作,我会把参数封装在 UserFilterOption 中,比如:排序、分页...有些场景检索条件较多十来个字段都有可能。这两个类是为了减少方法的参数,防止需求变更导致方法变更。这 2 个类也是外部按需传入,内部用 if 判断取值即可。举一个例子:

func (d *UserDao) query(query *UserQueryOption) *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
}

那有人会问了,UserQuery 、UserFilterOption 为啥不用“选项模式”呢?其实也是可以,但是没必要,直接定义类用“字面量”方式初始化并且设置字段值更简单了噻。

总结

“选项模式“和“Builder 模式“的区别

1 、“选项模式“是通过传入一个或多个函数并且在下游调用这些函数来修改对象的选项,而“Builder 模式“是通过调用一个或 多个方法来设置对象的属性。

2 、“选项模式“适用于对象的选项比较少,或者有一些默认值,而“Builder 模式“适用于对象的属性 比较多,  或者需要一些复杂的逻辑。

3 、“选项模式“可以在创建对象的时候一次性传入所有的选项,而“Builder 模式“需要先创建一个构 建者对象,然后逐步调用方法,最后再调用一个 build 方法校验和生成对象。

大家根据自己的业务场景来选择使用吧。按照我个人的习惯我比较倾向用“选项模式”,不额外引入新类。

思考题

1、  你们项目中有类似于我举例的 UserRepo 类吗?你们是怎么解决的呢?

2、  你们项目中有使用“选项模式”吗?用于哪些场景?

参考资料:

zh.wikipedia.org/wiki/%E7%94…

Functional Options Pattern in Golang - Michal Zalecki

公众号地址:

mp.weixin.qq.com/s/hGJRn3-l7…