结合Golang聊聊23种设计模式—结构型(1)

结构型模式主要总结了一些类或对象组合在一起的经典结构,这些经典的结构可以解决特定应用场景的问题。结构型模式包括:代理模式、桥接模式、装饰器模式、适配器模式、门面模式、组合模式、享元模式。

代理模式

代理模式(Proxy Design Pattern)它在不改变原始类(或叫被代理类)代码的情况下,通过引入代理类来给原始类附加功能。

为什么要使用代理模式

第一,防止框架如监控、统计、鉴权、限流、事务、幂等、日志,缓存等代码侵入到业务代码中,跟业务代码高度耦合。如果未来需要替换这个框架,那替换的成本会比较大。

第二,框架代码跟业务代码无关,本就不应该放到一个类中。业务类最好职责更加单一,只聚焦业务处理。

代理类负责在业务代码执行前后附加其他逻辑代码,并通过委托的方式调用原始类来执行业务代码。具体的代码实现如下所示:

Nginx 这样的 Web 服务器可充当应用程序服务器的代理:

  • 提供了对应用程序服务器的受控访问权限。
  • 可限制速度。
  • 可缓存请求。

server.go: 主体

1
2
3
4
5
package main

type server interface {
handleRequest(string, string) (int, string)
}

nginx.go: 代理

1
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
package main

type Nginx struct {
application *Application
maxAllowedRequest int
rateLimiter map[string]int
}

func newNginxServer() *Nginx {
return &Nginx{
application: &Application{},
maxAllowedRequest: 2,
rateLimiter: make(map[string]int),
}
}

func (n *Nginx) handleRequest(url, method string) (int, string) {
allowed := n.checkRateLimiting(url)
if !allowed {
return 403, "Not Allowed"
}
return n.application.handleRequest(url, method)
}

func (n *Nginx) checkRateLimiting(url string) bool {
if n.rateLimiter[url] == 0 {
n.rateLimiter[url] = 1
}
if n.rateLimiter[url] > n.maxAllowedRequest {
return false
}
n.rateLimiter[url] = n.rateLimiter[url] + 1
return true
}

application.go: 真实主体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

type Application struct {
}

func (a *Application) handleRequest(url, method string) (int, string) {
if url == "/app/status" && method == "GET" {
return 200, "Ok"
}

if url == "/create/user" && method == "POST" {
return 201, "User Created"
}
return 404, "Not Ok"
}

main.go: 客户端代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main

import "fmt"

func main() {

nginxServer := newNginxServer()
appStatusURL := "/app/status"
createuserURL := "/create/user"

httpCode, body := nginxServer.handleRequest(appStatusURL, "GET")
fmt.Printf("\nUrl: %s\nHttpCode: %d\nBody: %s\n", appStatusURL, httpCode, body)

httpCode, body = nginxServer.handleRequest(appStatusURL, "GET")
fmt.Printf("\nUrl: %s\nHttpCode: %d\nBody: %s\n", appStatusURL, httpCode, body)

httpCode, body = nginxServer.handleRequest(appStatusURL, "GET")
fmt.Printf("\nUrl: %s\nHttpCode: %d\nBody: %s\n", appStatusURL, httpCode, body)

httpCode, body = nginxServer.handleRequest(createuserURL, "POST")
fmt.Printf("\nUrl: %s\nHttpCode: %d\nBody: %s\n", appStatusURL, httpCode, body)

httpCode, body = nginxServer.handleRequest(createuserURL, "GET")
fmt.Printf("\nUrl: %s\nHttpCode: %d\nBody: %s\n", appStatusURL, httpCode, body)
}

output.txt: 执行结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Url: /app/status
HttpCode: 200
Body: Ok

Url: /app/status
HttpCode: 200
Body: Ok

Url: /app/status
HttpCode: 403
Body: Not Allowed

Url: /app/status
HttpCode: 201
Body: User Created

Url: /app/status
HttpCode: 404
Body: Not Ok

上面是一种静态代理的方式,这种方式的问题在于如果是这个代理想要加到其他不同接口的对象上还要再次实现这个对象的所有接口,还有一种动态代理的方式,但golang的反射是很难实现的,如果使用反射的方式代理的调用方法就要变化而且性能很差。

场景

业务系统的非功能性需求开发

