在本章中,我们将完成结构模式。我们将一些最复杂的问题留到了最后,以便您更习惯于设计模式的机制,以及 Go 语言的特性。
在本章中,我们将编写一个缓存来访问数据库、一个用于收集天气数据的库、一个带有运行时中间件的服务器,并讨论一种通过保存类型值之间的可共享状态来节省内存的方法。
我们将从代理模式开始关于结构模式的最后一章。这是一个简单的模式,只需很少的努力就可以提供有趣的特性和可能性。
代理模式通常包裹对象以隐藏其某些特征。这些特征可能是,它是一个远程对象(远程代理)、一个非常重的对象(如非常大的映像或 TB 数据库的转储(虚拟代理)或一个受限访问对象(保护代理)。
代理模式的可能性很多,但一般来说,它们都试图提供相同的以下功能:
在我们的示例中,我们将创建一个远程代理,它将在访问数据库之前作为对象的缓存。假设我们有一个包含许多用户的数据库,但不是每次我们需要关于用户的信息时都访问数据库,我们将有一个代理模式的先进先出(FIFO)用户堆栈(FIFO 是一种说法,当缓存需要清空时,它将删除首先输入的第一个对象)。
我们将用代理模式包装一个由切片表示的虚拟数据库。然后,模式必须遵守以下验收标准:
n
个最近用户的堆栈。从 Go 的 1.7 版开始,我们可以通过使用闭包在测试中嵌入测试,这样我们就可以以更人性化的方式对它们进行分组,并减少Test_
函数的数量。参见第 1 章、准备就绪。。。稳步的去了解如何在当前版本早于 1.7 版的情况下安装新版 Go。
此模式的类型将是代理用户和用户列表结构,以及数据库和代理将实现的UserFinder
接口。这是关键,因为代理必须实现与其尝试包装的类型的功能相同的接口:
type UserFinder interface {
FindUser(id int32) (User, error)
}
UserFinder
是数据库和代理实现的接口。User
是一种类型,其成员名为ID
,即int32
类型:
type User struct {
ID int32
}
最后,UserList
是一种用户切片。考虑下面的语法:
type UserList []User
如果你问我们为什么不直接使用一个用户片段,答案是通过这样声明一个用户序列,我们可以实现UserFinder
接口,但是使用一个片段,我们不能。
最后,名为UserListProxy
的代理类型将由UserList
片组成,这将是我们的数据库表示。为简单起见,StackCache
成员也将是UserList
类型,StackCapacity
为堆栈提供我们想要的大小。
为了本教程的目的,我们将进行一些欺骗,并在名为DidDidLastSearchUsedCache
的字段上声明一个布尔状态,如果上次执行的搜索使用了缓存或访问了数据库,该状态将保持不变:
type UserListProxy struct {
SomeDatabase UserList
StackCache UserList
StackCapacity int
DidDidLastSearchUsedCache bool
}
func (u *UserListProxy) FindUser(id int32) (User, error) {
return User{}, errors.New("Not implemented yet")
}
UserListProxy
类型将缓存最多StackCapacity
个用户,如果达到此限制,则旋转缓存。StackCache
成员将由SomeDatabase
类型的对象填充。
第一个测试称为TestUserListProxy
,下面列出:
import (
"math/rand"
"testing"
)
func Test_UserListProxy(t *testing.T) {
someDatabase := UserList{}
rand.Seed(2342342)
for i := 0; i < 1000000; i++ {
n := rand.Int31()
someDatabase = append(someDatabase, User{ID: n})
}
前面的测试创建了一个包含 100 万个随机名称用户的用户列表。为此,我们通过调用带有常数种子的Seed()
函数来为随机数生成器提供数据,这样我们的随机结果也是常数的;用户 ID 就是从中生成的。它可能有一些复制品,但它符合我们的目的。
接下来,我们需要一个引用了someDatabase
的代理,我们刚刚创建了该代理:
proxy := UserListProxy{
SomeDatabase: &someDatabase,
StackCapacity: 2,
StackCache: UserList{},
}
此时,我们有一个proxy
对象,它由一个拥有 100 万用户的模拟数据库和一个实现为大小为 2 的 FIFO 堆栈的缓存组成。现在我们将从someDatabase
中获得三个随机 ID,用于我们的堆栈中:
knownIDs := [3]int32 {someDatabase[3].ID, someDatabase[4].ID,someDatabase[5].ID}
我们从切片中获取了第四个、第五个和第六个 ID(请记住,数组和切片以 0 开头,因此索引 3 实际上是切片中的第四个位置)。
这将是我们启动嵌入式测试之前的起点。要创建嵌入式测试,我们必须调用testing.T
指针的Run
方法,并使用func(t *testing.T)
签名进行描述和闭包:
t.Run("FindUser - Empty cache", func(t *testing.T) {
user, err := proxy.FindUser(knownIDs[0])
if err != nil {
t.Fatal(err)
}
例如,在前面的代码片段中,我们给出了描述FindUser - Empty cache
。然后定义闭包。首先,它尝试查找具有已知 ID 的用户,并检查错误。如描述所示,此时缓存为空,必须从someDatabase
数组中检索用户:
if user.ID != knownIDs[0] {
t.Error("Returned user name doesn't match with expected")
}
if len(proxy.StackCache) != 1 {
t.Error("After one successful search in an empty cache, the size of it must be one")
}
if proxy.DidLastSearchUsedCache {
t.Error("No user can be returned from an empty cache")
}
}
最后,我们检查返回的用户是否与knownIDs
切片索引 0 处的预期用户具有相同的 ID,并且代理缓存现在的大小为 1。成员DidLastSearchUsedCache
代理的状态不得为true
,否则我们将无法通过测试。请记住,此成员告诉我们上次搜索是从表示数据库的切片中检索的,还是从缓存中检索的。
代理模式的第二个嵌入式测试是请求与以前相同的用户,现在必须从缓存返回。这与前面的测试非常相似,但现在我们必须检查用户是否从缓存返回:
t.Run("FindUser - One user, ask for the same user", func(t *testing.T) {
user, err := proxy.FindUser(knownIDs[0])
if err != nil {
t.Fatal(err)
}
if user.ID != knownIDs[0] {
t.Error("Returned user name doesn't match with expected")
}
if len(proxy.StackCache) != 1 {
t.Error("Cache must not grow if we asked for an object that is stored on it")
}
if !proxy.DidLastSearchUsedCache {
t.Error("The user should have been returned from the cache")
}
})
因此,我们再次请求第一个已知 ID。在这次搜索之后,代理缓存必须保持大小为 1,并且DidLastSearchUsedCache
成员这次必须为 true,否则测试将失败。
最后一次测试将使proxy
类型的StackCache
数组溢出。我们将搜索两个新用户,我们的proxy
类型必须从数据库中检索。我们的堆栈大小为 2,因此必须删除第一个用户才能为第二个和第三个用户分配空间:
user1, err := proxy.FindUser(knownIDs[0])
if err != nil {
t.Fatal(err)
}
user2, _ := proxy.FindUser(knownIDs[1])
if proxy.DidLastSearchUsedCache {
t.Error("The user wasn't stored on the proxy cache yet")
}
user3, _ := proxy.FindUser(knownIDs[2])
if proxy.DidLastSearchUsedCache {
t.Error("The user wasn't stored on the proxy cache yet")
}
我们已检索到前三个用户。我们没有检查错误,因为这是以前测试的目的。记住这一点很重要,不需要过度测试代码。如果这里有任何错误,它将出现在以前的测试中。此外,我们还检查了user2
和user3
查询是否不使用缓存;它们还不应该存放在那里。
现在我们将在代理中查找user1
查询。它不应该存在,因为堆栈的大小为 2,user1
是第一个进入的,因此,第一个离开:
for i := 0; i < len(proxy.StackCache); i++ {
if proxy.StackCache[i].ID == user1.ID {
t.Error("User that should be gone was found")
}
}
if len(proxy.StackCache) != 2 {
t.Error("After inserting 3 users the cache should not grow" +
" more than to two")
}
如果我们要求一千个用户,这并不重要;我们的缓存不能大于配置的大小。
最后,我们将再次搜索存储在缓存中的用户,并将其与我们查询的最后两个用户进行比较。这样,我们将检查缓存中是否只存储了这些用户。必须在其上找到这两者:
for _, v := range proxy.StackCache {
if v != user2 && v != user3 {
t.Error("A non expected user was found on the cache")
}
}
}
像往常一样,现在运行测试应该会出现一些错误。现在让我们运行它们:
$ go test -v .
=== RUN Test_UserListProxy
=== RUN Test_UserListProxy/FindUser_-_Empty_cache
=== RUN Test_UserListProxy/FindUser_-_One_user,_ask_for_the_same_user
=== RUN Test_UserListProxy/FindUser_-_overflowing_the_stack
--- FAIL: Test_UserListProxy (0.06s)
--- FAIL: Test_UserListProxy/FindUser_-_Empty_cache (0.00s)
proxy_test.go:28: Not implemented yet
--- FAIL: Test_UserListProxy/FindUser_-_One_user,_ask_for_the_same_user (0.00s)
proxy_test.go:47: Not implemented yet
--- FAIL: Test_UserListProxy/FindUser_-_overflowing_the_stack (0.00s)
proxy_test.go:66: Not implemented yet
FAIL
exit status 1
FAIL
那么,让我们实现FindUser
方法作为我们的代理。
在我们的代理中,FindUser
方法将在缓存列表中搜索指定的 ID。如果找到它,它将返回 ID。如果没有,它将在数据库中搜索。最后,如果它不在数据库列表中,它将返回一个错误。
如果您还记得,我们的代理模式由两个UserList
类型(其中一个是指针)组成,它们实际上是User
类型的片段。我们也将在User
类型中实现一个FindUser
方法,顺便说一下,该方法与UserFinder
接口具有相同的签名:
type UserList []User
func (t *UserList) FindUser(id int32) (User, error) {
for i := 0; i < len(*t); i++ {
if (*t)[i].ID == id {
return (*t)[i], nil
}
}
return User{}, fmt.Errorf("User %s could not be found\n", id)
}
UserList
切片中的FindUser
方法将迭代列表,尝试查找与id
参数具有相同 ID 的用户,如果找不到,则返回错误。
您可能想知道为什么指针t
位于括号之间。这是为了在访问底层数组的索引之前取消对其的引用。没有它,您将有一个编译错误,因为编译器试图在取消引用指针之前搜索索引。
因此,代理FindUser
方法的第一部分可以写为:
func (u *UserListProxy) FindUser(id int32) (User, error) {
user, err := u.StackCache.FindUser(id)
if err == nil {
fmt.Println("Returning user from cache")
u.DidLastSearchUsedCache = true
return user, nil
}
我们使用前面的方法在StackCache
成员中搜索用户。如果它能找到错误,则错误为零,因此我们检查此项以向控制台打印消息,将状态DidLastSearchUsedCache
更改为true
,以便测试可以检查用户是否从缓存中检索,最后返回用户。
因此,如果错误不是 nil,则表示无法在堆栈中找到用户。因此,下一步是在数据库中搜索:
user, err = u.SomeDatabase.FindUser(id)
if err != nil {
return User{}, err
}
在本例中,我们可以重用为UserList
数据库编写的FindUser
方法,因为在本例中,这两种方法的类型相同。再次,它在由UserList
切片表示的数据库中搜索用户,但在这种情况下,如果没有找到用户,它将返回在UserList
中生成的错误。
当找到用户(err
为 nil)时,我们必须将用户添加到堆栈中。为此,我们编写了一个专用的私有方法,用于接收类型为UserListProxy
的指针:
func (u *UserListProxy) addUserToStack(user User) {
if len(u.StackCache) >= u.StackCapacity {
u.StackCache = append(u.StackCache[1:], user)
}
else {
u.StackCache.addUser(user)
}
}
func (t *UserList) addUser(newUser User) {
*t = append(*t, newUser)
}
addUserToStack
方法接受用户参数,并将其添加到堆栈中。如果堆栈已满,则会在添加之前删除其中的第一个元素。我们还为UserList
编写了一个addUser
方法来帮助我们。所以,现在在FindUser
方法中,我们只需要添加一行:
u.addUserToStack(user)
这会将新用户添加到堆栈中,必要时删除最后一个用户。
最后,我们只需返回堆栈的新用户,并在DidLastSearchUsedCache
变量上设置适当的值。我们还向控制台写入一条消息,以帮助测试过程:
fmt.Println("Returning user from database")
u.DidLastSearchUsedCache = false
return user, nil
}
有了这些,我们就有足够的能力通过测试:
$ go test -v .
=== RUN Test_UserListProxy
=== RUN Test_UserListProxy/FindUser_-_Empty_cache
Returning user from database
=== RUN Test_UserListProxy/FindUser_-_One_user,_ask_for_the_same_user
Returning user from cache
=== RUN Test_UserListProxy/FindUser_-_overflowing_the_stack
Returning user from cache
Returning user from database
Returning user from database
--- PASS: Test_UserListProxy (0.09s)
--- PASS: Test_UserListProxy/FindUser_-_Empty_cache (0.00s)
--- PASS: Test_UserListProxy/FindUser_-_One_user,_ask_for_the_same_user (0.00s)
--- PASS: Test_UserListProxy/FindUser_-_overflowing_the_stack (0.00s)
PASS
ok
您可以在前面的消息中看到,我们的代理工作得非常完美。它已从数据库返回第一次搜索。然后,当我们再次搜索同一个用户时,它使用缓存。最后,我们做了一个新的测试,调用了三个不同的用户,通过查看控制台输出,我们可以观察到只有第一个用户从缓存中返回,另外两个用户从数据库中获取。
围绕需要一些中间操作的类型包装代理,例如向用户授予授权或提供对数据库的访问,如我们的示例中所示。
我们的示例是将应用程序需求与数据库需求分开的好方法。如果我们的应用程序访问数据库的次数过多,那么解决方案就不在您的数据库中。请记住,代理使用与其包装的类型相同的接口,对于用户来说,两者之间不应有任何区别。
我们将继续介绍代理模式的老大哥,也许是最强大的设计模式之一。Decorator模式非常简单,但是,例如,它在处理遗留代码时提供了很多好处。
Decorator 设计模式允许您使用更多功能特性来装饰已经存在的类型,而无需实际触摸它。怎么可能呢?嗯,它使用了一种类似于matryoshka 玩偶的方法,你有一个小玩偶,你可以把它放在一个形状相同但更大的玩偶里,等等。
Decorator 类型实现与它所修饰的类型相同的接口,并在其成员中存储该类型的实例。这样,只需将旧的 decorator 存储在新的 decorator 的字段中,就可以堆叠任意数量的 decorator(玩偶)。
当您考虑在不破坏某些东西的情况下扩展遗留代码时,应该首先考虑 Decorator 模式。这是处理这个特殊问题的一个非常有效的方法。
装饰器非常强大的另一个领域可能不那么明显,尽管它在基于用户输入、首选项或类似输入创建具有许多特性的类型时会暴露出来。就像瑞士刀一样,你有一个基本类型(刀的框架),从那里你可以展开它的功能。
那么,我们什么时候才能使用装饰图案呢?对这一问题的答复:
在我们的示例中,我们将准备一个Pizza
类型,其中核心是比萨饼,配料是装饰类型。我们的比萨饼将有两种配料:洋葱和肉。
装饰器模式的验收标准是有一个公共接口和一个核心类型,所有层都将构建在该接口和核心类型之上:
IngredientAdd
,并且将具有AddIngredient() string
方法。PizzaDecorator
类型(装饰),我们将添加成分。IngredientAdd
接口,将字符串onion
添加到返回的比萨饼中。IngredientAdd
接口的配料“肉”,该接口将把字符串meat
添加到返回的比萨饼中。AddIngredient
方法时,必须返回一个带有文本Pizza with the following ingredients: meat, onion
的完全修饰的pizza
。为了启动单元测试,我们必须首先根据验收标准创建所描述的基本结构。首先,所有装饰类型必须实现的接口如下:
type IngredientAdd interface {
AddIngredient() (string, error)
}
下面的代码定义了PizzaDecorator
类型,里面必须有IngredientAdd
,并且实现了IngredientAdd
:
type PizzaDecorator struct{
Ingredient IngredientAdd
}
func (p *PizzaDecorator) AddIngredient() (string, error) {
return "", errors.New("Not implemented yet")
}
Meat
类型的定义与PizzaDecorator
结构的定义非常相似:
type Meat struct {
Ingredient IngredientAdd
}
func (m *Meat) AddIngredient() (string, error) {
return "", errors.New("Not implemented yet")
}
现在我们以类似的方式定义Onion
结构:
type Onion struct {
Ingredient IngredientAdd
}
func (o *Onion) AddIngredient() (string, error) {
return "", errors.New("Not implemented yet")
}
这足以实现第一个单元测试,并允许编译器在没有任何编译错误的情况下运行它们:
func TestPizzaDecorator_AddIngredient(t *testing.T) {
pizza := &PizzaDecorator{}
pizzaResult, _ := pizza.AddIngredient()
expectedText := "Pizza with the following ingredients:"
if !strings.Contains(pizzaResult, expectedText) {
t.Errorf("When calling the add ingredient of the pizza decorator it must return the text %sthe expected text, not '%s'", pizzaResult, expectedText)
}
}
现在它必须无问题地编译,以便我们可以检查测试是否失败:
$ go test -v -run=TestPizzaDecorator .
=== RUN TestPizzaDecorator_AddIngredient
--- FAIL: TestPizzaDecorator_AddIngredient (0.00s)
decorator_test.go:29: Not implemented yet
decorator_test.go:34: When the the AddIngredient method of the pizza decorator object is called, it must return the text
Pizza with the following ingredients:
FAIL
exit status 1
FAIL
我们的第一个测试已经完成,我们可以看到PizzaDecorator
结构还没有返回任何东西,这就是它失败的原因。我们现在可以转到Onion
类型。Onion
类型的测试与Pizza
装饰器的测试非常相似,但我们还必须确保我们确实将成分添加到IngredientAdd
方法,而不是零指针:
func TestOnion_AddIngredient(t *testing.T) {
onion := &Onion{}
onionResult, err := onion.AddIngredient()
if err == nil {
t.Errorf("When calling AddIngredient on the onion decorator without" + "an IngredientAdd on its Ingredient field must return an error, not a string with '%s'", onionResult)
}
前面测试的前半部分检查了当没有IngredientAdd
方法传递给Onion
结构初始值设定项时返回的错误。由于没有比萨饼可用于添加配料,因此必须返回错误:
onion = &Onion{&PizzaDecorator{}}
onionResult, err = onion.AddIngredient()
if err != nil {
t.Error(err)
}
if !strings.Contains(onionResult, "onion") {
t.Errorf("When calling the add ingredient of the onion decorator it" + "must return a text with the word 'onion', not '%s'", onionResult)
}
}
Onion
型式试验的第二部分实际上将PizzaDecorator
结构传递给初始值设定者。然后,我们检查是否没有返回错误,以及返回的字符串是否包含单词onion
。这样,我们就可以确保比萨饼中添加了洋葱。
最后,对于Onion
类型,我们当前实现的本测试的控制台输出如下:
$ go test -v -run=TestOnion_AddIngredient .
=== RUN TestOnion_AddIngredient
--- FAIL: TestOnion_AddIngredient (0.00s)
decorator_test.go:48: Not implemented yet
decorator_test.go:52: When calling the add ingredient of the onion decorator it must return a text with the word 'onion', not ''
FAIL
exit status 1
FAIL
meat
成分完全相同,但我们将类型改为肉而不是洋葱:
func TestMeat_AddIngredient(t *testing.T) {
meat := &Meat{}
meatResult, err := meat.AddIngredient()
if err == nil {
t.Errorf("When calling AddIngredient on the meat decorator without" + "an IngredientAdd in its Ingredient field must return an error," + "not a string with '%s'", meatResult)
}
meat = &Meat{&PizzaDecorator{}}
meatResult, err = meat.AddIngredient()
if err != nil {
t.Error(err)
}
if !strings.Contains(meatResult, "meat") {
t.Errorf("When calling the add ingredient of the meat decorator it" + "must return a text with the word 'meat', not '%s'", meatResult)
}
}
因此,试验结果将类似:
go test -v -run=TestMeat_AddIngredient .
=== RUN TestMeat_AddIngredient
--- FAIL: TestMeat_AddIngredient (0.00s)
decorator_test.go:68: Not implemented yet
decorator_test.go:72: When calling the add ingredient of the meat decorator it must return a text with the word 'meat', not ''
FAIL
exit status 1
FAIL
最后,我们必须检查完整堆栈测试。用洋葱和肉制作比萨饼必须返回文本Pizza with the following ingredients: meat, onion
:
func TestPizzaDecorator_FullStack(t *testing.T) {
pizza := &Onion{&Meat{&PizzaDecorator{}}}
pizzaResult, err := pizza.AddIngredient()
if err != nil {
t.Error(err)
}
expectedText := "Pizza with the following ingredients: meat, onion"
if !strings.Contains(pizzaResult, expectedText){
t.Errorf("When asking for a pizza with onion and meat the returned " + "string must contain the text '%s' but '%s' didn't have it", expectedText,pizzaResult)
}
t.Log(pizzaResult)
}
我们的测试创建了一个名为pizza
的变量,它与 matryoshka dolls一样,将IngredientAdd
方法的类型嵌入到多个级别。调用AddIngredient
方法在“洋葱”级别执行该方法,洋葱执行“肉”方法,肉最后执行PizzaDecorator
结构的方法。在检查没有返回错误后,我们检查返回的文本是否符合验收标准 5的要求。使用以下命令运行测试:
go test -v -run=TestPizzaDecorator_FullStack .
=== RUN TestPizzaDecorator_FullStack
--- FAIL: TestPizzaDecorator_FullStack (0.
decorator_test.go:80: Not implemented yet
decorator_test.go:87: When asking for a pizza with onion and meat the returned string must contain the text 'Pizza with the following ingredients: meat, onion' but '' didn't have it
FAIL
exit status 1
FAIL
从前面的输出中,我们可以看到测试现在为我们的修饰类型返回一个空字符串。当然,这是因为还没有实施。这是检查完全修饰的实现的最后一次测试。那么让我们仔细看一下实现。
我们将开始实现PizzaDecorator
类型。其作用是提供完整比萨饼的初始文本:
type PizzaDecorator struct {
Ingredient IngredientAdd
}
func (p *PizzaDecorator) AddIngredient() (string, error) {
return "Pizza with the following ingredients:", nil
}
AddIngredient
方法返回时的单行变化足以通过测试:
go test -v -run=TestPizzaDecorator_Add .
=== RUN TestPizzaDecorator_AddIngredient
--- PASS: TestPizzaDecorator_AddIngredient (0.00s)
PASS
ok
继续讨论Onion
结构实现,我们必须取返回的IngredientAdd
字符串的开头,并在其末尾添加单词onion
,以便得到一个合成的比萨饼作为回报:
type Onion struct {
Ingredient IngredientAdd
}
func (o *Onion) AddIngredient() (string, error) {
if o.Ingredient == nil {
return "", errors.New("An IngredientAdd is needed in the Ingredient field of the Onion")
}
s, err := o.Ingredient.AddIngredient()
if err != nil {
return "", err
}
return fmt.Sprintf("%s %s,", s, "onion"), nil
}
首先检查我们是否有指向IngredientAdd
的指针,我们使用内部IngredientAdd
的内容,并检查它是否有错误。如果没有出现错误,我们将收到一个由该内容、空格和单词onion
(没有错误)组成的新字符串。看起来足以运行测试:
go test -v -run=TestOnion_AddIngredient .
=== RUN TestOnion_AddIngredient
--- PASS: TestOnion_AddIngredient (0.00s)
PASS
ok
Meat
结构的实现非常相似:
type Meat struct {
Ingredient IngredientAdd
}
func (m *Meat) AddIngredient() (string, error) {
if m.Ingredient == nil {
return "", errors.New("An IngredientAdd is needed in the Ingredient field of the Meat")
}
s, err := m.Ingredient.AddIngredient()
if err != nil {
return "", err
}
return fmt.Sprintf("%s %s,", s, "meat"), nil
}
下面是他们的测试执行:
go test -v -run=TestMeat_AddIngredient .
=== RUN TestMeat_AddIngredient
--- PASS: TestMeat_AddIngredient (0.00s)
PASS
ok
可以所以,现在所有的部件都要单独测试。如果一切正常,满叠溶液的测试必须顺利通过:
go test -v -run=TestPizzaDecorator_FullStack .
=== RUN TestPizzaDecorator_FullStack
--- PASS: TestPizzaDecorator_FullStack (0.00s)
decorator_test.go:92: Pizza with the following ingredients: meat, onion,
PASS
ok
令人惊叹的有了 Decorator 模式,我们可以继续堆叠IngredientAdds
,调用其内部指针向PizzaDecorator
添加功能。我们也没有触及核心类型,也没有修改或实现新事物。所有新功能都由外部类型实现。
现在,您应该已经了解了 Decorator 模式是如何工作的。现在,我们可以使用在适配器模式部分中设计的小型 HTTP 服务器来尝试一个更高级的示例。您了解到,可以通过使用http
包并实现http.Handler
接口来创建 HTTP 服务器。此接口只有一个名为ServeHTTP(http.ResponseWriter, http.Request)
的方法。我们可以使用 Decorator 模式向服务器添加更多功能吗?当然
我们将向该服务器添加两个部分。首先,我们将把它的每个连接记录到io.Writer
接口(为了简单起见,我们将使用os.Stdout
接口的io.Writer
实现,以便它输出到控制台)。第二部分将向向服务器发出的每个请求添加基本 HTTP 身份验证。如果认证通过,将出现一条Hello Decorator!
消息。最后,用户将能够在服务器中选择他/她想要的装饰项目的数量,并且服务器将在运行时被构造和创建。
我们已经有了共同的接口,我们将装饰使用嵌套类型。我们首先需要创建核心类型,它将是返回句子Hello Decorator!
的Handler
:
type MyServer struct{}
func (m *MyServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello Decorator!")
}
这个处理程序可以归因于http.Handle
方法来定义我们的第一个端点。现在让我们通过创建包的main
函数并向其发送GET
请求来检查这一点:
func main() {
http.Handle("/", &MyServer{})
log.Fatal(http.ListenAndServe(":8080", nil))
}
使用终端执行服务器执行 **go run main.go**
命令。然后,打开一个新的终端进行GET
请求。我们将使用curl
命令发出请求:
$ curl http://localhost:8080
Hello Decorator!
我们已经跨过了装饰服务器的第一个里程碑。下一步是用日志功能来修饰它。为此,我们必须以一种新的类型实现http.Handler
接口,如下所示:
type LoggerServer struct {
Handler http.Handler
LogWriter io.Writer
}
func (s *LoggerServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(s.LogWriter, "Request URI: %s\n", r.RequestURI)
fmt.Fprintf(s.LogWriter, "Host: %s\n", r.Host)
fmt.Fprintf(s.LogWriter, "Content Length: %d\n",
r.ContentLength)
fmt.Fprintf(s.LogWriter, "Method: %s\n", r.Method)fmt.Fprintf(s.LogWriter, "--------------------------------\n")
s.Handler.ServeHTTP(w, r)
}
我们称这种类型为LoggerServer
。如您所见,它不仅存储了一个Handler
,还存储了一个io.Writer
来写入日志的输出。我们实现的ServeHTTP
方法打印请求 URI、主机、内容长度和使用的方法io.Writer
。打印完成后,调用其内部Handler
字段的ServeHTTP
函数。
我们可以用这个LoggerMiddleware
来装饰MyServer
:
func main() {
http.Handle("/", &LoggerServer{
LogWriter:os.Stdout,
Handler:&MyServer{},
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
现在运行 **curl **
命令:
$ curl http://localhost:8080
Hello Decorator!
我们的curl命令返回相同的消息,但是如果您查看运行 Go 应用程序的终端,您可以看到日志记录:
$ go run server_decorator.go
Request URI: /
Host: localhost:8080
Content Length: 0
Method: GET
我们在MyServer
上添加了日志功能,但没有实际修改它。我们可以对身份验证执行同样的操作吗?当然在记录请求后,我们将使用HTTP 基本身份验证对其进行身份验证,如下所示:
type BasicAuthMiddleware struct {
Handler http.Handler
User string
Password string
}
BasicAuthMiddleware中间件存储了三个字段——一个像以前的中间件一样装饰的处理程序、一个用户和一个密码,这将是访问服务器上内容的唯一授权。decorating
方法的实施过程如下:
func (s *BasicAuthMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if ok {
if user == s.User && pass == s.Password {
s.Handler.ServeHTTP(w, r)
}
else {
fmt.Fprintf(w, "User or password incorrect\n")
}
}
else {
fmt.Fprintln(w, "Error trying to retrieve data from Basic auth")
}
}
在前面的实现中,我们使用http.Request
中的BasicAuth
方法从请求中自动检索用户和密码,再加上解析操作中的ok/ko
。然后检查解析是否正确(如果不正确,则向请求者返回消息,并完成请求)。如果解析过程中没有发现问题,我们会检查用户名和密码是否与BasicAuthMiddleware
中存储的用户名和密码匹配。如果凭证有效,我们将调用修饰类型(我们的服务器),但是如果凭证无效,我们将收到返回的User or password incorrect
消息,请求完成。
现在,我们需要为用户提供一种在不同类型的服务器之间进行选择的方法。我们将在 main 函数中检索用户输入数据。我们有三个选项可供选择:
我们必须使用Fscanf
功能从用户检索输入:
func main() {
fmt.Println("Enter the type number of server you want to launch from the following:")
fmt.Println("1.- Plain server")
fmt.Println("2.- Server with logging")
fmt.Println("3.- Server with logging and authentication")
var selection int
fmt.Fscanf(os.Stdin, "%d", &selection)
}
Fscanf
函数需要一个io.Reader
实现器作为第一个参数(将作为控制台中的输入),并从中获取用户选择的服务器。我们将通过os.Stdin
作为io.Reader
接口来检索用户输入。然后,我们将编写它要解析的数据类型。%d
说明符是指一个整数。最后,我们将写入内存方向以存储解析后的输入,在本例中,是selection
变量的内存位置。
一旦用户选择了一个选项,我们就可以使用基本服务器并在运行时装饰它,切换到所选选项:
switch selection {
case 1:
mySuperServer = new(MyServer)
case 2:
mySuperServer = &LoggerMiddleware{
Handler: new(MyServer),
LogWriter: os.Stdout,
}
case 3:
var user, password string
fmt.Println("Enter user and password separated by a space")
fmt.Fscanf(os.Stdin, "%s %s", &user, &password)
mySuperServer = &LoggerMiddleware{
Handler: &SimpleAuthMiddleware{
Handler: new(MyServer),
User: user,
Password: password,
},
LogWriter: os.Stdout,
}
default:
mySuperServer = new(MyServer)
}
第一个选项将由默认的switch
选项处理——一个普通的MyServer
。在第二个选项中,我们用日志来装饰一个普通服务器。第三个选项更为完善——我们再次使用Fscanf
要求用户输入用户名和密码。请注意,您可以扫描多个输入,正如我们检索用户和密码所做的那样。然后,我们使用基本服务器,使用身份验证对其进行修饰,最后使用日志记录。
如果您遵循选项 3 的嵌套类型的缩进,那么请求将通过记录器,然后是认证中间件,最后是MyServer
参数(如果一切正常)。请求将遵循相同的路径。
主函数的末尾接受修饰后的处理程序,并在8080
端口上启动服务器:
http.Handle("/", mySuperServer)
log.Fatal(http.ListenAndServe(":8080", nil))
那么,让我们使用第三个选项启动服务器:
$go run server_decorator.go
Enter the server type number you want to launch from the following:
1.- Plain server
2.- Server with logging
3.- Server with logging and authentication
Enter user and password separated by a space
mario castro
我们将首先通过选择第一个选项来测试普通服务器。使用命令go Run server_decorator.go运行服务器,并选择第一个选项。然后,在另一个终端中,使用 curl 运行基本请求,如下所示:
$ curl http://localhost:8080
Error trying to retrieve data from Basic auth
哦,哦!它不能让我们进入。我们没有传递任何用户和密码,因此它告诉我们无法继续。让我们尝试使用一些随机用户和密码:
$ curl -u no:correct http://localhost:8080
User or password incorrect
禁止进入!我们还可以在启动服务器和记录每个请求的终端进行检查:
Request URI: /
Host: localhost:8080
Content Length: 0
Method: GET
最后,输入正确的用户名和密码:
$ curl -u packt:publishing http://localhost:8080
Hello Decorator!
我们到了!我们的请求也已被记录,服务器已授予我们访问权限。现在,我们可以通过编写更多的中间件来装饰服务器的功能,从而尽可能地改进服务器。
Go 有一个大多数人一开始都不喜欢的特性——结构化输入。当您的结构定义了您的类型而没有显式地编写它时。例如,当您实现一个接口时,您不必显式地编写您实际上正在实现它,这与 Java 等语言相反,在 Java 等语言中,您必须编写关键字implements
。如果您的方法遵循接口的签名,那么您实际上是在实现接口。这也可能导致接口的意外实现,这可能会引发无法跟踪的错误,但这是不太可能的。
但是,结构类型还允许您在定义接口的实现者之后定义接口。想象一个MyPrinter
结构,如下所示:
type MyPrinter struct{}
func(m *MyPrinter)Print(){
println("Hello")
}
想象一下,我们已经使用MyPrinter
类型几个月了,但它没有实现任何接口,所以它不可能成为装饰器模式的候选对象,或者它可以?如果我们在几个月后编写了一个与其Print
方法相匹配的接口会怎么样?考虑下面的代码片段:
type Printer interface {
Print()
}
它实际上实现了Printer
接口,我们可以使用它来创建装饰器解决方案。
在编写程序时,结构类型允许很大的灵活性。如果您不知道某个类型是否应该是接口的一部分,那么可以保留该类型,并在完全确定该类型后添加该接口。通过这种方式,您可以非常轻松地修饰类型,并且只需对源代码进行很少的修改。
您可能会想,装饰器模式和代理模式之间有什么区别?在 Decorator 模式中,我们动态地修饰一个类型。这意味着装饰可能存在,也可能不存在,也可能由一种或多种类型组成。如果您还记得的话,代理模式以类似的方式包装一个类型,但它是在编译时包装的,更像是访问某个类型的一种方式。
同时,装饰器可能实现它所装饰的类型也实现的整个接口或不实现。因此,您可以有一个包含 10 个方法的接口和一个只实现其中一个方法的装饰器,它仍然有效。对装饰器未实现的方法的调用将传递给装饰类型。这是一个非常强大的特性,但如果您忘记实现任何接口方法,那么在运行时也很容易出现不希望出现的行为。
在这方面,您可能会认为代理模式不太灵活,而且是。但是 Decorator 模式较弱,因为运行时可能会出现错误,使用代理模式可以在编译时避免这些错误。请记住,当您希望在运行时向对象添加功能时,通常使用装饰器,就像在我们的 web 服务器中一样。这是你需要什么和你想牺牲什么来实现它之间的妥协。
我们将在本章中看到的下一个模式是 Facade 模式。当我们讨论代理模式时,您知道这是一种包装类型的方法,可以向用户隐藏其复杂性的一些特性。假设我们将多个代理组合在一个点上,例如文件或库。这可能是一种门面模式。
在建筑术语中,外观是隐藏建筑房间和走廊的前墙。它保护居民免受寒冷和雨水的侵袭,并为他们提供隐私。它对住宅进行排序和划分。
Facade 设计模式也是如此,但在我们的代码中。它保护代码不受不必要的访问,命令一些调用,并对用户隐藏复杂性范围。
当您想要隐藏某些任务的复杂性时,尤其是当其中大多数任务共享实用程序(例如 API 中的身份验证)时,可以使用 Facade。库是 facade 的一种形式,其中必须有人提供一些方法让开发人员以友好的方式完成某些事情。这样,如果开发人员需要使用您的库,他不需要知道所有内部任务来检索他/她想要的结果。
因此,您可以在以下场景中使用 Facade 设计模式:
例如,我们将采取第一步来编写我们自己的访问OpenWeatherMaps
服务的库。如果您不熟悉OpenWeatherMap
服务,它是一个 HTTP 服务,为您提供有关天气的实时信息以及历史数据。HTTP RESTAPI 非常易于使用,它将是如何创建门面模式以隐藏 REST 服务背后的网络连接复杂性的一个好例子。
OpenWeatherMap
API 提供了大量信息,因此我们将重点关注通过使用纬度和经度值获取某个地理位置的城市的实时天气数据。以下是该设计模式的要求和验收标准:
OpenWeatherMap
服务检索到的所有信息都将通过它。从我们的 API Facade 开始,我们需要一个接口,接口采用验收标准 2和验收标准 3中要求的方法:
type CurrentWeatherDataRetriever interface {
GetByCityAndCountryCode(city, countryCode string) (Weather, error)
GetByGeoCoordinates(lat, lon float32) (Weather, error)
}
我们将调用验收标准 2GetByCityAndCountryCode
;我们还需要字符串格式的城市名称和国家代码。国家代码是两个字符的代码,代表世界国家的国际标准化组织(ISO)名称。它返回一个Weather
值,我们将在后面定义,如果出现问题,则返回一个错误。
验收标准 3将被称为GetByGeoCoordinates
,需要float32
格式的纬度和经度值。它还将返回一个Weather
值和一个错误。Weather
值将根据OpenWeatherMap
API 使用的返回 JSON 进行定义。您可以在网页中找到此 JSON 的说明 http://openweathermap.org/current#current_JSON 。
如果查看 JSON 定义,它具有以下类型:
type Weather struct {
ID int `json:"id"`
Name string `json:"name"`
Cod int `json:"cod"`
Coord struct {
Lon float32 `json:"lon"`
Lat float32 `json:"lat"`
} `json:"coord"`
Weather []struct {
Id int `json:"id"`
Main string `json:"main"`
Description string `json:"description"`
Icon string `json:"icon"`
} `json:"weather"`
Base string `json:"base"`
Main struct {
Temp float32 `json:"temp"`
Pressure float32 `json:"pressure"`
Humidity float32 `json:"humidity"`
TempMin float32 `json:"temp_min"`
TempMax float32 `json:"temp_max"`
} `json:"main"`
Wind struct {
Speed float32 `json:"speed"`
Deg float32 `json:"deg"`
} `json:"wind"`
Clouds struct {
All int `json:"all"`
} `json:"clouds"`
Rain struct {
ThreeHours float32 `json:"3h"`
} `json:"rain"`
Dt uint32 `json:"dt"`
Sys struct {
Type int `json:"type"`
ID int `json:"id"`
Message float32 `json:"message"`
Country string `json:"country"`
Sunrise int `json:"sunrise"`
Sunset int `json:"sunset"`
}`json:"sys"`
}
这是一个相当长的结构,但我们拥有响应可能包含的所有内容。该结构称为Weather
,因为它由一个 ID、一个名称和一个代码(Cod
)以及几个匿名结构组成,它们是:Coord
、Weather
、Base
、Main
、Wind
、Clouds
、Rain
、Dt
、Sys
。我们可以在Weather
结构之外编写这些匿名结构,方法是给它们起一个名字,但只有当我们必须单独使用它们时,它才有用。
在我们的Weather
结构中的每个成员和结构之后,您可以找到一个json:"something"
行。这在区分 JSON 键名和成员名时非常方便。如果 JSON 密钥是something
,我们就不会被迫调用我们的成员something
。例如,我们的 ID 成员将在 JSON 响应中调用id
。
为什么不给我们的类型指定 JSON 键的名称呢?如果您的类型中的字段是小写的,encoding/json
包将无法正确解析它们。此外,最后一个注释为我们提供了一定的灵活性,不仅在更改成员名称方面,而且在我们不需要密钥时,还可以省略一些密钥,并带有以下签名:
`json:"something,omitempty"
最后是omitempty
,如果 JSON 键的字节表示中不存在此键,解析不会失败。
好的,我们的验收标准 1 要求一个访问 API 的单点。这将被称为CurrentWeatherData
:
type CurrentWeatherData struct {
APIkey string
}
CurrentWeatherData
类型有一个 API 密钥作为公共成员来工作。这是因为您必须是OpenWeatherMap
中的注册用户才能享受他们的服务。有关如何获取 API 密钥的文档,请参阅OpenWeatherMap
API 的网页。在我们的示例中不需要它,因为我们不打算进行集成测试。
我们需要模拟数据,这样我们就可以编写一个mock
函数来检索数据。发送 HTTP 请求时,响应以io.Reader
的形式包含在名为 body 的成员中。我们已经使用过实现io.Reader
接口的类型,因此您应该对它很熟悉。我们的mock
功能如下:
func getMockData() io.Reader {
response := `{
"coord":{"lon":-3.7,"lat":40.42},"weather : [{"id":803,"main":"Clouds","description":"broken clouds","icon":"04n"}],"base":"stations","main":{"temp":303.56,"pressure":1016.46,"humidity":26.8,"temp_min":300.95,"temp_max":305.93},"wind":{"speed":3.17,"deg":151.001},"rain":{"3h":0.0075},"clouds":{"all":68},"dt":1471295823,"sys":{"type":3,"id":1442829648,"message":0.0278,"country":"ES","sunrise":1471238808,"sunset":1471288232},"id":3117735,"name":"Madrid","cod":200}`
r := bytes.NewReader([]byte(response))
return r
}
上述模拟数据是通过使用 API 密钥向OpenWeatherMap
发出请求而生成的。response
变量是一个包含 JSON 响应的字符串。仔细看看用于打开和关闭字符串的严肃口音(`
。这样,您可以使用任意多的引号,而不会出现任何问题。
此外,我们在 bytes 包中使用了一个名为NewReader
的特殊函数,该函数接受一个字节片(我们通过从字符串转换类型来创建),并返回一个包含该片内容的io.Reader
实现器。这非常适合模拟 HTTP 响应的Body
成员。
我们将编写一个测试来尝试response parser
。两种方法都返回相同的类型,因此我们可以对这两种方法使用相同的JSON parser
:
func TestOpenWeatherMap_responseParser(t *testing.T) {
r := getMockData()
openWeatherMap := CurrentWeatherData{APIkey: ""}
weather, err := openWeatherMap.responseParser(r)
if err != nil {
t.Fatal(err)
}
if weather.ID != 3117735 {
t.Errorf("Madrid id is 3117735, not %d\n", weather.ID)
}
}
在前面的测试中,我们首先要求提供一些模拟数据,这些数据存储在变量r
中。后来,我们创建了一种类型的CurrentWeatherData
,我们称之为openWeatherMap
。最后,我们要求为存储在变量weather
中的提供的io.Reader
接口提供天气值。在检查错误后,我们确保 ID 与从getMockData
方法获得的模拟数据中存储的 ID 相同。
我们必须在运行测试之前声明responseParser
方法,否则代码将无法编译:
func (p *CurrentWeatherData) responseParser(body io.Reader) (*Weather, error) {
return nil, fmt.Errorf("Not implemented yet")
}
利用上述所有功能,我们可以运行此测试:
go test -v -run=responseParser .
=== RUN TestOpenWeatherMap_responseParser
--- FAIL: TestOpenWeatherMap_responseParser (0.00s)
facade_test.go:72: Not implemented yet
FAIL
exit status 1
FAIL
可以我们不会编写更多的测试,因为剩下的只是集成测试,这超出了结构模式的解释范围,将迫使我们拥有一个 API 密钥和一个 Internet 连接。如果您想看看本例中的集成测试是什么样子的,请参考本书附带的代码。
首先,我们将实现解析器,我们的方法将使用该解析器解析来自OpenWeatherMap
REST API 的 JSON 响应:
func (p *CurrentWeatherData) responseParser(body io.Reader) (*Weather, error) {
w := new(Weather)
err := json.NewDecoder(body).Decode(w)
if err != nil {
return nil, err
}
return w, nil
}
到目前为止,这应该足以通过测试:
go test -v -run=responseParser .
=== RUN TestOpenWeatherMap_responseParser
--- PASS: TestOpenWeatherMap_responseParser (0.00s)
PASS
ok
至少我们已经对解析器进行了很好的测试。让我们把代码构造成一个库。首先,我们将创建通过名称和国家代码检索城市天气的方法,以及使用纬度和经度的方法:
func (c *CurrentWeatherData) GetByGeoCoordinates(lat, lon float32) (weather *Weather, err error) {
return c.doRequest(
fmt.Sprintf("http://api.openweathermap.org/data/2.5/weather q=%s,%s&APPID=%s", lat, lon, c.APIkey))
}
func (c *CurrentWeatherData) GetByCityAndCountryCode(city, countryCode string) (weather *Weather, err error) {
return c.doRequest(
fmt.Sprintf("http://api.openweathermap.org/data/2.5/weather?lat=%f&lon=%f&APPID=%s", city, countryCode, c.APIkey) )
}
一块蛋糕?当然一切都必须尽可能简单,这是一份好工作的标志。这个外观的复杂性在于创建到OpenWeatherMap
API 的连接,并控制可能的错误。这个问题在我们示例中的所有 Facade 方法之间都有,因此我们现在不需要编写多个 API 调用。
我们要做的是传递 RESTAPI 需要的 URL,以便返回我们想要的信息。这是通过fmt.Sprintf
函数实现的,该函数对每种情况下的字符串进行格式化。例如,要使用城市名称和国家代码收集数据,我们使用以下字符串:
fmt.Sprintf("http://api.openweathermap.org/data/2.5/weather?lat=%f&lon=%f&APPID=%s", city, countryCode, c.APIkey)
这将采用预格式化字符串https://openweathermap.org/api 并通过将每个%s
说明符替换为 city、我们在参数中引入的countryCode
和CurrentWeatherData
类型的 API 键成员来格式化它。
但是,我们还没有设置任何 API 密钥!是的,因为这是一个库,库的用户必须使用自己的 API 密钥。我们隐藏了创建 URI 和处理错误的复杂性。
最后,doRequest
函数是一条大鱼,我们将一步一步地详细了解它:
func (o *CurrentWeatherData) doRequest(uri string) (weather *Weather, err error) {
client := &http.Client{}
req, err := http.NewRequest("GET", uri, nil)
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
首先,签名告诉我们,doRequest
方法接受 URI 字符串,并返回指向Weather
变量的指针和错误。我们首先创建一个http.Client
类,它将发出请求。然后,我们创建一个请求对象,它将使用OpenWeatherMap
网页中描述的GET
方法和我们传递的 URI。如果我们要使用不同的方法,或者不止一种,那么它们必须由签名中的参数产生。然而,我们将只使用GET
方法,因此我们可以在那里硬编码它。
然后,我们检查请求对象是否已成功创建,并设置一个标题,说明内容类型是 JSON:
resp, err := client.Do(req)
if err != nil {
return
}
if resp.StatusCode != 200 {
byt, errMsg := ioutil.ReadAll(resp.Body)
if errMsg == nil {
errMsg = fmt.Errorf("%s", string(byt))
}
err = fmt.Errorf("Status code was %d, aborting. Error message was:\n%s\n",resp.StatusCode, errMsg)
return
}
然后我们发出请求,并检查错误。因为我们已经为返回类型指定了名称,如果发生任何错误,我们只需返回函数,Go 将返回变量err
和变量weather
,它们当时的状态。
我们检查响应的状态代码,因为我们只接受 200 作为良好响应。如果未返回 200,我们将创建一条错误消息,其中包含正文内容和返回的状态代码:
weather, err = o.responseParser(resp.Body)
resp.Body.Close()
return
}
最后,如果一切顺利,我们使用前面编写的responseParser
函数解析 Body 的内容,这是一个io.Reader
接口。也许你想知道我们为什么不通过response parser
方法控制err
。这很有趣,因为我们实际上控制着它。responseParser
和doRequest
具有相同的返回签名。两者都返回一个Weather
指针和一个错误(如果有),因此我们可以直接返回结果。
我们有了使用 facade 模式的OpenWeatherMap
API 库的第一个里程碑。我们在doRequest
和responseParser
函数中隐藏了访问OpenWeatherMap
REST API 的复杂性,我们库的用户有一个易于使用的语法来查询 API。例如,要检索西班牙马德里的天气,用户只需在开始时引入参数和 API 键:
weatherMap := CurrentWeatherData{*apiKey}
weather, err := weatherMap.GetByCityAndCountryCode("Madrid", "ES")
if err != nil {
t.Fatal(err)
}
fmt.Printf("Temperature in Madrid is %f celsius\n", weather.Main.Temp-273.15)
在编写本章时,马德里天气的控制台输出如下:
$ Temperature in Madrid is 30.600006 celsius
典型的夏日!
我们的下一个模式是Flyweight设计模式。它在计算机图形学和视频游戏行业中非常常用,但在企业应用中却不多见。
Flyweight 是一种模式,允许在某些类型的多个实例之间共享重对象的状态。想象一下,您必须创建和存储太多基本相等的重类型对象。你的内存很快就会用完。使用 Flyweight 模式可以很容易地解决此问题,还可以借助 Factory 模式。正如我们前面看到的,工厂通常负责封装对象创建。
由于 Flyweight 模式,我们可以在单个公共对象中共享对象的所有可能状态,从而通过使用指向已创建对象的指针来最小化对象创建。
举个例子,我们将模拟您在博彩网页上找到的东西。想象一下欧洲锦标赛的最后一场比赛,全欧洲数百万人观看了这场比赛。现在想象一下,我们拥有一个博彩网页,在那里我们提供欧洲每支球队的历史信息。这是大量的信息,通常存储在一些分布式数据库中,每个球队都有关于他们的球员、比赛、冠军等等的兆字节的信息。
如果有一百万用户访问关于一个团队的信息,并且为每个查询历史数据的用户创建了一个新的信息实例,那么我们将在眨眼之间耗尽内存。使用我们的代理解决方案,我们可以缓存n最近的搜索以加快查询速度,但如果我们为每个团队返回一个克隆,我们仍然会缺少内存(但由于我们的缓存,速度更快)。很有趣,对吧?
相反,我们将只存储每个团队的信息一次,并将它们的引用传递给用户。因此,如果我们面对一百万用户试图访问一场比赛的信息,我们实际上只有两个团队在内存中,有一百万个指向同一内存方向的指针。
Flyweight 图案的验收标准必须始终减少使用的内存量,并且必须主要关注此目标:
Team
结构,其中包含一些基本信息,如球队名称、球员、历史成绩以及描述他们盾牌的图像。我们的Team
结构将包含其他结构,因此总共将创建四个结构。Team
结构具有以下签名:
type Team struct {
ID uint64
Name string
Shield []byte
Players []Player
HistoricalData []HistoricalData
}
每个团队都有一个 ID、一个名称、代表团队盾牌的字节片中的一些图像、一个玩家片和一个历史数据片。这样,我们将拥有两个团队的 ID:
const (
TEAM_A = iota
TEAM_B
)
我们使用const
和iota
关键字声明两个常量。const
关键字只是声明以下声明是常量。iota
是一个非类型整数,它会自动为括号之间的每个新常量增加其值。当我们声明TEAM_A
时iota
值开始重置为 0,所以TEAM_A
等于 0。在TEAM_B
变量上,iota
增加 1,因此TEAM_B
等于 1。当声明不需要特定值的常量值时,iota
赋值是一种省去键入的优雅方式(如math
包上的Pi常量)。
我们的Player
和HistoricalData
如下:
type Player struct {
Name string
Surname string
PreviousTeam uint64
Photo []byte
}
type HistoricalData struct {
Year uint8
LeagueResults []Match
}
如您所见,我们还需要一个Match
结构,它存储在HistoricalData
结构中。在此上下文中,Match
结构表示匹配的历史结果:
type Match struct {
Date time.Time
VisitorID uint64
LocalID uint64
LocalScore byte
VisitorScore byte
LocalShoots uint16
VisitorShoots uint16
}
这足以代表一个团队,并满足验收标准 1。你可能已经猜到了,每个团队都有很多信息,因为一些欧洲团队已经存在了 100 多年。
对于验收标准 2,这个词创建应该给我们一些关于如何处理这个问题的线索。我们将建立一个工厂来创建和存储我们的团队。我们的工厂将由一个年图组成,包括指向Teams
作为值的指针和一个GetTeam
函数。如果我们事先知道他们的名字,使用地图将促进团队搜索。我们还将处理一个返回已创建对象数量的方法,该方法称为GetNumberOfObjects
方法:
type teamFlyweightFactory struct {
createdTeams map[string]*Team
}
func (t *teamFlyweightFactory) GetTeam(name string) *Team {
return nil
}
func (t *teamFlyweightFactory) GetNumberOfObjects() int {
return 0
}
这足以编写我们的第一个单元测试:
func TestTeamFlyweightFactory_GetTeam(t *testing.T) {
factory := teamFlyweightFactory{}
teamA1 := factory.GetTeam(TEAM_A)
if teamA1 == nil {
t.Error("The pointer to the TEAM_A was nil")
}
teamA2 := factory.GetTeam(TEAM_A)
if teamA2 == nil {
t.Error("The pointer to the TEAM_A was nil")
}
if teamA1 != teamA2 {
t.Error("TEAM_A pointers weren't the same")
}
if factory.GetNumberOfObjects() != 1 {
t.Errorf("The number of objects created was not 1: %d\n", factory.GetNumberOfObjects())
}
}
在我们的测试中,我们验证了所有的验收标准。首先我们创建一个工厂,然后请求一个指针TEAM_A
。此指针不能为nil
,否则测试失败。
然后,我们调用指向同一团队的第二个指针。这个指针也不能为零,它应该指向与前一个相同的内存地址,这样我们就知道它没有分配新内存。
最后,我们应该检查创建的团队的数量是否只有一个,因为我们已经请求了同一个团队两次。我们有两个指针,但只是团队的一个实例。让我们运行测试:
$ go test -v -run=GetTeam .
=== RUN TestTeamFlyweightFactory_GetTeam
--- FAIL: TestTeamFlyweightFactory_GetTeam (0.00s)
flyweight_test.go:11: The pointer to the TEAM_A was nil
flyweight_test.go:21: The pointer to the TEAM_A was nil
flyweight_test.go:31: The number of objects created was not 1: 0
FAIL
exit status 1
FAIL
嗯,它失败了。这两个指针都为零,并且它没有创建任何对象。有趣的是,比较两个指针的函数并没有失败;总之,零等于零。
我们的GetTeam
方法需要扫描名为createdTeams
的map
字段,以确保查询的团队已经创建,如果已经创建,则返回。如果团队未创建,则必须在返回之前创建并存储在地图中:
func (t *teamFlyweightFactory) GetTeam(teamID int) *Team {
if t.createdTeams[teamID] != nil {
return t.createdTeams[teamID]
}
team := getTeamFactory(teamID)
t.createdTeams[teamID] = &team
return t.createdTeams[teamID]
}
前面的代码非常简单。如果参数名存在于createdTeams
映射中,则返回指针。否则,请调用工厂创建团队。这很有趣,可以停下来分析一下。使用 Flyweight 图案时,通常会有一个 Flyweight factory,它使用其他类型的创建图案来检索所需的对象。
因此,getTeamFactory
方法将为我们提供我们正在寻找的团队,我们将其存储在地图中,然后返回。团队工厂将能够创建两个团队:TEAM_A
和TEAM_B
:
func getTeamFactory(team int) Team {
switch team {
case TEAM_B:
return Team{
ID: 2,
Name: TEAM_B,
}
default:
return Team{
ID: 1,
Name: TEAM_A,
}
}
}
我们正在简化对象的内容,以便专注于 Flyweight 模式的实现。好的,我们只需要定义函数来检索所创建对象的数量,操作如下:
func (t *teamFlyweightFactory) GetNumberOfObjects() int {
return len(t.createdTeams)
}
这很容易。len
函数返回数组或切片中的元素数,string
中的字符数,依此类推。似乎一切都已完成,我们可以再次启动测试:
$ go test -v -run=GetTeam .
=== RUN TestTeamFlyweightFactory_GetTeam
--- FAIL: TestTeamFlyweightFactory_GetTeam (0.00s)
panic: assignment to entry in nil map [recovered]
panic: assignment to entry in nil map
goroutine 5 [running]:
panic(0x530900, 0xc0820025c0)
/home/mcastro/Go/src/runtime/panic.go:481 +0x3f4
testing.tRunner.func1(0xc082068120)
/home/mcastro/Go/src/testing/testing.go:467 +0x199
panic(0x530900, 0xc0820025c0)
/home/mcastro/Go/src/runtime/panic.go:443 +0x4f7
/home/mcastro/go-design-patterns/structural/flyweight.(*teamFlyweightFactory).GetTeam(0xc08202fec0, 0x0, 0x0)
/home/mcastro/Desktop/go-design-patterns/structural/flyweight/flyweight.go:71 +0x159
/home/mcastro/go-design-patterns/structural/flyweight.TestTeamFlyweightFactory_GetTeam(0xc082068120)
/home/mcastro/Desktop/go-design-patterns/structural/flyweight/flyweight_test.go:9 +0x61
testing.tRunner(0xc082068120, 0x666580)
/home/mcastro/Go/src/testing/testing.go:473 +0x9f
created by testing.RunTests
/home/mcastro/Go/src/testing/testing.go:582 +0x899
exit status 2
FAIL
惊恐我们忘了什么吗?通过读取紧急消息上的堆栈跟踪,我们可以看到一些地址、一些文件,GetTeam
方法似乎试图将一个条目分配给flyweight.go
文件行 71上的 nil 映射。让我们仔细看看第 71 行(请记住,如果您在学习本教程时编写代码,那么错误可能会出现在另一行,因此请仔细查看您自己的斯塔克轨迹):
t.createdTeams[teamName] = &team
好的,这一行在GetTeam
方法上,当该方法经过这里时,意味着它没有在地图上找到它创建的团队(变量团队),并试图将其分配给地图。但是映射是 nil,因为我们在创建工厂时没有初始化它。这有一个快速的解决方案。在我们的测试中,初始化创建工厂的映射:
factory := teamFlyweightFactory{
createdTeams: make(map[int]*Team,0),
}
我相信你已经看到这里的问题了。如果我们没有访问包的权限,我们可以初始化变量。我们可以公开变量,仅此而已。但这需要每个实现者都知道他们必须初始化映射,而映射的签名既不方便,也不优雅。相反,我们将创建一个简单的工厂建设者来为我们做这件事。这是 Go 中非常常见的方法:
func NewTeamFactory() teamFlyweightFactory {
return teamFlyweightFactory{
createdTeams: make(map[int]*Team),
}
}
现在,在测试中,我们用调用此函数替换工厂创建:
func TestTeamFlyweightFactory_GetTeam(t *testing.T) {
factory := NewTeamFactory()
...
}
我们再次运行测试:
$ go test -v -run=GetTeam .
=== RUN TestTeamFlyweightFactory_GetTeam
--- PASS: TestTeamFlyweightFactory_GetTeam (0.00s)
PASS
ok
完美的让我们通过添加第二个测试来改进测试,以确保所有内容都能以更大的容量按预期运行。我们将为团队创建创建一百万个呼叫,代表来自用户的一百万个呼叫。然后,我们只需检查创建的团队数量是否只有两个:
func Test_HighVolume(t *testing.T) {
factory := NewTeamFactory()
teams := make([]*Team, 500000*2)
for i := 0; i < 500000; i++ {
teams[i] = factory.GetTeam(TEAM_A)
}
for i := 500000; i < 2*500000; i++ {
teams[i] = factory.GetTeam(TEAM_B)
}
if factory.GetNumberOfObjects() != 2 {
t.Errorf("The number of objects created was not 2: %d\n",factory.GetNumberOfObjects())
}
}
在这个测试中,我们分别检索了TEAM_A
和TEAM_B
500000 次,以达到一百万用户。然后,我们确保只创建了两个对象:
$ go test -v -run=Volume .
=== RUN Test_HighVolume
--- PASS: Test_HighVolume (0.04s)
PASS
ok
完美的我们甚至可以检查指针指向的位置以及它们的位置。我们将以前三个为例进行检查。在上次测试结束时添加以下行,然后再次运行:
for i:=0; i<3; i++ {
fmt.Printf("Pointer %d points to %p and is located in %p\n", i, teams[i], &teams[i])
}
在前面的测试中,我们使用Printf
方法打印有关指针的信息。%p
标志提供指针指向的对象的内存位置。如果您通过传递&
符号来引用指针,它将为您提供指针本身的方向。
使用相同的命令再次运行测试;您将在输出中看到三个新行,其信息类似于以下内容:
Pointer 0 points to 0xc082846000 and is located in 0xc082076000
Pointer 1 points to 0xc082846000 and is located in 0xc082076008
Pointer 2 points to 0xc082846000 and is located in 0xc082076010
它告诉我们的是,地图中的前三个位置指向同一个位置,但实际上我们有三个不同的指针,它们实际上比我们的团队对象轻得多。
好吧,区别是微妙的,但它就在那里。对于 Singleton 模式,我们确保只创建一次相同的类型。此外,单身模式是一种创造性模式。对于 Flyweight,这是一种结构模式,我们不担心对象是如何创建的,而是担心如何以轻松的方式构造类型以包含大量信息。我们正在讨论的结构就是我们示例中的map[int]*Team
结构。在这里,我们真的不关心我们如何创建对象;我们只是为它编写了一个简单的getTeamFactory
方法。我们非常重视使用灯光结构来保存可共享的对象(一个或多个对象),在本例中是地图。
我们已经看到了几种组织代码结构的模式。结构模式关注的是如何创建对象,或者它们如何做业务(我们将在行为模式中看到这一点)。
不要对混合几种模式感到困惑。如果你严格遵循每种方法的目标,你可以很容易地混合六到七种方法。请记住,过度工程化和完全没有工程化一样糟糕。我记得有一天晚上,我做了一个负载平衡器的原型,经过两个小时疯狂的过度设计代码后,我脑子里乱七八糟,我宁愿从头开始。
在下一章中,我们将看到行为模式。它们有点复杂,它们经常使用结构和创作模式来实现目标,但我相信读者会发现它们非常具有挑战性和有趣。