golang 函数选项模式
1、前言
在日常开发工作中有些函数可能需要接受许多参数。其中有一些是必须的,而其他一部分参数是可选的。这样代码的问题也是非常明显的:①当函数参数变的过多时函数会变的臃肿且难以理解。②将来添加更多参数就必须修改函数的定义,这将影响到已有的调用代码。
而函数选项模式的出现则解决了这个问题。换句话说:该模式主要解决,如何实现一个函数的某几个入参的可选输入。
接下来看看比较标准的定义:
(1)选项模式:Options Pattern,又称函数选项模式(Functional Options Pattern)。选项模式是一种优雅的设计模式,用于处理函数的可选参数。它提供了一种灵活的方式,允许用户在函数调用时传递一组可选参数,而不是依赖于固定数量和顺序的参数列表。
(2)Builder模式:Builder Pattern,又称为建造模式,是一种对象构建模式。它的作用是将复杂对象的建造过程抽象出来,使这个抽象过程的不同实现方法可以构造出不同表现的对象。
2、选项模式举例
2.1、函数选项模式写法
函数选项模式的实现一般包含如下几个部分:
- 选项结构体:一般是存储配置参数的结构体;
- 选项函数类型:一般是接受选项结构体参数的函数;
- 定义功能函数:一般是接受0或多个固定参数 和 可变的选项函数参数 的函数;
- 设置选项的函数:定义多个设置选项的函数,用于设置选项;
2.2、一个具体的程序实例
package mainimport "fmt"// 1.选项结构体:用于存储函数的配置参数
type Message struct {// 标题、内容、信息类型(必填参数)title, message, messageType string//账号(可选参数)account stringaccountList []string//token(可选参数)token stringtokenList []string
}// 2.选项函数类型:接收选项结构体参数的函数
type MessageOption func(*Message)// 3.定义的功能函数:接收 0 个或多个固定参数和可变的选项函数参数
func NewMessage(title, message, messageType string, opts ...MessageOption) *Message {msg := &Message{title: title,message: message,messageType: messageType,}for _, opt := range opts {opt(msg)}return msg
}// 4.1 设置选项的函数:定义多个设置选项的函数,用于设置选项
func WithAccount(account string) MessageOption {return func(message *Message) {message.account = account}
}// 4.2 设置选项的函数:定义多个设置选项的函数,用于设置选项
func WithAccountList(accountList []string) MessageOption {return func(message *Message) {message.accountList = accountList}
}// 4.3 设置选项的函数:定义多个设置选项的函数,用于设置选项
func WithToken(token string) MessageOption {return func(message *Message) {message.token = token}
}// 4.4 设置选项的函数:定义多个设置选项的函数,用于设置选项
func WithTokenList(tokenList []string) MessageOption {return func(message *Message) {message.tokenList = tokenList}
}func main() {//1.单账号推送msg1 := NewMessage("来自zs的消息","你好,我是zs!!","单账号推送",WithAccount("111111"),)fmt.Println(*msg1)//2.多账号带token推送//定义可变的参数 注:此处需要谁就添加对应的Withxxx即可。opts := []MessageOption{WithAccountList([]string{"111111", "222222"}),WithTokenList([]string{"token1aaaaa", "token2bbbbb"}),}msg2 := NewMessage("来自zs的消息","你好,我是zs!!","单账号推送",opts...,)fmt.Println(*msg2)//3.单账号带tokenmsg3 := NewMessage("来自zs的消息","你好,我是zs!!","单账号推送",WithAccount("111111"),WithToken("tokenaaaaaa"),)fmt.Println(*msg3)}#输出如下:
{来自zs的消息 你好,我是zs!! 单账号推送 111111 [] []}
{来自zs的消息 你好,我是zs!! 单账号推送 [111111 222222] [token1aaaaa token2bbbbb]}
{来自zs的消息 你好,我是zs!! 单账号推送 111111 [] tokenaaaaaa []}
2.3、分析
上述例子中使用函数选项模式来创建 Message 结构体,可以配置不同属性的结构体出来。
1)首先定义了 Message 结构体,其包含 7 个字段;
2)其次定义 MessageOptionm选项函数类型,用于接收 Message 参数的函数;
3)再次定义 NewMessage 函数,用于创建一个 Message 指针变量,在 NewMessage 函数中,固定参数包括 title、message 和 messageType,它们是必需的参数。然后,通过可选参数 opts ...MessageOption 来接收一系列的函数选项;
4)然后定义了四个选项函数:WithAccount、WithAccountList、WithToken 和 WithTokenList。这些选项函数分别用于设置被推送消息的账号、账号列表、令牌和令牌列表。
最后,在 main 函数中,展示了三种不同的用法。第一个示例是创建单账号推送的消息,通过调用 NewMessage 并传递相应的参数和选项函数(WithAccount)来配置消息。第二个示例是创建多账号推送的消息,同样通过调用 NewMessage 并使用不同的选项函数(WithAccountList)来配置消息。
这种使用函数选项模式的方式可以根据需要消息类型去配置消息的属性,使代码更具灵活性和可扩展性。
3、为什么需要“选项模式” 和 “Builder模式”?
背景:假设贵部门计划基于某第三方开源redis库包装一个适合自己业务的driver包。其中定义了 RedisPoolConfig 类需要你来实现。该类暂时包含 2 个变量(不出意外的话后续肯定会继续增加),根据这 2 个变量做配置项提供给外部传入。含义如下:
字段名称 | 字段解释 | 是否是必填 | 是否有默认值 |
---|---|---|---|
name | 名称 | 是 | 无 |
maxTotal | 最大连接数 | 否 | 8 |
对于稍有研发经验的同学来说,实现需求并不难。于是就有了如下代码:
3.1、直来直去的写法
package mainconst (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 is empty!")}if maxTotal > MaxTotal || maxTotal == 0{maxTotal = MaxTotal}return &RedisPoolConfig{name: name,maxTotal: maxTotal,}, nil
}
func main() {config, err := NewRedisPoolConfig("redis pool", 20)if err != nil {fmt.Println("NewRedisPoolConfig error!")}fmt.Println(*config)}
这种写法看似直观,但问题也很明显。
(1)RedisPoolConfig类的字段会越来越多。NewRedisPoolConfig()函数定义会不断的修改,而且历史代码也要跟着改。
(2)参数校验放在 NewRedisPoolConfig() 中是否合理。
为了解决上述问题,你很快想到了其他方案:
方案一:将配置放到 json、ymal...文件,然后通过三方包将配置映射到 RedisPoolConfig 。
分析:这个方法确实可以,但是有些时候不适应。故此处不考虑。
3.2、引入Withxxx方法
方案二:改造代码引入 Withxxx 方法。以后有新增字段直接在 RedisPoolConfig 增加 WithXX 方法即可,另外把必传字段放到 NewRedisPoolConfig 函数代码可读性会好很多。
注:其实就是暴露Withxxx方法用于设置配置参数。
package mainconst (MaxTotal = 8
)type RedisPoolConfig struct {name string //连接池名称maxTotal int //最大连接数
}func (c *RedisPoolConfig) WithMaxTotal(maxTotal int) {if maxTotal == 0 || maxTotal > MaxTotal {maxTotal = MaxTotal}c.maxTotal = maxTotal
}func NewRedisPoolConfig(name string) (*RedisPoolConfig, error) {if len(name) == 0 {return nil, errors.New("name is empty!")}return &RedisPoolConfig{name: name,}, nil
}func main() {config, err := NewRedisPoolConfig("redis pool")if err != nil {fmt.Println("NewRedisPoolConfig failed!")}config.WithMaxTotal(20)fmt.Println(*config)
}
这段代码看起来似乎ok,但其实依然有有很多问题。例如:
1.通常我们不希望暴露修改 RedisPoolConfig 对象字段的能力,但是现在提供 WithXX 方法是可以直接修改字段值的。
2.参数之间的校验若有依赖关系 ,比如后续增加一个字段 MaxIdle ,它的值要小于 MaxTotal ,他们有依赖关系校验。
于是在一顿搜索下找到了如下两种较好的解决方案。 “Builder模式” 和 “选项模式”。
3.3、“Builder模式”
所谓“Builder模式”就是为要配置的结构体上层又包了一层对应的builder,追加参数、构建结构、校验逻辑都在builder层完成。本质就是加了一层。
更具体的做法:①定义一个组成字段和待配置结构相同的“builder”对象。②在 builder 对象下定义Withxxx方法并用该方法修改builder自身的属性值。③最后调用统一的build方法构建 RedisPoolConfig 对象;参数校验逻辑、构架结构等都在这个方法中完成。
package mainimport ("errors""fmt"
)const (MaxTotal = 8
)type RedisPoolConfig struct {name string //连接池名称maxTotal int //最大连接数
}// ①定义一个字段和带配置结构完全相同的build对象
type RedisPoolConfigBuilder struct {name string //连接池名称maxTotal int //最大连接数
}func NewBuilder() *RedisPoolConfigBuilder {return &RedisPoolConfigBuilder{}
}// ②在build对象下定义Withxxx方法
func (b *RedisPoolConfigBuilder) WithName(name string) *RedisPoolConfigBuilder {b.name = namereturn b
}// ②在build对象下定义Withxxx方法
func (b *RedisPoolConfigBuilder) WithMaxTotal(maxTotal int) *RedisPoolConfigBuilder {b.maxTotal = maxTotalreturn b
}// ③最后调用统一的build方法构建 RedisPoolConfig 对象
func (b *RedisPoolConfigBuilder) build() (*RedisPoolConfig, error) {if len(b.name) == 0 {return nil, errors.New("name is empty!")}if b.maxTotal == 0 || b.maxTotal > MaxTotal {b.maxTotal = MaxTotal}// TODO 可以增加其他校验return &RedisPoolConfig{name: b.name,maxTotal: b.maxTotal,}, nil
}func main() {config, err := NewBuilder().WithName("redispool").WithMaxTotal(20).build()if err != nil {fmt.Println("init config error!")}fmt.Println(*config)
}-------------------输出如下-------------------
{redispool 8}
一句话:就是对原结构体包了个builder层,除返回最终结构外所有逻辑都放在builder层去做。
优点:如果不需要传入 maxTotal 不调 WithMaxTotal 方法即可,实现了按需设置字段值。而且,新增字段也不会影响到历史代码,即只要历史代码不真的需要你新增的字段就不用WithNewxx,实现了完全无感。
缺点:但它也有缺点需要引入额外的对象。
3.4、“选项模式”
3.4.1、实现代码
package mainimport ("errors""fmt"
)const (MaxTotal = 8
)type RedisPoolConfig struct {name string //连接池名称maxTotal int //最大连接数
}func (c *RedisPoolConfig) check() error {if c.maxTotal == 0 || c.maxTotal > MaxTotal {c.maxTotal = MaxTotal}if len(c.name) == 0 {return errors.New("name is empty!")}return nil
}type ConfigOption func(*RedisPoolConfig)func WithName(name string) ConfigOption {return func(config *RedisPoolConfig) {config.name = name}
}func WithMaxTotal(maxTotal int) ConfigOption {return func(config *RedisPoolConfig) {config.maxTotal = maxTotal}
}func NewConfig(opts ...ConfigOption) (*RedisPoolConfig, error) {config := &RedisPoolConfig{}for _, opt := range opts {opt(config)}return config, config.check()
}func main() {config, err := NewConfig(WithName("redispool"),WithMaxTotal(20),)if err != nil {panic(err)}fmt.Println(*config)
}
3.4.2、代码分析
1)首先定义 ConfigOption 变量,类型是func(*RedisPoolConfig)。
2)定义若干个高阶函数 WithName(name string) 、WithMaxTotal(maxTotal int)...,返回值都是 ConfigOption
3)高阶函数返回值 ConfigOption 作为 NewConfig(opts ...Option) 函数参数,该函数遍历 opts 分别调用 WithXX 方法给 RedisPoolConfig 设置字段值。
3.4.3、优点分析
“选项模式” 优点是无需引入额外的类,也支持自定义传参,假设不需要设置 maxTotal 那 NewConfig 不传 WithMaxTotal(8) 即可,使用也是相当灵活。
注:这里会用到 高阶函数 和 闭包 的概念,晚点系统梳理。
参考:
https://zhuanlan.zhihu.com/p/682141451
go 语言的 option 模式 (函数式选项模式) - 沧海一声笑rush - 博客园
Go 函数选项模式(Functional Options Pattern)-腾讯云开发者社区-腾讯云