代理模式最常用的一个应用场景就是,在业务系统中开发一些非功能性需求,比如:监控、统计、鉴权、限流、事务、幂等、日志。我们将这些附加功能与业务功能解耦,放到代理类中统一处理,让程序员只需要关注业务方面的开发。实际上,前面举的搜集接口请求信息的例子,就是这个应用场景的一个典型例子。如果你熟悉 Java 语言和 Spring 开发框架,这部分工作都是可以在 Spring AOP 切面中完成的。前面我们也提到,Spring AOP 底层的实现原理就是基于动态代理。

代理模式在 RPC、缓存中的应用

实际上,RPC 框架也可以看作一种代理模式,GoF 的《设计模式》一书中把它称作远程代理。通过远程代理,将网络通信、数据编解码等细节隐藏起来。客户端在使用 RPC 服务的时候,就像使用本地函数一样,无需了解跟服务器交互的细节。除此之外,RPC 服务的开发者也只需要开发业务逻辑,就像开发本地使用的函数一样,不需要关注跟客户端的交互细节。

我们再来看代理模式在缓存中的应用。假设我们要开发一个接口请求的缓存功能,对于某些接口请求,如果入参相同,在设定的过期时间内,直接返回缓存结果,而不用重新进行逻辑处理。比如,针对获取用户个人信息的需求,我们可以开发两个接口,一个支持缓存,一个支持实时查询。对于需要实时数据的需求,我们让其调用实时查询接口,对于不需要实时数据的需求,我们让其调用支持缓存的接口。那如何来实现接口请求的缓存功能呢?

最简单的实现方法就是刚刚我们讲到的,给每个需要支持缓存的查询需求都开发两个不同的接口,一个支持缓存,一个支持实时查询。但是,这样做显然增加了开发成本,而且会让代码看起来非常臃肿(接口个数成倍增加),也不方便缓存接口的集中管理(增加、删除缓存接口)、集中配置(比如配置每个接口缓存过期时间)。

访问控制 (保护代理)

如果你只希望特定客户端使用服务对象, 这里的对象可以是操作系统中非常重要的部分, 而客户端则是各种已启动的程序 (包括恶意程序), 此时可使用代理模式。代理可仅在客户端凭据满足要求时将请求传递给服务对象。

记录日志请求 (日志记录代理)

适用于当你需要保存对于服务对象的请求历史记录时。代理可以在向服务传递请求前进行记录。

优点

  • 你可以在客户端毫无察觉的情况下控制服务对象。
  • 如果客户端对服务对象的生命周期没有特殊要求, 你可以对生命周期进行管理。
  • 即使服务对象还未准备好或不存在, 代理也可以正常工作。
  • 开闭原则。 你可以在不对服务或客户端做出修改的情况下创建新代理

缺点

  • 代码可能会变得复杂, 因为需要新建许多类。
  • 服务响应可能会延迟。

桥接模式

桥接模式,也叫作桥梁模式,英文是 Bridge Design Pattern。

这其中“最纯正”的理解方式,当属 GoF 的《设计模式》一书中对桥接模式的定义。毕竟,这 23 种经典的设计模式,最初就是由这本书总结出来的。在 GoF 的《设计模式》一书中,桥接模式是这么定义的:“Decouple an abstraction from its implementation so that the two can vary independently。”翻译成中文就是:“将抽象和实现解耦,让它们可以独立变化。”

这里的抽象还有另外一种理解方式:“一个类存在两个(或多个)独立变化的维度,我们通过组合的方式,让这两个(或多个)维度可以独立进行扩展。”通过组合关系来替代继承关系,避免继承层次的指数级爆炸。这种理解方式非常类似于“组合优于继承”设计原则。

我们如何理解 GoF 的话呢?我认为GoF 所说的抽象是指依赖类而实现是指依赖类。被依赖类是抽象出的接口对应一组实现中的一个实现类,依赖类也是抽象的接口同样对应一组实现,依赖类不依赖被依赖的的实现而是依赖他的抽象,这应该是作者想要表达的意思。

为什么要用桥接模式

我们在面向对象编程的时候如果一个对象和另一个有排列组合的关系那么很可能在这两个对象派生出两组对象时出现几何级别增加的组合类,这种关系维护起来非常难,这时候我们就需要把这两组类都抽象成接口,在依赖时用注入的方式进行组合,这时产生的类就只有一个。这其实就是面向接口而非面向实现编程的原则,同时也符合开闭原则。

