生成器模式-代码的艺术系列(一)

2021-06-24 11:20:16 +08:00
 xiaoxuz

前言

看代码"文档",学设计模式

网上讲解设计模式都是基于现实生活场景举例,但作为 coder,还需要有将现实生活场景代码实现场景的转化思维,所以我认为,了解设计模式简单,实践到对应代码场景中有难度。

so 我们的代码的艺术系列会以还原 coding 现场的方式,讲诉设计模式


生成器模式

来看概念:

生成器模式是一种创建型设计模式,也叫建造者模式。它把对象的创建步骤抽象成生成器,并且可以通过指导类(director)对所有生成步骤的先后顺序进行控制。客户端使用指导类并传入相应的生成器,通过指导类的接口便可以得到相应的对象。

The intent of the Builder design pattern is to separate the construction of a complex object from its representation. By doing so the same construction process can create different representations. 将一个复杂对象的构建与其表示分离,使得同样的构建过程可以创建不同的表示

概括的说:有些对象的创建流程是一样的,但是因为自身特性的不同,所以在创建他们的时候需要将创建过程个性化的属性分离出来。

基本看不懂啥意思,! 继续看结构~

来看结构:

  1. 生成器 ( Builder ) 接口声明在所有类型生成器中通用的产品构造步骤。
  2. 具体生成器 ( Concrete Builders ) 提供构造过程的不同实现。 具体生成器也可以构造不遵循通用接口的产品。
  3. 产品 ( Products ) 是最终生成的对象。 由不同生成器构造的产品无需属于同一类层次结构或接口。
  4. 主管 ( Director ) 类定义调用构造步骤的顺序, 这样你就可以创建和复用特定的产品配置。
  5. 客户端 ( Client ) 必须将某个生成器对象与主管类关联。 一般情况下, 你只需通过主管类构造函数的参数进行一次性关联即可。 此后主管类就能使用生成器对象完成后续所有的构造任务。


实战学习

概念性描述你懂了么?不懂就对了,程序员先理解概念不如直接上代码来的刺激。

这里贯彻下我们前言中的宗旨:

  1. 不要生活场景的举例,要产品需求的案例。
  2. 不要纯图文的描述,要可阅读的代码。

上实战~

产品需求

没有产品思维的程序员不是好销售,此需求纯属虚构

pm 要在自家的电商网站电脑产品垂类下增加报价功能。

PRD 描述当用户进入 mac 品牌的详情页,他可以选择 I7 CPU,500G 内存,1T 磁盘的配置,查看报价。

不同品牌的部件价格不相同,并且不同品牌在同一时刻有不同的优惠折扣。

需求目标: 实时计算出用户选择的电脑配置折后价钱。

需求收益: 提高下单率 50%

技术文档

技术脑爆时刻到了!

乍一听感觉很简单,没什么复杂逻辑。

其实真的很简单。但是问题是不同品牌的部件配置价格不同,而且不同品牌的折扣也是不同的。用户选择了 A,B,C-Z 一坨配置,我的代码要这么写么?

var cpuPrice map[string]float {
  xxx : 100,
  xxxx : 200
}
// 电脑有 N 个部件,我的函数入参就要有 N 个么?
// 这个函数谁敢用?
getMaxPrice(type= '', cpu='',mem='',ram='',disk=''...一堆配置){
	if type == 'mac' {
    price := 0
    if(cpu == '') {
      cpu="默认配置"
    }
    price +=  cpuPrice[cpu]
    if(mem == '') {
      mem="默认配置"
    }
    price +=  memPrice[mem]
    ...
	} elseif (type == 'huawei') {
		...
	}
}

这样看这坨代码的代码量绝对高,并且大部分是重复代码,而且当电脑配置越来越多,getMaxPrice 函数入参也跟着变多,参数顺序谁能保证?有的产生是必填有的是非必填,怎么帮助必填的没有被漏掉?

这样的代码时间久了,逻辑看着很简单,但是没人敢用吧。

