写点什么

Builder 模式在 Go 语言中的应用

作者:江湖十年
  • 2024-08-29
    浙江
  • 本文字数:8112 字

    阅读完需:约 27 分钟

Builder 模式是一种创建型模式,即用来创建对象。


Builder 模式,中文翻译不太统一,有时候被翻译为建造者模式或构建者模式,有时候也被翻译为生成器模式。为了不给读者造成困扰,我还是直接叫它 Builder 模式好了。


《设计模式:可复用面向对象软件的基础》 一书中对 Builder 模式的意图阐明如下:


将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。

经典 Builder 模式

假设我们要建造一个大 House,其结构体定义如下:


// House 代表一个由多个部分构建的复杂对象type House struct {  // 地基  Foundation string  // 墙壁  Walls string  // 屋顶  Roof string}
复制代码


通常我们会定义如下构造函数:


// NewHouse 一个普通的 House 构造函数func NewHouse(foundation, walls, roof string) *House {  return &House{    Foundation: foundation,    Walls:      walls,    Roof:       roof,  }}
复制代码


可以使用构造函数来创建 House


house := NewHouse("Concrete Foundation", "Wooden Walls", "Shingle Roof")fmt.Printf("%+v\n", house)
复制代码


现在,如果我们再为这个 House 增加一些属性的话,NewHouse 的参数列表就会变得很长。并且,如果有些参数是带有默认值的,有些参数是必传的,NewHouse 用起来就会比较别扭。


此时,有经验的读者应该会想到使用 Options 模式。没错,这是一个在 Go 语言中非常流行的设计模式,能够很好的解决可选参数问题。


NOTE:如果你对 Go 的 Options(选项)模式不太熟悉,可以参考我的另一篇文章 《Go 常见设计模式之选项模式》


不过,咱们今天不讲如何使用 Options 模式来解决此类问题,今天来看看如何使用 Builder 模式来解决这个问题。


要使用 Builder 模式,首先我们就要定义一个 Builder 接口:


// Builder 用于构建 House 对象的接口type Builder interface {  BuildFoundation()  BuildWalls()  BuildRoof()  GetResult() *House}
复制代码


Builder 接口声明了构建 House 对象都有哪些行为。


其包含 4 个方法,BuildFoundation 用来构建地基,BuildWalls 用来构建墙壁,BuildRoof 用来构建屋顶,最后还有一个 GetResult 方法用来获取构建完成的 House 对象。


这是 Builder 模式的惯用法,定义若干个 BuildXxx 方法用来构建对象的属性,最后再定义一个 GetXxx 方法用来获取被构建的对象。


我们再定义一个 ConcreteBuilder 结构体,用来实现 Builder 接口:


// ConcreteBuilder Builder 接口的具体实现,用于构建具体的 Housetype ConcreteBuilder struct {  house *House}
// NewConcreteBuilder 创建一个新的 ConcreteBuilder 实例func NewConcreteBuilder() *ConcreteBuilder { return &ConcreteBuilder{house: &House{}}}
// BuildFoundation 构建地基func (b *ConcreteBuilder) BuildFoundation() { b.house.Foundation = "Concrete Foundation"}
// BuildWalls 构建墙壁func (b *ConcreteBuilder) BuildWalls() { b.house.Walls = "Wooden Walls"}
// BuildRoof 构建屋顶func (b *ConcreteBuilder) BuildRoof() { b.house.Roof = "Shingle Roof"}
// GetResult 返回构建完成的 House 对象func (b *ConcreteBuilder) GetResult() *House { return b.house}
复制代码


在典型的 Builder 设计模式中,我们还差一个 Director 对象,定义如下:


// Director 用于控制构建过程的指挥者type Director struct {  builder Builder}
// NewDirector 创建一个新的 Director 实例func NewDirector(builder Builder) *Director { return &Director{builder: builder}}
// Construct 构建 House 的方法func (d *Director) Construct() { d.builder.BuildFoundation() d.builder.BuildWalls() d.builder.BuildRoof()}
复制代码


Director 是一个用于控制构建过程的「指挥者」,它依赖一个 Builder 接口类型的对象,传入不同的 Builder,可以实现不同的行为,这也是依赖注入的思想。


