1 建造者模式
本期和大家探讨一下设计模式中的建造者模式.
建造者模式通常应用于对一些成员属性较多的类的构造场景中,由于这些成员属性中有一些在构造过程中是可以选填的,因此导致构造器方法的入参组合会比较灵活多变.
在 JAVA 中,可以通过方法重载的方式,实现同一个构造器方法对应多个不同的入参组合,可以在一定程度上缓解这个问题;但是在 Golang 中,每个方法的入参类型以及组合是确定的,这样就可能导致类的构造器方法数量发生膨胀,产生大量的冗余方法和代码.
针对于这个场景,就很适合使用建造者模式,通过额外引入建造者的角色,使其通过链式调用的方式让用户能够灵活地完成成员属性的组装以及类实例的构造.
建造者模式的优势就是内聚了构造类实例的职责,为构造过程中成员属性的组合提高了更高的灵活度,从而降低了代码的冗余度.
2 代码实现
2.1 经典建造者模式
初步聊完理完概念之后,下面我们一起进入代码实现的环节.
建造者模式对应的类图结构如下所示:
其中包含了两个关键角色:
• 待建造的实例 Instance:这个类中包含了大量需要在构造过程中完成初始化的成员属性,其中一部分字段是选填的,因此导致构造器方法的入参组合非常灵活
• 建造者 InstanceBuilder:建造者暴露出一系列链式调用风格的方法,让用户能够灵活地进行成员属性的设置,最后再调用 Build 方法一次性完成实例的构造
下面给出一个具体的示例,帮助大家形成更直观的认识:
• 假设我们需要构造食物 Food 这个类
• 类中包含了种类 typ、名称 name、重量 weight、品牌 brand、费用 cost 等成员属性
• 成员属性中, typ 和 name 是两个必填字段,其余字段都是选填的
由于选填字段的存在,用户构造 Food 时,构造器方法的入参是灵活多变的,比如只传必填字段 type 和 name 是一种方式,传入 typ、name 和 weight 是一种方式,同时传入 Food 中所包含的五个成员属性也是一种方式.
此时我们如果使用 Golang 中比较推崇的 NewXXX 风格进行 Food 构造器函数的定义,此时构造器就会存在很多种不同的入参组合数,最终我们不得不重复声明多个构造器函数,以应对不同的入参组合.
在这个背景下,我们选择引入 FoodBuilder 这个建造者角色:
• 在 FoodBuilder 中,包含了 Food 在构造过程中所需要关心的所有成员属性
• 此外 FoodBuilder 暴露出一系列属性设置方法,比如 Type、Name、Weight 等,用户可以通过链式调用的方式,很方便地完成初始化属性的设置
• 最后,用户在完成属性设置后调用 Build 方法,基于之前完成的属性配置,完成 Food 实例的构造
该流程对应的类图结构如下:
下面展示具体的代码实现.
首先我们定义出 Food 类:
type Food struct {
// 种类
typ string
// 名称
name string
// 重量
weight float64
// 品牌
brand string
// 价格
cost float64
}
func NewFood(typ string, name string, weight float64, brand string, cost float64) *Food {
return &Food{
typ: typ,
name: name,
weight: weight,
brand: brand,
cost: cost,
}
}
接下来我们定义好 FoodBuilder 类,其中通过 embed Food 的方式,使其包含有 Food 的所有成员属性.
type FoodBuilder struct {
Food
}
func NewFoodBuilder() *FoodBuilder {
return &FoodBuilder{}
}
同时,我们为 FoodBuilder 声明好一系列通过通过链式调用完成 Food 成员属性设值的方法.
func (f *FoodBuilder) Type(typ string) *FoodBuilder {
f.typ = typ
return f
}
func (f *FoodBuilder) Name(name string) *FoodBuilder {
f.name = name
return f
}
func (f *FoodBuilder) Weight(weight float64) *FoodBuilder {
f.weight = weight
return f
}
func (f *FoodBuilder) Brand(brand string) *FoodBuilder {
f.brand = brand
return f
}
func (f *FoodBuilder) Cost(cost float64) *FoodBuilder {
f.cost = cost
return f
}
最后,我们为 FoodBuilder 声明 Build 方法,可以基于用户预设好的成员属性,完成 Food 实例的构造. 在此期间,我们还可以利用这个 Build 方法产生的切面,来对 Food 类中所要求的一些必填字段进行校验,包括 typ 和 name 两个字段.
func (f *FoodBuilder) Build() (*Food, error) {
if f.typ == "" {
return nil, errors.New("miss type info")
}
if f.name == "" {
return nil, errors.New("miss name info")
}
return &Food{
typ: f.typ,
name: f.name,
brand: f.brand,
weight: f.weight,
cost: f.cost,
}, nil
}
下面通过单测,给出使用示例代码:
func Test_Builder(t *testing.T) {
// 创建 Food 建造者实例
fb := NewFoodBuilder()
// 通过链式调用完成属性设置与实例建造
food1, err := fb.Type("苹果").Cost(12.12).Brand("山东红富士").Build()
if err != nil {
t.Error(err)
} else {
t.Logf("food: %+v", food1)
}
// 通过链式调用完成属性设置与实例建造
food2, err := fb.Type("芒果").Name("我是大芒果1号").Cost(30.30).Build()
if err != nil {
t.Error(err)
} else {
t.Logf("food: %+v", food2)
}
}
上述单测代码运行后,输出结果如下:
/Users/didi/my_first_test/builder_test.go:75: miss name info
/Users/didi/my_first_test/builder_test.go:84: food: &{typ:芒果 name:我是大芒果1号 weight:0 brand:山东红富士 cost:30.3}
2.2 Options 模式
接下来大家介绍另一种建造者模式的实现思路,这是我个人更加推崇的一种编程风格:Options 模式.
首先,摆在我们面前的是一个 BigClass 类,里面包含了茫茫多的成员属性,其中相当一部分都是选填的,因此其构造函数的入参组合也同样不胜枚举.
type BigClass struct {
name string
age int
sex string
weight float64
height float64
width float64
fieldA string
fieldB string
fieldC string
// ...
}
此时我们对 BigClass 进行改造,将构造过程中需要关心的成员属性统统收拢聚合在配置项类 Options 当中,然后让 BigClass 直接 embed Options.
type BigClass struct {
Options
}
type Options struct {
name string
age int
sex string
weight float64
height float64
width float64
fieldA string
fieldB string
fieldC string
}
接下来我们定义一个配置函数类: Option,其对应的类型是 func(*Options),通过在入参中接收到 Options 配置项类的指针,使 Option 在运行过程中能够完成对 Options 当中成员属性的赋值修改.
(注意,这里有两个看起来很相似的类大家不要搞混了. 一个是 Options,是为 BigClass 聚合收拢了成员属性的配置项类;另一个是 Option,是配置函数的类型,设计目的的是为了通过 Option 的运行完成 Options 中成员属性的赋值)
type Option func(opts *Options)
下面,我们根据需要在构造过程中关心的成员属性范围,提前声明好一系列配置器方法,统一以 WithXXX 的风格进行命名,在入参中接收用户设置的成员属性值,然后出参为 Option 配置函数的类型,通过闭包的方式,将用户传入的属性值赋值到 Options 配置项类的成员属性当中.
这里我们根据可涉及到的 name、age、sex、weight、height、width、fieldA、fieldB、fieldC 等字段,一一预定好对应的 WithName、WithAge、WithSex、WithWeight、WithHeight、WithWidth、WithFieldA、WithFieldB、WithFieldC 方法,对应代码如下:
func WithName(name string) Option {
return func(opts *Options) {
opts.name = name
}
}
func WithAge(age int) Option {
return func(opts *Options) {
opts.age = age
}
}
func WithSex(sex string) Option {
return func(opts *Options) {
opts.sex = sex
}
}
func WithWeight(weight float64) Option {
return func(opts *Options) {
opts.weight = weight
}
}
func WithHeight(height float64) Option {
return func(opts *Options) {
opts.height = height
}
}
func WithWidth(width float64) Option {
return func(opts *Options) {
opts.width = width
}
}
func WithFieldA(fieldA string) Option {
return func(opts *Options) {
opts.fieldA = fieldA
}
}
func WithFieldB(fieldB string) Option {
return func(opts *Options) {
opts.fieldB = fieldB
}
}
func WithFieldC(fieldC string) Option {
return func(opts *Options) {
opts.fieldC = fieldC
}
}
最后,我们通过一个兜底修复方法 repair,完成构造 BigClass 实例过程中一些缺省值的设置. 比如用户如果没有通过 Option 显式设置 BigClass 中的 name 和 age 字段,则会通过 repair 方法将其设置为兜底的默认值.
func repair(opts *Options) {
if opts.name == "" {
opts.name = "小明"
}
if opts.age == 0 {
opts.age = 20
}
}
下面我们展示一下使用了 Options 模式后, BigClass 构造器函数的定义. 在构造器函数 NewBigClass 的入参中,我们通过 Golang 的语法糖 ... 实现可变长度的 Option list 的传入,在方法执行过程中对 Option list 进行遍历,依次执行每个 Option 完成对 Options 中成员属性的赋值操作,最后再通过兜底的 repair 方法,保证对一些缺省字段也完成设置.
最后,我们返回构造好的 BigClass 实例,如此一来,一个通过 Options 建造者模式实现的实例构造流程就完成了.
func NewBigClass(opts ...Option) *BigClass {
bigClass := BigClass{}
for _, opt := range opts {
opt(&bigClass.Options)
}
repair(&bigClass.Options)
return &bigClass
}
Options 建造者模式的类图结构如下所示:
下面是针对 Options 模式的代码示例展示.
由于语法糖的存在,用户在使用 NewBigClass 方法时,可以非常灵活地选择传入的 Option 种类、个数以及组合,甚至一个 Option 也不传也是可以的,整体的语法显得非常简洁优雅,使用起来也是很方便快捷.
下面我们分别展示不传 Option、传入 WithAge+WithName 组合 以及传入 WithHeight+WithWidth+WithField+WithFieldB 组合后,对 bigClass 的构造流程示例:
func Test_options_builder(t *testing.T) {
bigClass1 := NewBigClass()
bigClass2 := NewBigClass(WithAge(20), WithName("小乌龟"))
bigClass3 := NewBigClass(WithHeight(180), WithWidth(200), WithFieldA("aaa"), WithFieldB("bbb"))
t.Logf("bigClass1: %+v", bigClass1)
t.Logf("bigClass2: %+v", bigClass2)
t.Logf("bigClass3: %+v", bigClass3)
}
上述单测代码执行后,对应的输出结果为:
/Users/didi/my_first_test/option_test.go:8: bigClass1: &{Options:{name:小明 age:20 sex: weight:0 height:0 width:0 fieldA: fieldB: fieldC:}}
/Users/didi/my_first_test/option_test.go:9: bigClass2: &{Options:{name:小乌龟 age:20 sex: weight:0 height:0 width:0 fieldA: fieldB: fieldC:}}
/Users/didi/my_first_test/option_test.go:10: bigClass3: &{Options:{name:小明 age:20 sex: weight:0 height:180 width:200 fieldA:aaa fieldB:bbb fieldC:}}
2.3 Options 场景扩展
这种 Options 建造者模式除了可以应用在构造器场景之外,我个人还总结了另一类适用场景——DB 条件查询.
下面我们通过一个案例加以展示.
首先呢,由于该场景涉及到和 DB(database)的交互,我们统一使用 Golang 中经典的 DB 客户端 Gorm 框架,用于案例中的代码展示:
gorm 开源地址:https://github.com/go-gorm/gorm/
现在,我们在 DB 中存在一个名为 User 的 PO(Persistant Object),类型定义如下:
type User struct {
ID int64 `gorm:"primary_key;column:id;type:bigint(20) unsigned;not null;autoIncrementIncrement:2" json:"id"` // 主键ID
Name string `gorm:"column:name;type:varchar(256);not null" json:"name"` // 名称
Age int64 `gorm:"column:age;type:bigint(20) unsigned;not null" json:"age"` // 名称
CityID int `gorm:"column:city_id;type:int(11) unsigned;not null" json:"city_id"` // 城市ID
Phone string `gorm:"column:phone;type:varchar(512);not null" json:"phone"` // 手机号码
CreateTime time.Time `gorm:"column:create_time;type:timestamp;not null" json:"create_time"` // 创建时间
ModifyTime time.Time `gorm:"column:modify_time;type:timestamp;not null" json:"modify_time"` // 修改时间
}
接下来我们封装好一个 User DAO 模块(data access object),负责代理和 DB 中 User 表有关的交互操作:
type UserDAO struct {
db *gorm.DB
}
func NewUserDAO(db *gorm.DB) *UserDAO {
return &UserDAO{
db: db,
}
}
接下来问题就产生了,我们在查询 user 的时候,可能会使用到多种组合的查询条件.
比如,我们可以通过主键 id 作为查询条件,可以通过名称 name 作为查询条件,可以通过名称 name +年龄 age 作为联合查询条件,也可以通过年龄 age + 城市 cityID + 创建时间create_time 作为联合查询条件,凡此种种,组合数不胜枚举.
这里根据实际业务场景的需要,查询条件的组合可以是多种多样的,假如我们为了支持每一种查询条件组合,都声明一个查询方法,那样的查询方法的数量会急剧膨胀,大量冗余的代码由此滋生,我们也就此在 CRUD 工程师的路上渐行渐远.
该场景下的代码案例展示如下:
// 通过主键 id 查询 user
func (u *UserDAO) GetUser(ctx context.Context, id int64) (*User, error) {
var user User
return &user, u.db.WithContext(ctx).Model(&User{}).Where("id = ?", id).First(&user).Error
}
// 通过名字 name 查询 user
func (u *UserDAO) GetUserByName(ctx context.Context, name string) (*User, error) {
var user User
return &user, u.db.WithContext(ctx).Model(&User{}).Where("name = ?", name).First(&user).Error
}
// 通过名字 name + 年龄 age 查询 user
func (u *UserDAO) GetUserByNameAndAge(ctx context.Context, name string, age int64) (*User, error) {
var user User
return &user, u.db.WithContext(ctx).Model(&User{}).Where("name = ? and age = ?", name, age).First(&user).Error
}
为了规避上面的问题,我们可以选择使用 Options 模式进行优化改造.
我们预先声明好一个 Option 类型,是函数的类型,入参和出参都是 gorm.DB 的指针:
type Option func(db *gorm.DB) *gorm.DB
接下来我们把一系列可能使用到的查询条件都预先做好声明,每次通过闭包的方式接收使用方传入的属性值,然后通过 gorm.DB 链式调用的方式,将对应的筛选条件以 where 语句(或者其他任意我们需要的语法)组装到查询条件语句当中:
func WithID(id int64) Option {
return func(db *gorm.DB) *gorm.DB {
return db.Where("id = ?", id)
}
}
func WithName(name string) Option {
return func(db *gorm.DB) *gorm.DB {
return db.Where("name = ?", name)
}
}
func WithAge(age int64) Option {
return func(db *gorm.DB) *gorm.DB {
return db.Where("age = ?", age)
}
}
func WithCityID(cityID int) Option {
return func(db *gorm.DB) *gorm.DB {
return db.Where("city_id = ?", cityID)
}
}
func WithPhone(phone string) Option {
return func(db *gorm.DB) *gorm.DB {
return db.Where("phone = ?", phone)
}
}
func WithCreateTime(begin, end time.Time) Option {
return func(db *gorm.DB) *gorm.DB {
return db.Where("create_time >= ? and create_time <= end", begin, end)
}
}
完成上述准备工作之后,我们只需要声明好一个通用的查询用户的方法——GetUser,然后在方法的入参中追加 Option list.
接下来在 GetUser 方法的执行过程中,通过遍历 Option list 的方式,把使用方注入的查询条件一一拼接到条件语句中,最后再一步到位完成查询操作.
func (u *UserDAO) GetUser(ctx context.Context, opts ...Option) (*User, error) {
db := u.db.WithContext(ctx).Model(&User{})
for _, opt := range opts {
db = opt(db)
}
var user User
return &user, db.First(&user).Error
}
同样的思路还可以用于数量统计的 Count 方法当中,示例如下:
func (u *UserDAO) CountUser(ctx context.Context, opts ...Option) (int64, error) {
db := u.db.WithContext(ctx).Model(&User{})
for _, opt := range opts {
db = opt(db)
}
var cnt int64
return cnt, db.Count(&cnt).Error
}
最后,我们给出基于 Options 模式实现的 User DAO 模块的使用示例:
func Test_dao(t *testing.T) {
// 连接 db
db, err := gorm.Open(mysql.Open("dsn://xxxx"), &gorm.Config{})
if err != nil {
t.Error(err)
return
}
// 构造 user dao
dao := NewUserDAO(db)
ctx := context.Background()
// 根据id查询
user1, err := dao.GetUser(ctx, WithID(1))
if err != nil {
t.Error(err)
return
}
t.Logf("user1: %+v", user1)
// 根据名称+年龄查询
user2, err := dao.GetUser(ctx, WithName("小洪"), WithAge(18))
if err != nil {
t.Error(err)
return
}
t.Logf("user2: %+v", user2)
// 根据名称+城市+手机号查询
user3, err := dao.GetUser(ctx, WithName("小红"), WithPhone("11822"), WithCityID(1))
if err != nil {
t.Error(err)
return
}
t.Logf("user3: %+v", user3)
// 根据名称+创建时间查询
user4, err := dao.GetUser(ctx, WithName("小张"), WithCreateTime(time.Now().AddDate(0, 0, -7), time.Now()))
if err != nil {
t.Error(err)
return
}
t.Logf("user4: %+v", user4)
}
我想表达的核心就是,这种存在多种可选条件或属性组合的场景,都可以使用 Options 建造者模式,来帮助我们实现代码的优化设计.
3 工程案例
接下来我们找一个应用到建造者模式的工程实践案例进行交流探讨.
我本次选择的案例是 grpc-go 服务端基于 Options 建造器模式完成 grpc Server 实例构造的案例.
grpc-go 开源地址为:https://github.com/grpc/grpc-go
本文走读的源码版本为 v1.54.0
grpc-go 中,会通过一个 Server 类完成对整个服务端模块的抽象. 在构造 Server 类实例的过程中,就采用了我们本次所介绍的 Options 建造者模式.
在 Server 类中,内置了一个 serverOptions 成员字段,构造 Server 实例期间所有需要关心的成员属性字段都被内聚在 serverOptions 当中:
type Server struct {
opts serverOptions
// ...
}
serverOptions 类声明如下,里面包含了大量的配置属性字段,绝大多数都是可选的. 用户在构造 grpc Server 实例时,可以根据自身使用的需要,进行灵活的配置和组合.
type serverOptions struct {
creds credentials.TransportCredentials
codec baseCodec
cp Compressor
dc Decompressor
unaryInt UnaryServerInterceptor
streamInt StreamServerInterceptor
chainUnaryInts []UnaryServerInterceptor
chainStreamInts []StreamServerInterceptor
binaryLogger binarylog.Logger
inTapHandle tap.ServerInHandle
statsHandlers []stats.Handler
maxConcurrentStreams uint32
maxReceiveMessageSize int
maxSendMessageSize int
unknownStreamDesc *StreamDesc
keepaliveParams keepalive.ServerParameters
keepalivePolicy keepalive.EnforcementPolicy
initialWindowSize int32
initialConnWindowSize int32
writeBufferSize int
readBufferSize int
connectionTimeout time.Duration
maxHeaderListSize *uint32
headerTableSize *uint32
numServerWorkers uint32
}
与 serverOptions 所配对出现的,是 grpc-go 中声明的 ServerOption interface 以及 funcServiceOption class,该类会通过 apply 方法将使用方传入的属性值赋值给 serverOptions 中的成员属性.
type ServerOption interface {
apply(*serverOptions)
}
type funcServerOption struct {
f func(*serverOptions)
}
func (fdo *funcServerOption) apply(do *serverOptions) {
fdo.f(do)
}
func newFuncServerOption(f func(*serverOptions)) *funcServerOption {
return &funcServerOption{
f: f,
}
}
下面给出 grpc-go 中提供的几个配置函数示例,包含对 serverOptions 中 writeBufferSize、readBufferSize、initalWindowSize 等成员属性的设置:
func WriteBufferSize(s int) ServerOption {
return newFuncServerOption(func(o *serverOptions) {
o.writeBufferSize = s
})
}
// ...
func ReadBufferSize(s int) ServerOption {
return newFuncServerOption(func(o *serverOptions) {
o.readBufferSize = s
})
}
// ...
func InitialWindowSize(s int32) ServerOption {
return newFuncServerOption(func(o *serverOptions) {
o.initialWindowSize = s
})
}
最后,在 grpc-go Server 的构造器方函数 NewServer 中,传入了 ServerOption list,然后通过遍历执行对应的 apply 方法,实现将使用方传入的属性值依次注入到 Server 内部的 ServerOptions 当中.
func NewServer(opt ...ServerOption) *Server {
opts := defaultServerOptions
// ...
for _, o := range opt {
o.apply(&opts)
}
s := &Server{
// ...
opts: opts,
// ...
}
// ...
return s
}
4 总结
本期和大家探讨了设计模式中的建造者模式. 建造者模式适用于构造流程中存在灵活多变的入参组合的场景之下,本文通过类图展示结合代码实现的方式,向大家介绍了经典建造者模式和Options 模式两种实现方案.