怎么办呢?

使用生成器模式来解决是不是好一点,每个部件作为一个生成步骤,每次执行一个步骤即添加一个部件配置,最终生成一个完整的电脑报价,并且设置部件、获取折扣、计数报价这些步骤本身是有序的,是可以通过生成器模式中的 Director 小干部来统一操作的。

好,来看代码吧!

代码"文档"

Tips: 代码 + 注释。

自我要求:注释覆盖率 > 60%

1.先定义一个电脑报价的配置总类,即我们要生成的产品:Computer

package builder

// 产品: 这个是我们的目标,computer 要有这些配置
// computer 可以理解成我们要制作一个什么产品
// 结构体字段 可以理解为我们要做的产品都要哪些配置,对应上文 生成函数的 N 多个入参
type Computer struct {
	name       string    // 电脑类型 比如 mac/华为
	cpuModel   CPU_MODEL // cpu 型号
	gpuModel   GPU_MODEL // gpu 型号
	memorySize MEM_SIZE  // 内存大小
	diskSize   DISK_SIZE // 磁盘大小

	discount float64 // 折扣
	price    float64 // 整体报价
}

2.再定义一个电脑生成的步骤规范接口

package builder

// 生成器接口: 产品的生成器接口
// 可以理解为这个产品生成 必须要有哪些具体的步骤和行为, 后面每一个抽象的产品生成对象都要继承这个生成器接口
type builder interface {
	setCpuModel(CPU_MODEL)  // 设置 cpu 型号
	setGpuModel(GPU_MODEL)  // 设置 gpu 型号
	setMemorySize(MEM_SIZE) // 设置 内存型号
	setDiskSize(DISK_SIZE)  // 设置磁盘型号

	setDiscount()    // 设置折扣粒度, 这个折扣粒度是系统内置的,不需要客户端设置也就是说此功能不是给前台用户询价时自定义的。
	calculatePrice() // 计算报价

  getComputer() *Computer // 给主管(director)使用
}

3.开始定义各个电脑品牌抽象生成器

先看 Mac 的

package builder

import (
	"time"
)

// 抽象的产品生成器
// 可以理解为 computer 这个产品中某一类型产品的生成器
// 抽象生成器即包含了产品(computer)的所有配置,也继承了 builder 公共生成器的所有生成步骤
type MacComputerBuilder struct {
	c *Computer
}

// 实力化一个 Mac 电脑报价
func NewMacComputerBuilder() builder {
	return &MacComputerBuilder{
		c: &Computer{name: "mac"},
	}
}

// 返回*Computer
func (mc *MacComputerBuilder) getComputer() *Computer {
	return mc.c
}

// 设置 CPU 型号
// 设置配置的时候要判断,如果客户端已经配置了,那么跳过
// 这块是因为 director 会在最后编译的时候统一整体执行一遍,防止客户端漏掉配置,走默认配置
func (mc *MacComputerBuilder) setCpuModel(m CPU_MODEL) {
	// demo
	if mc.c.cpuModel != "" {
		return
	}
	if price, ok := partsCpuPriceMap[m]; ok {
		mc.c.cpuModel = m
		mc.c.price += price
	} else {
		mc.c.cpuModel = MAC_CPU_I5 // 此为 mac 电脑默认 cpu 配置
		mc.c.price += partsCpuPriceMap[MAC_CPU_I5]
	}
}

// 设置 GPU 型号
// 设置配置的时候要判断,如果客户端已经配置了,那么跳过
// 这块是因为 director 会在最后编译的时候统一整体执行一遍,防止客户端漏掉配置,走默认配置
func (mc *MacComputerBuilder) setGpuModel(m GPU_MODEL) {
	// demo
	if mc.c.gpuModel != "" {
		return
	}
	if price, ok := partsGpuPriceMap[m]; ok {
		mc.c.gpuModel = m
		mc.c.price += price
	} else {
		mc.c.gpuModel = MAC_GPU_NVIDIA // 此为 mac 电脑默认 gpu 配置
		mc.c.price += partsGpuPriceMap[MAC_GPU_NVIDIA]
	}
}

