3 设计模式
# 3 设计模式
设计模式是软件工程中反复出现问题的解决方案。它不是一个完整的解决方案,而是对问题的描述以及解决问题的模板。这个模板在许多不同的场景中都能适用。
其核心思路是对问题和适用的解决方案进行研究。这样做的好处在于,一旦你完成了这项工作,就很容易在产品需求和架构中识别出模式,并能运用成熟的、预先构思好的方案来解决问题。另一个关键优势是,当你的设计由广为人知的模式构成时,与其他同事或利益相关者交流和讨论设计就会变得更加容易。
设计模式大致可以分为三个类别:
- 创建型模式(Creational)
- 结构型模式(Structural)
- 行为型模式(Behavioral)
我们将在接下来的章节中详细探讨这些模式。不过,我们首先要讨论构成所有模式指导原则的基本设计原则。
# 设计原则
在底层设计中,有两个关键方面需要关注:
- 职责分配:每个类的职责是什么?
- 依赖管理:这个类应该依赖哪些其他类,这些类之间的契约是什么?
罗伯特·C·马丁(“鲍勃大叔”)在他的《敏捷软件开发:原则、模式与实践》一书中,很好地阐述了良好类设计的五条原则,为我们进行底层面向对象设计提供了指导。尽管这本书以及其中使用的语言有些年头了,但这些原则仍然有效,并且可以应用于Go语言。记住这些原则的一个助记词是SOLID(每个字母对应一个特定原则),如下所示:
# 单一职责原则(Single Responsibility Principle,S)
该原则表述如下:
“一个类应该只有一项职责。”
虽然这一点看似显而易见,但在产品需求日益复杂的情况下,严格遵循这一原则非常重要。例如,在我们的旅游网站上,假设我们使用一个实体结构体(实体类是内存中持久化对象的表示,用于呈现数据)来对机票进行建模。最初,公司只做机票预订业务,机票也是按照这种情况进行建模的。然而,一段时间后,产品有了酒店预订和巴士车票预订的需求。了解到这些新需求后,我们之前的设计可能就不是最佳选择了。当出现这类新需求时,重要的是对代码进行重构,以遵循这里提到的指导原则。
因此,在这个具体例子中,与其将所有业务领域的票务语义合并在一起,不如构建一个以预订(reservation)为基类,以机票预订(AirlineTicket)、巴士车票预订(BusTicket)和酒店预订(HotelReservations)为派生类的层次结构。本章后面会介绍构建这种层次结构的模式。这里展示一个预订接口的示例:
type Reservation interface {
GetReservationDate() string
CalculateCancellationFee() float64
Cancel()
GetCustomerDetails() []Customer
GetSellerDetails() Seller
}
2
3
4
5
6
7
当然,这只是为了说明目的而列出的非常精简的方法集。当客户端代码不关心预订的具体类型时,就不会引入不必要的耦合。
除了类之外,这条原则在包设计中更为重要。例如,名为utils
的包往往会成为各种杂项函数的存放地。这种命名和集合方式应该避免。另一方面,Go标准库中有一些很好的包名示例,它们清晰地表明了包的用途,如下所示:
net/http
:提供HTTP客户端和服务器功能。encoding/json
:实现JSON序列化/反序列化。
# 开闭原则(Open/Closed Principle,O)
该原则的原文表述是: “你应该能够在不修改类的情况下扩展其行为。”
这本质上意味着类应该对扩展开放,对修改关闭,也就是说,应该可以在不修改代码的情况下扩展或覆盖类的行为。行为的改变应该通过覆盖某些方法或注入某些配置来实现,从而可以插入到类中。展现这一原则的一个优秀框架示例是Spring框架。
使用这条原则的一个常见场景是算法或业务逻辑。在我们的旅游网站上,假设有两个需求:
- 我们应该能够将机票和酒店预订捆绑到一个“行程”(Trip)对象中。
- 我们应该能够计算行程的取消费用。
因此,我们可以将“行程”建模为一个包含预订集合(存储库)的结构体,对于取消费用的计算,让每个预订的派生类型来计算,如下所示:
type Trip struct {
reservations []Reservation
}
func (t *Trip) CalculateCancellationFee() float64 {
total := 0.0
for _, r := range(t.reservations) {
total += r.CalculateCancellationFee()
}
return total
}
func (t *Trip) AddReservation (r Reservation) {
t.reservations = append(t.reservations, r)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
在未来,如果我们有了一种新的预订类型,只要它实现了预订接口的CalculateCancellationFee()
方法,CalculateCancellationFee()
方法就应该能够计算取消费用。
# 里氏替换原则(Liskov Substitution Principle,L)
这是开闭原则的一个变体,“鲍勃大叔”对其表述如下: “派生类型必须能够替换其基类型。”
这个原则之所以被称为里氏替换原则,是因为它最早由芭芭拉·利斯科夫提出: “这里所需要的是类似这样的替换属性:对于类型S的每个对象o1,都存在类型T的一个对象o2,使得对于所有根据T定义的程序P,当用o1替换o2时,P的行为不变,那么S就是T的子类型。”
该原则的核心是,派生类必须能够通过基类接口使用,而客户端无需知道具体的派生类。
正如我们前面所见,在Go语言中,面向对象是通过组合(原型模式)实现的,而不是类层次结构。所以,尽管这条原则在Go语言中的意义不像在其他语言中那么强烈,但它确实为我们的接口设计提供了指导:接口应该能够满足所有实现该接口的结构体的需求。
应用这条原则的一个很好的例子是前面提到的取消费用计算。这实现了清晰的关注点分离:
- 想要计算行程取消费用的客户端,并不关心行程中预订的具体类型。
- 行程的代码也不知道每个预订是如何计算取消费用的。
然而,如果客户端代码试图通过反射等方式检查派生类的类型,以便显式地调用每个具体预订类型的取消费用计算方法,那么抽象就会被破坏。里氏替换原则(LSP)明确不建议这样做。
# 接口隔离原则(Interface Segregation Principle,I)
该原则表述如下: “多个特定于客户端的接口比一个通用接口更好。”
随着代码的演进,基类往往会成为各种行为的集合,这是一种常见的现象。然而,这会使整个代码变得脆弱:派生类不得不实现对它们来说没有意义的方法。客户端也可能会对派生类的这种多变特性感到困惑。为了避免这种情况,该原则建议为每种类型的客户端提供一个接口。
例如,在前面的预订示例中,假设我们有一个针对所有机票、酒店、巴士车票等的“胖”预订基类。现在假设机票预订有一个特殊方法,比如AddExtraLuggageAllowance()
,用于允许持票人携带额外行李。对于酒店预订,我们需要有更改房型的功能——ChangeType()
。在一个简单的设计中,所有这些方法都会被塞进预订基类中,派生类不得不实现一些不相关的方法。更好的设计是让预订基类只处理通用行为,并为机票、巴士和酒店预订提供特定的接口:
type Reservation interface {
GetReservationDate() string
CalculateCancellationFee() float64
}
type HotelReservation interface {
Reservation
ChangeType()
}
type FlightReservation interface {
Reservation
AddExtraLuggageAllowance(peices int)
}
type HotelReservationImpl struct{
reservationDate string
}
func (r HotelReservationImpl) GetReservationDate() string {
return r.reservationDate
}
func (r HotelReservationImpl) CalculateCancellationFee() float64 {
return 1.0 // 固定值
}
type FlightReservationImpl struct{
reservationDate string
luggageAllowed int
}
func (r FlightReservationImpl) AddExtraLuggageAllowance(peices int) {
r.luggageAllowed = peices
}
func (r FlightReservationImpl) CalculateCancellationFee() float64 {
return 2.0 // 固定值,但比酒店稍高
}
func (r FlightReservationImpl) GetReservationDate() string {
// 这看起来可能有些重复,但目的是让派生类能够相互独立地变化
return r.reservationDate
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# 依赖倒置原则(Dependency Inversion Principle,D)
最后一条原则是依赖倒置原则,表述如下: “依赖于抽象,而非具体实现。”
这意味着高层次的模块应该只依赖于接口,而不是具体的实现。在Java中,像Spring这样的大型框架提供了依赖注入功能,以便在运行时将Bean(对象)注入到应用程序中,而代码的其他部分只与Bean的接口(而非具体实现)进行交互。
在Go语言中,这条原则可以归结为两条建议:
- 每个包都应该有用于展示功能的接口,而不涉及实现细节。
- 当一个包需要依赖时,应该将该依赖作为参数传入。
为了说明第二点,假设我们为旅游网站的搜索微服务构建了两个包(层):
- 服务层:这一层包含了大量搜索和排序的业务逻辑。
- 通信层:这一层负责从不同的卖家获取数据。每个卖家都有自己的API,因此这一层有很多实现
SellerCommunication
接口的不同实现类。
根据这条原则,我们应该能够将通信层的特定实例注入到服务层中。通信层具体实现的注入可以通过驱动程序的主函数来完成。这使得服务层只需要依赖(了解)SellerCommunication
接口,而不是依赖特定的实现。立即利用这一点的一种方式是进行模拟——可以为服务层组件的测试用例模拟SellerDAO
接口。
牢记这些原则后,让我们开始学习具体的设计模式,首先从创建型设计模式开始。
# 创建型设计模式
创建型设计模式是用于以安全、高效的方式处理对象创建机制,并将客户端与实现细节解耦的设计模式。使用这些模式时,使用对象的代码无需了解对象的创建细节,甚至无需知道对象的具体类型,只要该对象符合预期的接口即可。
# 工厂方法
工厂是用于创建其他对象的对象。我们前面提到了预订接口。本质上,预订是待售物品与购买它的用户之间的关联,还包含一些元数据,比如日期等,并且会有多种类型的预订。那么,客户端代码如何在不知道HotelReservationImpl
等实现类的情况下创建预订呢?
在工厂方法模式中,会定义一个辅助方法(或函数),以便在不知道实现类细节的情况下创建对象。例如,对于预订,简单的工厂方法可以是这样:
func NewReservation(vertical, reservationDate string) Reservation {
switch(vertical) {
case "flight":
return FlightReservationImpl{reservationDate,}
case "hotel":
return HotelReservationImpl{reservationDate,}
default:
return nil
}
}
2
3
4
5
6
7
8
9
10
它可以这样使用:
hotelReservation := NewReservation("hotel","20180101")
# 生成器模式(Builder)
有时,对象的创建并非那么简单直接。例如:
- 可能需要遵循一些业务规则来验证某些参数,或者推导一些额外的属性。比如,在预订业务中,我们可能会根据业务领域、销售方以及出行日期等详细信息来推导一个“不可取消”属性。
- 可能需要编写一些代码来提高效率,例如从缓存中检索对象,而不是从数据库读取。
- 在对象创建过程中,可能需要具备幂等性和线程安全性。也就是说,使用相同参数多次请求创建对象,应该得到同一个对象。
- 对象的构造函数可能有多个参数(通常称为 telescopic constructors, telescopic 意为 “可伸缩的”,这里指参数数量和形式多样的构造函数 ),客户端很难记住这些参数的顺序。而且其中一些参数可能是可选的,这样的构造函数经常会导致客户端代码出现错误。
生成器模式允许你在满足上述约束的同时,创建不同 “风格” 的对象。以我们的预订业务为例,生成器代码如下:
type ReservationBuilder interface {
Vertical(string) ReservationBuilder
ReservationDate(string) ReservationBuilder
Build() Reservation
}
type reservationBuilder struct {
vertical string
rdate string
}
func (r *reservationBuilder) Vertical(v string) ReservationBuilder {
r.vertical = v
return r
}
func (r *reservationBuilder) ReservationDate(date string) ReservationBuilder {
r.rdate = date
return r
}
func (r *reservationBuilder) Build() Reservation {
var builtReservation Reservation
switch r.vertical {
case "flight":
builtReservation = FlightReservationImpl{r.rdate}
case "hotel":
builtReservation = HotelReservationImpl{r.rdate}
}
return builtReservation
}
func NewReservationBuilder() ReservationBuilder {
return &reservationBuilder{}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
可以看到,我们的对象创建功能变得更强大了。延迟创建(在获取所有参数之后再创建对象)也有助于我们实现效率目标,比如从缓存中加载创建成本较高的对象。
# 抽象工厂模式(Abstract factory)
在实际问题中,常常有许多相关(属于同一 “家族” )的对象需要一起创建。例如,如果我们的旅游网站决定为预订业务开具发票,那么基于两种业务领域,我们基本上有以下情况:
- 两种类型的实体:预订和发票。
- 两种业务领域/产品类型:酒店和航班。
当客户端代码创建这些相关产品时,我们如何确保客户端不会出错(例如,将航班发票与酒店预订关联起来)呢?简单工厂方法在这里并不适用,因为客户端需要为每种类型的实体/对象找出所有合适的工厂。
抽象工厂模式试图通过 “工厂的工厂” 结构来解决这个问题:将不同的相关/依赖工厂组合在一起,而不指定它们的具体类。
下面是一个结合预订/发票场景的抽象工厂实现:
// 我们将Reservation和Invoice视为两种通用产品
type Reservation interface{}
type Invoice interface{}
type AbstractFactory interface {
CreateReservation() Reservation
CreateInvoice() Invoice
}
type HotelFactory struct{}
func (f HotelFactory) CreateReservation() Reservation {
return new(HotelReservation)
}
func (f HotelFactory) CreateInvoice() Invoice {
return new(HotelInvoice)
}
type FlightFactory struct{}
func (f FlightFactory) CreateReservation() Reservation {
return new(FlightReservation)
}
func (f FlightFactory) CreateInvoice() Invoice {
return new(FlightReservation)
}
type HotelReservation struct{}
type HotelInvoice struct{}
type FlightReservation struct{}
type FlightInvoice struct{}
func GetFactory(vertical string) AbstractFactory {
var factory AbstractFactory
switch vertical {
case "flight":
factory = FlightFactory{}
case "hotel":
factory = HotelFactory{}
}
return factory
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
客户端可以像这样使用抽象工厂:
hotelFactory := GetFactory("hotel")
reservation := hotelFactory.CreateReservation()
invoice := hotelFactory.CreateInvoice()
2
3
# 单例模式(Singleton)
有时,你可能会遇到需要限制系统中特定类型对象数量的情况。单例模式就是将对象的创建限制为只有一个的设计模式。例如,当你希望在代码的多个地方使用同一个协调对象时,单例模式就很有用。
以下代码片段展示了如何在Go语言中实现单例模式。注意,我们使用了sync.Do()
方法:如果多次调用once.Do(f)
,即使有多个线程同时调用,也只有第一次调用会执行函数f
。
type MyClass struct {
attrib string
}
func (c *MyClass) SetAttrib(val string) {
c.attrib = val
}
func (c *MyClass) GetAttrib() string {
return c.attrib
}
var (
once sync.Once
instance *MyClass
)
func GetMyClass() *MyClass {
once.Do(func() {
instance = &MyClass{"first"}
})
return instance
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
使用方式如下:
a := GetMyClass()
a.SetAttrib("second")
fmt.Println(a.GetAttrib()) // 将会输出second
b := GetMyClass()
fmt.Println(b.GetAttrib()) // 也会输出second
2
3
4
5
TIP 需要注意的是,由于单例模式引入了全局状态,它实际上被视为一种反模式。这会在组件之间造成隐藏的耦合,可能导致难以调试的情况,所以不应过度使用。
# 结构型设计模式
在软件工程中,结构型设计模式有助于明确对象之间的清晰关系,简化设计。与我们前面介绍的创建型模式不同,这些模式种类繁多,针对各种不同的情况提供了一系列解决方案。
# 适配器模式(Adaptor)
在编写代码时,你经常会遇到这样的情况:有一个新的需求,而现有的某个组件几乎能满足该需求。一个非软件领域的例子是电源适配器:印度的三脚插头无法直接插入美国的两孔插座,你需要使用电源适配器来实现兼容,使两者都能正常使用。
在该模式中,有一个适配器类,它代理所需的功能,并使用被适配者(不兼容的类)期望的方法,将工作委托给被适配者。如下图所示:
理论上,有两种实现该模式的方式:
- 对象适配器:在这里,适配器类包含一个被适配者类的实例,适配器方法将工作委托给包装的实例。
- 类适配器:在这里,适配器是一个混合类(具有多重继承的类),从两个地方继承:期望的接口和被适配者接口。
在Go语言中,对象适配器的实现方式更合适。下面是一个示例:
type Adaptee struct{}
func (a *Adaptee) ExistingMethod() {
fmt.Println("using existing method")
}
type Adapter struct {
adaptee *Adaptee
}
func NewAdapter() *Adapter {
return &Adapter{new(Adaptee)}
}
func (a *Adapter) ExpectedMethod() {
fmt.Println("doing some work")
a.adaptee.ExistingMethod()
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
客户端的doWork()
方法看起来就这么简单:
adaptor := NewAdapter()
adaptor.ExpectedMethod()
2
# 桥接模式(Bridge)
考虑我们旅游产品中的一个用例,有两种类型的预订:
- 高级预订:享有特殊福利,如免费取消、更高的现金返还等。
- 普通预订:有正常的限制条件。
还记得在第2章“代码打包”中提到的两种销售方类型吗:
- 机构销售方:那些能够提供API让我们获取数据并进行预订的销售方。
- 小规模销售方:那些使用我们作为产品一部分构建的平台来上架库存的销售方。
预订最终由销售方来完成。如果采用简单的实现方式,针对每种销售方类型都要有不同的预订类型矩阵。然而,很快代码就会变得难以维护。随着代码复杂度的增加,我们可能会发现接口和实现之间的绑定很难设计,接口和实现之间的关系开始变得混乱,抽象和实现无法独立扩展。
桥接模式旨在通过以下方式解决这些问题:
- 将抽象与实现解耦,使两者能够独立变化。
- 将接口层次结构和实现层次结构分离为两个独立的体系。
该模式的描述如下:
下面是上述用例的虚拟实现:
type Reservation struct {
sellerRef Seller // 这是实现者的引用
}
func (r Reservation) Cancel() {
r.sellerRef.CancelReservation(10) // 收取10美元的取消费用
}
type PremiumReservation struct {
Reservation
}
func (r PremiumReservation) Cancel() {
r.sellerRef.CancelReservation(0) // 不收取费用
}
// 这是所有销售方的接口
type Seller interface {
CancelReservation(charge float64)
}
type InstitutionSeller struct{}
func (s InstitutionSeller) CancelReservation(charge float64) {
fmt.Println("InstitutionSeller CancelReservation charge =", charge)
}
type SmallScaleSeller struct{}
func (s SmallScaleSeller) CancelReservation(charge float64) {
fmt.Println("SmallScaleSeller CancelReservation charge =", charge)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
其使用方式如下:
res := Reservation{InstitutionSeller{}}
res.Cancel() // 将会输出取消费用为10
premiumRes := PremiumReservation{Reservation{SmallScaleSeller{}}}
premiumRes.Cancel() // 将会输出取消费用为0
2
3
4
TIP 这里的抽象是一个结构体而不是接口,因为在Go语言中,不存在可以存储销售方实现引用的抽象结构体/接口。
# 组合模式(Composite)
很多时候,我们会遇到由叶子元素组成的结构。然而,客户端并不关心这种结构特性,他们希望以相同的方式与单个(叶子)结构和组合结构进行交互。其期望是组合结构将行为委托给每个组成的叶子元素。
该模式可以用下图来表示:
下面是用Go语言实现的示例代码:
type InterfaceX interface {
MethodA()
AddChild(InterfaceX)
}
type Composite struct {
children []InterfaceX
}
func (c *Composite) MethodA() {
if len(c.children) == 0 {
fmt.Println("I'm a leaf ")
return
}
fmt.Println("I'm a composite ")
for _, child := range c.children {
child.MethodA()
}
}
func (c *Composite) AddChild(child InterfaceX) {
c.children = append(c.children, child)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
使用方法如下:
func test() {
var parent InterfaceX
parent = &Composite{}
parent.MethodA() // 此时只有一个叶子节点,打印结果会证实这一点!
var child Composite
parent.AddChild(&child)
parent.MethodA() // 一个组合节点,一个叶子节点
}
2
3
4
5
6
7
8
提示:组合模式的代码使用指针接收者来实现接口,因为AddChild
方法需要修改结构体。
# 装饰器模式(Decorator)
装饰器模式允许在不改变原有对象的情况下,动态扩展现有对象的功能。这是通过将原始对象和函数包装到一个新函数中来实现的。
下面的代码展示了装饰器模式,它可用于分析另一个函数的执行时间。
这里展示一个简单的分析器,它仅适用于输入和返回值均为float
类型的函数:
type Function func(float64) float64
// 装饰器函数
func ProfileDecorator(fn Function) Function {
return func(params float64) float64 {
start := time.Now()
result := fn(params)
elapsed := time.Now().Sub(start)
fmt.Println("Function completed with time: ", elapsed)
return result
}
}
func client() {
decoratedSquqreRoot := ProfileDecorator(SquareRoot)
fmt.Println(decoratedSquqreRoot(16))
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
与跟踪示例类似,日志记录和其他类型的中间件通常会用到装饰器模式。
# 外观模式(Facade)
当一个包包含多个接口时,客户端使用起来可能会很困难。虽然对于高级用户来说,各个接口可能很有用,但大多数客户端会被他们不关心的复杂细节弄糊涂。
外观设计模式通过向代码的其他部分提供一个替代的/简化的接口来解决这个问题。如下图所示:
在Go语言中,可以使用接口和结构体来实现外观模式,它为其他接口提供了一种简化的交互模型。
# 代理模式(Proxy)
在我们的产品中,订单履行由外部卖家完成。对于机构卖家,需要调用一个外部API来执行预订等操作。假设一个机构卖家,比如HotelBoutique,提供了一个REST API(后续章节会详细介绍API类型),系统应该如何与这个外部代理进行交互呢?显然,我们首先想到的是将HotelBoutique的特定细节封装并隔离在一个地方。
代理本质上是一个充当其他事物接口的类。它是一个将工作委托给主体(被代理的对象)的对象,使客户端无需了解主体的具体细节。这些细节包括位置信息,这使得设计更加灵活,客户端和主体可能在同一个编译二进制文件中,也可能不在,但其余代码无需任何更改即可正常工作。
代理委托可以只是简单的转发,也可以提供额外的逻辑(例如缓存)。
对于HotelBoutique的例子,下面给出使用代理模式的代码,其中用一个虚拟类代替了API调用:
// 代理
type HotelBoutiqueProxy struct {
subject *HotelBoutique
}
func (p *HotelBoutiqueProxy) Book() {
if p.subject == nil {
p.subject = new(HotelBoutique)
}
fmt.Println("Proxy Delegating Booking call")
// API调用将在此处进行
// 为示例起见,这里实现了一个简单的委托
p.subject.Book()
}
// 虚拟主体
type HotelBoutique struct{}
func (s *HotelBoutique) Book() {
fmt.Println("Booking done on external site")
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 行为型设计模式
行为型设计模式是识别对象之间通信模式,并为特定情况提供解决方案模板的设计模式。通过这种方式,这些模式增强了交互的可扩展性。
有些模式关注于减少发送者和接收者之间的耦合,而其他模式则描述了对象状态的内部状态(以及任何状态变化通知)如何提供给其他感兴趣的对象。所以,正如你所想,这是一组非常多样化的模式。
# 命令模式(Command)
命令模式是一种行为型设计模式,其中一个对象用于表示请求(或操作),并封装处理该请求所需的所有信息。这些信息包括方法名、拥有该方法的对象以及方法参数的值。
很多时候,命令需要被持久化。如果命令需要长时间运行,或者当你需要记住这些命令以支持诸如文字处理器中的撤销功能时(这里,对文档的每次更改都是一个命令),就会出现这种情况。
命令模式中的角色如下:
- 一个命令接口,封装了要执行的各种操作。它通常有一个
execute()
方法来实际执行请求的命令。通常会为所有具体命令声明一个通用接口。 - 具体命令实现实际的操作。有些命令可以是自包含的。其他命令需要一个接收者,即一个实际执行工作的外部对象。在后一种情况下,具体命令的
execute()
方法会委托给接收者。 - 一个调用者对象知道如何执行命令,并且可以选择记录命令的执行情况。调用者只依赖于命令接口,而不依赖于具体的命令。它通常有一个命令存储库。在长时间运行的后台任务中,调用者还充当这些命令的调度器。
- 最后是客户端,它引用所有调用者对象、命令对象和接收者对象,并协调整个流程:
旅游市场的一个关键需求是从报告中获取业务洞察。这些洞察可能包括从用户对各种功能的反馈到特定类别的顶级卖家信息等。
正如我们在引言章节中看到的,所有这些功能都分为前端(主要负责渲染元素的客户端代码)和后端(进行实际计算的API服务器)。后面我们会有专门的章节介绍API设计,但对这类API进行建模的一种常见方法是表述性状态转移(Representational State Transfer,REST)。在这里,我们有一个类似于对象的资源,而API本质上是对这个资源进行获取/设置的方法。
我们可以将这个分析API建模为报告API,客户端可以通过向/reports
URL发送POST请求来请求报告。后端会将这个请求转换为一个命令,并安排后台工作线程来处理。下面的代码片段展示了在这种场景下命令模式的实际应用。这里,report
是命令接口,client
是API处理程序的Web层:
// 命令
type Report interface {
Execute()
}
// 具体命令
type ConcreteReportA struct {
receiver *Receiver
}
func (c *ConcreteReportA) Execute() {
c.receiver.Action("ReportA")
}
type ConcreteReportB struct {
receiver *Receiver
}
func (c *ConcreteReportB) Execute() {
c.receiver.Action("ReportB")
}
// 接收者
type Receiver struct{}
func (r *Receiver) Action(msg string) {
fmt.Println(msg)
}
// 调用者
type Invoker struct {
repository []Report
}
func (i *Invoker) Schedule(cmd Report) {
i.repository = append(i.repository, cmd)
}
func (i *Invoker) Run() {
for _, cmd := range i.repository {
cmd.Execute()
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
客户端代码如下:
func client() {
receiver := new(Receiver)
ReportA := &ConcreteReportA{receiver}
ReportB := &ConcreteReportB{receiver}
invoker := new(Invoker)
invoker.Schedule(ReportA)
invoker.Run()
invoker.Schedule(ReportB)
invoker.Run()
}
2
3
4
5
6
7
8
9
10
11
# 职责链模式(Chain of Responsibility)
很多时候,前面描述的命令可能需要这样处理:我们希望接收者只有在有能力处理时才进行工作,如果不能处理,则将命令传递给链中的下一个接收者。例如,对于报告用例,我们可能希望以不同方式处理已认证和未认证的用户。
职责链模式通过将一组接收者对象链接起来实现这一点。链首的接收者首先尝试处理命令,如果它无法处理,就将其委托给下一个接收者。
示例代码如下:
type ChainedReceiver struct {
canHandle string
next *ChainedReceiver
}
func (r *ChainedReceiver) SetNext(next *ChainedReceiver) {
r.next = next
}
func (r *ChainedReceiver) Finish() error {
fmt.Println(r.canHandle, " Receiver Finishing")
return nil
}
func (r *ChainedReceiver) Handle(what string) error {
// 检查此接收者是否能处理该命令
if what==r.canHandle {
// 在此处处理命令
return r.Finish()
} else if r.next != nil {
// 委托给下一个接收者
return r.next.Handle(what)
} else {
fmt.Println("No Receiver could handle the request!")
return errors.New("No Receiver to Handle")
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 中介者模式(Mediator)
中介者模式添加了一个第三方对象(称为中介者)来控制两个对象(同事对象)之间的交互。借助中介者对象,通信的类不会耦合到彼此的实现中。
它有助于减少相互通信的类之间的耦合,因为现在它们无需了解彼此的实现。
示例代码如下:
// 中介者接口
type Mediator interface {
AddColleague(colleague Colleague)
}
// 同事接口
type Colleague interface {
setMediator(mediator Mediator)
}
// 具体同事1 - 使用字符串类型的状态
type Colleague1 struct {
mediator Mediator
state string
}
func (c *Colleague1) SetMediator(mediator Mediator) {
c.mediator = mediator
}
func (c *Colleague1) SetState(state string) {
fmt.Println("Colleague1: setting state: ", state)
c.state = state
}
func (c *Colleague1) GetState() string {
return c.state
}
// 具体同事2 - 使用整型的状态
type Colleague2 struct {
mediator Mediator
state int
}
func (c *Colleague2) SetState(state int) {
fmt.Println("Colleague2: setting state: ", state)
c.state = state
}
func (c *Colleague2) GetState() int {
return c.state
}
func (c *Colleague2) SetMediator(mediator Mediator) {
c.mediator = mediator
}
// 具体中介者
type ConcreteMediator struct {
c1 Colleague1
c2 Colleague2
}
func (m *ConcreteMediator) SetColleagueC1(c1 Colleague1) {
m.c1 = c1
}
func (m *ConcreteMediator) SetColleagueC2(c2 Colleague2) {
m.c2 = c2
}
func (m *ConcreteMediator) SetState(s string) {
m.c1.SetState(s)
stateAsString, err:= strconv.Atoi(s)
if err == nil {
m.c2.SetState(stateAsString)
fmt.Println("Mediator set status for both colleagues")
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
客户端代码如下:
c1:= Colleague1{}
c2:= Colleague2{}
// 使用同事初始化中介者
mediator:= ConcreteMediator{}
mediator.SetColleagueC1(c1)
mediator.SetColleagueC2(c2)
// 中介者使同事保持同步
mediator.SetState("10")
2
3
4
5
6
7
8
9
# 备忘录模式(Memento)
备忘录模式用于捕获和存储对象的当前状态,以便日后能够顺利恢复该状态。该模式涉及三个角色:
- 原发器(Originator):是一个具有内部状态的对象。
- 负责人(Caretaker):打算执行某些可能会改变原发器状态的操作。但如果出现问题,它希望能够撤销(或回滚)这些更改。
- 为实现这一点,负责人首先向原发器请求一个备忘录对象。之后,负责人执行变更/操作。如果出现问题,它将备忘录对象返回给原发器。
此模式的关键在于,备忘录对象是不透明的,因此不会破坏原发器对象的封装性。
该模式的代码如下:
// 原发器
type Originator struct {
state string
}
func (o *Originator) GetState() string {
return o.state
}
func (o *Originator) SetState(state string) {
fmt.Println("Setting state to " + state)
o.state = state
}
func (o *Originator) GetMemento() Memento {
// 将状态外部化到备忘录对象
return Memento{o.state}
}
func (o *Originator) Restore(memento Memento) {
// 恢复状态
o.state = memento.GetState()
}
// 备忘录
type Memento struct {
serializedState string
}
func (m *Memento) GetState() string {
return m.serializedState
}
// 负责人
func Caretaker() {
// 假设A是原发器的初始状态
theOriginator:= Originator{"A"}
theOriginator.SetState("A")
fmt.Println("theOriginator state = ", theOriginator.GetState() )
// 在变更前,获取一个备忘录
theMomemto:= theOriginator.GetMemento()
// 变更为“unclean”
theOriginator.SetState("unclean")
fmt.Println("theOriginator state = ", theOriginator.GetState() )
// 回滚
theOriginator.Restore(theMomemto)
fmt.Println("RESTORED: theOriginator state = ",
theOriginator.GetState() )
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# 观察者模式(Observer)
在许多情况下,存在一个具有状态的实体(主题)和其他几个对该状态感兴趣的实体(观察者)。观察者模式是一种软件设计模式,它定义了主题和观察者之间的交互。本质上,主题维护着一个观察者列表,并在状态发生任何变化时通知它们。该模式如下图所示:
以下是用Go语言实现的代码:
// 主题
type Subject struct {
observers []Observer
state string
}
func (s *Subject) Attach(observer Observer) {
s.observers = append(s.observers, observer)
}
func (s *Subject) SetState(newState string) {
s.state = newState
for _,o:= range(s.observers) {
o.Update()
}
}
func (s *Subject) GetState() string {
return s.state
}
// 观察者接口
type Observer interface {
Update()
}
// 具体观察者A
type ConcreteObserverA struct {
model *Subject
viewState string
}
func (ca *ConcreteObserverA) Update() {
ca.viewState = ca.model.GetState()
fmt.Println("ConcreteObserverA: updated view state to ", ca.viewState)
}
func (ca *ConcreteObserverA) SetModel(s *Subject) {
ca.model = s
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
客户端调用方式如下:
func client() {
// 创建主题
s:= Subject{}
// 创建具体观察者
ca:= &ConcreteObserverA{}
ca.SetModel(&s) // 设置模型
// 附加观察者
s.Attach(ca)
s.SetState("s1")
}
2
3
4
5
6
7
8
9
10
请注意,在标准的对象模式(如图中所示)中,模型引用位于观察者接口中(就像Java中的抽象基类那样)。然而,由于Go语言的接口不能包含数据且不支持继承,模型引用被下放到具体的观察者中。
# 访问者模式(Visitor)
很多时候,我们希望对聚合对象(数组、树、列表等)的元素(节点)执行不同的操作。简单的做法当然是为每个功能在节点上添加方法。如果存在不同类型的节点,那么具体的节点都必须实现每个处理函数。可以看出,这种方法并不理想,它违背了隔离原则,并且在节点和不同类型的处理之间引入了大量耦合。
访问者设计模式的目标是封装和隔离对聚合对象的每个元素(节点)需要进行的处理。这避免了前面提到的方法泛滥问题。
该模式的关键元素如下:
- 访问者(Visitor):这个类定义了各种处理的接口,并有一个名为
visit()
的方法,该方法接受一个节点对象作为参数。具体的访问者类在其实现的visit()
方法中描述实际的节点处理过程。 - 节点(Node):这是聚合对象中我们想要进行访问操作的元素。它有一个
accept()
方法,该方法接受访问者作为参数,并通过调用访问者的visit()
方法开始处理。每个具体节点可以根据需要在此基础上添加更多功能。
在大多数设计模式中,多态性是通过单分派实现的;也就是说,执行的操作取决于被调用对象的类型。在访问者模式中,我们看到了双分派的变体:最终执行的操作既取决于被调用对象(具体的节点对象),也取决于调用者对象(具体的访问者):
通过创建一个新的访问者子类并将其添加到原始的继承层次结构中,就可以轻松添加新的处理逻辑。以下是一些用Go语言编写的示例代码:
// 节点接口
type Node interface {
Accept(Visitor)
}
type ConcreteNodeX struct{}
func (n ConcreteNodeX) Accept(visitor Visitor) {
visitor.Visit(n)
}
type ConcreteNodeY struct{}
func (n ConcreteNodeY) Accept(visitor Visitor) {
// 在访问前执行一些特定于NodeY的操作
fmt.Println("ConcreteNodeY being visited !")
visitor.Visit(n)
}
// 访问者接口
type Visitor interface {
Visit(Node)
}
// 一个实现
type ConcreteVisitor struct{}
func (v ConcreteVisitor) Visit(node Node) {
fmt.Println("doing something concrete")
// 由于没有函数重载...
// 这是检查具体节点类型的一种方法
switch node.(type) {
case ConcreteNodeX:
fmt.Println("on Node ")
case ConcreteNodeY:
fmt.Println("on Node Y")
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
客户端代码如下:
func main() {
// 一个简单的聚合对象
aggregate:= []Node {ConcreteNodeX{}, ConcreteNodeY{},}
// 一个访问者
visitor:= new(ConcreteVisitor)
// 迭代并访问
for _, node:= range(aggregate){
node.Accept(visitor)
}
}
2
3
4
5
6
7
8
9
10
11
# 策略模式
策略模式(Strategy Pattern)的目标很简单:允许用户在不改变代码其他部分的情况下,更换所使用的算法。在这种模式下,开发者专注于通用算法的输入和输出,并将其实现为接口的方法。然后,每个具体的算法实现类都实现这个接口。客户端代码只与接口耦合,而不与具体的实现类耦合。这使得用户能够动态地插拔新算法。
下面是一个用于计算数组中元素极差(数组中最大数与最小数的差值)的算法示例代码:
type Strategy interface {
FindBreadth([]int) int // 算法
}
// 时间复杂度为O(nlgn)的实现
type NaiveAlgo struct{}
func (n *NaiveAlgo) FindBreadth(set []int) int {
sort.Ints(set)
return set[len(set)-1] - set[0]
}
// 时间复杂度为O(n)的实现
type FastAlgo struct{}
func (n *FastAlgo) FindBreadth(set []int) int {
min := math.MaxInt32
max := math.MinInt64
for _, x := range(set) {
if x < min {
min = x
}
if x > max {
max = x
}
}
return max - min
}
// 客户端无需了解具体算法
func client(s Strategy) int {
a := []int{ -1, 10, 3, 1}
return s.FindBreadth(a)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
这种模式的一个变体称为模板方法(Template Method)。在这种方法中,一个算法被分解为多个部分:主类定义主算法,并使用一些方法(步骤),这些方法在具体的子类中定义,子类通过不同的方式实现这些步骤,从而微妙地改变主算法。
下面是相关代码:
// “抽象”的主算法
type MasterAlgorithm struct {
template Template
}
func (c *MasterAlgorithm) TemplateMethod() {
// 编排步骤
c.template.Step1()
c.template.Step2()
}
// 可定制的步骤
type Template interface {
Step1()
Step2()
}
// 变体A
type VariantA struct{}
func (c *VariantA) Step1() {
fmt.Println("VariantA step 1")
}
func (c *VariantA) Step2() {
fmt.Println("VariantA step 2")
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
实例化过程如下:
func client() {
masterAlgorithm := MasterAlgorithm{new(VariantA)}
masterAlgorithm.TemplateMethod()
}
2
3
4
# 状态模式
现实生活中的大多数对象都是有状态的,并且它们会根据状态改变自身行为。例如,一个预订可能处于以下状态,在取消预订的用户流程中,相应的行为也会发生变化:
- 初始(INITIAL):在这个状态下,预订处于非常初步的阶段。此时取消预订很简单。
- 已支付(PAID):用户已经为酒店或机票付款,但与卖家的实际预订尚未完成。在这个状态下取消预订可能意味着给客户全额退款。
- 已确认(CONFIRMED):预订已完成,此时取消预订,卖家可能会收取费用。
- 已完成(FULFILLED):客户已经使用了预订服务,无法再取消。
- 已取消(CANCELLED):预订已经被取消,不能再进行取消操作。
状态设计模式(State Design Pattern)可以让我们优雅地编码实现这种有状态的行为,并且清晰地分离不同状态下的行为,而无需使用庞大的switch语句。主要涉及以下几个部分:
- 上下文(Context):这个对象包含客户端的当前状态,同时也是客户端进行交互的点。
- 状态(State):这是定义对象在不同状态下行为的接口。该接口中的每个方法都需要根据当前状态表现出多态行为。
- 具体状态(Concrete state(s)):这些是对象可能处于的实际状态,并且实现了该状态下的行为方法。通常,这些方法也会导致状态转换。新的状态和转换通过状态的新子类来建模。
下面是该模式的代码:
// 具有多态方法的状态接口
type State interface {
Op1(*Context)
Op2(*Context)
}
// 上下文类
type Context struct {
state State
}
func (c *Context) Op1() {
c.state.Op1(c)
}
func (c *Context) Op2() {
c.state.Op2(c)
}
func (c *Context) SetState(state State) {
c.state = state
}
func NewContext() *Context{
c := new(Context)
c.SetState(new(StateA)) // 初始状态
return c
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
客户端与所有状态变化隔离开来,如下所示:
func client() {
context := NewContext()
// 状态操作
context.Op1()
context.Op2() // <- 这会将状态更改为State 2
context.Op1()
context.Op2() // <- 这会将状态更改回State 1
}
2
3
4
5
6
7
8
# 总结
在本章中,我们详细探讨了底层设计原则和各种设计模式。希望这能让读者对各种底层结构有更实际的理解。
在下一章中,我们将探讨应用程序的可扩展性意味着什么,以及为确保应用程序满足预期的性能和可靠性要求,需要关注哪些方面。