Go 行为模式|模板、备忘录和解释器设计模式详解

在本章中,我们将看到接下来的三种行为设计模式。困难正在增加,因为现在我们将使用结构模式和创造性模式的组合来更好地解决某些行为模式的目标。

我们将从模板设计模式开始,该模式看起来与策略模式非常相似,但提供了更大的灵活性。Memento 设计模式在我们每天使用的 99%的应用程序中使用,以实现撤销功能和事务操作。最后,我们将编写一个反向波兰符号解释程序来执行简单的数学运算。

让我们从模板设计模式开始。

模板模式是广泛使用的模式之一,非常有用,尤其是在编写库和框架时。其思想是为用户提供某种在算法中执行代码的方法。

在本节中,我们将了解如何编写惯用的 Go 模板模式,并了解一些 Go 源代码在哪些地方得到了明智的使用。我们将编写一个由三个步骤组成的算法,其中第二步委托给用户,而第一步和第三步不委托给用户。算法的第一步和第三步表示模板。

说明

使用策略模式时,我们将算法实现封装在不同的策略中,使用模板模式时,我们将尝试实现类似的功能,但只使用算法的一部分。

模板设计模式允许用户编写算法的一部分,而其余部分由抽象执行。当创建库以简化某些复杂任务时,或者当某些算法的可重用性仅受到其一部分的影响时,这是很常见的。

例如,假设我们有一个 HTTP 请求的长事务。我们必须执行以下步骤:

  1. 验证用户身份。
  2. 授权他。
  3. 从数据库中检索一些详细信息。
  4. 做一些修改。
  5. 在新请求中发回详细信息。

每当用户需要修改数据库中的某些内容时,重复用户代码中的步骤 1 到 5 是没有意义的。相反,步骤 1、2、3 和 5 将抽象为同一个算法,该算法接收第五步完成事务所需的接口。它也不需要是一个接口,它可以是一个回调。

目标

模板设计模式是关于可重用性的,并将责任赋予用户。因此,这种模式的目标如下:

示例-具有延迟步骤的简单算法

在第一个示例中,我们将编写一个由三个步骤组成的算法,每个步骤都返回一条消息。第一步和第三步由模板控制,第二步由用户执行。

要求及验收标准

模板模式必须做的一个简要描述是为三个步骤的算法定义一个模板,将第二个步骤的实现推迟到用户:

  1. 算法中的每一步都必须返回一个字符串。
  2. 第一步是一个名为first() 的方法,返回字符串hello
  3. 第三步是名为third() 的方法,返回字符串template
  4. 第二步是用户想要返回的任何字符串,但它是由具有Message() string方法的MessageRetriever接口定义的。
  5. 该算法由一个名为ExecuteAlgorithm的方法顺序执行,并返回每个步骤返回的字符串,每个步骤通过一个空格连接到一个字符串中。

简单算法的单元测试

我们将只专注于测试公共方法。这是一种非常常见的方法。总而言之,若您的私有方法并没有从公共方法的某个级别调用,那个么它们就根本不会被调用。这里我们需要两个接口,一个用于模板实现者,一个用于算法的抽象步骤:

type MessageRetriever interface { 
  Message()string 
} 

type Template interface { 
   first() string 
   third() string 
   ExecuteAlgorithm(MessageRetriever) string 
} 

模板实现者将接受MessageRetriever接口作为其执行算法的一部分执行。我们需要一个实现此接口的类型,称为Template,我们将其称为TemplateImpl

type TemplateImpl struct{} 

func (t *TemplateImpl) first() string { 
  return "" 
} 

func (t *TemplateImpl) third() string { 
  return "" 
} 

func (t *TemplateImpl) ExecuteAlgorithm(m MessageRetriever) string { 
  return "" 
} 

因此,我们的第一个测试检查第四和第五个验收标准。我们将创建实现返回字符串worldMessageRetriever接口并嵌入模板的TestStruct类型,以便调用ExecuteAlgorithm方法。它将作为模板和抽象:

type TestStruct struct { 
  Template 
} 

func (m *TestStruct) Message() string { 
  return "world" 
} 

首先,我们将定义TestStruct类型。在这种情况下,延迟给我们的算法部分将返回world文本。这是我们稍后将在测试中查找的字符串,检查类型为“此字符串上是否存在单词world?”。

仔细看,TestStruct嵌入了一个名为Template的类型,它代表了我们算法的模板模式。

当我们实现Message()方法时,我们隐式地实现了MessageRetriever接口。所以现在我们可以使用TestStruct类型作为指向MessageRetriever接口的指针:

func TestTemplate_ExecuteAlgorithm(t *testing.T) { 
  t.Run("Using interfaces", func(t *testing.T){ 
    s := &TestStruct{} 
    res := s.ExecuteAlgorithm(s) 
   expected := "world" 

    if !strings.Contains(res, expected) { 
      t.Errorf("Expected string '%s' wasn't found on returned string\n", expected) 
    } 
  }) 
} 

在测试中,我们将使用刚刚创建的类型。调用ExecuteAlgorithm方法时,需要传递MessageRetriever接口。由于TestStruct类型也实现了MessageRetriever接口,我们可以将其作为参数传递,但这当然不是强制性的。

第五个验收标准中定义的ExecuteAlgorithm方法的结果必须返回一个字符串,该字符串包含first()方法的返回值、TestStruct方法的返回值(world字符串)和third()方法的返回值,并用空格分隔。我们的执行是在第二位;这就是为什么我们检查字符串world上是否有空格前缀和后缀。

因此,如果调用ExecuteAlgorithm方法时返回的字符串不包含字符串world,则测试失败。

这足以使项目编译并运行应失败的测试:

go test -v . 
=== RUN   TestTemplate_ExecuteAlgorithm
=== RUN   TestTemplate_ExecuteAlgorithm/Using_interfaces
--- FAIL: TestTemplate_ExecuteAlgorithm (0.00s)
 --- FAIL: TestTemplate_ExecuteAlgorithm/Using_interfaces (0.00s)
 template_test.go:47: Expected string ' world ' was not found on returned string
FAIL
exit status 1
FAIL

现在是实现此模式的时候了。

实现模板模式

根据验收标准的定义,我们必须在first()方法中返回字符串hello,在third()方法中返回字符串template。这很容易实现:

type Template struct{} 

func (t *Template) first() string { 
  return "hello" 
} 

func (t *Template) third() string { 
  return "template" 
} 