// 设置内存大小
// 设置配置的时候要判断,如果客户端已经配置了,那么跳过
// 这块是因为 director 会在最后编译的时候统一整体执行一遍,防止客户端漏掉配置,走默认配置
func (mc *MacComputerBuilder) setMemorySize(s MEM_SIZE) {
	// demo
	if mc.c.memorySize != "" {
		return
	}
	if price, ok := partsMemPriceMap[s]; ok {
		mc.c.memorySize = s
		mc.c.price += price
	} else {
		mc.c.memorySize = MAC_MEM_8G // 此为 mac 电脑默认 内存 配置
		mc.c.price += partsMemPriceMap[MAC_MEM_8G]
	}
}

// 设置 磁盘大小
// 设置配置的时候要判断,如果客户端已经配置了,那么跳过
// 这块是因为 director 会在最后编译的时候统一整体执行一遍,防止客户端漏掉配置,走默认配置
func (mc *MacComputerBuilder) setDiskSize(s DISK_SIZE) {
	// demo
	if mc.c.diskSize != "" {
		return
	}
	if price, ok := partsDiskPriceMap[s]; ok {
		mc.c.diskSize = s
		mc.c.price += price
	} else {
		mc.c.diskSize = MAC_DISK_500G // 此为 mac 电脑默认 磁盘 配置
		mc.c.price += partsDiskPriceMap[MAC_DISK_500G]
	}
}

// 设置折扣
// 不同产品策略不一样
// 此操作为内置操作,不需要外部设置
func (mc *MacComputerBuilder) setDiscount() {
	// 2021-06-24 00:17:33
	// 如果大于这个时间,那么 mac 电脑整体打 5 折
	// 否则 整体打 8 折
	if time.Now().Unix() > 1624465043 {
		mc.c.discount = 0.5
	} else {
		mc.c.discount = 0.8
	}
}

// 计数价格
// 注意看,这块就是需要时序的地方,需要先 setDiscount 才能进行报价
// 所以 需要通过 指挥者来统一进行构建,保证各个行为执行顺序
func (mc *MacComputerBuilder) calculatePrice() {
	mc.c.price = (mc.c.price * mc.c.discount)
}

在看一个 huawei 的。

package builder

import "C"

// 抽象的产品生成器
// 可以理解为 computer 这个产品中某一类型产品的生成器
// 抽象生成器即包含了产品(computer)的所有配置,也继承了 builder 公共生成器的所有生成步骤
type HuaweiComputerBuilder struct {
	c *Computer
}

func NewHuaweiComputerBuilder() builder {
	return &HuaweiComputerBuilder{
		c: &Computer{name: "huawei"},
	}
}

func (hc *HuaweiComputerBuilder) getComputer() *Computer {
	return hc.c
}

/**
 * 以下设置各个配置方法和 Mac 逻辑一样,当然也可以自定义策略,不过 demo 就这样了,保证篇幅,所以就不写了
 */
// 设置 CPU 型号
func (hc *HuaweiComputerBuilder) setCpuModel(m CPU_MODEL) {}
// 设置 GPU 型号
func (hc *HuaweiComputerBuilder) setGpuModel(m GPU_MODEL) {}
// 设置内存大小
func (hc *HuaweiComputerBuilder) setMemorySize(s MEM_SIZE) {}
// 设置 磁盘大小
func (hc *HuaweiComputerBuilder) setDiskSize(s DISK_SIZE) {}


// 设置优惠折扣,这块是内部逻辑,不需要外部调用方定义,而且不同产品策略不一样
func (hc *HuaweiComputerBuilder) setDiscount() {
	// 华为机器不打折,国产赞。 这块就是和 mac 差异化的地方
	hc.c.discount = 1
}