通过 Director 对象,我们可以控制构建 House 的整个过程,Construct 方法就是用来干这个的。


使用 Builder 模式创建一个大 House 流程如下:


// 创建具体的 Builderbuilder := NewConcreteBuilder()
// 创建 Director 并传入具体的 Builderdirector := NewDirector(builder)
// 通过 Director 控制构建过程director.Construct()
// 获取构建的最终产品house := builder.GetResult()
// 输出构建好的 Housefmt.Printf("%+v\n", house)
复制代码


这就是 Builder 模式的经典写法。


这里涉及几个主要角色:


  • Builder 接口是一个用于构建 House 对象的接口,它抽象了构建 House 各个属性的方法,以及一个获取构建完成的 House 对象的方法。

  • ConcreteBuilder 结构体是 Builder 接口的具体实现,其内部保存了 House 结构体。


如果你不嫌麻烦,有人也会这么定义它:


type ConcreteBuilder struct {  // 地基  Foundation string  // 墙壁  Walls string  // 屋顶  Roof string}
复制代码


此时 GetResult 方法应该怎么写就留给你去思考了。


  • Director 结构体是一个指挥者,主要功能就是负责执行生成步骤,即它的核心功能在于 Construct 方法的定义。我们可以根据需要来决定传入不同的 Builder 对象。


可以发现,在标准的 Builder 设计模式中,Builder 的方法通常是没有参数的,每个方法负责一步固定的构建过程。这种设计的好处是过程非常清晰,步骤明确,但它的灵活性较低。


我称这种写法为经典的 Builder 设计模式。


在这种情况下,每个步骤的实现通常是固定的,比如所有使用 ConcreteBuilder 构造的的 House 都使用同样的地基、墙壁和屋顶材料。


要想创建一个具有不同属性的 House,我们就需要实现另外一个新的 ConcreteBuilder1


为了增加灵活性,可以在 Builder 的方法中添加参数,以允许在构建过程中设置不同的属性值。


具体如何实现你可以先思考下,我们在接下来的示例中会进行讲解。

简化版 Builder 模式

经典版本的 Builder 模式代码看起来有点“死板”,因为这是我模仿 Java 版本“抄”过来的。


我们接下来介绍下 Go 语言特色版本 Builder 模式该如何编写。


这次我们以构造一个 Car 为例,定义如下:


// Car 代表一个汽车对象type Car struct {  // 品牌  Brand string  // 型号  Model string  // 颜色  Color string  // 发动机类型  Engine string}
复制代码


定义 CarBuilder 结构体作为用来构建 CarBuilder 对象:


// CarBuilder 用于构建 Car 对象的构建器type CarBuilder struct {  car Car}
// NewCarBuilder 创建一个新的 CarBuilder 实例func NewCarBuilder() *CarBuilder { return &CarBuilder{car: Car{}}}
复制代码


接下来我们就可以为其定义构建方法了:


// SetBrand 设置汽车的品牌func (b *CarBuilder) SetBrand(brand string) *CarBuilder {  b.car.Brand = brand  return b}
// SetModel 设置汽车的型号func (b *CarBuilder) SetModel(model string) *CarBuilder { b.car.Model = model return b}
// SetColor 设置汽车的颜色func (b *CarBuilder) SetColor(color string) *CarBuilder { b.car.Color = color return b}
// SetEngine 设置汽车的发动机类型func (b *CarBuilder) SetEngine(engine string) *CarBuilder { b.car.Engine = engine return b}
// Build 构建并返回最终的 Car 对象func (b *CarBuilder) Build() Car { return b.car}
复制代码


这里的 SetXxx 方法用于设置属性,对标的是前文构建 House 示例中的 BuildXxx 方法。并且 SetXxx 系列方法都支持传入参数,在构建过程中可以更加灵活的设置不同的属性值。这样仅需要定义一个 CarBuilder 对象,不必再定义 CarBuilder2CarBuilder3 ... 了。


NOTE:这里之所以换了一种命名方式,采用 SetXxx,是为了给你演示两种不同的命名风格,这两种写法都比较常见,看到其他人写的代码时你不要疑惑。


Build 方法则对标前文示例中的 GetResult 方法,作用相同。


并且,这个版本的 Builder 模式省略了 Builder 接口的定义。


NOTE:当然有时候为了方便编写测试代码,我们还是需要定义接口的。这里只是演示 Builder 模式在 Go 语言中的最小化实现。


最后,我们还去掉了 Director 这个角色。这可以让代码更加简洁,使用起来也更加灵活。


至此,Go 语言简化版的 Builder 模式就定义完成了。


用法如下:


// 使用 CarBuilder 构建一个 Car 对象car := NewCarBuilder().  SetBrand("Tesla").  SetModel("Model S").  SetColor("Red").  SetEngine("Electric").  Build()
fmt.Printf("Car: %+v\n", car)
复制代码


需要提醒的是,我们不应该对构建方法 SetXxx 的调用顺序做任何假设,所以这样使用也是可以的:


car := NewCarBuilder().  SetEngine("Electric").  SetModel("Model S").  SetBrand("Tesla").  SetColor("Red").  Build()
fmt.Printf("Car: %+v\n", car)
复制代码


我们只需要保证 Build 方法在最后调用即可。


我们还可以在 Build 方法内部做统一的属性值校验。所有通过 SetXxx 设置的属性,都可以在 Build 方法中进行校验。


因为我们对用户调用了几个 SetXxx 方法是无法预知的,所以也就不建议在 SetXxx 方法中参数校验。假如用户少调用了一个方法,那么定义在 SetXxx 方法中的校验就不会生效。所以我们应该在 Build 方法内部做统一的属性值校验。


其实这也能暴露 Builder 模式的一个问题,就是将构建过程(属性赋值操作)分为多个步骤以后,我们构建的对象可能不完整(用户可能少调用了哪个 SetXxx),所以一定要定义一个 Build 方法来获取最终的构建产物(可以校验属性值),这个方法是不可省略的。


讲解完了 Go 语言中我们如何定义和使用 Builder 模式,接下来我再介绍一个实用场景,来加深你对 Builder 模式的理解。

实用场景

我们就以一个 Web Server 为例,演示下在中间件场景中如何使用 Builder 模式。


假设我们有这样一个使用 Gin 框架编写的 HTTP Server 程序:


package main
import ( "net/http"
"github.com/gin-gonic/gin")
func main() { r := gin.Default()
r.GET("/admin", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "message": "Welcome Admin!", }) })
r.GET("/user", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "message": "Welcome User!", }) })
r.Run(":8000")}
复制代码


示例程序有两个接口,分别是 /admin/user,它们都接收 GET 请求并返回一段字符串内容。


现在我们想为这两个接口增加 RBAC 权限控制,/admin 接口只有角色为 admin 的用户才可以访问,/user 接口则角色为 adminuser 的用户都可以访问。


这个需求显然可以使用 Gin 的中间件来实现。


我们最容易想到的方式,就是定义一个用于控制 RBAC 权限的中间件函数:


func RBACMiddleware(allowedRoles []string) gin.HandlerFunc {  return func(c *gin.Context) {    userRole := c.GetHeader("Role") // 从请求头中获取用户角色    for _, role := range allowedRoles {      if role == userRole {        c.Next()        return      }    }    c.AbortWithStatus(http.StatusForbidden)  }}
复制代码


代码非常简单,我就不过多解释了,RBACMiddleware 中间件可以这样使用:


// 为 /admin 路由设置只有管理员角色才能访问adminMiddleware := RBACMiddleware([]string{"admin"})
r.GET("/admin", adminMiddleware, func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "message": "Welcome Admin!", })})
// 为 /user 路由设置普通用户和管理员角色都能访问userMiddleware := RBACMiddleware([]string{"admin", "user"})
r.GET("/user", userMiddleware, func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "message": "Welcome User!", })})
复制代码


现在启动这个 HTTP Server,来测试一下我们的中间件效果:


# 使用 admin 角色访问 /admin 接口$ curl -i -H "Role: admin" "http://localhost:8000/admin"HTTP/1.1 200 OKContent-Type: application/json; charset=utf-8Date: Mon, 26 Aug 2024 06:33:07 GMTContent-Length: 28
{"message":"Welcome Admin!"}
# 使用 user 角色访问 /admin 接口$ curl -i -H "Role: user" "http://localhost:8000/admin"HTTP/1.1 403 ForbiddenDate: Mon, 26 Aug 2024 06:33:03 GMTContent-Length: 0
# 使用 admin 角色访问 /user 接口$ curl -i -H "Role: admin" "http://localhost:8000/user"HTTP/1.1 200 OKContent-Type: application/json; charset=utf-8Date: Mon, 26 Aug 2024 06:32:53 GMTContent-Length: 27
{"message":"Welcome User!"}
# 使用 user 角色访问 /user 接口$ curl -i -H "Role: user" "http://localhost:8000/user"HTTP/1.1 200 OKContent-Type: application/json; charset=utf-8Date: Mon, 26 Aug 2024 06:32:59 GMTContent-Length: 27
{"message":"Welcome User!"}
复制代码


根据客户端的请求日志来看,我们的目的实现了。除了使用 user 角色访问 /admin 接口时,接口返回 403 状态码,没有返回响应体,其他请求都能得到正常响应。


我们再来看下如何使用 Builder 模式来实现 RBACMiddleware 中间件:


// RBACMiddleware RBAC 中间件结构体type RBACMiddleware struct {  allowedRoles []string}
// Middleware 返回一个 Gin 中间件函数func (r *RBACMiddleware) Middleware() gin.HandlerFunc { return func(c *gin.Context) { userRole := c.GetHeader("Role") // 从请求头中获取用户角色 for _, role := range r.allowedRoles { if role == userRole { c.Next() return } } c.AbortWithStatus(http.StatusForbidden) }}
// RBACMiddlewareBuilder 用于构建 RBACMiddleware 的构建器type RBACMiddlewareBuilder struct { rbacMiddleware *RBACMiddleware}
// NewRBACMiddlewareBuilder 创建一个新的 RBACMiddlewareBuilder 实例func NewRBACMiddlewareBuilder() *RBACMiddlewareBuilder { return &RBACMiddlewareBuilder{ rbacMiddleware: &RBACMiddleware{}, }}
// AllowRole 添加允许访问的角色func (b *RBACMiddlewareBuilder) AllowRole(role string) *RBACMiddlewareBuilder { b.rbacMiddleware.allowedRoles = append(b.rbacMiddleware.allowedRoles, role) return b}
// Build 返回构建完成的 RBACMiddlewarefunc (b *RBACMiddlewareBuilder) Build() *RBACMiddleware { return b.rbacMiddleware}
复制代码


有了前文对 Builder 模式的讲解,这段代码就很好理解了。


NOTE:为了增强代码的语义,这里的 AllowRole 方法命名并没有定义为 SetXxxBuildXxx,但作用相同。


使用 Builder 模式实现的 RBAC 中间件用法如下:


// 为 /admin 路由设置只有管理员角色才能访问adminMiddleware := NewRBACMiddlewareBuilder().  AllowRole("admin").  Build().  Middleware()
r.GET("/admin", adminMiddleware, func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "message": "Welcome Admin!", })})
// 为 /user 路由设置普通用户和管理员角色都能访问userMiddleware := NewRBACMiddlewareBuilder(). AllowRole("admin"). AllowRole("user"). Build(). Middleware()
r.GET("/user", userMiddleware, func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "message": "Welcome User!", })})
复制代码


你可以再次尝试使用 curl 命令来测试我们的中间件效果,我就不再进行演示了。


这就是一个典型的 Builder 模式在 Go 语言中的使用场景。


也许你会觉得这个示例程序中第一种中间件实现 func RBACMiddleware(allowedRoles []string) gin.HandlerFunc 更加简单易用。


没错,这不是错觉。但是,我想要说的是,如果在版本迭代的过程中,RBACMiddleware 函数需要增加参数。那么 RBACMiddleware 的所有调用方,就都需要修改。因为函数签名改变了,即使新增的参数可能并不是一个必须参数。


这就增加了代码的维护成本。解决方案就是 Builder 模式。


使用 Builder 模式来实现中间件,如果需要新增参数,我们只需要为 RBACMiddlewareBuilder 增加一个 SetXxx 方法即可。调用方可以根据需要决定是否调用 SetXxx 方法,完全不影响现有调用方的代码。


所以,其实这个场景下 Builder 模式并不是一种不得不用的方式,而是一种可以更为优雅的解决未来需求变更的方式。这就为我们留足了“后门”,即使刚开始代码设计的不够合理,未来我们也可以使用对现有调用方无感知的方式更新我们的代码。


这其实也可以作为套路代码,以后 Gin 框架的中间件代码都可以参考这样设计。


不过,且慢!其实我平常还会使用一种比这个更加“简陋”的写法来实现中间件:


// RBACMiddlewareBuilder RBAC 中间件结构体type RBACMiddlewareBuilder struct {  allowedRoles []string}
// NewRBACMiddlewareBuilder 创建一个新的 RBACMiddlewareBuilder 实例func NewRBACMiddlewareBuilder() *RBACMiddlewareBuilder { return &RBACMiddlewareBuilder{}}
// Build 返回一个 Gin 中间件函数func (b *RBACMiddlewareBuilder) Build() gin.HandlerFunc { return func(c *gin.Context) { userRole := c.GetHeader("Role") // 从请求头中获取用户角色 for _, role := range b.allowedRoles { if role == userRole { c.Next() return } } c.AbortWithStatus(http.StatusForbidden) }}
// AllowRole 添加允许访问的角色func (b *RBACMiddlewareBuilder) AllowRole(role string) *RBACMiddlewareBuilder { b.allowedRoles = append(b.allowedRoles, role) return b}
复制代码


用法如下:


adminMiddleware := NewRBACMiddlewareBuilder().  AllowRole("admin").  Build()
复制代码


所以,其实我们不必纠结于设计模式的具体定义。对于 Builder 模式,在 Go 语言的实践中,我们完全可以视情况而定舍弃如 Builder 接口、Director 指挥者这样的角色,以最小化的代码,来实现 Builder 模式。


此外,其实使用 Options 模式也可以实现这个 RBACMiddleware。Builder 模式与 Options 模式最大的区别就是 Options 模式不能链式调用,不过却可以支持任意数量的参数。你可以自行尝试实现一下,对比下与 Builder 模式的区别。不过,如果你懒得实现,也可以参考 我实现的版本

总结

Builder 模式是一种创建型模式,可以用来创建对象。


Builder 模式中有几个角色,分别是 Builder 接口,ConcreteBuilder 具体实现,以及 Director 指挥者。对这几个角色了解清楚,你就能理解什么是 Builder 模式。


不过,在生产实践中,我们也不要过于“学院派”,可以按照自己的理解来实现 Builder 模式。毕竟设计模式是拿来用的,而不是让我们死记硬背应付考试的。


在实用场景介绍中,我讲解了如何使用 Builder 模式来实现一个 RBAC 权限控制中间件。


实现 Builder 模式时,需要注意的一点是,不能假设用户对 SetXxx 方法的调用顺序,所以代码实现上,一定不能依赖这些 SetXxx 方法的调用顺序。


你认为适配器模式还有哪些应用场景,可以一起交流学习。


本文示例源码我都放在了 GitHub 中,欢迎点击查看。


希望此文能对你有所启发。


延伸阅读


  • 《设计模式:可复用面向对象软件的基础》:https://book.douban.com/subject/34262305/

  • Go 常见设计模式之选项模式:https://jianghushinian.cn/2021/12/12/Golang-常见设计模式之选项模式/

  • Go 常见设计模式之装饰模式:https://jianghushinian.cn/2022/02/28/Golang-常见设计模式之装饰模式/

  • Go 常见设计模式之单例模式:https://jianghushinian.cn/2022/03/04/Golang-常见设计模式之单例模式/

  • 适配器模式在 Go 语言中的应用:https://jianghushinian.cn/2024/08/04/go-design-patterns-adapter/

  • 本文 GitHub 示例代码:https://github.com/jianghushinian/blog-go-example/tree/main/design-patterns/builder


联系我


发布于: 刚刚阅读数: 4
用户头像

江湖十年

关注

野生程序员|公众号:Go编程世界 2018-11-10 加入

资深容器云开发工程师,分享不限于 Go、Python、Docker、K8s 技术。

评论

发布
暂无评论
Builder 模式在 Go 语言中的应用_Go_江湖十年_InfoQ写作社区