在这个实现中,我们应该覆盖第二个第三个验收标准,部分覆盖第一个标准(算法中的每个步骤都必须返回一个字符串)。

为了涵盖第五个接受标准,我们定义了一个ExecuteAlgorithm方法,该方法接受MessageRetriever接口作为参数,并返回完整的算法:将first()Message() stringthird()方法返回的字符串连接起来,形成一个字符串:

func (t *Template) ExecuteAlgorithm(m MessageRetriever) string { 
  return strings.Join([]string{t.first(), m.Message(), t.third()},  " ") 
} 

strings.Join函数具有以下签名:

func Join([]string,string) string 

它接受一个字符串数组并连接它们,将第二个参数放置在数组中的每个项之间。在本例中,我们动态创建一个字符串数组,将其作为第一个参数传递。然后我们传递一个空格作为第二个参数。

使用此实现,测试必须已经通过:

go test -v . 
=== RUN   TestTemplate_ExecuteAlgorithm 
=== RUN   TestTemplate_ExecuteAlgorithm/Using_interfaces 
--- PASS: TestTemplate_ExecuteAlgorithm (0.00s) 
    --- PASS: TestTemplate_ExecuteAlgorithm/Using_interfaces (0.00s) 
PASS 
ok

测试通过了。测试已检查返回结果中是否存在字符串world,即hello world template消息。hello文本是first()方法返回的字符串,world字符串是我们的MessageRetriever实现返回的字符串,templatethird()方法返回的字符串。空格由 Go 的strings.Join函数插入。但是任何使用TemplateImpl.ExecuteAlgorithm类型的方法都会在结果中返回“hello[something]template”。

匿名函数

这不是实现模板设计模式的唯一方法。我们还可以使用匿名函数来实现ExecuteAlgorithm方法。

让我们用之前在测试之后使用的相同方法编写一个测试(用粗体标记):

func TestTemplate_ExecuteAlgorithm(t *testing.T) { 
  t.Run("Using interfaces", func(t *testing.T){ 
    s := &TestStruct{} 
    res := s.ExecuteAlgorithm(s) 

    expectedOrError(res, " world ", t) 
  }) 

 t.Run("Using anonymous functions", func(t *testing.T)
  {
 m := new(AnonymousTemplate)
 res := m.ExecuteAlgorithm(func() string {
 return "world"
 })
 expectedOrError(res, " world ", t)
 }) 
} 

func expectedOrError(res string, expected string, t *testing.T){ 
  if !strings.Contains(res, expected) { 
    t.Errorf("Expected string '%s' was not found on returned string\n", expected) 
  } 
} 

我们的新测试名为,使用匿名函数。我们还将测试中的检查提取到一个外部函数,以便在测试中重用它。我们之所以调用此函数expectedOrError,是因为如果未收到预期值,它将失败并出现错误。

在我们的测试中,我们将创建一个名为AnonymousTemplate的类型,它将替换以前的Template类型。这个新类型的ExecuteAlgorithm方法接受func() 方法string类型,我们可以在测试中直接实现该方法返回字符串world

AnonymousTemplate型应具有以下结构:

type AnonymousTemplate struct{} 

func (a *AnonymousTemplate) first() string { 
  return "" 
} 

func (a *AnonymousTemplate) third() string { 
  return "" 
} 

func (a *AnonymousTemplate) ExecuteAlgorithm(f func() string) string { 
  return "" 
} 

Template类型的唯一区别在于ExecuteAlgorithm方法接受返回字符串的函数,而不是MessageRetriever接口。让我们运行新的测试:

go test -v .
=== RUN   TestTemplate_ExecuteAlgorithm
=== RUN   TestTemplate_ExecuteAlgorithm/Using_interfaces
=== RUN   TestTemplate_ExecuteAlgorithm/Using_anonymous_functions
--- FAIL: TestTemplate_ExecuteAlgorithm (0.00s)
 --- PASS: TestTemplate_ExecuteAlgorithm/Using_interfaces (0.00s)
 --- FAIL: TestTemplate_ExecuteAlgorithm/Using_anonymous_functions (0.00s)
 template_test.go:47: Expected string ' world ' was not found on returned string
FAIL
exit status 1
FAIL

正如您在测试执行的输出中所看到的,使用匿名函数测试在上抛出错误,这是我们所期望的。现在,我们将编写以下实现:

type AnonymousTemplate struct{} 

func (a *AnonymousTemplate) first() string { 
  return "hello" 
} 

func (a *AnonymousTemplate) third() string { 
  return "template" 
} 

func (a *AnonymousTemplate) ExecuteAlgorithm(f func() string) string { 
  return strings.Join([]string{a.first(), f(), a.third()}, " ") 
} 

该实现与Template类型中的实现非常相似。但是,现在我们已经传递了一个名为f的函数,我们将它用作Join函数上使用的字符串数组中的第二项。由于f只是一个返回字符串的函数,我们只需要在适当的位置(数组中的第二个位置)执行它。

再次运行测试:

go test -v .
=== RUN   TestTemplate_ExecuteAlgorithm
=== RUN   TestTemplate_ExecuteAlgorithm/Using_interfaces
=== RUN   TestTemplate_ExecuteAlgorithm/Using_anonymous_functions
--- PASS: TestTemplate_ExecuteAlgorithm (0.00s)
 --- PASS: TestTemplate_ExecuteAlgorithm/Using_interfaces (0.00s)
 --- PASS: TestTemplate_ExecuteAlgorithm/Using_anonymous_functions (0.00s)
PASS
ok

令人惊叹的现在我们知道了两种实现模板设计模式的方法。

如何避免界面修改

前一种方法的问题是,现在我们有两个模板需要维护,我们可以结束重复代码。在无法更改正在使用的接口的情况下,我们可以做什么?我们的接口是MessageRetriever,但我们现在想使用匿名函数。

你还记得适配器的设计模式吗?我们只需要创建一个Adapter类型,接受func() string类型,返回MessageRetriever接口的实现。我们将此类型称为TemplateAdapter

type TemplateAdapter struct { 
  myFunc func() string 
} 

func (a *TemplateAdapter) Message() string { 
  return "" 
} 

func MessageRetrieverAdapter(f func() string) MessageRetriever { 
  return nil 
} 

如您所见,TemplateAdapter类型有一个名为myFunc的字段,属于func() string类型。我们还将适配器定义为 private,因为如果没有在myFunc字段中定义的函数,就不应该使用它。我们创建了一个名为MessageRetrieverAdapter的公共函数来实现这一点。我们的测试应该大致如下所示:

t.Run("Using anonymous functions adapted to an interface", func(t *testing.T){ 
  messageRetriever := MessageRetrieverAdapter(func() string { 
    return "world" 
  }) 

  if messageRetriever == nil { 
    t.Fatal("Can not continue with a nil MessageRetriever") 
  } 

  template := Template{} 
  res := template.ExecuteAlgorithm(messageRetriever) 

  expectedOrError(res, " world ", t) 
}) 

请看我们称之为MessageRetrieverAdapter方法的语句。我们传递了一个匿名函数作为参数,该参数定义为func()字符串。然后,我们重用前面定义的第一个测试中的Template类型,以通过messageRetriever变量。最后,我们用expectedOrError方法再次检查。看看MessageRetrieverAdapter方法,它将返回一个具有 nil 值的函数。如果严格遵循测试驱动的开发规则,我们必须首先进行测试,并且在实现完成之前不能通过测试。这就是我们在MessageRetrieverAdapter函数中返回 nil 的原因。

那么,让我们运行测试:

go test -v .
=== RUN   TestTemplate_ExecuteAlgorithm
=== RUN   TestTemplate_ExecuteAlgorithm/Using_interfaces
=== RUN   TestTemplate_ExecuteAlgorithm/Using_anonymous_functions
=== RUN   TestTemplate_ExecuteAlgorithm/Using_anonymous_functions_adapted_to_an_interface
--- FAIL: TestTemplate_ExecuteAlgorithm (0.00s)
 --- PASS: TestTemplate_ExecuteAlgorithm/Using_interfaces (0.00s)
 --- PASS: TestTemplate_ExecuteAlgorithm/Using_anonymous_functions (0.00s)
 --- FAIL: TestTemplate_ExecuteAlgorithm/Using_anonymous_functions_adapted_to_an_interface (0.00s)
 template_test.go:39: Can not continue with a nil MessageRetriever
FAIL
exit status 1
FAIL

代码的行 39上的测试失败,测试无法继续(同样,根据您编写代码的方式,表示错误的行可能在其他地方)。我们停止测试执行,因为我们在调用ExecuteAlgorithm方法时需要一个有效的MessageRetriever接口。

对于模板模式适配器的实现,我们将从MessageRetrieverAdapter方法开始:

func MessageRetrieverAdapter(f func() string) MessageRetriever { 
  return &adapter{myFunc: f} 
} 

这很容易,对吧?您可能想知道,如果我们为f参数传递nil值,会发生什么。那么我们将通过调用myFunc函数来解决这个问题。

adapter类型完成此实现:

type adapter struct { 
  myFunc func() string 
} 

func (a *adapter) Message() string { 
  if a.myFunc != nil { 
    return a.myFunc() 
  } 

  return "" 
} 

调用Message()函数时,我们会在调用之前检查myFunc函数中是否存储了某些内容。如果未存储任何内容,则返回一个空字符串。

现在,我们使用适配器模式完成了Template类型的第三个实现:

go test -v .
=== RUN   TestTemplate_ExecuteAlgorithm
=== RUN   TestTemplate_ExecuteAlgorithm/Using_interfaces
=== RUN   TestTemplate_ExecuteAlgorithm/Using_anonymous_functions
=== RUN   TestTemplate_ExecuteAlgorithm/Using_anonymous_functions_adapted_to_an_interface
--- PASS: TestTemplate_ExecuteAlgorithm (0.00s)
 --- PASS: TestTemplate_ExecuteAlgorithm/Using_interfaces (0.00s)
 --- PASS: TestTemplate_ExecuteAlgorithm/Using_anonymous_functions (0.00s)
 --- PASS: TestTemplate_ExecuteAlgorithm/Using_anonymous_functions_adapted_to_an_interface (0.00s)
PASS
ok

在 Go 的源代码中寻找模板模式

Go 源代码中的Sort包可以被视为排序算法的模板实现。正如包本身所定义的,Sort包提供用于排序切片和用户定义集合的原语。

在这里,我们还可以找到一个很好的例子,说明为什么 Go 作者不担心实现泛型。对列表进行排序可能是其他语言中通用用法的最好例子。Go 处理此问题的方式也非常优雅,它通过一个界面处理此问题:

type Interface interface { 
  Len() int 
  Less(i, j int) bool 
  Swap(i, j int) 
} 

这是需要使用sort包进行排序的列表的界面。用 Go 的作者的话说:

“类型通常是满足排序的集合。接口可以通过此包中的例程进行排序。这些方法要求集合的元素由整数索引枚举。”

换句话说,编写一个实现这个Interface的类型,这样Sort包就可以用来对任何片进行排序。排序算法就是模板,我们必须定义如何在切片中检索值。

如果我们查看sort包,我们还可以找到如何使用排序模板的示例,但我们将创建自己的示例:

package main 

import ( 
  "sort" 
  "fmt" 
) 

type MyList []int 

func (m MyList) Len() int { 
  return len(m) 
} 

func (m MyList) Swap(i, j int) { 
  m[i], m[j] = m[j], m[i] 
} 

func (m MyList) Less(i, j int) bool { 
  return m[i] < m[j] 
} 

首先,我们做了一个非常简单的类型,它存储一个int列表。这可以是任何类型的列表,通常是某种结构的列表。然后我们通过定义LenSwapLess方法实现了sort.Interface接口。

最后,main函数创建一个无序的MyList类型的数字列表:

func main() { 
  var myList MyList = []int{6,4,2,8,1} 

  fmt.Println(myList) 
  sort.Sort(myList) 
  fmt.Println(myList) 
} 

我们打印我们创建的列表(无序),然后对其进行排序(sort.Sort方法实际上修改了我们的变量,而不是返回一个新列表,所以要小心!)。最后,我们再次打印结果列表。此main方法的控制台输出如下:

go run sort_example.go 
[6 4 2 8 1]
[1 2 4 6 8]

sort.Sort函数以透明的方式对列表进行排序。它编写了大量代码,并将LenSwapLess方法委托给接口,就像我们在模板委托给MessageRetriever接口时所做的那样。

总结模板设计模式

我们想把重点放在这个模式上,因为它在开发库和框架时非常重要,并且允许我们库的用户有很大的灵活性和控制。

我们还再次看到,混合模式为用户提供灵活性是非常常见的,不仅在行为方式上,而且在结构上。在处理并发应用程序时,这将非常方便,因为我们需要限制对部分代码的访问以避免竞争。

