这是关于行为模式的最后一章,也是本书关于 Go 语言中常见的、众所周知的设计模式部分的结尾。
在本章中,我们将研究另外三种设计模式。当您想要从一组对象中抽象出一些功能时,访问者模式非常有用。
状态通常用于构建有限状态机(FSM),在本节中,我们将开发一个小型猜数字游戏。
最后,观察者模式在事件驱动的体系结构中得到了广泛的应用,并再次获得了广泛的关注,尤其是在微服务领域。
在本章之后,在深入研究并发性及其给设计模式带来的优势(和复杂性)之前,我们需要熟悉常见的设计模式。
在下一个设计模式中,我们将把对象类型的一些逻辑委托给称为访问者的外部类型,访问者将访问我们的对象以对其执行操作。
在 Visitor 设计模式中,我们试图将处理特定对象所需的逻辑分离到对象本身之外。因此,我们可以有许多不同的访问者,他们可以针对特定类型做一些事情。
例如,假设我们有一个向控制台写入的日志编写器。我们可以使记录器“可访问”,以便您可以在每个日志中预先添加任何文本。我们可以编写一个访问者模式,将日期、时间和主机名前置到存储在对象中的字段中。
对于行为设计模式,我们主要处理算法。访问者模式也不例外。我们正在努力实现的目标如下:
- 将某些类型的算法与其在其他类型中的实现分离
- 通过在几乎没有逻辑的情况下使用某些类型来提高它们的灵活性,这样就可以在不改变对象结构的情况下添加所有新功能
- 修复会破坏类型中打开/关闭原则的结构或行为
你可能在想什么是开放/封闭原则。在计算机科学中,打开/关闭原则规定:实体应为**扩展打开,但为修改关闭。这种简单的状态有很多含义,允许构建更易于维护的软件,并且更不容易出错。访问者模式帮助我们将一些经常变化的算法从需要“稳定”的类型委托给外部类型,外部类型可以经常变化而不影响原始类型。
我们将开发一个简单的日志附加器作为访问者模式的一个示例。按照我们在前几章中采用的方法,我们将从一个非常简单的示例开始,以清楚地了解访问者设计模式是如何工作的,然后再跳到更复杂的模式。我们已经开发了类似的例子来修改文本,但方式略有不同。
对于这个特定的示例,我们将创建一个访问者,它将不同的信息附加到它“访问”的类型中。
为了有效地使用访问者设计模式,我们必须有两个角色——访问者和可访问者。Visitor
是将在Visitable
类型中起作用的类型。因此,Visitable
接口实现有一个与Visitor
类型分离的算法:
- 我们需要两个消息记录器:
MessageA
和MessageB
将在消息之前分别打印一条带有A:
或B:
的消息。 - 我们需要一个访问者能够修改要打印的消息。它将分别在它们后面附加文本“访问 A”或“访问 B”。
如前所述,Visitor
和Visitable
接口需要一个角色。它们将是接口。我们还需要MessageA
和MessageB
结构:
package visitor
import (
"io"
"os"
"fmt"
)
type MessageA struct {
Msg string
Output io.Writer
}
type MessageB struct {
Msg string
Output io.Writer
}
type Visitor interface {
VisitA(*MessageA)
VisitB(*MessageB)
}
type Visitable interface {
Accept(Visitor)
}
type MessageVisitor struct {}
类型MessageA
和MessageB
结构都有一个Msg
字段来存储它们将打印的文本。输出io.Writer
将默认实现os.Stdout
接口或一个新的io.Writer
接口,就像我们用来检查内容是否正确的接口一样。
Visitor
接口有一个Visit
方法,Visitable
接口的MessageA
和MessageB
类型各一个。Visitable
接口有一个名为Accept(Visitor)
的方法,该方法将执行解耦算法。
与前面的示例一样,我们将创建一个实现io.Writer
包的类型,以便在测试中使用它:
package visitor
import "testing"
type TestHelper struct {
Received string
}
func (t *TestHelper) Write(p []byte) (int, error) {
t.Received = string(p)
return len(p), nil
}
TestHelper
结构实现io.Writer
接口。它的功能相当简单;它将写入的字节存储在Received
字段中。稍后我们可以检查Received
的内容,以对照我们的预期值进行测试。
我们将只编写一个测试来检查代码的总体正确性。在这个测试中,我们将编写两个子测试:一个用于MessageA
类型,另一个用于MessageB
类型:
func Test_Overall(t *testing.T) {
testHelper := &TestHelper{}
visitor := &MessageVisitor{}
...
}
对于每种消息类型,我们将在每个测试中使用一个TestHelper
结构和一个MessageVisitor
结构。首先,我们将测试MessageA
类型:
func Test_Overall(t *testing.T) {
testHelper := &TestHelper{}
visitor := &MessageVisitor{}
t.Run("MessageA test", func(t *testing.T){
msg := MessageA{
Msg: "Hello World",
Output: testHelper,
}
msg.Accept(visitor)
msg.Print()
expected := "A: Hello World (Visited A)"
if testHelper.Received != expected {
t.Errorf("Expected result was incorrect. %s != %s",
testHelper.Received, expected)
}
})
...
}
这是第一次完整的测试。我们创建了MessageA
结构,为Msg
字段和指向TestHelper
的指针提供了一个值Hello World
,这是我们在测试开始时创建的。然后,我们执行它的Accept
方法。在MessageA
结构上的Accept(Visitor)
方法中,执行VisitA(*MessageA)
方法来更改Msg
字段的内容(这就是为什么我们将指针传递给VisitA
方法,如果没有指针,内容将不会被持久化)。
为了测试Visitor
类型是否在Accept
方法中完成了它的工作,我们必须稍后在MessageA
类型上调用Print()
方法。这样,MessageA
结构必须将Msg
的内容写入提供的io.Writer
接口(我们的TestHelper
。
测试的最后一部分是检查。根据验收准则 2的描述,MessageA
类型的输出文本必须以文本A:
、存储的消息和结尾的文本"(Visited)"
作为前缀。因此,对于MessageA
类型,预期的文本必须是"A: Hello World (Visited)"
,这是我们在if
部分所做的检查。
MessageB
类型具有非常相似的实现:
t.Run("MessageB test", func(t *testing.T){
msg := MessageB {
Msg: "Hello World",
Output: testHelper,
}
msg.Accept(visitor)
msg.Print()
expected := "B: Hello World (Visited B)"
if testHelper.Received != expected {
t.Errorf("Expected result was incorrect. %s != %s",
testHelper.Received, expected)
}
})
}
事实上,我们刚刚将类型从MessageA
更改为MessageB
,现在预期的文本是"B: Hello World (Visited B)"
。Msg
字段也是"Hello World"
,我们也使用了TestHelper
类型。
我们仍然缺乏正确的接口实现来编译代码和运行测试。MessageA
和MessageB
结构必须实现Accept(Visitor)
方法:
func (m *MessageA) Accept(v Visitor) {
//Do nothing
}
func (m *MessageB) Accept(v Visitor) {
//Do nothing
}
我们需要在Visitor
接口上声明的VisitA(*MessageA)
和VisitB(*MessageB)
方法的实现。MessageVisitor
接口是必须实现它们的类型:
func (mf *MessageVisitor) VisitA(m *MessageA){
//Do nothing
}
func (mf *MessageVisitor) VisitB(m *MessageB){
//Do nothing
}
最后,我们将为每种消息类型创建一个Print()
方法。这是我们将用于测试每种类型的Msg
字段内容的方法:
func (m *MessageA) Print(){
//Do nothing
}
func (m *MessageB) Print(){
//Do nothing
}
现在,我们可以运行测试来真正检查它们是否失败:
go test -v .
=== RUN Test_Overall
=== RUN Test_Overall/MessageA_test
=== RUN Test_Overall/MessageB_test
--- FAIL: Test_Overall (0.00s)
--- FAIL: Test_Overall/MessageA_test (0.00s)
visitor_test.go:30: Expected result was incorrect. != A: Hello World (Visited A)
--- FAIL: Test_Overall/MessageB_test (0.00s)
visitor_test.go:46: Expected result was incorrect. != B: Hello World (Visited B)
FAIL
exit status 1
FAIL
测试的输出是明确的。预期的消息不正确,因为内容为空。现在是创建实现的时候了。
我们将开始完成VisitA(*MessageA)
和VisitB(*MessageB)
方法的实施:
func (mf *MessageVisitor) VisitA(m *MessageA){
m.Msg = fmt.Sprintf("%s %s", m.Msg, "(Visited A)")
}
func (mf *MessageVisitor) VisitB(m *MessageB){
m.Msg = fmt.Sprintf("%s %s", m.Msg, "(Visited B)")
}
它的功能非常简单,fmt.Sprintf
方法返回一个格式化字符串,其中包含m.Msg
的实际内容、一个空格和消息Visited
。此字符串将存储在Msg
字段中,覆盖以前的内容。
现在我们将为每个必须执行相应访问者的消息类型开发Accept
方法:
func (m *MessageA) Accept(v Visitor) {
v.VisitA(m)
}
func (m *MessageB) Accept(v Visitor) {
v.VisitB(m)
}
这段小代码有一些含义。在这两种情况下,我们都使用了一个Visitor
,在我们的示例中,它与MessageVisitor
接口完全相同,但它们可能完全不同。关键是要理解访问者模式在其处理Visitable
对象的Visit
方法中执行一个算法。Visitor
可能在做什么?在本例中,它改变了Visitable
对象,但可能只是从中获取信息。例如,我们可以有一个Person
类型,其中包含许多字段:姓名、姓氏、年龄、地址、城市、邮政编码等等。我们可以编写一个访问者来获取一个人的名字和姓氏作为唯一字符串,一个访问者来获取应用程序不同部分的地址信息,等等。
最后,还有Print()
方法,它将帮助我们测试类型。我们前面提到,默认情况下必须打印到Stdout
调用:
func (m *MessageA) Print() {
if m.Output == nil {
m.Output = os.Stdout
}
fmt.Fprintf(m.Output, "A: %s", m.Msg)
}
func (m *MessageB) Print() {
if m.Output == nil {
m.Output = os.Stdout
}
fmt.Fprintf(m.Output, "B: %s", m.Msg)
}
它首先检查Output
字段的内容,以分配os.Stdout
调用的输出(如果为空)。在我们的测试中,我们在那里存储了一个指向TestHelper
类型的指针,所以这一行永远不会在我们的测试中执行。最后,每个消息类型打印到Output
字段,完整消息存储在Msg
字段中。这是通过使用Fprintf
方法完成的,该方法将io.Writer
包作为第一个参数,将要格式化的文本作为下一个参数。
我们的实现现在已经完成,我们可以再次运行测试,看看它们是否都通过了:
go test -v .
=== RUN Test_Overall
=== RUN Test_Overall/MessageA_test
=== RUN Test_Overall/MessageB_test
--- PASS: Test_Overall (0.00s)
--- PASS: Test_Overall/MessageA_test (0.00s)
--- PASS: Test_Overall/MessageB_test (0.00s)
PASS
ok
一切都好!访问者模式完美地完成了它的工作,消息内容在调用它们的Visit
方法后被更改。这里非常重要的一点是,我们可以在不改变结构类型的情况下,为结构MessageA
和MessageB
添加更多功能。我们只需要创建一个新的访问者类型,它可以在Visitable
上执行所有操作,例如,我们可以创建一个Visitor
来添加一个打印Msg
字段内容的方法:
type MsgFieldVisitorPrinter struct {}
func (mf *MsgFieldVisitorPrinter) VisitA(m *MessageA){
fmt.Printf(m.Msg)
}
func (mf *MsgFieldVisitorPrinter) VisitB(m *MessageB){
fmt.Printf(m.Msg)
}
我们刚刚为这两种类型添加了一些功能,但没有改变它们的内容!这就是访问者设计模式的威力。
我们将开发第二个示例,这个示例稍微复杂一些。在本例中,我们将用一些产品模拟一个在线商店。产品将有简单的类型,只有字段,我们将让一些访客在小组中处理它们。
首先,我们将开发接口。ProductInfoRetriever
类型有一个获取价格和产品名称的方法。与前面一样,Visitor
接口有一个接受ProductInfoRetriever
类型的Visit
方法。最后,Visitable
接口完全相同;它有一个以Visitor
类型作为参数的Accept
方法:
type ProductInfoRetriever interface {
GetPrice() float32
GetName() string
}
type Visitor interface {
Visit(ProductInfoRetriever)
}
type Visitable interface {
Accept(Visitor)
}
网店所有产品必须执行ProductInfoRetriever
类型。此外,大多数产品都有一些公共字段,如名称或价格(在ProductInfoRetriever
接口中定义的字段)。我们创建了Product
类型,实现了ProductInfoRetriever
和Visitable
接口,并将其嵌入到每个产品中:
type Product struct {
Price float32
Name string
}
func (p *Product) GetPrice() float32 {
return p.Price
}
func (p *Product) Accept(v Visitor) {
v.Visit(p)
}
func (p *Product) GetName() string {
return p.Name
}
现在我们有了一个非常通用的Product
类型,它可以存储商店中几乎任何产品的信息。例如,我们可以有一个Rice
和一个Pasta
产品:
type Rice struct {
Product
}
type Pasta struct {
Product
}
每个都有嵌入的Product
类型。现在我们需要创建两个Visitors
接口,一个用于计算所有产品的价格,另一个用于打印每个产品的名称:
type PriceVisitor struct {
Sum float32
}
func (pv *PriceVisitor) Visit(p ProductInfoRetriever) {
pv.Sum += p.GetPrice()
}
type NamePrinter struct {
ProductList string
}
func (n *NamePrinter) Visit(p ProductInfoRetriever) {
n.Names = fmt.Sprintf("%s\n%s", p.GetName(), n.ProductList)
}
PriceVisitor
结构接受ProductInfoRetriever
类型的Price
变量的值,作为参数传递,并将其添加到Sum
字段中。NamePrinter
结构存储ProductInfoRetriever
类型的名称,作为参数传递,并将其附加到ProductList
字段的新行。
main
功能的时间:
func main() {
products := make([]Visitable, 2)
products[0] = &Rice{
Product: Product{
Price: 32.0,
Name: "Some rice",
},
}
products[1] = &Pasta{
Product: Product{
Price: 40.0,
Name: "Some pasta",
},
}
//Print the sum of prices
priceVisitor := &PriceVisitor{}
for _, p := range products {
p.Accept(priceVisitor)
}
fmt.Printf("Total: %f\n", priceVisitor.Sum)
//Print the products list
nameVisitor := &NamePrinter{}
for _, p := range products {
p.Accept(nameVisitor)
}
fmt.Printf("\nProduct list:\n-------------\n%s", nameVisitor.ProductList)
}
我们创建一个由两个Visitable
对象组成的切片:一个Rice
和一个Pasta
类型,具有一些任意名称。然后我们使用一个PriceVisitor
实例作为参数对它们进行迭代。我们打印的总价格后的范围为。最后,我们使用NamePrinter
重复此操作并打印生成的ProductList
。此main
功能的输出如下:
go run visitor.go
Total: 72.000000
Product list:
-------------
Some pasta
Some rice
好的,这是访客模式的一个很好的例子,但是。。。如果对产品有特殊的考虑怎么办?例如,如果我们需要一台冰箱的总价格加上 20 怎么办?好的,让我们写下Fridge
结构:
type Fridge struct {
Product
}
这里的想法是覆盖GetPrice()
方法,返回产品价格加 20:
type Fridge struct {
Product
}
func (f *Fridge) GetPrice() float32 {
return f.Product.Price + 20
}
不幸的是,对于我们的例子来说,这还不够。Fridge
结构不是Visitable
类型。Product
结构是Visitable
类型,Fridge
结构嵌入了Product
结构,但正如我们在前面章节中提到的,嵌入第二种类型的类型不能被视为后一种类型,即使它有其所有字段和方法。解决方案是实施Accept(Visitor)
方法,以便将其视为Visitable
:
type Fridge struct {
Product
}
func (f *Fridge) GetPrice() float32 {
return f.Product.Price + 20
}
func (f *Fridge) Accept(v Visitor) {
v.Visit(f)
}
让我们重写main
函数,将这个新的Fridge
产品添加到切片中:
func main() {
products := make([]Visitable, 3)
products[0] = &Rice{
Product: Product{
Price: 32.0,
Name: "Some rice",
},
}
products[1] = &Pasta{
Product: Product{
Price: 40.0,
Name: "Some pasta",
},
}
products[2] = &Fridge{
Product: Product{
Price: 50,
Name: "A fridge",
},
}
...
}
其他一切都是一样的。运行这个新的main
函数会产生以下输出:
$ go run visitor.go
Total: 142.000000
Product list:
-------------
A fridge
Some pasta
Some rice
正如预期的那样,现在的总价格更高了,输出的大米(32)、意大利面(40)和冰箱(50%的产品加上 20%的运输,所以 70%)。我们可以将访问者永远添加到此产品中,但想法很清楚——我们将类型之外的一些算法解耦到访问者中。
我们已经看到了一个强大的抽象,可以为某些类型添加新的算法。然而,由于 Go 中缺少重载,这种模式在某些方面可能会受到限制(我们在第一个示例中看到了它,我们必须创建VisitA
和VisitB
实现)。在第二个例子中,我们没有处理这个限制,因为我们使用了一个接口到Visitor
结构的Visit
方法,但是我们只使用了一种类型的访问者(ProductInfoRetriever
,如果我们为第二种类型实现Visit
方法,我们会遇到同样的问题,这是原始的目标之一四人帮设计模式。
状态模式与 FSM 直接相关。一个 FSM,用非常简单的术语来说,就是有一个或多个状态并在它们之间移动以执行某些行为的东西。让我们看看状态模式如何帮助我们定义 FSM。
电灯开关是 FSM 的常见示例。它有两种状态——开和关。一个国家可以过渡到另一个国家,反之亦然。状态模式的工作方式类似。我们有一个State
接口和我们想要实现的每个状态的实现。通常还存在一个包含州与州之间交叉信息的上下文。
使用有限状态机,我们可以通过在状态之间划分行为范围来实现非常复杂的行为。通过这种方式,我们可以基于任何类型的输入对执行管道进行建模,或者创建以指定方式响应特定事件的事件驱动软件。
州模式的主要目标是开发 FSM,如下所示:
- 当某些内部事物发生变化时,类型会改变其自身的行为
- 通过添加更多状态并重新路由其输出状态,可以轻松升级复杂的模型图和管道
我们将开发一个使用 FSM 的非常简单的游戏。这个游戏是一个猜数字的游戏。想法很简单——我们将不得不猜测 0 到 10 之间的某个数字,我们只需几次尝试,否则我们将失败。
我们将让玩家通过询问用户在失败之前有多少次尝试来选择难度级别。然后,我们会询问玩家正确的数字,并不断询问他们是否猜不到,或者尝试次数是否达到零。
对于这个简单的游戏,我们有五个基本描述游戏机制的验收标准:
- 游戏将询问玩家在输掉游戏之前会尝试多少次。
- 要猜测的数字必须介于 0 和 10 之间。
- 每次玩家输入一个数字进行猜测,重试次数就会减少一次。
- 如果重试次数为零,但仍然不正确,则游戏结束,玩家失败。
- 如果玩家猜到了数字,玩家就赢了。
单元测试的概念在状态模式中非常简单,因此我们将花更多的时间详细解释使用它的机制,这比通常情况要复杂一些。
首先,我们需要一个接口来表示不同的状态,并需要一个游戏上下文来存储状态之间的信息。对于这个游戏,上下文需要存储重试次数、用户是否获胜、要猜测的秘密号码以及当前状态。状态将有一个executeState
方法,该方法接受其中一个上下文,并在游戏结束时返回true
,否则返回false
:
type GameState interface {
executeState(*GameContext) bool
}
type GameContext struct {
SecretNumber int
Retries int
Won bool
Next GameState
}
如验收标准 1所述,玩家必须能够引入他们想要的重试次数。这将由一个名为StartState
的状态实现。此外,StartState
结构必须准备游戏,在玩家之前将上下文设置为其初始值:
type StartState struct{}
func(s *StartState) executeState(c *GameContext) bool {
c.Next = &AskState{}
rand.Seed(time.Now().UnixNano())
c.SecretNumber = rand.Intn(10)
fmt.Println("Introduce a number a number of retries to set the difficulty:")
fmt.Fscanf(os.Stdin, "%d\n", &c.Retries)
return true
}
首先,StartState
结构实现了GameState
结构,因为它的结构上有布尔类型的executeState(*Context)
方法。在该状态开始时,它设置执行该状态后唯一可能的状态--AskState
状态。AskState
结构尚未声明,但它将是我们要求玩家猜测数字的状态。
在接下来的两行中,我们使用 Go 的Rand
包生成一个随机数。在第一行中,我们将当前时刻返回的int64
类型的数字馈送给随机生成器,因此我们确保在每次执行中都有一个随机馈送(如果在此处输入一个常量,随机发生器也将生成相同的数字)。rand.Intn(int)
方法返回一个介于零和指定数字之间的整数,因此我们在这里介绍验收标准 2。
接下来,在fmt.Fscanf
方法之前会出现一条请求设置多次重试的消息,这是一个强大的功能,您可以向其传递一个io.Reader
(控制台的标准输入)、一个格式(编号)和一个接口来存储读卡器的内容,在本例中,是上下文的Retries
字段。
最后,我们返回true
告诉引擎游戏必须继续。让我们看看函数开头使用的AskState
结构:
type AskState struct {}
func (a *AskState) executeState(c *GameContext) bool{
fmt.Printf("Introduce a number between 0 and 10, you have %d tries left\n", c.Retries)
var n int
fmt.Fscanf(os.Stdin, "%d", &n)
c.Retries = c.Retries - 1
if n == c.SecretNumber {
c.Won = true
c.Next = &FinishState{}
}
if c.Retries == 0 {
c.Next = &FinishState{}
}
return true
}
AskState
结构还实现了GameState
状态,您可能已经猜到了。该状态以一条消息开始,要求玩家插入一个新号码。在接下来的三行中,我们创建一个局部变量来存储播放器将引入的数字的内容。我们再次使用了fmt.Fscanf
方法,就像在StartState
结构中一样,捕获播放器的输入并将其存储在变量n
中。然后,我们的计数器中的重试次数减少了一次,因此我们必须将Retries
字段中表示的重试次数减去一次。
然后,有两个检查:一个检查用户是否输入了正确的数字,在这种情况下,上下文字段Won
设置为true
,下一个状态设置为FinishState
结构(尚未声明)。
第二个检查是控制重试次数是否没有达到零,在这种情况下,它不会让玩家再次请求一个数字,它会直接将玩家发送到FinishState
结构。毕竟,我们必须再次告诉游戏引擎,游戏必须通过在executeState
方法中返回true
来继续。
最后,我们定义了FinishState
结构。控制游戏退出状态,检查上下文对象中Won
字段的内容:
type FinishState struct{}
func(f *FinishState) executeState(c *GameContext) bool {
if c.Won {
println("Congrats, you won")
}
else {
println("You lose")
}
return false
}
TheFinishState
结构还通过在其结构中包含executeState
方法来实现GameState
状态。这里的想法很简单——如果玩家赢了(该字段先前在AskState
结构中设置),则FinishState
结构将打印消息Congrats, you won
。如果玩家尚未获胜(请记住布尔变量的零值为false
,则FinishState
打印消息You lose.
在这种情况下,可以认为游戏已经结束,所以我们返回false
来说明游戏不能继续。
我们只需要main
方法来玩我们的游戏:
func main() {
start := StartState{}
game := GameContext{
Next:&start,
}
for game.Next.executeState(&game) {}
}
嗯,是的,再简单不过了。游戏必须从start
方法开始,虽然它可以被更多地抽象到外部,以防将来游戏需要更多的初始化,但在我们的例子中它是好的。然后,我们创建一个上下文,将Next
状态设置为指向start
变量的指针。因此,在游戏中执行的第一个状态将是StartState
状态。
main
函数的最后一行有很多东西。我们创建一个循环,其中没有任何语句。与任何循环一样,它在条件不满足后继续循环。我们使用的条件是当游戏尚未完成时,GameStates
结构true
的返回值。
因此,想法很简单:我们在上下文中执行状态,将指向上下文的指针传递给它。每个状态返回true
,直到游戏结束,FinishState
结构将返回false
。因此,我们的 for 循环将继续循环,等待FinishState
结构发送的false
条件结束应用程序。
让我们玩一次:
go run state.go
Introduce a number a number of retries to set the difficulty:
5
Introduce a number between 0 and 10, you have 5 tries left
8
Introduce a number between 0 and 10, you have 4 tries left
2
Introduce a number between 0 and 10, you have 3 tries left
1
Introduce a number between 0 and 10, you have 2 tries left
3
Introduce a number between 0 and 10, you have 1 tries left
4
You lose
我们输了!我们将重试次数设置为 5 次。然后我们不断插入数字,试图猜出秘密数字。我们输入了 8、2、1、3 和 4,但它们都不是。我甚至不知道正确的数字是多少;让我们来解决这个问题!
转到FinishState
结构的定义,并更改其显示为You lose
的行,并将其替换为以下内容:
fmt.Printf("You lose. The correct number was: %d\n", c.SecretNumber)
现在它将显示正确的数字。让我们再玩一次:
go run state.go
Introduce a number a number of retries to set the difficulty:
3
Introduce a number between 0 and 10, you have 3 tries left
6
Introduce a number between 0 and 10, you have 2 tries left
2
Introduce a number between 0 and 10, you have 1 tries left
1
You lose. The correct number was: 9
这一次,我们只做了三次尝试,让它变得更难一些。。。我们又输了。我输入了 6、2 和 1,但正确的数字是 9。最后一次尝试:
go run state.go
Introduce a number a number of retries to set the difficulty:
5
Introduce a number between 0 and 10, you have 5 tries left
3
Introduce a number between 0 and 10, you have 4 tries left
4
Introduce a number between 0 and 10, you have 3 tries left
5
Introduce a number between 0 and 10, you have 2 tries left
6
Congrats, you won
伟大的这次我们降低了难度,最多尝试了五次,我们赢了!我们甚至还有一次尝试,但在输入 3、4、5 后,我们在第四次尝试中猜到了数字。正确的数字是 6,这是我第四次尝试。
您是否意识到我们可以有一个赢和输的状态,而不是直接在FinishState
结构中打印消息?例如,通过这种方式,我们可以检查 win 部分中的一些假设记分牌,看看我们是否创造了记录。让我们重构我们的游戏。首先我们需要一个WinState
和LoseState
结构:
type WinState struct{}
func (w *WinState) executeState(c *GameContext) bool {
println("Congrats, you won")
return false
}
type LoseState struct{}
func (l *LoseState) executeState(c *GameContext) bool {
fmt.Printf("You lose. The correct number was: %d\n", c.SecretNumber)
return false
}
这两个新的州没有什么新的东西。它们包含以前处于FinishState
状态的相同消息,顺便说一下,必须修改这些消息才能使用这些新状态:
func (f *FinishState) executeState(c *GameContext) bool {
if c.Won {
c.Next = &WinState{}
} else {
c.Next = &LoseState{}
}
return true
}
现在,finish 状态不打印任何内容,而是将其委托给链中的下一个状态--WinState
结构(如果用户赢了)和LoseState
结构(如果没有)。记住,游戏现在还没有在FinishState
结构上结束,我们必须返回true
而不是false
来通知引擎它必须保持链中的执行状态。
你现在一定在想,你可以用新的州来永远延续这个游戏,这是真的。状态模式的力量不仅在于创建复杂 FSM 的能力,还在于通过添加新状态和修改一些旧状态以指向新状态而不影响 FSM 的其余部分来尽可能多地改进它的灵活性。
让我们继续使用中介模式。顾名思义,它是一种介于两种类型之间的模式,用于交换信息。但是,我们为什么要这种行为呢?让我们详细看看这个。
任何设计模式的关键目标之一是避免对象之间的紧密耦合。正如我们已经看到的那样,这可以通过多种方式实现。
但是,当应用程序大量增长时,一种特别有效的方法是中介模式。中介模式是一个完美的模式示例,每个程序员都会使用它,而不必考虑太多。
中介模式将充当负责在两个对象之间交换通信的类型。这样,通信对象不需要相互了解,可以更自由地更改。维护哪些对象提供什么信息的模式是中介。
如前所述,中介模式的主要目标是松耦合和封装。目标是:
- 在必须在它们之间通信的两个对象之间提供松散耦合
- 通过将这些需求传递给中介模式,将特定类型的依赖项数量降至最低
对于中介模式,我们将开发一个非常简单的算术计算器。您可能认为计算器非常简单,不需要任何模式。但我们会发现这并不完全正确。
我们的计算器只做两个非常简单的运算:求和和和减法。
谈论定义计算器的验收标准听起来很有趣,但我们还是要这样做:
- 定义一个名为
Sum
的操作,该操作获取一个数字并将其添加到另一个数字。 - 定义一个名为
Subtract
的操作,该操作获取一个数字并将其减为另一个数字。
嗯,我不知道你的情况,但在这复杂的标准之后,我真的需要休息一下。那么,我们为什么要对这个定义这么多呢?耐心点,你很快就会得到答案的。
我们必须直接跳转到实现,因为我们无法测试总和是否正确(当然,我们可以,但我们将测试 Go 是否正确编写!)。我们可以测试我们是否通过了验收标准,但对于我们的示例来说,这有点过头了。
因此,让我们从实现必要的类型开始:
package main
type One struct{}
type Two struct{}
type Three struct{}
type Four struct{}
type Five struct{}
type Six struct{}
type Seven struct{}
type Eight struct{}
type Nine struct{}
type Zero struct{}
好这看起来很尴尬。我们已经在 Go 中有了数字类型来执行这些操作,我们不需要每个数字都有一个类型!
但让我们继续用这种疯狂的方法。让我们实现One
结构:
type One struct{}
func (o *One) OnePlus(n interface{}) interface{} {
switch n.(type) {
case One:
return &Two{}
case Two:
return &Three{}
case Three:
return &Four{}
case Four:
return &Five{}
case Five:
return &Six{}
case Six:
return &Seven{}
case Seven:
return &Eight{}
case Eight:
return &Nine{}
case Nine:
return [2]interface{}{&One{}, &Zero{}}
default:
return fmt.Errorf("Number not found")
}
}
好的,我就到此为止。这种实现有什么问题?这太疯狂了!让数与数之间的每一个运算都可以求和,这太过分了!特别是当我们有一个以上的数字。
信不信由你,今天很多软件都是这样设计的。一个小的应用程序,其中一个对象使用两个或三个对象,并且它最终使用了几十个对象。简单地从应用程序中添加或删除一个类型就成了一个绝对的地狱,因为它隐藏在这种疯狂之中。
那么在这个计算器里我们能做什么呢?使用释放所有案例的中介类型:
func Sum(a, b interface{}) interface{}{
switch a := a.(type) {
case One:
switch b.(type) {
case One:
return &Two{}
case Two:
return &Three{}
default:
return fmt.Errorf("Number not found")
}
case Two:
switch b.(type) {
case One:
return &Three{}
case Two:
return &Four{}
default:
return fmt.Errorf("Number not found")
}
case int:
switch b := b.(type) {
case One:
return &Three{}
case Two:
return &Four{}
case int:
return a + b
default:
return fmt.Errorf("Number not found")
}
default:
return fmt.Errorf("Number not found")
}
}
我们刚刚开发了几个数字来保持简短。Sum
函数充当两个数字之间的中介。首先检查名为a
的第一个数字的类型。然后,对于第一个数字的每种类型,它检查名为b
的第二个数字的类型,并返回结果类型。
虽然这个解决方案现在看起来仍然很疯狂,但唯一知道计算器中所有可能数字的是Sum
函数。但是仔细看看,您会发现我们为int
类型添加了一个类型 case。我们有病例One
、Two
和int
。在int
案例中,我们还有另一个int
案例用于b
编号。我们在这里做什么?如果两种类型都是int
案例,我们可以返回它们的总和。
你认为这样行吗?让我们编写一个简单的main
函数:
func main(){
fmt.Printf("%#v\n", Sum(One{}, Two{}))
fmt.Printf("%d\n", Sum(1,2))
}
我们打印类型One
和类型Two
的总和。通过使用"%#v"
格式,我们要求打印有关类型的信息。函数中的第二行使用了int
类型,我们还打印结果。这在控制台中产生以下输出:
$go run mediator.go
&main.Three{}
7
不是很令人印象深刻,对吧?但让我们想一想。通过使用中介模式,我们已经能够重构初始计算器,我们必须将每个类型上的每个操作定义为中介模式Sum
函数。
好在,多亏了中介模式,我们能够开始使用整数作为计算器的值。我们刚刚通过添加两个整数定义了一个最简单的示例,但是我们可以对一个整数和type
执行相同的操作:
case One:
switch b := b.(type) {
case One:
return &Two{}
case Two:
return &Three{}
case int:
return b+1
default:
return fmt.Errorf("Number not found")
}
通过这个小的修改,我们现在可以使用类型One
和一个int
作为数字b
。如果我们继续使用这种中介模式,我们可以在类型之间实现很大的灵活性,而不必在它们之间实现所有可能的操作,从而产生紧密耦合。
我们将在 main 函数中添加一个新的Sum
方法,以查看此操作:
func main(){
fmt.Printf("%#v\n", Sum(One{}, Two{}))
fmt.Printf("%d\n", Sum(1,2))
fmt.Printf("%d\n", Sum(One{},2))
}
$go run mediator.go&main.Three{}33
美好的中介模式负责了解可能的类型,并为我们的案例返回最方便的类型,即整数。现在我们可以继续扩展这个Sum
函数,直到我们完全摆脱使用我们定义的数字类型。
我们举了一个破坏性的例子,试图跳出框框,深入思考调解人模式。应用程序中实体之间的紧密耦合在将来可能会变得非常复杂,需要时可以进行更困难的重构。
请记住,中介模式在两个互不了解的类型之间充当管理类型,这样您就可以在不影响另一个类型的情况下使用其中一个类型,并以更简单、更方便的方式替换一个类型。
我们将用我最喜欢的方式完成常见的四人帮设计模式:观察者模式,也称为发布/订户或发布/侦听器。使用状态模式,我们定义了我们的第一个事件驱动架构,但是使用观察者模式,我们将真正达到一个新的抽象级别。
观察者模式背后的思想很简单——订阅一些事件,这些事件将触发许多订阅类型的某些行为。为什么这么有趣?因为我们将事件与其可能的处理程序分离。
例如,想象一个登录按钮。我们可以编写代码,当用户单击按钮时,按钮颜色会改变,执行一个操作,并且在后台执行表单检查。但对于观察者模式,更改颜色的类型将订阅按钮单击事件。检查表单的类型和执行操作的类型也将订阅此事件。
观察者模式对于实现一个事件触发的多个操作特别有用。当您不知道事件发生后提前执行了多少操作,或者在不久的将来操作的数量可能会增加时,它也特别有用。要继续,请执行以下操作:
- 提供一个事件驱动的体系结构,其中一个事件可以触发一个或多个操作
- 将执行的操作与触发它们的事件解耦
- 提供多个触发同一操作的事件
我们将开发尽可能简单的应用程序,以充分理解观察者模式的根源。我们将创建一个Publisher
结构,它触发一个事件,因此它必须接受新的观察者,并在必要时删除它们。当Publisher
结构被触发时,它必须将新事件和相关数据通知其所有观察者。
需求必须告诉我们在一个或多个操作中有某种类型触发某种方法:
- 我们必须有一个具有
NotifyObservers
方法的发布者,该方法接受消息作为参数,并在订阅的每个观察者上触发Notify
方法。 - 我们必须有一个向发布服务器添加新订阅服务器的方法。
- 我们必须有一个从发布服务器中删除新订阅服务器的方法。
也许您已经意识到,我们的需求几乎只定义了Publisher
类型。这是因为观察者执行的操作与观察者模式无关。它应该只执行一个操作,在本例中是一个或多个类型将实现的Notify
方法。因此,让我们为这个模式定义这个唯一的接口:
type Observer interface {
Notify(string)
}
Observer
接口有一个Notify
方法,该方法接受一个string
类型,该类型将包含要传播的消息。它不需要返回任何内容,但是如果我们想检查调用Publisher
结构的publish
方法时是否到达了所有观察者,我们可以返回一个错误。
为了测试所有验收标准,我们只需要一个名为Publisher
的结构,它有三种方法:
type Publisher struct {
ObserversList []Observer
}
func (s *Publisher) AddObserver(o Observer) {}
func (s *Publisher) RemoveObserver(o Observer) {}
func (s *Publisher) NotifyObservers(m string) {}
Publisher
结构将订阅的观察者列表存储在名为ObserversList
的切片字段中。然后是验收标准中提到的三种方法,AddObserver
方法向发布者订阅新的观察者,RemoveObserver
方法取消订阅观察者,以及NotifyObservers
方法,其中的字符串充当我们希望在所有观察者之间传播的消息。
使用这三种方法,我们必须设置根测试来配置Publisher
和三个子测试来测试每种方法。我们还需要定义一个实现Observer
接口的测试类型结构。该结构将被称为TestObserver
:
type TestObserver struct {
ID int
Message string
}
func (p *TestObserver) Notify(m string) {
fmt.Printf("Observer %d: message '%s' received \n", p.ID, m)
p.Message = m
}
TestObserver
结构通过在其结构中定义Notify(string)
方法来实现观察者模式。在这种情况下,它将接收到的消息与其自己的观察者 ID 一起打印。然后,它将消息存储在其Message
字段中。这允许我们稍后检查Message
字段的内容是否符合预期。请记住,也可以通过传递testing.T
指针和预期消息并在TestObserver
结构中进行检查来完成。
现在我们可以设置Publisher
结构来执行这三个测试。我们将创建TestObserver
结构的三个实例:
func TestSubject(t *testing.T) {
testObserver1 := &TestObserver{1, ""}
testObserver2 := &TestObserver{2, ""}
testObserver3 := &TestObserver{3, ""}
publisher := Publisher{}
我们为每个观察者提供了一个不同的 ID,以便稍后可以看到每个观察者都打印了预期的消息。然后,我们通过调用Publisher
结构上的AddObserver
方法添加了观察者。
让我们编写一个AddObserver
测试,它必须在Publisher
结构的ObserversList
字段中添加一个新的观察者:
t.Run("AddObserver", func(t *testing.T) {
publisher.AddObserver(testObserver1)
publisher.AddObserver(testObserver2)
publisher.AddObserver(testObserver3)
if len(publisher.ObserversList) != 3 {
t.Fail()
}
})
我们在Publisher
结构中添加了三个观察者,因此切片的长度必须为 3。如果不是 3,测试将失败。
RemoveObserver
测试将选取 ID 为 2 的观察者,并将其从列表中删除:
t.Run("RemoveObserver", func(t *testing.T) {
publisher.RemoveObserver(testObserver2)
if len(publisher.ObserversList) != 2 {
t.Errorf("The size of the observer list is not the " +
"expected. 3 != %d\n", len(publisher.ObserversList))
}
for _, observer := range publisher.ObserversList {
testObserver, ok := observer.(TestObserver)
if !ok {
t.Fail()
}
if testObserver.ID == 2 {
t.Fail()
}
}
})
移除第二个观察者后,Publisher
结构的长度现在必须为 2。我们还检查没有留下任何观察者有ID
2,因为它必须被移除。
最后一种测试方法是Notify
方法。当使用Notify
方法时,TestObserver
结构的所有实例必须将其Message
字段从空改为传递的消息(本例中为Hello World!
。首先,在调用NotifyObservers
测试之前,我们将检查所有Message
字段是否为空:
t.Run("Notify", func(t *testing.T) {
for _, observer := range publisher.ObserversList {
printObserver, ok := observer.(*TestObserver)
if !ok {
t.Fail()
break
}
if printObserver.Message != "" {
t.Errorf("The observer's Message field weren't " + " empty: %s\n", printObserver.Message)
}
}
使用一个for
语句,我们在ObserversList
字段上迭代,以在publisher
实例中切片。我们需要从指向观察者的指针到指向TestObserver
结构的指针进行类型转换,并检查转换是否正确。然后,我们检查Message
字段是否为空。
下一步是创建要发送的消息——在这种情况下,它将是"Hello World!"
,然后将此消息传递给NotifyObservers
方法,以通知列表中的每个观察者(目前仅限观察者 1 和 3):
...
message := "Hello World!"
publisher.NotifyObservers(message)
for _, observer := range publisher.ObserversList {
printObserver, ok := observer.(*TestObserver)
if !ok {
t.Fail()
break
}
if printObserver.Message != message {
t.Errorf("Expected message on observer %d was " +
"not expected: '%s' != '%s'\n", printObserver.ID,
printObserver.Message, message)
}
}
})
}
调用NotifyObservers
方法后,ObserversList
字段中的每个TestObserver
测试必须在其Message
字段中存储消息"Hello World!"
。同样,我们使用for
循环迭代ObserversList
字段的每个观察者,并将每个观察者输入TestObserver
测试(记住TestObserver
结构没有任何字段,因为它是一个接口)。我们可以通过向Observer
实例添加新的Message()
方法并在TestObserver
结构中实现它来返回Message
字段的内容,从而避免类型转换。这两种方法同样有效。一旦我们将类型转换为名为printObserver
变量的TestObserver
方法作为局部变量,我们将检查ObserversList
结构中的每个实例是否在其Message
字段中存储了字符串"Hello World!"
。
运行必须全部失败的测试的时间,以便在以后的实施中检查其有效性:
go test -v
=== RUN TestSubject
=== RUN TestSubject/AddObserver
=== RUN TestSubject/RemoveObserver
=== RUN TestSubject/Notify
--- FAIL: TestSubject (0.00s)
--- FAIL: TestSubject/AddObserver (0.00s)
--- FAIL: TestSubject/RemoveObserver (0.00s)
observer_test.go:40: The size of the observer list is not the expected. 3 != 0
--- PASS: TestSubject/Notify (0.00s)
FAIL
exit status 1
FAIL
有些东西没有按预期工作。如果我们还没有实现该功能,Notify
方法如何通过测试?再看一下Notify
方法的测试。测试在ObserversList
结构上迭代,每个F``ail
调用都在这个 for 循环中。如果列表为空,则不会迭代,因此不会执行任何Fail call
。
让我们通过在Notify
测试开始时添加一个小的非空列表检查来解决这个问题:
if len(publisher.ObserversList) == 0 {
t.Errorf("The list is empty. Nothing to test\n")
}
我们将重新运行测试,看看TestSubject/Notify
方法是否已经失败:
go test -v
=== RUN TestSubject
=== RUN TestSubject/AddObserver
=== RUN TestSubject/RemoveObserver
=== RUN TestSubject/Notify
--- FAIL: TestSubject (0.00s)
--- FAIL: TestSubject/AddObserver (0.00s)
--- FAIL: TestSubject/RemoveObserver (0.00s)
observer_test.go:40: The size of the observer list is not the expected. 3 != 0
--- FAIL: TestSubject/Notify (0.00s)
observer_test.go:58: The list is empty. Nothing to test
FAIL
exit status 1
FAIL
很好,他们都不及格,现在我们的测试有了一些保证。我们可以着手实施。
我们的实现只是定义了AddObserver
、RemoveObserver
和NotifyObservers
方法:
func (s *Publisher) AddObserver(o Observer) {
s.ObserversList = append(s.ObserversList, o)
}
AddObserver
方法通过将指针追加到当前指针列表,将Observer
实例添加到ObserversList
结构中。这个很简单。AddObserver
测试现在必须通过(但不能通过其他测试,否则我们可能会做错什么):
go test -v
=== RUN TestSubject
=== RUN TestSubject/AddObserver
=== RUN TestSubject/RemoveObserver
=== RUN TestSubject/Notify
--- FAIL: TestSubject (0.00s)
--- PASS: TestSubject/AddObserver (0.00s)
--- FAIL: TestSubject/RemoveObserver (0.00s)
observer_test.go:40: The size of the observer list is not the expected. 3 != 3
--- FAIL: TestSubject/Notify (0.00s)
observer_test.go:87: Expected message on observer 1 was not expected: 'default' != 'Hello World!'
observer_test.go:87: Expected message on observer 2 was not expected: 'default' != 'Hello World!'
observer_test.go:87: Expected message on observer 3 was not expected: 'default' != 'Hello World!'
FAIL
exit status 1
FAIL
杰出的只有AddObserver
方法通过了测试,我们现在可以继续RemoveObserver
方法:
func (s *Publisher) RemoveObserver(o Observer) {
var indexToRemove int
for i, observer := range s.ObserversList {
if observer == o {
indexToRemove = i
break
}
}
s.ObserversList = append(s.ObserversList[:indexToRemove], s.ObserversList[indexToRemove+1:]...)
}
RemoveObserver
方法将迭代ObserversList
结构中的每个元素,将Observer
对象的o
变量与列表中存储的变量进行比较。如果找到匹配项,则将索引保存在局部变量indexToRemove
中,并停止迭代。在 Go 中删除切片索引的方法有点棘手:
- 首先,我们需要使用切片索引来返回一个新切片,该切片包含从切片开始到要删除(不包括)的索引的每个对象。
- 然后,我们从要删除(不包括)的索引中获取另一个切片,并将其添加到切片中的最后一个对象
- 最后,我们将前面的两个新切片合并成一个新切片(函数
append
)
例如,在从 1 到 10 的列表中,我们要删除数字 5,我们必须创建一个新的切片,将 1 到 4 的切片和 6 到 10 的切片连接起来。
这个索引删除再次通过append
函数完成,因为我们实际上是将两个列表附加在一起。仔细看一下append
函数第二个参数末尾的三个点。append
函数将一个元素(第二个参数)添加到一个切片(第一个),但我们希望附加一个完整的列表。这可以通过使用三个点来实现,这三个点转化为类似于的东西,不断添加元素,直到完成第二个数组。
好的,让我们现在运行这个测试:
go test -v
=== RUN TestSubject
=== RUN TestSubject/AddObserver
=== RUN TestSubject/RemoveObserver
=== RUN TestSubject/Notify
--- FAIL: TestSubject (0.00s)
--- PASS: TestSubject/AddObserver (0.00s)
--- PASS: TestSubject/RemoveObserver (0.00s)
--- FAIL: TestSubject/Notify (0.00s)
observer_test.go:87: Expected message on observer 1 was not expected: 'default' != 'Hello World!'
observer_test.go:87: Expected message on observer 3 was not expected: 'default' != 'Hello World!'
FAIL
exit status 1
FAIL
我们继续沿着好的道路前进。RemoveObserver
测试已修复,没有修复任何其他内容。现在我们必须通过定义NotifyObservers
方法来完成我们的实现:
func (s *Publisher) NotifyObservers(m string) {
fmt.Printf("Publisher received message '%s' to notify observers\n", m)
for _, observer := range s.ObserversList {
observer.Notify(m)
}
}
NotifyObservers
方法非常简单,因为它将消息打印到控制台,以宣布特定消息将被传递到Observers
。在此之后,我们使用 for 循环迭代ObserversList
结构,并通过传递参数m
来执行每个Notify(string)
方法。执行此操作后,所有观察者必须将消息Hello World!
存储在其Message
字段中。让我们通过运行测试来了解这是否正确:
go test -v
=== RUN TestSubject
=== RUN TestSubject/AddObserver
=== RUN TestSubject/RemoveObserver
=== RUN TestSubject/Notify
Publisher received message 'Hello World!' to notify observers
Observer 1: message 'Hello World!' received
Observer 3: message 'Hello World!' received
--- PASS: TestSubject (0.00s)
--- PASS: TestSubject/AddObserver (0.00s)
--- PASS: TestSubject/RemoveObserver (0.00s)
--- PASS: TestSubject/Notify (0.00s)
PASS
ok
杰出的我们还可以在控制台上看到Publisher
和Observer
类型的输出。Publisher
结构打印以下消息:
hey! I have received the message 'Hello World!' and I'm going to pass the same message to the observers
之后,所有观察者将各自的信息打印如下:
hey, I'm observer 1 and I have received the message 'Hello World!'
第三个观察者也是如此。
我们已经用状态模式和观察者模式解锁了事件驱动架构的威力。现在,您可以在应用程序中执行响应系统中事件的异步算法和操作。
观察者模式通常在 UI 中使用。Android 编程中充满了观察者模式,因此 Android SDK 可以委托创建应用程序的程序员执行操作。