// 既然华为不打折,那么直接输出就好了
func (hc *HuaweiComputerBuilder) calculatePrice() {
}

看到区别了吧,两个品牌生成器的优惠策略不同计数价格方法不同,但是统一生成步骤一样,所以需要主管来统一调度执行

来看主管director

package builder

// director 主管,负责整体 build 执行
// 可以理解为总指挥,他来负责计算报价
type director struct {
	builder builder
}

// 实例化一个主管
func NewDirector(b builder) *director {
	return &director{
		builder: b,
	}
}

// 手动重置主管,方便进行多次不同产品生成构建
func (d *director) resetBuilder(b builder) {
	d.builder = b
}

// 执行编译生成,这块就是要严格统一管理编译的步骤和顺序
// 当前这个 demo , 因为时计算报价的例子而不是生成电脑配置的例子,所以前置的那些 setXXX 都在客户端自定义执行了
// 但是有可能前台用户没有选择某些配置,所以需要主管统一兜底
// 1. 兜底每个电脑配置
// 2. 根据当前时间选择折扣粒度
// 3. 计算报价
func (d *director) buildComputer() *Computer {
	// 第一步,兜底每一个电脑配置
	d.builder.setCpuModel(DIRECTOR_CHECK_PARAMS)
	d.builder.setGpuModel(DIRECTOR_CHECK_PARAMS)
	d.builder.setMemorySize(DIRECTOR_CHECK_PARAMS)
	d.builder.setDiskSize(DIRECTOR_CHECK_PARAMS)

	// 第二步设置折扣
	d.builder.setDiscount()
	// 第三步 计算报价
	d.builder.calculatePrice()

	// 返回产品对象
	return d.builder.getComputer()
}

到这块是不是差不多看懂了?最后我们看下客户端是如何调用实现的:

package builder

import "fmt"

// 客户端询问报价
// 即用户在前台页面选择了 mac 电脑
// CPU i7
// GPU xxx
func getPrice() {
	// 先实例化抽象生成器对象,即 mac 电脑
	mcb := NewMacComputerBuilder()
	// 设置我想询问的配置
	mcb.setCpuModel(MAC_CPU_I7)
	mcb.setGpuModel(MAC_GPU_NVIDIA)
	mcb.setMemorySize(MAC_MEM_16G)
	// 磁盘我不选了,用默认的
	//mcb.setDiskSize()

	// 然后实例化一个主管,来准备生成报价
	d := NewDirector(mcb)
	// 执行编译,生成最终产品
	product := d.buildComputer()

	// ok 搞定了,我们可以看看最终这个产品的配置和报价
	fmt.Printf("current computer name: %s\n", product.name)
	fmt.Printf("choose config cpuModel: %s\n", product.cpuModel)
	fmt.Printf("choose config gpuModel: %s\n", product.gpuModel)
	fmt.Printf("choose config memorySize: %s\n", product.memorySize)
	fmt.Printf("choose config diskSize: %s\n", product.diskSize)

	fmt.Printf("give you discount: %f\n", product.discount)
	fmt.Printf("final offer: %f\n", product.price)

	fmt.Printf("---------------询问下一个电脑---------------\n")
	// 下面 我们再生成一个华为的电脑报价
	hwcb := NewHuaweiComputerBuilder()
	hwcb.setCpuModel(HW_CPU_I7)
	hwcb.setGpuModel(HW_GPU_ATI)
	hwcb.setMemorySize(HW_MEM_16G)
	hwcb.setDiskSize(HW_DISK_1T)
	d.resetBuilder(hwcb)
	// 执行编译,生成最终产品
	product2 := d.buildComputer()

	// ok 搞定了,我们可以看看最终这个产品的配置和报价
	fmt.Printf("current computer name: %s\n", product2.name)
	fmt.Printf("choose config cpuModel: %s\n", product2.cpuModel)
	fmt.Printf("choose config gpuModel: %s\n", product2.gpuModel)
	fmt.Printf("choose config memorySize: %s\n", product2.memorySize)
	fmt.Printf("choose config diskSize: %s\n", product2.diskSize)

	fmt.Printf("give you discount: %f\n", product2.discount)
	fmt.Printf("final offer: %f\n", product2.price)

}