现在让我们看一个具有奇特名称的图案。如果我们查字典查看memento的含义,我们会发现以下描述:

“作为一个人或事件的提醒而保存的物品。”

这里的关键词是提醒,因为我们将记住这种设计模式下的动作。

说明

memento 的含义与它在设计模式中提供的功能非常相似。基本上,我们将有一个带有一些状态的类型,我们希望能够保存其状态的里程碑。保存了有限数量的状态后,如果需要执行各种任务、撤消操作、历史记录等,我们可以恢复这些状态。

备忘录设计模式通常有三名玩家(通常称为演员

  • Memento:存储我们要保存的类型的类型。通常,我们不会直接存储业务类型,而是通过该类型提供额外的抽象层。
  • 发起人:负责创建备忘录并存储当前活动状态的类型。我们说过 Memento 类型封装了业务类型的状态,我们使用 originator 作为 Memento 的创建者。
  • 看管人:一种存储备忘录列表的类型,可以将其存储在数据库中,也可以不存储超过指定数量的备忘录。

目标

Memento 是随着时间推移的一系列操作,比如撤销一个或两个操作,或者为某个应用程序提供某种事务性。

Memento 为许多任务提供了基础,但其主要目标可定义如下:

  • 捕获对象状态而不修改对象本身
  • 保存有限数量的状态,以便我们以后可以检索它们

一个简单的字符串示例

我们将开发一个简单的示例,使用字符串作为要保存的状态。通过这种方式,我们将重点讨论常见的 Memento 模式实现,然后使用一个新的示例使其变得更复杂。

存储在State实例字段中的字符串将被修改,我们将能够撤消在此状态下执行的操作。

要求及验收标准

我们一直在谈论国家;总之,Memento 模式是关于存储和检索状态的。我们的验收标准必须与国家有关:

  1. 我们需要存储字符串类型的有限数量的状态。
  2. 我们需要一种方法将当前存储的状态恢复到状态列表中的一个。

有了这两个简单的需求,我们就可以开始为这个例子编写一些测试了。

单元测试

如前所述,Memento 设计模式通常由三个参与者组成:国家、Memento 和发起人。因此,我们需要三种类型来代表这些参与者:

type State struct { 
  Description string 
} 

State类型是我们将在本例中使用的核心业务对象。它是我们想要跟踪的任何类型的对象:

type memento struct { 
  state State 
} 

memento类型有一个名为state的字段,表示State类型的单个值。我们的states在储存到care taker型之前,将在该型中进行集装箱运输。你可能想知道为什么我们不直接存储State实例。基本上,因为它将originatorcareTaker耦合到业务对象,我们希望耦合尽可能少。正如我们将在第二个示例中看到的那样,它的灵活性也会降低:

type originator struct { 
  state State 
} 

func (o *originator) NewMemento() memento { 
  return memento{} 
} 

func (o *originator) ExtractAndStoreState(m memento) { 
  //Does nothing 
} 

originator类型还存储状态。originator结构的对象将从 Memento 获取状态,并使用存储状态创建新的 Memento。

提示

发起者对象和备忘录模式之间有什么区别?为什么我们不直接使用发起者模式的对象呢?那么,如果 Memento 包含一个特定的状态,originator类型包含当前加载的状态。此外,保存某个对象的状态可以简单到获取某个值,也可以复杂到维护某个分布式应用程序的状态。

发起人将有两种公开的方法--NewMemento()方法和ExtractAndStoreState(m memento)方法。NewMemento方法将返回一个使用originator当前State值构建的新备忘录。ExtractAndStoreState方法将获取备忘录的状态并将其存储在Originator的状态字段中:

type careTaker struct { 
  mementoList []memento 
} 

func (c *careTaker) Add(m memento) { 
  //Does nothing 
} 

func (c *careTaker) Memento(i int) (memento, error) { 
  return memento{}, fmt.Errorf("Not implemented yet") 
} 

careTaker类型存储了我们需要保存的所有状态的备忘录列表。它还存储了一个Add方法,用于在列表中插入一个新的 Memento,以及一个 Memento 检索器,该检索器在 Memento 列表中获取索引。

所以让我们从careTaker类型的Add方法开始。Add方法必须将memento对象添加到careTaker对象的备忘录列表中:

func TestCareTaker_Add(t *testing.T) { 
  originator := originator{} 
  originator.state = State{Description:"Idle"} 

  careTaker := careTaker{} 
  mem := originator.NewMemento() 
  if mem.state.Description != "Idle" { 
    t.Error("Expected state was not found") 
  } 

在我们的测试开始时,我们为 memento 创建了两个基本角色--originatorcareTaker。我们使用描述Idle对发起人设置了第一个状态。

然后,我们创建了第一个 Memento,称为NewMemento方法。这应该将当前发起人的状态包装为memento类型。我们的第一个检查非常简单——返回备忘录的状态描述必须与我们传递给发起者的状态描述类似,即Idle描述。

检查我们的 MementoAdd方法是否正确的最后一步是查看添加一项后 Memento 列表是否增长:

  currentLen := len(careTaker.mementoList) 
  careTaker.Add(mem) 

  if len(careTaker.mementoList) != currentLen+1 { 
    t.Error("No new elements were added on the list") 
  } 

我们还必须测试Memento(int) memento方法。这应该从careTaker列表中获取memento值。它采用您希望从列表中检索的索引,因此,与列表一样,我们必须检查它在负数和超出索引值时的行为是否正确:

func TestCareTaker_Memento(t *testing.T) { 
  originator := originator{} 
  careTaker := careTaker{} 

  originator.state = State{"Idle"} 
  careTaker.Add(originator.NewMemento()) 

我们必须像之前的测试一样开始创建一个originatorcareTaker对象,并将第一个备忘录添加到caretaker

  mem, err := careTaker.Memento(0) 
  if err != nil { 
    t.Fatal(err) 
  } 

  if mem.state.Description != "Idle" { 
    t.Error("Unexpected state") 
  } 

一旦我们在careTaker对象上有了第一个对象,我们就可以使用careTaker.Memento(0)来请求它。Memento(int)方法上的索引0检索切片上的第一项(记住切片以0开头)。不应返回任何错误,因为我们已经向caretaker对象添加了一个值。

然后,在检索第一个备忘录后,我们检查了描述是否与我们在测试开始时通过的描述匹配:

  mem, err = careTaker.Memento(-1) 
  if err == nil { 
    t.Fatal("An error is expected when asking for a negative number but no error was found") 
  } 
} 

此测试的最后一步涉及使用负数检索某些值。在这种情况下,必须返回一个错误,表明不能使用负数。传递负数时也可以返回第一个索引,但这里我们将返回一个错误。

最后一个要检查的功能是ExtractAndStoreState方法。此函数必须获取一个 Memento 并提取其所有状态信息,以便在Originator对象中进行设置:

func TestOriginator_ExtractAndStoreState(t *testing.T) { 
  originator := originator{state:State{"Idle"}} 
  idleMemento := originator.NewMemento() 

  originator.ExtractAndStoreState(idleMemento) 
  if originator.state.Description != "Idle" { 
    t.Error("Unexpected state found") 
  } 
} 

这个测试很简单。我们创建了一个带有Idle状态的默认originator变量。然后,我们检索一个新的 Memento 对象以供以后使用。我们将originator变量的状态更改为Working状态,以确保写入新状态。

最后,我们必须使用idleMemento变量调用ExtractAndStoreState方法。这应该将发起者的状态恢复为idleMemento状态的值,这是我们在上一个if语句中检查的。

现在是运行测试的时候了:

go test -v . 
=== RUN   TestCareTaker_Add
--- FAIL: TestCareTaker_Add (0.00s)
 memento_test.go:13: Expected state was not found
 memento_test.go:20: No new elements were added on the list
=== RUN   TestCareTaker_Memento
--- FAIL: TestCareTaker_Memento (0.00s)
 memento_test.go:33: Not implemented yet
=== RUN   TestOriginator_ExtractAndStoreState
--- FAIL: TestOriginator_ExtractAndStoreState (0.00s)
 memento_test.go:54: Unexpected state found
FAIL
exit status 1
FAIL

因为这三个测试都失败了,我们可以继续执行。

实施纪念模式

如果不太疯狂,Memento 模式的实现通常非常简单。这三个参与者(mementooriginatorcare taker在模式中有一个非常明确的角色,它们的实现非常简单:

type originator struct { 
  state State 
} 

func (o *originator) NewMemento() memento { 
  return memento{state: o.state} 
} 

func (o *originator) ExtractAndStoreState(m memento) { 
  o.state = m.state 
} 

Originator对象在调用NewMemento方法时需要返回 Memento 类型的新值。它还需要根据ExtractAndStoreState方法的需要在结构的状态字段中存储memento对象的值:

type careTaker struct { 
  mementoList []memento 
} 

func (c *careTaker) Push(m memento) { 
  c.mementoList = append(c.mementoList, m) 
} 

func (c *careTaker) Memento(i int) (memento, error) { 
  if len(c.mementoList) < i || i < 0 { 
    return memento{}, fmt.Errorf("Index not found\n") 
  } 
  return c.mementoList[i], nil 
} 

careTaker类型也很简单。当我们调用Add方法时,我们通过使用参数中传递的值调用append方法来覆盖mementoList字段。这将创建包含新值的新列表。

调用Memento方法时,我们必须事先做一些检查。在这种情况下,我们检查索引是否不在切片范围之外,并且索引在if语句中不是负数,在这种情况下,我们返回一个错误。如果一切正常,它只返回指定的memento对象,没有错误。

提示

关于方法和函数命名约定的说明。你可能会发现一些人喜欢给方法起更具描述性的名字,比如Memento。一个例子是使用一个名称,例如MementoOrError方法,清楚地表明在调用此函数甚至GetMementoOrError方法时返回两个对象。这可能是一种非常明确的命名方法,也不一定不好,但在 Go 的源代码中不会很常见。

Time to check the test results:

go test -v .
=== RUN   TestCareTaker_Add
--- PASS: TestCareTaker_Add (0.00s)
=== RUN   TestCareTaker_Memento
--- PASS: TestCareTaker_Memento (0.00s)
=== RUN   TestOriginator_ExtractAndStoreState
--- PASS: TestOriginator_ExtractAndStoreState (0.00s)
PASS
ok

这足以达到 100%的覆盖率。虽然这远不是一个完美的指标,但至少我们知道,我们正在触及源代码的每一个角落,并且我们没有在测试中作弊来实现它。

使用命令和外观模式的另一个示例

前面的示例非常简单,足以理解 Memento 模式的功能。但是,它通常与命令模式和简单的外观模式结合使用。

其思想是使用命令模式来封装一组不同类型的状态(实现Command接口的状态),并提供一个小的外观来自动插入caretaker对象。

我们将开发一个假设的音频混音器的小示例。我们将使用相同的 Memento 模式保存两种状态:VolumeMuteVolume状态将是字节类型,Mute状态将是布尔类型。我们将使用两种完全不同的类型来展示这种方法的灵活性(及其缺点)。

作为补充说明,我们还可以在接口上为每个Command接口提供各自的序列化方法。这样,我们就可以让管理员在不知道存储的内容的情况下,将状态存储在某种存储中。

我们的Command接口将有一个方法返回其实现者的值。非常简单,我们想要撤销的音频混音器中的每个命令都必须实现以下接口:

type Command interface { 
  GetValue() interface{} 
} 

这个界面中有一些有趣的东西。GetValue方法返回一个值的接口。这也意味着此方法的返回类型是。。。好非类型化?不是真的,但它返回一个可以是任何类型表示的接口,如果我们想使用它的特定类型,我们需要稍后对其进行类型转换。现在我们需要定义VolumeMute类型并实现Command接口:

type Volume byte 

func (v Volume) GetValue() interface{} { 
  return v 
} 

type Mute bool 

func (m Mute) GetValue() interface{} { 
  return m 
} 

它们都是非常简单的实现。但是,Mute类型将在GetValue()方法上返回bool类型,Volume将返回byte类型。

如前一个示例中所示,我们需要一个包含CommandMemento类型。换句话说,它将存储指向MuteVolume类型的指针:

type Memento struct { 
  memento Command 
} 

originator类型的工作方式与上一示例相同,但使用Command关键字而不是state关键字:

type originator struct { 
  Command Command 
} 

func (o *originator) NewMemento() Memento { 
  return Memento{memento: o.Command} 
} 

func (o *originator) ExtractAndStoreCommand(m Memento) { 
  o.Command = m.memento 
} 

caretaker对象几乎相同,但这次我们将使用堆栈而不是简单的列表,并且我们将存储命令而不是状态:

type careTaker struct { 
  mementoList []Memento 
} 

func (c *careTaker) Add(m Memento) { 
  c.mementoList = append(c.mementoList, m) 
} 

func (c *careTaker) Pop() Memento { 
  if len(c.mementoStack) > 0 { 
    tempMemento := c.mementoStack[len(c.mementoStack)-1] 
    c.mementoStack = c.mementoStack[0:len(c.mementoStack)-1] 
    return tempMemento 
  } 

  return Memento{} 
} 

然而,我们的Memento列表被Pop方法取代。它还返回一个memento对象,但它将返回它们作为堆栈(最后进入,首先退出)。因此,我们获取堆栈上的最后一个元素并将其存储在tempMemento变量中。然后我们用一个新版本替换堆栈,该版本不包含下一行的最后一个元素。最后,我们返回tempMemento变量。

到目前为止,所有内容都与上一个示例中的内容几乎相同。我们还讨论了使用 Facade 模式自动化一些任务,所以让我们来做吧。这将被称为MementoFacade类型,将有SaveSettingsRestoreSettings方法。SaveSettings方法获取Command,将其存储在内部发起人中,并将其保存在内部careTaker字段中。RestoreSettings方法使反向流恢复careTaker的索引,并返回Memento对象内的Command

type MementoFacade struct { 
  originator originator 
  careTaker  careTaker 
} 

func (m *MementoFacade) SaveSettings(s Command) { 
  m.originator.Command = s 
  m.careTaker.Add(m.originator.NewMemento()) 
} 

func (m *MementoFacade) RestoreSettings(i int) Command { 
  m.originator.ExtractAndStoreCommand(m.careTaker.Memento(i)) 
  return m.originator.Command 
} 

我们的 Facade 模式将保存发起人和管理员的内容,并将提供这两种易于使用的方法来保存和恢复设置。

那么,我们如何使用这个呢?

func main(){ 
  m := MementoFacade{} 

  m.SaveSettings(Volume(4)) 
  m.SaveSettings(Mute(false)) 

首先,我们得到一个带有 Facade 模式的变量。零值初始化将为我们提供零值的originatorcaretaker对象。它们没有任何意外字段,因此所有内容都将正确初始化(例如,如果其中任何一个有指针,它将被初始化为nil,如第 1 章中的零初始化部分所述,准备就绪……稳定……开始!

我们用Volume(4)创建Volume值,是的,我们使用了括号。Volume类型没有任何类似于结构的内部字段,因此我们不能使用大括号来设置其值。设置它的方法是使用括号(或者创建指向类型Volume的指针,然后设置指向的空格的值)。我们还使用 Facade 模式保存类型为Mute的值。

我们不知道这里返回了什么Command类型,所以我们需要做一个类型断言。我们将制作一个小函数来帮助我们检查类型并打印适当的值:

func assertAndPrint(c Command){ 
  switch cast := c.(type) { 
  case Volume: 
    fmt.Printf("Volume:\t%d\n", cast) 
  case Mute: 
    fmt.Printf("Mute:\t%t\n", cast) 
  } 
} 

assertAndPrint方法采用Command类型,并将其强制转换为两种可能的类型—VolumeMute。在每种情况下,它都会向控制台打印一条带有个性化消息的消息。现在我们可以继续并完成main函数,它将如下所示:

func main() { 
  m := MementoFacade{} 

  m.SaveSettings(Volume(4)) 
  m.SaveSettings(Mute(false)) 

 assertAndPrint(m.RestoreSettings(0))
 assertAndPrint(m.RestoreSettings(1)) 
} 

粗体突出显示的部分显示了main功能中的新变化。我们从careTaker对象中获取索引 0,并将其传递给新函数,与索引1相同。运行这个小程序,我们应该在控制台上得到VolumeMute的值:

$ go run memento_command.go
Mute:   false
Volume: 4

伟大的在这个小示例中,我们结合了三种不同的设计模式,以保持使用各种模式的舒适性。请记住,我们也可以将VolumeMute状态的创建抽象为一个工厂模式,因此这不是停止的地方。

纪念图案上的遗言

通过 Memento 模式,我们学习了一种创建可撤销操作的强大方法,这种方法在编写 UI 应用程序时非常有用,在开发事务性操作时也非常有用。在任何情况下,情况都是一样的:你需要一个Memento、一个Originator和一个caretaker演员。

提示

事务操作是一组必须全部完成或失败的原子操作。换句话说,如果您有一个由五个操作组成的事务,而其中只有一个操作失败,那么该事务将无法完成,而其他四个操作所做的所有修改都必须撤消。

现在我们要深入研究一个非常复杂的模式。事实上,解释器模式被广泛用于解决业务案例,在这些案例中,使用一种语言来执行常见操作非常有用。让我们看看语言是什么意思。

说明

我们可以谈论的最著名的解释器可能是 SQL。它被定义为一种特殊用途的编程语言,用于管理关系数据库中的数据。SQL 非常复杂和庞大,但总而言之,它是一组字和运算符,允许我们执行插入、选择或删除等操作。

另一个典型的例子是记谱法。它本身就是一种语言,口译员是一位音乐家,他知道音符和他们演奏的乐器上的音符之间的联系。

在计算机科学中,出于各种原因设计一种小型语言可能很有用:重复性任务、非开发人员的高级语言,或接口定义语言IDL),如协议缓冲区Apache Thrift

目标

设计一种新的语言,无论大小,都可能是一项耗时的任务,因此在投入时间和资源编写语言的解释程序之前,明确目标非常重要:

  • 为某些范围内非常常见的操作(如播放音符)提供语法。
  • 使用中间语言在两个系统之间转换动作。例如,生成Gcode的应用程序需要使用 3D 打印机打印。
  • 以更易于使用的语法简化某些操作的使用。

SQL 允许以非常易于使用的语法使用关系数据库(也可能变得非常复杂),但其思想是不需要编写自己的函数来进行插入和搜索。

示例-波兰符号计算器

解释器的一个非常典型的例子是创建一个反向波兰符号计算器。对于那些不知道波兰语符号是什么的人来说,这是一种数学符号,可以先写运算(求和),然后写值(34),因此+34相当于更常见的3+4,其结果将是7。因此,对于反向波兰符号,您首先输入值,然后输入操作,因此34+也将是7

计算器的验收标准

对于我们的计算器,我们应该通过的验收标准考虑如下:

  1. 创建一种允许进行常用算术运算(和、减、乘和除)的语言。语法为sum表示和,mul表示乘法,sub表示减法,div表示除法。
  2. 必须使用反向波兰符号来完成。
  3. 用户必须能够在一行中写入任意数量的操作。
  4. 操作必须从左到右进行。

因此,3 4 sum 2 sub符号与(3+4)-2符号相同,结果为5

部分操作的单元测试

在这种情况下,我们只有一个名为Calculate的公共方法,它接受一个值定义为字符串的操作,并返回一个值或一个错误:

func Calculate(o string) (int, error) { 
  return 0, fmt.Errorf("Not implemented yet") 
} 

因此,我们将向Calculate方法发送一个类似于"3 4 +"的字符串,它应该返回7,nil。还有两个测试将检查正确的实现:

func TestCalculate(t *testing.T) { 
  tempOperation = "3 4 sum 2 sub" 
  res, err = Calculate(tempOperation) 
  if err != nil { 
    t.Error(err) 
  } 

  if res != 5 { 
    t.Errorf("Expected result not found: %d != %d\n", 5, res) 
  } 

首先,我们将以我们使用的操作为例。3 4 sum 2 sub符号是我们语言的一部分,我们在Calculate函数中使用它。如果返回错误,则测试失败。最后,结果必须等于5,我们在最后几行检查它。下一个测试将检查其余操作员的稍微复杂的操作:

  tempOperation := "5 3 sub 8 mul 4 sum 5 div" 
  res, err := Calculate(tempOperation) 
  if err != nil { 
    t.Error(err) 
  } 

  if res != 4 { 
    t.Errorf("Expected result not found: %d != %d\n", 4, res) 
  } 
} 

在这里,我们用更长的操作重复前面的过程,((5-3)8)+4)/5表示法,它等于4*。从左到右,如下所示:

(((5 - 3) * 8) + 4) / 5
 ((2 * 8) + 4) / 5
 (16 + 4) / 5
 20 / 5
 4

当然,考试一定会失败!

$ go test -v .
 interpreter_test.go:9: Not implemented yet
 interpreter_test.go:13: Expected result not found: 4 != 0
 interpreter_test.go:19: Not implemented yet
 interpreter_test.go:23: Expected result not found: 5 != 0
exit status 1
FAIL

实施

这次的实现将比测试更长。首先,我们将在常量中定义可能的运算符:

const ( 
  SUM = "sum" 
  SUB = "sub" 
  MUL = "mul" 
  DIV = "div" 
) 

解释器模式通常使用抽象语法树实现,这通常是使用堆栈实现的。我们在本书之前已经创建了堆栈,因此读者应该已经熟悉了:

type polishNotationStack []int 

func (p *polishNotationStack) Push(s int) { 
  *p = append(*p, s) 
} 

func (p *polishNotationStack) Pop() int { 
  length := len(*p) 

  if length > 0 { 
    temp := (*p)[length-1] 
    *p = (*p)[:length-1] 
    return temp 
  } 

  return 0 
} 

我们有两种方法——将元素添加到堆栈顶部的Push方法和删除元素并返回它们的Pop方法。如果你认为这一行*p = (*p)[:length-1]有点晦涩,我们会解释它。

存储在p方向上的值将被p (*p)方向上的实际值覆盖,但只取数组(:length-1)从开始到倒数第二个元素的元素。

因此,现在我们将逐步使用Calculate函数,根据需要创建更多函数:

func Calculate(o string) (int, error) { 
  stack := polishNotationStack{} 
  operators := strings.Split(o, " ") 

我们需要做的前两件事是创建堆栈并从传入操作中获取所有不同的符号(在本例中,我们不检查它是否为空)。我们将传入的字符串操作按空间分割,以获得一个很好的符号片段(值和运算符)。

接下来,我们将使用 range 迭代每个符号,但我们需要一个函数来知道传入符号是值还是运算符:

func isOperator(o string) bool { 
  if o == SUM || o == SUB || o == MUL || o == DIV { 
    return true 
  } 

  return false 
} 

如果传入符号是常量中定义的任何符号,则传入符号是一个运算符:

func Calculate(o string) (int, error) { 
  stack := polishNotationStack{} 
  operators := strings.Split(o, " ") 

for _, operatorString := range operators {
 if isOperator(operatorString) {
 right := stack.Pop()
 left := stack.Pop()
 } 
  else 
  {
 //Is a value
 } 
}

如果它是一个运算符,我们认为我们已经通过了两个值,所以我们要做的是从栈中获取这两个值。第一个值是最右边的,第二个值是最左边的(记住,在减法和除法中,操作数的顺序很重要)。然后,我们需要一些函数来获取要执行的操作:

func getOperationFunc(o string) func(a, b int) int { 
  switch o { 
  case SUM: 
    return func(a, b int) int { 
      return a + b 
    } 
  case SUB: 
    return func(a, b int) int { 
      return a - b 
    } 
  case MUL: 
    return func(a, b int) int { 
      return a * b 
    } 
  case DIV: 
    return func(a, b int) int { 
      return a / b 
    } 
  } 
  return nil 
} 

getOperationFunc函数返回一个返回整数的双参数函数。我们检查传入运算符并返回执行指定操作的匿名函数。那么现在我们的for range是这样继续的:

func Calculate(o string) (int, error) { 
  stack := polishNotationStack{} 
  operators := strings.Split(o, " ") 

for _, operatorString := range operators { 
  if isOperator(operatorString) { 
      right := stack.Pop() 
      left := stack.Pop() 
 mathFunc := getOperationFunc(operatorString)
 res := mathFunc(left, right)
 stack.Push(res) 
    } else { 
      //Is a value 
    } 
} 

函数返回mathFunc变量。我们立即使用它对从堆栈中获取的左右值执行操作,并将其结果存储在一个名为res的新变量中。最后,我们需要将这个新值推送到堆栈中,以便以后继续使用它进行操作。

现在,以下是传入符号为值时的实现:

func Calculate(o string) (int, error) { 
  stack := polishNotationStack{} 
  operators := strings.Split(o, " ") 

for _, operatorString := range operators { 
    if isOperator(operatorString) { 
      right := stack.Pop() 
      left := stack.Pop() 
      mathFunc := getOperationFunc(operatorString) 
      res := mathFunc(left, right) 
      stack.Push(res) 
    } else { 
 val, err := strconv.Atoi(operatorString)
 if err != nil {
 return 0, err
 }
 stack.Push(val) 
    } 
  } 

每当我们得到一个符号时,我们需要做的就是把它推到堆栈中。我们必须将字符串符号解析为可用的int类型。这通常是通过使用strconv包的Atoi功能来完成的。Atoi函数接受一个字符串并从中返回一个整数或一个错误。如果一切顺利,该值将被推入堆栈。

range语句的末尾,只需要存储一个值,所以我们只需要返回它,函数就完成了:

func Calculate(o string) (int, error) { 
  stack := polishNotationStack{} 
  operators := strings.Split(o, " ") 

for _, operatorString := range operators { 
    if isOperator(operatorString) { 
      right := stack.Pop() 
      left := stack.Pop() 
      mathFunc := getOperationFunc(operatorString) 
      res := mathFunc(left, right) 
      stack.Push(res) 
    } else { 
      val, err := strconv.Atoi(operatorString) 
      if err != nil { 
        return 0, err 
      } 

      stack.Push(val) 
    } 
  } 
 return int(stack.Pop()), nil
}

再次运行测试的时间:

$ go test -v .
ok

伟大的我们刚刚以一种非常简单的方式创建了一个反向波兰符号解释器(我们仍然缺少解析器,但这是另一回事)。

解释器设计模式的复杂性

在本例中,我们没有使用任何接口。这并不是解释器设计模式在更多面向对象语言中的定义方式。然而,这个例子是理解语言目标的最简单的例子,下一个层次不可避免地要复杂得多,不适合初学者。

在一个更复杂的示例中,我们必须定义一个类型,该类型包含更多的自身类型、一个值或什么都不包含。使用解析器,您可以创建这个抽象语法树,以便稍后对其进行解释。

通过使用接口完成的相同示例将如以下描述部分所示。

再次使用解释器模式-现在使用接口

我们将要使用的主接口称为Interpreter接口。此接口有一个Read()方法,每个符号(值或运算符)必须实现:

type Interpreter interface { 
  Read() int 
} 

我们将只实现运算符的求和和和减法,并为数字实现一种称为Value的类型:

type value int 

func (v *value) Read() int { 
  return int(*v) 
} 

Value是一种int类型,在实现Read方法时,只返回其值:

type operationSum struct { 
  Left  Interpreter 
  Right Interpreter 
} 

func (a *operationSum) Read() int { 
  return a.Left.Read() + a.Right.Read() 
} 

operationSum结构具有LeftRight字段,其Read方法返回其Read方法的总和。operationSubtract结构相同,但减去:

type operationSubtract struct { 
  Left  Interpreter 
  Right Interpreter 
} 

func (s *operationSubtract) Read() int { 
  return s.Left.Read() - s.Right.Read() 
} 

我们还需要一个工厂模式来创建运营商;我们称之为operatorFactory方法。现在的区别在于,它不仅接受符号,还接受从堆栈中获取的LeftRight值:

func operatorFactory(o string, left, right Interpreter) Interpreter { 
  switch o { 
  case SUM: 
    return &operationSum{ 
      Left: left, 
      Right: right, 
    } 
  case SUB: 
    return &operationSubtract{ 
      Left: left, 
      Right: right, 
    } 
  } 

  return nil 
} 

正如我们刚才提到的,我们还需要一个堆栈。我们可以通过更改其类型来重用上一示例中的一个:

type polishNotationStack []Interpreter 

func (p *polishNotationStack) Push(s Interpreter) { 
  *p = append(*p, s) 
} 

func (p *polishNotationStack) Pop() Interpreter { 
  length := len(*p) 

  if length > 0 { 
    temp := (*p)[length-1] 
    *p = (*p)[:length-1] 
    return temp 
  } 

  return nil 
} 

现在堆栈使用解释器指针而不是int,但其功能是相同的。最后,我们的main方法看起来也类似于前面的示例:

func main() { 
  stack := polishNotationStack{} 
  operators := strings.Split("3 4 sum 2 sub", " ") 

  for _, operatorString := range operators { 
    if operatorString == SUM || operatorString == SUB { 
      right := stack.Pop() 
      left := stack.Pop() 
      mathFunc := operatorFactory(operatorString, left, right) 
      res := value(mathFunc.Read()) 
      stack.Push(&res) 
    } else { 
      val, err := strconv.Atoi(operatorString) 
      if err != nil { 
        panic(err) 
      } 

      temp := value(val) 
      stack.Push(&temp) 
    } 
  } 

  println(int(stack.Pop().Read())) 
} 

与前面一样,我们首先检查符号是运算符还是值。当它是一个值时,它会将其推入堆栈。

当符号是运算符时,我们也从堆栈中获取右值和左值,我们使用当前运算符和刚从堆栈中获取的左值和右值调用工厂模式。一旦我们有了操作符类型,我们只需要调用它的Read方法,将返回的值也推送到堆栈中。

最后,堆栈上必须只剩下一个示例,因此我们打印它:

$ go run interpreter.go
5

解释器模式的力量

这种模式非常强大,但也必须小心使用。为了创建一种语言,它在用户和它提供的功能之间产生了强烈的耦合。人们可能会犯这样的错误:试图创建一种过于灵活、难以使用和维护的语言。此外,人们还可以创建一种相当小且有用的语言,这种语言有时无法正确解释,这可能会让用户感到痛苦。

在我们的示例中,我们忽略了很多错误检查,而将重点放在解释器的实现上。但是,您需要大量的错误检查和详细的错误输出来帮助用户纠正语法错误。所以,在编写语言时要开心,但要善待用户。

本章讨论了三种非常强大的模式,在将它们用于生产代码之前需要大量的实践。通过模拟典型的生产问题,与他们进行一些练习是一个非常好的主意:

  • 创建一个简单的 REST 服务器,该服务器重用大部分错误检查和连接功能,以提供一个易于使用的接口来实践模板模式
  • 制作一个小的库,可以写入不同的数据库,但只有在所有写入都正常的情况下,或者删除新创建的写入,例如 practice Memento
  • 写你自己的语言,做一些简单的事情,比如回答像机器人这样的简单问题,这样你就可以练习一些解释器模式

我们的想法是练习编码并重读任何部分,直到您对每个模式都感到满意为止。

教程来源于Github,感谢apachecn大佬的无私奉献,致敬!

技术教程推荐

赵成的运维体系管理课 -〔赵成〕

从0开始学游戏开发 -〔蔡能〕

网络编程实战 -〔盛延敏〕

研发效率破局之道 -〔葛俊〕

OAuth 2.0实战课 -〔王新栋〕

成为AI产品经理 -〔刘海丰〕

React Hooks 核心原理与实战 -〔王沛〕

大厂设计进阶实战课 -〔小乔〕

超级访谈:对话道哥 -〔吴翰清(道哥)〕