代码示例

假设你有两台电脑: 一台 Mac 和一台 Windows。 还有两台打印机: 爱普生和惠普。 这两台电脑和打印机可能会任意组合使用。 客户端不应去担心如何将打印机连接至计算机的细节问题。

如果引入新的打印机, 我们也不会希望代码量成倍增长。 所以, 我们创建了两个层次结构, 而不是 2x2 组合的四个结构体:

  • 抽象层: 代表计算机
  • 实施层: 代表打印机

这两个层次可通过桥接进行沟通, 其中抽象层 (计算机) 包含对于实施层 (打印机) 的引用。 抽象层和实施层均可独立开发, 不会相互影响。

computer.go: 抽象

1
2
3
4
5
6
package main

type Computer interface {
Print()
SetPrinter(Printer)
}

mac.go: 精确抽象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import "fmt"

type Mac struct {
printer Printer
}

func (m *Mac) Print() {
fmt.Println("Print request for mac")
m.printer.PrintFile()
}

func (m *Mac) SetPrinter(p Printer) {
m.printer = p
}

windows.go: 精确抽象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import "fmt"

type Windows struct {
printer Printer
}

func (w *Windows) Print() {
fmt.Println("Print request for windows")
w.printer.PrintFile()
}

func (w *Windows) SetPrinter(p Printer) {
w.printer = p
}

printer.go: 实施

1
2
3
4
5
package main

type Printer interface {
PrintFile()
}

epson.go: 具体实施

1
2
3
4
5
6
7
8
9
10
package main

import "fmt"

type Epson struct {
}

func (p *Epson) PrintFile() {
fmt.Println("Printing by a EPSON Printer")
}

hp.go: 具体实施

1
2
3
4
5
6
7
8
9
10
package main

import "fmt"

type Hp struct {
}

func (p *Hp) PrintFile() {
fmt.Println("Printing by a HP Printer")
}

main.go: 客户端代码

1
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
package main

import "fmt"

func main() {

hpPrinter := &Hp{}
epsonPrinter := &Epson{}

macComputer := &Mac{}

macComputer.SetPrinter(hpPrinter)
macComputer.Print()
fmt.Println()

macComputer.SetPrinter(epsonPrinter)
macComputer.Print()
fmt.Println()

winComputer := &Windows{}

winComputer.SetPrinter(hpPrinter)
winComputer.Print()
fmt.Println()

winComputer.SetPrinter(epsonPrinter)
winComputer.Print()
fmt.Println()
}

output.txt: 执行结果

1
2
3
4
5
6
7
8
9
10
Print request for mac
Printing by a HP Printer

Print request for mac
Printing by a EPSON Printer

Print request for windows
Printing by a HP Printer

Print request for windows

场景

如果你想要拆分或重组一个具有多重功能的庞杂类 (例如能与多个数据库服务器进行交互的类), 可以使用桥接模式。

类的代码行数越多, 弄清其运作方式就越困难, 对其进行修改所花费的时间就越长。 一个功能上的变化可能需要在整个类范围内进行修改, 而且常常会产生错误, 甚至还会有一些严重的副作用。

桥接模式可以将庞杂类拆分为几个类层次结构。 此后, 你可以修改任意一个类层次结构而不会影响到其他类层次结构。 这种方法可以简化代码的维护工作, 并将修改已有代码的风险降到最低。

如果你希望在几个独立维度上扩展一个类, 可使用该模式。

桥接建议将每个维度抽取为独立的类层次。 初始类将相关工作委派给属于对应类层次的对象, 无需自己完成所有工作。

如果你需要在运行时切换不同实现方法, 可使用桥接模式。

当然并不是说一定要实现这一点, 桥接模式可替换抽象部分中的实现对象, 具体操作就和给成员变量赋新值一样简单。

顺便提一句, 最后一点是很多人混淆桥接模式和策略模式的主要原因。 记住, 设计模式并不仅是一种对类进行组织的方式, 它还能用于沟通意图和解决问题。

优点

  • 你可以创建与平台无关的类和程序。
  • 客户端代码仅与高层抽象部分进行互动, 不会接触到平台的详细信息。
  • 开闭原则。 你可以新增抽象部分和实现部分, 且它们之间不会相互影响。
  • 单一职责原则。 抽象部分专注于处理高层逻辑, 实现部分处理平台细节。

缺点

  • 对高内聚的类使用该模式可能会让代码更加复杂。

装饰器模式

装饰模式是一种结构型设计模式, 允许你通过将对象放入包含行为的特殊封装对象中来为原对象绑定新的行为。

为什么要使用装饰器模式

装饰器模式主要解决继承关系过于复杂的问题,通过组合来替代继承,给原始类添加增强功能。

装饰器模式就是简单的“用组合替代继承”吗?当然不是。装饰器模式相对于简单的组合关系,还有两个比较特殊的地方。第一个比较特殊的地方是:装饰器类和原始类继承同样的父类,这样我们可以对原始类“嵌套”多个装饰器类。

第二个比较特殊的地方是:装饰器类是对功能的增强,这也是装饰器模式应用场景的一个重要特点。实际上,符合“组合关系”这种代码结构的设计模式有很多,比如之前讲过的代理模式、桥接模式,还有现在的装饰器模式。尽管它们的代码结构很相似,但是每种设计模式的意图是不同的。就拿比较相似的代理模式和装饰器模式来说吧,代理模式中,代理类附加的是跟原始类无关的功能,而在装饰器模式中,装饰器类附加的是跟原始类相关的增强功能。

代码示例

装饰是一种结构设计模式, 允许你通过将对象放入特殊封装对象中来为原对象增加新的行为。

由于目标对象和装饰器遵循同一接口, 因此你可用装饰来对对象进行无限次的封装。 结果对象将获得所有封装器叠加而来的行为。

pizza.go: 零件接口

1
2
3
4
5
package main

type IPizza interface {
getPrice() int
}

veggieMania.go: 具体零件

1
2
3
4
5
6
7
8
package main

type VeggieMania struct {
}

func (p *VeggieMania) getPrice() int {
return 15
}

tomatoTopping.go: 具体装饰

1
2
3
4
5
6
7
8
9
10
package main

type TomatoTopping struct {
pizza IPizza
}

func (c *TomatoTopping) getPrice() int {
pizzaPrice := c.pizza.getPrice()
return pizzaPrice + 7
}

cheeseTopping.go: 具体装饰

1
2
3
4
5
6
7
8
9
10
package main

type CheeseTopping struct {
pizza IPizza
}

func (c *CheeseTopping) getPrice() int {
pizzaPrice := c.pizza.getPrice()
return pizzaPrice + 10
}

main.go: 客户端代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

import "fmt"

func main() {

pizza := &VeggieMania{}

//Add cheese topping
pizzaWithCheese := &CheeseTopping{
pizza: pizza,
}

//Add tomato topping
pizzaWithCheeseAndTomato := &TomatoTopping{
pizza: pizzaWithCheese,
}

fmt.Printf("Price of veggeMania with tomato and cheese topping is %d\n", pizzaWithCheeseAndTomato.getPrice())
}

output.txt: 执行结果

1
Price of veggeMania with tomato and cheese topping is 32

场景

如果你希望在无需修改代码的情况下即可使用对象, 且希望在运行时为对象新增额外的行为, 可以使用装饰模式。

装饰能将业务逻辑组织为层次结构, 你可为各层创建一个装饰, 在运行时将各种不同逻辑组合成对象。 由于这些对象都遵循通用接口, 客户端代码能以相同的方式使用这些对象。

如果用继承来扩展对象行为的方案难以实现或者根本不可行, 你可以使用该模式。

许多编程语言使用 final最终关键字来限制对某个类的进一步扩展。 复用最终类已有行为的唯一方法是使用装饰模式: 用封装器对其进行封装。

优点

  • 你可以创建与平台无关的类和程序。
  • 客户端代码仅与高层抽象部分进行互动, 不会接触到平台的详细信息。
  • 开闭原则。 你可以新增抽象部分和实现部分, 且它们之间不会相互影响。
  • 单一职责原则。 抽象部分专注于处理高层逻辑, 实现部分处理平台细节。

缺点

  • 对高内聚的类使用该模式可能会让代码更加复杂。