上线效果

=== RUN   TestGetPrice
current computer name: mac
choose config cpuModel: maci7
choose config gpuModel: mac-NVIDIA
choose config memorySize: mac-16g
choose config diskSize: mac-500g
give you discount: 0.500000
final offer: 600.000000

---------------询问下一个电脑---------------

current computer name: huawei
choose config cpuModel: hwi7
choose config gpuModel: hw-ATI
choose config memorySize: hw-16g
choose config diskSize: hw-1t
give you discount: 1.000000
final offer: 2800.000000
--- PASS: TestGetPrice (0.00s)
PASS

还是符合预期的!

Demo 源码https://github.com/xiaoxuz/design-pattern/tree/main/create/builder

生成器优缺点

思考

认识我们的职业,不是码农,是软件工程师!

收工

打完收工,感谢阅读!

** [点击] 关注再看,您的关注是我前进的动力~!**

2296 次点击
所在节点    程序员
38 条回复
AlexChing
2021-06-24 11:46:27 +08:00
写的不错呀,为啥没有人回复呢?
anxn
2021-06-24 11:55:52 +08:00
加个 go 语言标签吧,往下翻了很久代码才发现是 golang
xiaoxuz
2021-06-24 12:01:24 +08:00
@anxn 哎呀,我记得 markdown 的代码块中我设置 golang 语法了,go 语言标签怎么加。。。
xiaoxuz
2021-06-24 12:01:56 +08:00
@AlexChing 可能都关注公众号去了吧! 哈哈 卷了~
lesismal
2021-06-24 12:16:19 +08:00
“模式”党不要过度污染 go 了吧,隔三差五就看到 java 味道的 go,本来简简单单几行代码搞定,被你们一搞,哦豁。
Jirajine
2021-06-24 12:53:21 +08:00
Java 味也太冲了。
xiaoxuz
2021-06-24 13:31:58 +08:00
@lesismal 如果只有一把铁锤, 那么任何东西看上去都像是钉子。设计模式的争议缺失挺多的,感觉还是看个人代码风格。
xiaoxuz
2021-06-24 13:33:03 +08:00
@Jirajine 我写的 java 代码量真的没有 go 多 - -,可能是设计模式太多用于 java 了吧
yuancoder
2021-06-24 14:09:08 +08:00
感觉没啥用,if else 更清晰好懂
xiaoxuz
2021-06-24 14:30:20 +08:00
@yuancoder 确实比较好理解,刚开始看生成器这个模式的时候也感觉很冗余,想了半天才想到一个稍微符合场景的例子,电脑报价。。。。发文的目的是总结下啥是『生成器模式』。
lesismal
2021-06-24 14:55:44 +08:00
@xiaoxuz

是的,看个人风格——所以手持臃肿的铁锤的人就是那么喜欢臃肿的钉子。

如果能只在自家臃肿就好了,放过那些新手,免得把 go 社区更多小白也整成臃肿的了。

另外,中了臃肿的毒的人,可能根本不知道自己中毒了。
xiaoxuz
2021-06-24 15:40:25 +08:00
@lesismal 可能文章写的比较粗糙哈,你可能理解的不太清楚或者主观意识太强。

这个文章重点是介绍设计模式,而不是安利谁要干什么,没有个人观点的输出昂!

还有,感觉 存在即合理,知 和 行 冲突么? 没有人能控制你拿铁锤去砸哪个钉子。

最后,写好文章,撸好代码就挺好。真学不会业界大佬这样 瞧病把脉,银针验毒啥的。
jiayong2793
2021-06-24 15:53:37 +08:00
你这需求比喻得不恰当
xiaoxuz
2021-06-24 15:57:58 +08:00
@jiayong2793 纯属虚构哈,只是感觉这种模式在正常生产环境不是很常见,如果去哪现实生活中什么盖房子,造车 balala 的比喻肯定恰强,但是感觉再转化到代码场景中也需要成本,所以想了一个稍微贴近业务代码场景的例子。
jiayong2793
2021-06-24 16:01:43 +08:00
@xiaoxuz 这种场景都是从 SKU 数据库里面直接取数据出来计算的,不需要复制的代码,这种设计模式只适合需要做复制逻辑计算的场景,就像第一张图那样,需要根据图纸制造部件
xiaoxuz
2021-06-24 16:06:28 +08:00
@jiayong2793 哦,学习了,感谢!
lesismal
2021-06-24 16:13:33 +08:00
这个文章重点是介绍设计模式,而不是安利谁要干什么,没有个人观点的输出昂!
—— 写这种文章本身就意味着一种宣传,并且文章主体包括楼主后续的回复,其实都是非常认可文章内容、对内容产生的是正向推介作用,所以就不要说安利不安利了,写了文章本身就是一种安利文章观点,而观点本身又是对内容的正向推介

存在即合理
——我不是搞哲学的,解释不清楚,有兴趣的话可以到某乎上去看下,也不要再误解这句话了: https://www.zhihu.com/question/19851152

比如截取一段:

```text
并被当做颇有来历的谚语警句讹传于大众话语圈。通常的表述形式有:

> 存在即合理

甚至干脆就是

> 凡是存在的都是正当的 /正确的。What is,is right.
```

另外,不管楼主安利不安利,我要安利:keep go clean, keep it simple stupid.
xiaoxuz
2021-06-24 16:23:35 +08:00
@lesismal 感谢大篇幅回复 + 推文讲解!

我不是搞哲学的,解释不清楚,有兴趣的话可以到某乎上去看下,也不要再误解这句话
-- 最开始我以为你是搞医学的,差点给我看出毒来!

文章主体包括楼主后续的回复,其实都是非常认可文章内容
-- 思维逻辑可能正确,但是感觉主观意识略强,`差点给我整的以为我就是要推广设计模式呢` 恐怖!

另外,不管楼主安利不安利,我要安利:keep go clean, keep it simple stupid.
-- 现在我有主观态度了: 爱看不看,话太密! 你写 go,不能证明你可以代表所有 goer~
lesismal
2021-06-24 18:04:10 +08:00
@xiaoxuz
现在我有主观态度了: 爱看不看,话太密! 你写 go,不能证明你可以代表所有 goer~
—— 对,这一点我非常赞同。你写你的,我认为不好的我就提出来,不让某个观点独霸市场。各有说辞,其他人看了也才能有更多思考、减小误入歧途的概率

另外夹带个私活,我自己的异步网络仓库,已经支持  http1.x/websocket/tls:
https://github.com/lesismal/nbio

以往有些老帖子,golang websocket 百万连接相关的,引入 poller 通过事件的方式控制数据读,不用为每个连接都分配一个协程,进而降低海量并发场景下协程数量过多带来的的巨大内存开销和调度成本。
这些方案对应有 gobwas/ws + easygo/netpoll 之类的框架。
但是他们都做错了,只是把 fd 加入到 poller,读事件时进行读取完整的 http upgrade request/websocket message,但是读取过程中如果该连接数据只到达一半,该读取过程将阻塞,进而造成该连接所在的 poller 的 event loop 阻塞或者专用于读取的协程池协程被阻塞占用,这种连接多了则服务相应速度变慢甚至不可用,如果协程池 size 大于连接数,则异步网络网络库减少 goroutine 数量的目的就达不到了
实际要解决问题,需要的不只是异步 io 接口,更需要的是粘包解析,nbio 的实现方式是解决了这些问题的,欢迎试用
sheepzh
2021-06-24 19:03:56 +08:00
真别拿设计模式搞 go 了...

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/785494

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX