Go-微服务构建指南-全-

Go 微服务构建指南(全)

原文:zh.annas-archive.org/md5/a1277f7208f41609e82de5cdeffdf508

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

微服务架构作为构建基于 Web 应用程序的事实上的模式,正在席卷全球。Golang 是一种特别适合构建它们的语言。它强大的社区、鼓励惯用风格和静态链接的二进制工件使其与其他技术集成以及大规模管理微服务变得一致且直观。这本书将教你常见的模式和惯例,并展示如何使用 Go 编程语言应用这些模式。

本书将教授你架构设计和 RESTful 通信的基本概念,并介绍提供可管理代码的图案,这些代码在开发中可支持,在生产中可大规模运行。我们将提供如何使用 Go 将这些概念和模式付诸实践的示例。

无论你是在规划新的应用程序还是在现有的单体应用程序中工作,本书都将解释并使用实际示例说明所有规模团队如何开始使用微服务解决问题。它将帮助你理解 Docker 和 Docker Compose,以及它们如何用于隔离微服务依赖关系和构建环境。我们将通过展示各种监控、测试和确保你的微服务安全的技术来结束本书。

到最后,你将了解微服务系统弹性的好处以及 Go 语法的优势。

本书涵盖内容

第一章,微服务简介,探讨了 Go 语言为何适合构建微服务,并查看包含构建基本微服务所需所有组件的标准库。首先查看标准元素将为你打下基础,并让你欣赏到我们稍后讨论的一些框架如何极其有用。

第二章,设计优秀的 API,探讨了什么因素使 API 变得优秀。我们将介绍 REST 和 RPC,解释它们之间的区别。我们还将检查编写和版本控制 API 的最佳实践。

第三章,介绍 Docker,解释了如何将你的应用程序包装成 Docker 镜像,以及如何将 Docker 和 Docker Compose 作为开发工作流程的一部分使用。我们将看到如何为你的应用程序构建一个小型轻量级的镜像,以及使用 Docker 和编写 Dockerfile 的一些良好实践。

第四章,测试,将介绍确保你的微服务达到最高质量的各种技术。我们将查看单元测试、行为测试和性能测试,为你提供实用的建议和核心测试框架的知识。

第五章,常见模式,介绍了在微服务架构中经常使用的标准模式。我们将深入探讨负载均衡、断路器、服务发现和自动驾驶模式,看看 Go 特定的实现会是什么样子。

第六章,微服务框架,基于实现许多微服务所需常见特性的框架。我们将通过它们的用法示例进行比较和对比。

第七章,日志和监控,检查了确保您的服务表现正确的必要技术,当它不正确时,确保您有所有必要的信息来进行成功的诊断和调试。我们将探讨使用 StatsD 进行简单的指标和计时,如何处理日志文件信息,以及记录更详细数据和平台(如 NewRelic)的方法,它提供了对您服务的整体概述。

第八章,安全,探讨了微服务中的身份验证、授权和安全。我们将探讨 JWT 以及如何实现用于验证您的请求并保持安全性的中间件。我们还将从更大的视角来看,探讨为什么您应该实现 TLS 加密以及服务之间不信任的原则。

第九章,事件驱动架构,讨论了允许您的微服务通过事件进行协作的常见模式;您将了解两种最常见的事件模式,并了解如何在 Go 中实现它们。我们还将探讨领域驱动设计的引入以及使用通用语言如何帮助软件开发过程。

第十章,持续交付,讨论了持续交付背后的概念。然后我们将详细检查我们之前在书中创建的一个简单应用程序的持续交付设置。

您需要这本书的内容

用于成功运行 Go 代码的 Go 编译器。您可以在golang.org找到它。

您还需要 Docker 来运行容器应用程序。Docker 可在www.docker.com/找到。

这本书面向的对象

如果您想通过迈出微服务架构的第一步将技术应用到自己的项目中,这本书适合您。

规范

在这本书中,您会发现许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:

@SpringBootApplication注解取代了 Spring 框架中所需的不同注解。”

代码块设置如下:

@SpringBootApplication
@EnableZuulProxy
public class ApiGatewayExampleInSpring
{
  public static void main(String[] args)    
  {
    SpringApplication.run(ApiGatewayExampleInSpring.class, args);
  }
}

任何命令行输入或输出都如下所示:

mvn spring-boot:run

新术语重要词汇以粗体显示。

警告或重要提示看起来像这样。

小贴士和技巧看起来像这样。

读者反馈

我们欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大价值的标题。要发送一般反馈,请简单地发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书籍的标题。如果您在某个主题领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在您是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大价值。

下载示例代码

您可以从www.packtpub.com的账户下载此书的示例代码文件。如果您在其他地方购买了此书,您可以访问www.packtpub.com/support并注册,以便直接将文件通过电子邮件发送给您。您可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的“支持”标签上。

  3. 点击“代码下载 & 错误清单”。

  4. 在搜索框中输入书籍名称。

  5. 选择您想要下载代码文件的书籍。

  6. 从下拉菜单中选择您购买此书的来源。

  7. 点击“代码下载”。

文件下载完成后,请确保您使用最新版本的以下软件解压或提取文件夹:

  • WinRAR / 7-Zip for Windows

  • Zipeg / iZip / UnRarX for Mac

  • 7-Zip / PeaZip for Linux

书籍的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Building-Microservices-with-Go。我们还有其他丰富的书籍和视频代码包,可在github.com/PacktPublishing/找到。查看它们吧!

错误清单

尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这个问题,我们将不胜感激。通过这样做,您可以避免其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。

侵权

互联网上版权材料的侵权是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。请通过copyright@packtpub.com与我们联系,并提供疑似侵权材料的链接。我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。

问题

如果您在这本书的任何方面遇到问题,您可以联系我们的questions@packtpub.com,我们将尽力解决问题。

第一章:微服务简介

首先,我们将看看如何使用net/http包轻松创建一个具有单个端点的简单 Web 服务器。然后,我们将检查encoding/json包,看看 Go 如何使我们能够轻松地使用 JSON 对象进行请求和响应。最后,我们将查看路由和处理器的工作方式以及我们如何在这些处理器之间管理上下文。

使用 net/http 构建简单 Web 服务器

net/http包提供了我们编写 HTTP 客户端和服务器所需的所有功能。它使我们能够向使用 HTTP 协议的其他服务器发送请求,以及运行可以将请求路由到单独的 Go 函数、提供静态文件以及更多功能的 HTTP 服务器。

首先,我们应该提出一个问题,没有简单 hello world 示例的技术书会完整吗? 我认为没有,这正是我们将开始的地方。

在这个例子中,我们将创建一个具有单个端点的 HTTP 服务器,该端点返回由 JSON 标准表示的静态文本,这将介绍 HTTP 服务器和处理器的基本功能。然后,我们将修改此端点以接受编码为 JSON 的请求,并使用encoding/json包向客户端返回响应。我们还将通过添加第二个端点返回简单图像来检查路由的工作方式。

到本章结束时,你将基本掌握基本包及其如何快速高效地构建简单微服务的方法。

由于标准库中包含的 HTTP 包,使用 Go 构建 Web 服务器变得极其简单。

它拥有你管理路由、处理传输层安全性TLS)(我们将在第八章中介绍),支持 HTTP/2 开箱即用,以及运行一个处理大量请求的非常高效的服务器的所有功能。

本章的源代码可以在 GitHub 上找到,网址为github.com/building-microservices-with-go/chapter1.git,本章节和随后的章节将广泛引用这些示例,所以如果你还没有这样做,请在继续之前去克隆这个仓库。

让我们看看创建基本服务器的语法,然后我们可以更深入地了解这些包:

示例 1.0 basic_http_example/basic_http_example.go

09 func main() { 
10  port := 8080 
11 
12  http.HandleFunc("/helloworld", helloWorldHandler) 
13 
14  log.Printf("Server starting on port %v\n", 8080) 
15  log.Fatal(http.ListenAndServe(fmt.Sprintf(":%v", port), nil)) 
16 } 
17 
18 func helloWorldHandler(w http.ResponseWriter, r *http.Request) { 
19   fmt.Fprint(w, "Hello World\n") 
20 } 

我们首先做的事情是在http包上调用HandleFunc方法。HandleFunc方法在DefaultServeMux处理器上创建一个Handler类型,将第一个参数中传入的路径映射到第二个参数中的函数:

func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) 

在第15行,我们启动 HTTP 服务器,ListenAndServe接受两个参数,将服务器绑定到的 TCP 网络地址和用于路由请求的处理程序:

func ListenAndServe(addr string, handler Handler) error 

在我们的例子中,我们传递了网络地址 :8080",这意味着我们希望将服务器绑定到所有可用的 IP 地址上的端口 8080

我们传递的第二个参数是 nil,这是因为我们正在使用 DefaultServeMux 处理器,它是通过我们的 http.HandleFunc 调用来设置的。在 第三章 介绍 Docker 中,你将看到当我们介绍更复杂的路由器时使用这个第二个参数,但现在我们可以忽略它。

如果 ListenAndServe 函数无法启动服务器,它将返回一个错误,最常见的原因可能是你正在尝试绑定服务器上已经使用的端口。在我们的例子中,我们将 ListenAndServe 的输出直接传递给 log.Fatal(error),这是一个方便的函数,相当于调用 fmt.Print(a ...interface{}) 然后调用 os.Exit(1)。由于 ListenAndServe 如果服务器启动正确则会阻塞,所以我们永远不会在成功启动时退出。

让我们快速运行并测试我们的新服务器:

$ go run ./basic_http_example.go  

现在,你应该看到应用程序的输出:

2016/07/30 01:08:21 Server starting on port 8080  

如果你没有看到前面的输出,而是看到以下内容?

2016/07/19 03:51:11 listen tcp :8080: bind: address already in use exit status 1  

再看看 ListenAndServe 的签名和我们调用它的方式。还记得我们之前说的为什么我们使用 log.Fatal 吗?

如果你确实收到这个错误消息,这意味着你的电脑上已经运行了一个使用端口 8080 的应用程序,这可能是你程序的另一个实例,也可能是另一个应用程序。你可以通过检查正在运行的过程来确认没有其他实例在运行:

$ ps -aux | grep 'go run'  

如果你看到另一个 go run ./basic_http_example.go,你可以简单地将其终止并重试。如果你没有其他实例在运行,那么你可能有一些其他软件绑定到了这个端口。尝试在行 10 上更改端口并重新启动你的程序。

要测试服务器,打开一个新的浏览器,输入 URI http://127.0.0.1:8080/helloworld,如果一切正常,你应该从服务器看到以下响应:

Hello World 

恭喜你,这是进入微服务大师的第一步。现在我们有了第一个运行起来的程序,让我们更仔细地看看我们如何返回和接受 JSON。

读取和写入 JSON

感谢内置在标准库中的 encoding/json 包,它使得将 JSON 编码和解码为 Go 类型既快速又简单。它实现了简单的 MarshalUnmarshal 函数;然而,如果需要的话,该包还提供了 EncoderDecoder 类型,这让我们在读取和写入 JSON 数据流时有了更大的控制权。在本节中,我们将探讨这两种方法,但首先让我们看看将标准的 Go struct 转换为其对应的 JSON 字符串是多么简单。

将 Go 结构体序列化为 JSON

要编码 JSON 数据,encoding/json 包提供了 Marshal 函数,其签名如下:

func Marshal(v interface{}) ([]byte, error) 

这个函数接受一个参数,其类型为 interface,因此在 Go 中,几乎任何你能想到的对象都可以,因为 interface 代表了任何类型。它返回一个元组 ([]byte, error),你会在 Go 中经常看到这种返回风格,一些语言实现了 try catch 方法,鼓励在操作无法执行时抛出错误,Go 建议使用 (return type, error) 模式,其中错误为 nil 表示操作成功。

在 Go 中,未处理的错误是坏事,尽管该语言实现了 PanicRecover,这与其他语言的异常处理类似,但你应该使用这些函数的情况是相当不同的(参见 The Go Programming Language,Kernaghan)。在 Go 中,panic 函数会导致正常执行停止,并且执行 Go 线程中所有延迟调用的函数,然后程序会崩溃并显示日志消息。它通常用于指示代码中存在错误的意外错误,良好的健壮的 Go 代码将尝试处理这些运行时异常,并将详细的 error 对象返回给调用函数。

这种模式正是使用 Marshal 函数实现的。在 Marshal 无法从给定的对象创建 JSON 编码的字节数组的情况下,这可能是因为运行时崩溃,那么这个错误会被捕获,并返回一个详细说明问题的错误对象给调用者。

让我们尝试一下,基于我们现有的示例进行扩展,而不是简单地从我们的处理器中打印一个字符串,让我们创建一个简单的 struct 用于响应,并返回这个 struct

示例 1.1 reading_writing_json_1/reading_writing_json_1.go

10 type helloWorldResponse struct { 
11    Message string 
12 } 

在我们的处理器中,我们将创建这个对象的实例,设置消息,然后使用 Marshal 函数将其编码为字符串后再返回。

让我们看看它会是什么样子:

23 func helloWorldHandler(w http.ResponseWriter, r *http.Request) { 
24   response := helloWorldResponse{Message: "HelloWorld"} 
25   data, err := json.Marshal(response) 
26   if err != nil { 
27     panic("Ooops") 
28   } 
29  
30   fmt.Fprint(w, string(data)) 
31 } 

现在,当我们再次运行我们的程序并刷新浏览器时,我们会看到以下输出以有效的 JSON 格式渲染:

{"Message":"Hello World"} 

这很棒;然而,Marshal 的默认行为是取字段的字面名称并将其用作 JSON 输出中的字段。如果我想使用驼峰命名法,并希望看到 "message",我们能否在 helloWorldResponse struct 中重命名字段?

不幸的是,我们不能这样做,因为在 Go 中,小写属性不是导出的,Marshal 会忽略这些属性,并且不会将它们包含在输出中。

虽然如此,encoding/json 包实现了 struct 字段属性,允许我们将属性输出更改为我们选择的任何内容。

示例 1.2 reading_writing_json_2/reading_writing_json_2.go

10 type helloWorldResponse struct { 
11   Message string `json:"message"` 
12 } 

使用 struct 字段的标签,我们可以更好地控制输出格式。在前面的示例中,当我们对 struct 进行编码时,服务器的输出会是:

{"message":"Hello World"} 

这正是我们想要的,但我们可以使用字段标签来进一步控制输出。我们可以转换对象类型,甚至如果我们需要的话,可以完全忽略一个字段:

type helloWorldResponse struct {
// change the output field to be "message" 
   Message   string `json:"message"` 
   // do not output this field 
   Author  string `json:"-"` 
   // do not output the field if the value is empty 
   Date    string `json:",omitempty"` 
   // convert output to a string and rename "id" 
   Id    int    `json:"id, string"` 
} 

通道、复杂数据类型和函数不能被编码成 JSON;尝试编码这些类型将会导致Marshal函数返回一个UnsupportedTypeError

它也不能表示循环数据结构;如果你的struct包含循环引用,那么Marshal将导致无限递归,这对网络请求来说绝不是好事。

如果我们想要以缩进格式导出我们的 JSON,我们可以使用MarshallIndent函数,这允许你传递一个额外的string参数来指定你想要的缩进。右对齐两个空格,不是制表符吗?

func MarshalIndent(v interface{}, prefix, indent string) ([]byte, error) 

精明的读者可能已经注意到,我们在将我们的struct解码成字节数组,然后将它写入响应流中,这似乎并不特别高效,实际上确实如此。Go 提供了EncodersDecoders,它们可以直接写入流,因为我们已经有了带有ResponseWriter的流,那么我们就这么做吧。

在我们这么做之前,我认为我们需要稍微看看一下ResponseWriter,看看那里发生了什么。

ResponseWriter是一个定义了三个方法的接口:

// Returns the map of headers which will be sent by the 
// WriteHeader method. 
Header() 

// Writes the data to the connection. If WriteHeader has not 
// already been called then Write will call 
// WriteHeader(http.StatusOK). 
Write([]byte) (int, error) 

// Sends an HTTP response header with the status code. 
WriteHeader(int) 

如果我们有一个ResponseWriter接口,我们如何使用它与fmt.Fprint(w io.Writer, a ...interface{})方法一起使用?这个方法需要一个Writer接口作为参数,而我们有一个ResponseWriter接口。如果我们查看Writer的签名,我们可以看到它是:

Write(p []byte) (n int, err error) 

因为ResponseWriter接口实现了这个方法,所以它也满足了Writer接口,因此任何实现了ResponseWriter的对象都可以传递给任何期望Writer的函数。

太棒了,Go 真是强大,但我们还没有回答我们的问题,有没有更好的方法在返回之前不将数据序列化到一个临时对象中,直接发送到输出流?

encoding/json包有一个名为NewEncoder的函数,这个函数返回一个Encoder对象,可以用来将 JSON 直接写入一个打开的写入器,猜猜看;我们就有这样一个:

func NewEncoder(w io.Writer) *Encoder 

因此,我们不需要将Marshal的输出存储到字节数组中,我们可以直接将其写入 HTTP 响应。

示例 1.3 reading_writing_json_3/reading_writing_json_3.go

func helloWorldHandler(w http.ResponseWriter, r *http.Request) { 
    response := HelloWorldResponse{Message: "HelloWorld"} 
    encoder := json.NewEncoder(w) 
    encoder.Encode(&response) 
}                

我们将在后面的章节中讨论基准测试,但为了了解为什么这很重要,我们创建了一个简单的基准测试来检查这两种方法之间的差异,看看输出结果。

示例 1.4 reading_writing_json_2/reading_writing_json_2.go

$go test -v -run="none" -bench=. -benchtime="5s" -benchmem  BenchmarkHelloHandlerVariable-8  20000000  511 ns/op  248 B/op  5 allocs/op BenchmarkHelloHandlerEncoder-8  20000000  328 ns/op   24 B/op  2 allocs/op BenchmarkHelloHandlerEncoderReference-8  20000000  304 ns/op  8 B/op  1 allocs/op PASS ok  github.com/building-microservices-with-go/chapter1/reading_writing_json_2  24.109s 

使用 Encoder 而不是将数据序列化到字节数组中几乎快了 50%。我们在这里处理的是纳秒,所以时间可能看起来无关紧要,但事实并非如此;这仅仅是两行代码。如果你在代码的其他部分也有这种低效的情况,那么你的应用程序将运行得更慢,你需要更多的硬件来满足负载,这将花费你金钱。这两种方法之间的差异并没有什么巧妙之处,我们所做的只是理解标准包的工作原理,并为我们自己的需求选择了正确的选项,这并不是性能调优,这是理解框架。

将 JSON 解码到 Go 结构体

现在我们已经学会了如何将 JSON 发送回客户端,如果我们需要在返回输出之前读取输入怎么办?我们可以使用 URL 参数,我们将在下一章中看到这一点,但通常你将需要更复杂的数据结构,这些结构涉及服务以接受 JSON 作为 HTTP POST 请求的一部分。

将我们在上一节中学到的类似技术应用于编写 JSON,读取 JSON 也很容易。要将 JSON 解码到 struct 字段,encoding/json 包为我们提供了 Unmarshal 函数:

func Unmarshal(data []byte, v interface{}) error 

Unmarshal 函数与 Marshal 函数的作用相反;它根据需要分配映射、切片和指针。传入的对象键使用 struct 字段名或其标签进行匹配,并将进行不区分大小写的匹配;然而,精确匹配是首选。与 Marshal 类似,Unmarshal 只会设置导出的 struct 字段,即以大写字母开头的字段。

我们首先添加一个新的 struct 字段来表示请求,而 Unmarshal 可以将 JSON 解码到 interface{} 中,这将是一个 map[string]interface{} // 用于 JSON 对象类型[]interface{} // 用于 JSON 数组,具体取决于我们的 JSON 是对象还是数组。

在我看来,如果我们明确地说明我们期望的请求内容,这将使我们的代码对读者来说更加清晰。我们还可以通过在需要使用数据时不必手动转换数据来节省我们的工作。

记住两点:

  • 你不是为编译器编写代码,你是为人类编写代码以便理解

  • 你阅读代码的时间将比编写代码的时间多

考虑到这两点,我们创建了一个简单的 struct 来表示我们的请求,它看起来是这样的:

示例 1.5 reading_writing_json_4/reading_writing_json_4.go:

14 type helloWorldRequest struct { 
15   Name string `json:"name"` 
16 } 

再次,我们将使用 struct 字段标签,因为我们可以让 Unmarshal 进行不区分大小写的匹配,所以 {"name": "World} 会正确地解码到 struct 中,就像 {"Name": "World"} 一样,当我们指定一个标签时,我们是在明确请求的形式,这是好事。在速度和性能方面,它也大约快了 10%,记住,性能很重要。

要访问与请求一起发送的 JSON,我们需要查看传递给我们的处理器的 http.Request 对象。以下列表并没有显示请求上的所有方法,只是我们立即要处理的方法,对于完整的文档,我建议查看 godoc.org/net/http#Request 上的文档:

type Requests struct { 
... 
  // Method specifies the HTTP method (GET, POST, PUT, etc.). 
  Method string 

// Header contains the request header fields received by the server. The type Header is a link to map[string] []string.  
Header Header 

// Body is the request's body. 
Body io.ReadCloser 
... 
} 

请求中发送的 JSON 数据可以在 Body 字段中访问。Body 实现了 io.ReadCloser 接口作为流,并且不会返回 []bytestring。如果我们需要获取正文中的数据,我们可以简单地将其读取到一个字节数组中,如下面的示例所示:

30 body, err := ioutil.ReadAll(r.Body) 
31 if err != nil { 
32     http.Error(w, "Bad request", http.StatusBadRequest) 
33     return   
34 } 

这里有一些我们需要记住的事情。我们没有调用 Body.Close(),如果我们用客户端进行调用,我们就需要这样做,因为它是不会自动关闭的;然而,当在 ServeHTTP 处理器中使用时,服务器会自动关闭请求流。

为了了解所有这些是如何在我们的处理器中工作的,我们可以查看以下处理器:

28 func helloWorldHandler(w http.ResponseWriter, r *http.Request) { 
29  
30   body, err := ioutil.ReadAll(r.Body) 
31   if err != nil { 
32     http.Error(w, "Bad request", http.StatusBadRequest) 
33             return 
34   } 
35  
36   var request helloWorldRequest 
37   err = json.Unmarshal(body, &request) 
38   if err != nil { 
39     http.Error(w, "Bad request", http.StatusBadRequest) 
40             return 
41   } 
42  
43  response := helloWorldResponse{Message: "Hello " + request.Name} 
44  
45   encoder := json.NewEncoder(w) 
46   encoder.Encode(response) 
47 } 

让我们运行这个示例,看看它是如何工作的。为了测试,我们可以简单地使用 curl 命令向运行中的服务器发送请求。如果你觉得使用 GUI 工具(如 Postman,它适用于 Google Chrome 浏览器)比使用 Postman 更舒服,它们也可以正常工作,或者你可以自由使用你喜欢的工具:

$ curl localhost:8080/helloworld -d '{"name":"Nic"}'  

你应该看到以下响应:

{"message":"Hello Nic"} 

如果你在请求中不包括正文,你认为会发生什么?

$ curl localhost:8080/helloworld  

如果你猜对了,你会得到 HTTP 状态 400 错误请求,那么你就能赢得奖品:

func Error(w ResponseWriter, error string, code int) 

错误会以给定的消息和状态码回复请求。一旦我们发送了这些,我们就需要返回停止函数的进一步执行,因为这个操作不会自动关闭 ResponseWriter 接口并返回到调用函数。

在你认为你已经完成之前,尝试一下,看看你是否能提高处理器的性能。想想我们在序列化 JSON 时讨论的事情。

明白了?

好吧,如果不是这样,这里就是答案,我们只是在使用 Decoder,它是我们在写入 JSON 时使用的 Encoder 的相反。它有即时 33% 的性能提升,代码也更少。

示例 1.6 reading_writing_json_5/reading_writing_json_5.go

27 func helloWorldHandler(w http.ResponseWriter, r *http.Request) { 
28 
29   var request HelloWorldRequest 
30   decoder := json.NewDecoder(r.Body) 
31  
32   err := decoder.Decode(&request) 
33   if err != nil { 
34     http.Error(w, "Bad request", http.StatusBadRequest) 
35             return 
36   } 
37  
38   response := HelloWorldResponse{Message: "Hello " + request.Name} 
39  
40   encoder := json.NewEncoder(w) 
41   encoder.Encode(response) 
42 } 

现在我们可以看到使用 Go 编码和解码 JSON 是多么简单,我建议现在花五分钟时间花些时间研究 encoding/json 包的文档 (golang.org/pkg/encoding/json/),因为你可以用这个包做很多事情。

net/http 中的路由

即使是一个简单的微服务也需要能够根据请求的路径或方法将请求路由到不同的处理程序。在 Go 中,这由 DefaultServeMux 方法处理,它是一个 ServerMux 实例。在本章的早期,我们简要介绍了当将 nil 传递给 ListenAndServe 函数的处理程序参数时,将使用 DefaultServeMux 方法。当我们调用 http.HandleFunc("/helloworld", helloWorldHandler) 包函数时,我们实际上只是间接调用 http.DefaultServerMux.HandleFunc(…)

Go HTTP 服务器没有特定的路由器,而是将实现 http.Handler 接口的对象作为顶级函数传递给 Listen() 函数。当请求进入服务器时,此处理程序的 ServeHTTP 方法被调用,并负责执行或委托任何工作。为了便于处理多个路由,HTTP 包有一个特殊对象 ServerMux,它实现了 http.Handler 接口。

ServerMux 处理程序添加处理程序有两个函数:

func HandlerFunc(pattern string, handler func(ResponseWriter, *Request)) 
func Handle(pattern string, handler Handler) 

HandleFunc 函数是一个便利函数,它创建了一个处理程序,该处理程序的 ServeHTTP 方法会调用一个带有 func(ResponseWriter, *Request) 签名的普通函数,该函数作为参数传递。

Handle 函数要求你传递两个参数,你想要注册的处理程序的模式以及实现 Handler 接口的对象:

type Handler interface { 
  ServeHTTP(ResponseWriter, *Request) 
} 

路径

我们已经解释了 ServeMux 负责将传入请求路由到已注册的处理程序的方式,然而路由匹配的方式可能相当令人困惑。ServeMux 处理程序有一个非常简单的路由模型,它不支持通配符或正则表达式,使用 ServeMux 时,你必须明确注册的路径。

你可以注册固定根路径,例如 /images/cat.jpg,或者根子树,例如 /images/。根子树中的尾部斜杠很重要,因为任何以 /images/ 开头的请求,例如 /images/happy_cat.jpg,都将被路由到与 /images/ 关联的处理程序。

如果我们将路径 /images/ 注册到处理程序 foo,并且用户向我们的服务发送到 /images 的请求(注意没有尾部斜杠),那么 ServerMux 将将请求转发到 /images/ 处理程序,并附加一个尾部斜杠。

如果我们还将路径 /images(注意没有尾部斜杠)注册到处理程序 bar,并且用户请求 /images,则此请求将被导向 bar;然而,/images//images/cat.jpg 将被导向 foo:

http.Handle("/images/", newFooHandler())
http.Handle("/images/persian/", newBarHandler())
http.Handle("/images", newBuzzHandler())
/images                  => Buzz
/images/                 => Foo
/images/cat              => Foo
/images/cat.jpg          => Foo
/images/persian/cat.jpg  => Bar

较长的路径始终优先于较短的路径,因此可以有一个显式路由指向不同的处理程序,以捕获所有路由。

我们还可以指定主机名,例如可以注册路径 search.google.com/,那么 /ServerMux 将将任何请求转发到 http://search.google.comhttp://www.google.com,并转发到它们各自的处理程序。

如果你习惯了基于框架的应用程序开发方法,例如使用 Ruby on Rails 或 ExpressJS,你可能会觉得这个路由器非常简单,确实如此,记住我们不是使用框架,而是 Go 的标准包,我们的目的是始终提供一个可以在此基础上构建的基础。在非常简单的情况下,ServeMux 方法已经足够好了,事实上我个人根本不使用其他任何东西。然而,每个人的需求都是不同的,标准包的美丽和简洁性使得构建自己的路由变得极其简单,因为所需的所有东西只是一个实现了 Handler 接口的对象。快速在谷歌上搜索会找到一些非常好的第三方路由器,但我的建议是,在决定选择第三方包之前,首先学习 ServeMux 的限制,这将大大有助于你的决策过程,因为你将知道你试图解决的问题。

方便处理程序

net/http 包实现了几个创建不同类型方便处理程序的方法,让我们来检查这些。

FileServer

FileServer 函数返回一个处理程序,它使用文件系统的内容来服务 HTTP 请求。这可以用来服务静态文件,如图像或其他存储在文件系统上的内容:

func FileServer(root FileSystem) Handler 

看看下面的代码:

http.Handle("/images", http.FileServer(http.Dir("./images")))

这允许我们将文件系统路径 ./images 的内容映射到服务器路由 /imagesDir 实现了一个仅限于特定目录树的文件系统,FileServer 方法使用它来提供资产服务。

NotFoundHandler

NotFoundHandler 函数返回一个简单的请求处理程序,对每个请求回复一个 404 页面未找到 的回复:

func NotFoundHandler() Handler 

RedirectHandler

RedirectHandler 函数返回一个请求处理程序,它将接收到的每个请求重定向到给定的 URI,并使用给定的状态码。提供的代码应在 3xx 范围内,通常是 StatusMovedPermanentlyStatusFoundStatusSeeOther

func RedirectHandler(url string, code int) Handler 

StripPrefix

StripPrefix 函数返回一个处理程序,它通过从请求 URL 的路径中移除给定的前缀来服务 HTTP 请求,然后调用 h 处理程序。如果路径不存在,则 StripPrefix 将回复一个 HTTP 404 未找到错误:

func StripPrefix(prefix string, h Handler) Handler 

TimeoutHandler

TimeoutHandler 函数返回一个 Handler 接口,它以给定的时间限制运行 h。当我们研究第六章(74445ff8-eb01-4a2f-a910-0551e7d85a5f.xhtml)中常见的模式,即 微服务框架 时,我们将看到这如何有助于避免服务中的级联故障:

func TimeoutHandler(h Handler, dt time.Duration, msg string) Handler 

新的处理程序调用 h.ServeHTTP 来处理每个请求,但如果调用运行时间超过了其时间限制,处理程序将回复一个包含给定消息 (msg)503 服务不可用 响应:

最后两个处理器特别有趣,因为它们实际上是在链式处理。这是一个我们将在后面的章节中更深入探讨的技术,它允许你练习编写干净的代码,同时也允许你保持代码的 DRY(Don't Repeat Yourself)。

我可能直接从 Go 文档中提取了这些处理器的多数描述,你可能已经阅读过这些内容,因为你已经阅读过文档,对吧?在 Go 中,文档非常出色,并且强烈鼓励(甚至强制)为你的包编写文档,如果你使用标准包中包含的golint命令,那么它将报告你的代码中不符合标准的部分。我强烈建议在使用某个包时花点时间浏览标准文档,这不仅可以帮助你了解正确的用法,你可能会发现更好的方法。你肯定会接触到良好的实践和风格,甚至可能在你继续工作的那一天(Stack Overflow 停止工作,整个行业停滞不前)有所帮助。

静态文件处理器

虽然在这本书中我们主要会处理 API,但通过添加一个二级端点来观察默认路由器和路径的工作方式是有益的说明。

作为一个小练习,尝试修改reading_writing_json_5/reading_writing_json_5.go中的代码,添加一个端点/cat,它返回 URI 中指定的猫的图片。为了给你一点提示,你需要使用net/http包中的FileServer函数,你的 URI 将类似于http://localhost:8080/cat/cat.jpg

第一次尝试就成功了,还是你忘记添加StripPrefix处理器了?

示例 1.7 reading_writing_json_6/reading_writing_json_6.go:

21 cathandler := http.FileServer(http.Dir("./images")) 
22 http.Handle("/cat/", http.StripPrefix("/cat/", cathandler)) 

在前面的示例中,我们向路径/cat/注册了一个StripPrefix处理器。如果我们没有这样做,那么FileServer处理器就会在我们的images/cat目录中寻找我们的图片。也值得提醒一下关于/cat/cat/作为路径的区别。如果我们注册我们的路径为/cat,那么我们不会匹配/cat/cat.jpg。如果我们注册我们的路径为/cat/,我们将匹配/cat/cat/whatever

创建处理器

现在我们将通过展示如何创建Handler而不是仅仅使用HandleFunc来结束这里的示例。我们将把为我们的helloworld端点执行请求验证的代码和返回响应的代码分别放入单独的处理程序中,以说明如何链式处理。

示例 1.8 chapter1/reading_writing_json_7.go:

31 type validationHandler struct { 
32   next http.Handler 
33 } 
34  
35 func newValidationHandler(next http.Handler) http.Handler { 
36   return validationHandler{next: next} 
37 } 

创建我们自己的 Handler 时,我们首先需要定义一个 struct 字段,该字段将实现 Handlers 接口中的方法。由于在这个例子中,我们将要链式连接处理程序,第一个处理程序,也就是我们的验证处理程序,需要有一个对链中下一个处理程序的引用,因为它有调用 ServeHTTP 或返回响应的责任。

为了方便,我们添加了一个返回新处理程序的函数;然而,我们也可以直接设置下一个字段。但是,这种方法是更好的形式,因为它使我们的代码更容易阅读,并且当我们需要通过一个创建函数传递复杂依赖项到处理程序时,它可以使事情保持整洁一些:

37 func (h validationHandler) ServeHTTP(rw http.ResponseWriter, r  
*http.Request) {
38   var request helloWorldRequest
39   decoder := json.NewDecoder(r.Body)
40
41   err := decoder.Decode(&request)
42   if err != nil {
43     http.Error(rw, "Bad request", http.StatusBadRequest)
44     return
45   }
46
47   h.next.ServeHTTP(rw, r)
48 } 

之前的代码块展示了我们如何实现 ServeHTTP 方法。这里需要注意的唯一有趣的事情是从第 44 行开始的语句。如果解码请求返回错误,我们将向响应写入 500 错误,处理链在这里会停止。只有当没有错误返回时,我们才会调用链中的下一个处理程序,我们通过调用其 ServeHTTP 方法来完成此操作。为了传递从请求中解码出的名称,我们只是设置了一个变量:

53 type helloWorldHandler struct{} 
54  
55 func newHelloWorldHandler() http.Handler { 
56   return helloWorldHandler{} 
57 } 
58  
59 func (h helloWorldHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { 
60   response := helloWorldResponse{Message: "Hello " + name} 
61  
62   encoder := json.NewEncoder(rw) 
63   encoder.Encode(response) 
64 } 

写入响应的 helloWorldHandler 类型与我们使用简单函数时看起来并没有太大区别。如果你将其与 示例 1.6 进行比较,你会发现我们真正做的只是移除了请求解码。

现在我首先要提到的是,这段代码纯粹是为了说明你可以如何做某事,而不是你应该这样做。在这个简单的情况下,将请求验证和响应发送拆分为两个处理程序增加了许多不必要的复杂性,并且并没有真正使我们的代码更加简洁。然而,这项技术是有用的。当我们稍后在章节中检查身份验证时,你会看到这个模式,因为它允许我们将身份验证逻辑集中化并在处理程序之间共享。

Context

之前模式的问题是没有办法在不破坏 http.Handler 接口的情况下,将验证过的请求从一个处理程序传递到下一个处理程序,但是猜猜看,Go 已经为我们解决了这个问题。context 包在 Go 1.7 之前被列为实验性包,最终被纳入标准包。Context 类型实现了一种安全的方法来访问请求范围内的数据,该数据可以由多个 Go 线程安全地同时使用。让我们快速看一下这个包,然后更新我们的示例以查看其使用情况。

背景

Background 方法返回一个没有任何值的空上下文;它通常用于主函数和顶级 Context

func Background() Context 

WithCancel

WithCancel 方法返回一个带有取消函数的父上下文副本,调用取消函数会释放与上下文关联的资源,应该在 Context 类型的操作完成后立即调用:

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) 

WithDeadline

WithDeadline 方法返回父上下文的副本,该副本在当前时间大于截止日期后过期。此时,上下文的 Done 通道关闭,与之关联的资源被释放。它还返回一个 CancelFunc 方法,允许手动取消上下文:

func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc) 

WithTimeout

WithTimeout 方法与 WithDeadline 类似,但你需要传递一个持续时间,Context 类型应该存在。一旦这个持续时间过去,Done 通道关闭,与上下文关联的资源被释放:

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) 

WithValue

WithValue 方法返回父 Context 的一个副本,其中 val 值与键相关联。Context 值非常适合用于请求作用域的数据:

func WithValue(parent Context, key interface{}, val interface{}) Context 

为什么不尝试修改 示例 1.7 来实现一个请求作用域的上下文。关键可能就在上一句中;每个请求都需要自己的上下文。

使用上下文

你可能觉得这相当痛苦,尤其是如果你来自像 Rails 或 Spring 这样的框架背景。编写这类代码并不是你想要花费时间的事情,构建应用程序功能要重要得多。然而,有一点需要注意,Ruby 或 Java 的基础包中都没有更高级的功能。幸运的是,自从 Go 存在的七年里,许多优秀的人已经做到了这一点,当我们查看第三章 介绍 Docker 中的框架时,我们会发现所有这些复杂性都已被一些出色的开源作者处理好了。

除了将上下文引入 Go 的主要发布版本 1.7 以外,还对 http.Request 结构体进行了重要更新,我们还有以下新增功能:

func (r *Request) Context() context.Context

Context() 方法让我们能够访问一个 context.Context 结构体,它始终不为空,因为它在请求最初创建时被填充。对于入站请求,http.Server 自动管理上下文的生命周期,当客户端连接关闭时自动取消上下文。对于出站请求,Context 通过以下方式控制取消:这意味着如果我们取消 Context() 方法,我们可以取消出站请求。这一概念在以下示例中得到了说明:

70 func fetchGoogle(t *testing.T) {
71   r, _ := http.NewRequest("GET", "https://google.com", nil)
72
73   timeoutRequest, cancelFunc := context.WithTimeout(r.Context(), 1*time.Millisecond)
74   defer cancelFunc()
75
76   r = r.WithContext(timeoutRequest)
77
78   _, err := http.DefaultClient.Do(r)
79   if err != nil {
80     fmt.Println("Error:", err)
81   }
82 }

在第 74 行,我们正在从请求中的原始上下文创建一个超时上下文,与自动取消上下文的入站请求不同,我们必须在出站请求中手动执行此步骤。

77 行实现了添加到 http.Request 对象的两个新上下文方法中的第二个:

func (r *Request) WithContext(ctx context.Context) *Request

WithContext 对象返回原始请求的一个浅拷贝,其上下文已更改为给定的 ctx 上下文。

当我们执行这个函数时,我们会发现 1 毫秒后请求将因错误而完成:

Error: Get https://google.com: context deadline exceeded

上下文在请求有机会完成之前就超时了,而do方法立即返回。这是一个用于出站连接的绝佳技术,多亏了 Go 1.7 中的变化,现在实现起来非常简单。

我们的入站连接怎么办?让我们看看我们如何更新之前的示例。示例 1.9 更新了我们的示例,展示了我们如何利用context包实现 Go 协程安全访问对象。完整的示例可以在reading_writing_json_8/reading_writing_json_8.go中找到,但我们需要的所有修改都在我们处理器的两个ServeHTTP方法中:

41 func (h validationHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
42   var request helloWorldRequest
43   decoder := json.NewDecoder(r.Body)
44
45   err := decoder.Decode(&request)
46   if err != nil {
47     http.Error(rw, "Bad request", http.StatusBadRequest)
48     return
49   }
50
51   c := context.WithValue(r.Context(), validationContextKey("name"), request.Name)
52   r = r.WithContext(c)
53
54   h.next.ServeHTTP(rw, r)
55 }

如果我们快速查看我们的validationHandler,你会看到当我们收到一个有效的请求时,我们为这个请求创建一个新的上下文,然后将请求中的Name字段的值设置到上下文中。你也可能想知道第51行发生了什么。当你向上下文中添加一个项目,比如使用WithValue调用时,该方法返回前一个上下文的副本,为了节省一点时间并增加一点混淆,我们持有上下文的指针,因此为了将这个副本传递给WithValue,我们必须取消引用它。为了更新我们的指针,我们还必须将返回的值设置到指针引用的值,因此我们再次需要取消引用它。我们还需要关注这个方法调用中的键,我们使用validationContextKey,这是一个显式声明的字符串类型:

13 type validationContextKey string

我们之所以不直接使用简单的字符串,是因为上下文经常跨越包,如果我们只使用字符串,那么我们可能会遇到键冲突,其中一个受我们控制的包正在写入name键,而另一个不受我们控制的包也在使用上下文并写入名为name的键,在这种情况下,第二个包会意外地覆盖你的上下文值。通过声明包级别的类型validationContextKey并使用它,我们可以确保避免这些冲突:

64 func (h helloWorldHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
65   name := r.Context().Value(validationContextKey("name")).(string)
66   response := helloWorldResponse{Message: "Hello " + name}
67
68   encoder := json.NewEncoder(rw)
69   encoder.Encode(response)
70 }

要检索值,我们只需获取上下文,然后调用Value方法将其转换为字符串。

Go 标准库中的 RPC

如预期的那样,Go 标准库对 RPC 的支持非常出色,直接开箱即用。让我们看看一些示例,了解我们如何使用它。

简单的 RPC 示例

在这个简单的示例中,我们将看到如何使用标准 RPC 包创建一个客户端和服务器,它们使用共享接口通过 RPC 进行通信。我们将遵循我们在学习net/http包时运行的典型 Hello World 示例,看看构建基于 RPC 的 API 在 Go 中有多简单:

rpc/server/server.go:

34 type HelloWorldHandler struct{} 
35  
36 func (h *HelloWorldHandler) HelloWorld(args *contract.HelloWorldRequest, reply *contract.HelloWorldResponse) error { 
37   reply.Message = ""Hello "" + args.Name 
38   return nil 
39 } 

就像我们在使用标准库创建 RPC API 的例子中一样,我们也将定义一个处理器。这个处理器与http.Handler的区别在于它不需要符合一个接口;只要我们有一个带有方法的struct字段,我们就可以将其注册到 RPC 服务器上:

func Register(rcvr interface{}) error 

Register函数,位于rpc包中,将给定接口中的方法发布到默认服务器,并允许客户端连接到服务时调用它们。方法名称使用具体类型的名称,所以在我们这个实例中,如果我的客户端想要调用HelloWorld方法,我们将使用HelloWorldHandler.HelloWorld来访问它。如果我们不想使用具体类型的名称,我们可以使用RegisterName函数将其注册为不同的名称,该函数使用提供的名称:

func RegisterName(name string, rcvr interface{}) error 

这将使我能够将struct字段的名称保持为对我的代码有意义的任何名称;然而,对于我的客户端合同,我可能决定使用不同的名称,例如Greet

19 func StartServer() { 
20   helloWorld := &HelloWorldHandler{} 
21   rpc.Register(helloWorld) 
22  
23   l, err := net.Listen("("tcp",", fmt.Sprintf(":%(":%v",", port)) 
24   if err != nil { 
25     log.Fatal(fmt.Sprintf("("Unable to listen on given port: %s",", err)) 
26   } 
27  
28   for { 
29     conn, _ := l.Accept() 
30     go rpc.ServeConn(conn) 
31   } 
32 } 

StartServer函数中,我们首先创建我们处理器的新的实例,然后将其注册到默认 RPC 服务器。

net/http的便利性不同,我们可以直接使用ListenAndServe创建服务器,当我们使用 RPC 时,我们需要做一些更多的人工工作。在23行,我们使用给定的协议创建一个套接字,并将其绑定到 IP 地址和端口。这使我们能够特别选择我们希望服务器使用的协议,tcptcp4tcp6unixunixpacket

func Listen(net, laddr string) (Listener, error) 

Listen()函数返回一个实现了Listener接口的实例:

type Listener interface { 
  // Accept waits for and returns the next connection to the listener. 
  Accept() (Conn, error) 

  // Close closes the listener. 
  // Any blocked Accept operations will be unblocked and return errors. 
  Close() error 

  // Addr returns the listener's network address. 
  Addr() Addr 
} 

为了接收连接,我们必须在监听器上调用Accept方法。如果你查看29行,你会看到我们有一个无限循环,这是因为与ListenAndServe不同,它会阻塞所有连接,而在 RPC 服务器中,我们逐个处理每个连接,一旦我们处理了第一个连接,我们就需要再次调用Accept来处理后续的连接,否则应用程序会退出。Accept是一个阻塞方法,所以如果没有客户端当前尝试连接到服务,则Accept将阻塞,直到有一个客户端尝试连接。一旦我们收到一个连接,我们就需要再次调用Accept方法来处理下一个连接。如果你查看我们的示例代码中的30行,你会看到我们正在调用ServeConn方法:

func ServeConn(conn io.ReadWriteCloser) 

ServeConn方法在给定的连接上运行DefaultServer方法,并且会阻塞直到客户端完成。在我们的示例中,我们在运行服务器之前使用 go 语句,这样我们就可以立即处理下一个等待的连接,而不会阻塞第一个客户端关闭其连接。

在通信协议方面,ServeConn使用gob线格式golang.org/pkg/encoding/gob/,当我们查看 JSON-RPC 时,我们将看到如何使用不同的编码。

gob格式是专门设计来促进 Go 到 Go 之间的通信,并且围绕着一个更容易使用且可能比协议缓冲区等更高效的思路进行设计,但这会牺牲跨语言通信的能力。

使用 gob,源和目标值以及类型不需要完全对应,当你发送struct时,如果源中有一个字段但接收到的struct中没有,那么解码器将忽略这个字段,并且处理将继续而不会出错。如果目标中有一个字段在源中不存在,那么解码器同样会忽略这个字段,并成功处理消息的其余部分。虽然这似乎是一个小的优势,但与旧版本的 RPC 消息(如 JMI)相比,这是一个巨大的进步,因为 JMI 要求客户端和服务器上必须存在完全相同的接口。JMI 的这种不灵活性在两个代码库之间引入了紧密耦合,并在需要部署应用程序更新时带来了无尽的复杂性。

为了向我们的客户提出请求,我们不能再简单地使用 curl,因为我们不再使用 HTTP 协议,消息格式也不再是 JSON。如果我们查看rpc/client/client.go中的示例,我们可以看到如何实现一个连接客户端:

13 func CreateClient() *rpc.Client {
14   client, err := rpc.Dial("tcp", fmt.Sprintf("localhost:%v", port))
15   if err != nil {
16     log.Fatal("dialing:", err)
17   }
18
19   return client
20 }

之前的代码块显示了我们需要如何设置rpc.Client,在14行,我们首先需要使用rpc包中的Dial()函数创建客户端本身:

func Dial(network, address string) (*Client, error)

然后,我们使用这个返回的连接向服务器发送请求:

22 func PerformRequest(client *rpc.Client) 
contract.HelloWorldResponse {
23   args := &contract.HelloWorldRequest{Name: "World"}
24   var reply contract.HelloWorldResponse
25
26   err := client.Call("HelloWorldHandler.HelloWorld", args, &reply)
27   if err != nil {
28     log.Fatal("error:", err)
29   }
30
31   return reply
32 }

26行,我们正在使用客户端上的Call()方法在服务器上调用命名函数:

func (client *Client) Call(serviceMethod string, args interface{}, reply interface{}) error

Call是一个阻塞函数,它等待服务器发送回复,假设没有错误,将响应写入我们传递给方法的HelloWorldResponse引用中,如果在处理请求时发生错误,则返回错误,并可以相应地处理。

通过 HTTP 的 RPC

在你需要使用 HTTP 作为传输协议的情况下,rpc包可以通过调用HandleHTTP方法来提供这种支持。

HandleHTTP方法在你的应用程序中设置两个端点:

const ( 
  // Defaults used by HandleHTTP 
  DefaultRPCPath   = "/_"/_goRPC_"_" 
  DefaultDebugPath = "/"/debug/rpc"" 
) 

如果你将浏览器指向DefaultDebugPath,你可以看到已注册端点的详细信息,有两点需要注意:

  • 这并不意味着你可以轻松地从网页浏览器与你的 API 进行通信。消息仍然是gob编码的,因此你需要编写 JavaScript 中的 gob 编码器和解码器,我实际上不确定这是否可能。包的明确意图绝不是支持这种功能,因此我不会建议这样做,基于 JSON 或 JSON-RPC 的消息更适合这个用例。

  • 调试端点不会为你提供 API 的自动生成文档。输出相当基础,意图似乎是让你可以跟踪对端点的调用次数。

话虽如此,可能存在需要使用 HTTP 的原因,可能是你的网络不允许使用任何其他协议,或者可能你有一个无法处理纯 TCP 连接的负载均衡器。我们还可以利用 HTTP 标头和其他元数据,这些在纯 TCP 请求中是不可用的。

rpc_http/server/server.go

22 func StartServer() { 
23   helloWorld := &HelloWorldHandler{} 
24   rpc.Register(helloWorld) 
25   rpc.HandleHTTP() 
26  
27   l, err := net.Listen("("tcp",", fmt.Sprintf(":%(":%v",", port)) 
28   if err != nil { 
29       log.Fatal(fmt.Sprintf("("Unable to listen on given port: %s",", err)) 
30   } 
31  
32   log.Printf("("Server starting on port %v\n",", port) 
33  
34   http.Serve(l, nil) 
35 } 

如果我们查看前面的示例中的第 25 行,我们可以看到我们调用的是 rpc.HandleHTTP 方法,这是使用 HTTP 与 RPC 一起使用的要求,因为它会将我们之前提到的 HTTP 处理程序与 DefaultServer 方法注册。然后我们调用 http.Serve 方法,并传递我们在第 27 行创建的监听器,我们将第二个参数设置为 nil,因为我们希望使用 DefaultServer 方法。这正是我们在之前的示例中查看 RESTful 端点时所查看的方法。

通过 HTTP 的 JSON-RPC

在这个最后的例子中,我们将查看 net/rpc/jsonrpc 包,它提供了一个内置的编解码器,用于将数据序列化和反序列化为 JSON-RPC 标准。我们还将查看如何通过 HTTP 发送这些响应,虽然你可能想知道为什么不直接使用 REST,在某种程度上,我会同意你的观点,这是一个有趣的例子,可以展示我们如何扩展标准框架。

StartServer 方法中包含的内容我们之前都见过,这是标准的 rpc 服务器设置,主要区别在于第 42 行,在那里我们不是启动 RPC 服务器,而是启动一个 http 服务器,并将监听器及其处理程序传递给它:

rpc_http_json/server/server.go

33 func StartServer() { 
34  helloWorld := new(HelloWorldHandler) 
35  rpc.Register(helloWorld) 
36 
37  l, err := net.Listen("("tcp",", fmt.Sprintf(":%(":%v",", port)) 
38  if err != nil { 
39    log.Fatal(fmt.Sprintf("("Unable to listen on given port: %s",", err)) 
40  } 
41 
42 http.Serve(l, http.HandlerFunc(httpHandler)) 
43 } 

我们传递给服务器的处理程序是魔法发生的地方:

45 func httpHandler(w http.ResponseWriter, r *http.Request) { 
46   serverCodec := jsonrpc.NewServerCodec(&HttpConn{in: r.Body, out: w}) 
47   err := rpc.ServeRequest(serverCodec) 
48   if err != nil { 
49     log.Printf("("Error while serving JSON request: %v",", err) 
50   http.Error(w, ""Error while serving JSON request, details have been logged.",.", 500) 
51   return 
52   } 
53 } 

在第 46 行,我们调用 jsonrpc.NewServerCodec 函数,并传递一个实现 io.ReadWriteCloser 类型的类型。NewServerCodec 方法返回一个实现 rpc.ClientCodec 类型的类型,它具有以下方法:

type ClientCodec interface { 
  // WriteRequest must be safe for concurrent use by multiple goroutines. 
  WriteRequest(*Request, interface{}) error 
  ReadResponseHeader(*Response) error 
  ReadResponseBody(interface{}) error 

  Close() error 
} 

ClientCodec 类型实现了 RPC 请求的写入和 RPC 响应的读取。为了将请求写入连接,客户端调用 WriteRequest 方法。为了读取响应,客户端必须成对地调用 ReadResponseHeaderReadResponseBody。一旦读取了主体,客户端就有责任调用 Close 方法来关闭连接。如果将 nil 接口传递给 ReadResponseBody,则应读取并丢弃响应的主体:

17 type HttpConn struct { 
18   in  io.Reader 
19   out io.Writer 
20 } 
21 
22 func (c *HttpConn) Read(p []byte) (n int, err error)  { return c.in.Read(p) } 
23 func (c *HttpConn) Write(d []byte) (n int, err error) { return c.out.Write(d) } 
24 func (c *HttpConn) Close() error                      { return nil } 

NewServerCodec 方法要求我们传递一个实现了 ReadWriteCloser 接口类型的参数。由于在我们的 httpHandler 方法中并没有传递给我们这样的类型作为参数,因此我们定义了自己的类型 HttpConn,它封装了 http.Request 的主体,实现了 io.Reader 接口,以及 ResponseWriter 方法,实现了 io.Writer 接口。然后我们可以编写自己的方法,代理对读取器和写入器的调用,创建一个具有正确接口的类型。

对于我们关于标准库中 RPC 的简短介绍,这就结束了;我们将在第三章“介绍 Docker”中更深入地探讨一些框架,看看这些框架是如何被用来构建生产级微服务的。

摘要

这章的内容就到这里,我们刚刚用 Go 语言编写了我们的第一个微服务,并且只使用了标准库,你现在应该已经对标准库的强大功能有了认识,它为我们提供了编写基于 RESTful 和 RPC 的微服务所需的大部分功能。我们还探讨了使用 encoding/json 包进行数据编码和解码的方法,以及如何通过使用 gobs 创建轻量级消息。

随着你阅读这本书的进程,你将看到许多奇妙的开源软件包是如何建立在这些基础之上,使得 Go 语言成为微服务开发的绝佳选择,并且到书的结尾,你将拥有成功使用 Go 语言构建微服务所需的所有知识。

第二章:设计一个优秀的 API

无论你是经验丰富的 API 和微服务构建者,正在寻找如何使用 Go 应用这些技术的技巧,还是你对微服务世界一无所知,花时间阅读这一章都是值得的。

编写 API 合约感觉像是艺术与科学的结合,当你与其他工程师讨论你的设计时,你很可能会同意不同意,不仅仅是关于制表符与空格的问题,但 API 合约确实有一些个人特色。

在本章中,我们将探讨两种最流行的选项,即 RESTful 和 RPC。我们将检查每种方法的语义,这将为你提供在不可避免讨论(即争论)发生时论证你观点的知识。选择 REST 或 RPC 可能完全取决于你当前的环境。如果你目前有运行实现 RESTful 方法的服务的,那么我建议你继续使用它,同样,如果你现在使用 RPC。我建议的一件事是,你应该阅读整个章节,以了解每种方法的语义、优点和缺点。

RESTful API

术语REST是由 Roy Fielding 在 2000 年的博士论文中提出的。它代表表征状态转移,其描述如下:

"REST 强调组件交互的可扩展性、接口的通用性、组件的独立部署、以及中间组件以减少交互延迟、加强安全和封装遗留系统。"

具备符合 REST 原则的 API 才是 RESTful 的。

URI

HTTP 协议中的一个主要组件是 URI。URI代表统一资源标识符,是你访问 API 的方法。你可能想知道 URI 和 URL(统一资源定位符)之间的区别是什么?当我开始写这一章时,我自己也对此感到困惑,并做了任何自重的开发者都会做的事情,即前往 Stack Overflow。不幸的是,我的困惑反而加深了,因为那里有很多详细的答案,但没有一个我认为特别有启发性。是时候前往地狱的内部圈层,也就是 W3C 标准,查找 RFC 以获取官方答案了。

简而言之,两者没有区别,URL 是 URI 的一种,通过其网络位置标识资源,在描述资源时可以互换使用术语。

2001 年发布的澄清文档(www.w3.org/TR/uri-clarification)继续解释说,在 90 年代初,有一个假设认为标识符被归入一个或两个类别。标识符可能指定资源的位置(URL)或其名称(统一资源名称 URN),而不考虑位置。URI 可以是 URL 或 URN。使用这个例子,http://将是一个 URL 方案,isbn:是一个 URN 方案。然而,随着时间的推移,这一级层次结构的重要性降低了。观点发生了变化,即单个方案不需要被归入离散类型集合中的一个。

传统的方法是http:是一个 URI 方案,urn:也是一个 URI 方案。URN 的形式为urn:isbn:n-nn-nnnnnn-nisbn:是一个 URN 命名空间标识符,而不是 URN 方案或 URI 方案。

依据这一观点,术语 URL 并不指代 URI 空间的正式划分,相反,URL 是一个非正式的概念;URL 是一种 URI 类型,通过其网络位置来标识资源。

在本书的其余部分,我们将使用术语 URI,当我们这样做时,我们将讨论一种访问远程服务器上运行的资源的方法。

URI 格式

2005 年发布的 RFC 3986www.ietf.org/rfc/rfc3986.txt定义了使 URI 有效的格式:

URI = scheme "://" authority "/" path [ "?" query] ["#" fragment"] 
URI = http://myserver.com/mypath?query=1#document 

我们将使用路径元素来定位我们服务器上运行的端点。在 REST 端点中,这可以包含参数以及文档位置。查询字符串同样重要,因为你将使用它来传递参数,如页码或排序,以控制返回的数据。

URI 格式化的一些通用规则:

  • 正斜杠/用于表示资源之间的层次关系

  • URI 中不应包含尾随正斜杠/

  • 使用连字符-可以提高可读性

  • 在 URI 中不应使用下划线_

  • 优先使用小写字母,因为大小写敏感性是 URI 路径部分的一个区分因素

许多规则背后的概念是 URI 应该易于阅读和构建。它也应该在构建方式上保持一致,因此你应该为 API 中的所有端点遵循相同的分类法。

REST 服务的 URI 路径设计

路径被分解为文档、集合、存储和控制器。

集合

集合是资源的目录,通常通过参数来访问单个文档。例如:

GET /cats   -> All cats in the collection 
GET /cats/1 -> Single document for a cat 1 

当定义一个集合时,我们应该始终使用复数名词,例如catspeople作为集合名称。

文档

文档是指向单个对象的资源,类似于数据库中的一行。它具有拥有子资源的能力,这些子资源可以是子文档或集合。例如:

GET /cats/1           -> Single document for cat 1 
GET /cats/1/kittens   -> All kittens belonging to cat 1 
GET /cats/1/kittens/1 -> Kitten 1 for cat 1 

控制器

控制器资源就像一个过程,这通常用于资源无法映射到标准的 CRUD创建检索更新删除)函数时。

控制器的名称出现在 URI 路径的最后一段,没有子资源。如果控制器需要参数,这些参数通常包含在查询字符串中:

POST /cats/1/feed           -> Feed cat 1 
POST /cats/1/feed?food=fish ->Feed cat 1 a fish 

定义控制器名称时,我们应该始终使用动词。动词是一个表示动作或状态的词,例如 feedsend

存储

存储是一个客户端管理的资源仓库,它允许客户端添加、检索和删除资源。与集合不同,存储永远不会生成新的 URI,它将使用客户端指定的 URI。以下是一个示例,它将向我们的存储中添加一只新猫:

PUT /cats/2 

这将在存储中添加一只 ID 为 2 的新猫,如果我们向集合中发布了没有 ID 的新猫,则响应需要包含对新定义的文档的引用,这样我们就可以稍后与之交互。像控制器一样,我们应该使用复数名词作为存储名称。

CRUD 函数名称

在设计优秀的 REST URI 时,我们从不使用 CRUD 函数名称作为 URI 的一部分,而是使用 HTTP 动词。例如:

DELETE /cats/1234

我们不在方法名称中包含动词,因为这由 HTTP 动词指定,以下 URI 被认为是反模式:

GET /deleteCat/1234 
DELETE /deleteCat/1234 
POST /cats/1234/delete

在下一节中查看 HTTP 动词时,这将会更清楚。

HTTP 动词

常用的 HTTP 动词有:

  • GET

  • POST

  • PUT

  • PATCH

  • DELETE

  • HEAD

  • OPTIONS

这些方法中的每一个都在我们的 REST API 的上下文中有一个明确的语义,正确的实现将帮助用户理解你的意图。

GET

GET 方法用于检索资源,不应用于更改操作,例如更新记录。通常,GET 请求不会传递正文;然而,这样做并不构成无效的 HTTP 请求。

请求:

GET /v1/cats HTTP/1.1 

响应:

HTTP/1.1 200 OK 
Content-Type: application/json 
Content-Length: xxxx 

{"name": "Fat Freddie's Cat", "weight": 15} 

POST

POST 方法用于在集合中创建一个新的资源或执行一个控制器。它通常是一个非幂等操作,这意味着多次向集合中创建一个元素将创建多个元素,而这些元素在第一次调用后不会被更新。

POST 方法在调用控制器时始终使用,因为其操作被认为是非幂等的。

请求:

POST /v1/cats HTTP/1.1 
Content-Type: application/json 
Content-Length: xxxx 

{"name": "Felix", "weight": 5} 

响应:

HTTP/1.1 201 Created 
Content-Type: application/json 
Content-Length: 0 
Location: /v1/cats/12343 

PUT

PUT 方法用于更新可变资源,并且必须始终包含资源定位符。PUT 方法的调用也是幂等的,这意味着多次请求不会将资源状态更改为与第一次调用不同的状态。

请求:

PUT /v1/cats HTTP/1.1 
Content-Type: application/json 
Content-Length: xxxx 

{"name": "Thomas", "weight": 7 } 

响应:

HTTP/1.1 201 Created 
Content-Type: application/json 
Content-Length: 0 

PATCH

PATCH 动词用于执行部分更新,例如,如果我们只想更新我们猫的名字,我们可以发出只包含我们想要更改的详细信息的 PATCH 请求。

请求:

PATCH /v1/cats/12343 HTTP/1.1 
Content-Type: application/json 
Content-Length: xxxx 

{"weight": 9} 

响应:

HTTP/1.1 204 No Body 
Content-Type: application/json 
Content-Length: 0 

在我的经验中,PATCH 更新很少被使用,通常的做法是使用 PUT 来更新整个对象,这不仅使得代码更容易编写,而且使得 API 更易于理解。

删除

当我们想要删除一个资源时,使用 DELETE 动词,通常我们会将资源的 ID 作为路径的一部分传递,而不是在请求体中。这样,我们就有了一个一致的方法来更新、删除和检索文档。

请求

DELETE /v1/cats/12343 HTTP/1.1 
Content-Type: application/json 
Content-Length: 0 

响应

HTTP/1.1 204 No Body 
Content-Type: application/json 
Content-Length: 0 

头部

当客户端想要检索资源的头部信息而不需要正文时,会使用 HEAD 动词。HEAD 动词通常用于替代 GET 动词,当客户端只想检查资源是否存在或读取元数据时。

请求

HEAD /v1/cats/12343 HTTP/1.1 
Content-Type: application/json 
Content-Length: 0 

响应

HTTP/1.1 200 OK 
Content-Type: application/json 
Last-Modified: Wed, 25 Feb 2004 22:37:23 GMT 
Content-Length: 45 

选项

当客户端想要检索资源的可能交互时,会使用 OPTIONS 动词。通常,服务器会返回一个 Allow 头部,其中包含可以与该资源一起使用的 HTTP 动词。

请求

OPTIONS /v1/cats/12343 HTTP/1.1 
Content-Length: 0 

响应

HTTP/1.1 200 OK 
Content-Length: 0 
Allow: GET, PUT, DELETE

URI 查询设计

在 API 调用中使用查询字符串作为一部分是完全可接受的;然而,我建议不要用它来传递数据给服务。相反,查询应该用于执行如下操作:

  • 分页

  • 过滤

  • 排序

如果我们需要调用一个控制器,我们之前讨论过,应该使用 POST 请求,因为这很可能是非幂等请求。为了传递数据给服务,我们应该在体中包含数据。然而,我们可以使用查询字符串来过滤控制器的动作:

POST /sendStatusUpdateEmail?$group=admin 
{ 
  ""message": "": "All services are now operational\nPlease accept our 
              apologies for any inconvenience caused.\n 
              The Kitten API team"" 
} 

在前面的例子中,我们会发送包含在请求体中的状态更新电子邮件,因为我们使用了查询字符串中传递的组过滤器,我们可以将这个控制器的动作限制为只发送给管理员组。

如果我们将消息添加到查询字符串而没有传递消息体,那么我们可能会给自己造成两个问题。第一个问题是 URI 的最大长度为 2083 个字符。第二个问题是,通常一个 POST 请求总是会包括请求体。虽然这不是 HTTP 规范的要求,但这是大多数用户期望的行为。

响应代码

当编写一个优秀的 API 时,我们应该使用 HTTP 状态码来向客户端指示请求的成功或失败。在本章中,我们不会全面查看所有可用的状态码;互联网上有许多资源提供了这些信息。我们将提供一些进一步阅读的资源,我们将要做的是查看作为软件工程师,你希望你的微服务返回的状态码。

目前,普遍认为这是一种好的做法,因为它允许客户端立即确定请求的状态,而无需深入请求体以获得更多信息。在失败的情况下,如果 API 总是向用户返回包含进一步信息的200 OK响应,这不是一个好的做法,因为它要求客户端检查体以确定结果。这也意味着消息体将包含除了它应该表示的对象之外的其他信息。考虑以下不良做法:

错误请求体:

POST /kittens 
RESPONSE HTTP 200 OK 
{ 
  ""status":": 401, 
  ""statusMessage": "": "Bad Request"" 
} 

成功的请求:

POST /kittens 
RESPONSE HTTP 201 CREATED 
{ 
  ""status":": 201, 
  ""statusMessage": "": "Created",", 
  ""kitten":": { 
    ""id": "": "1234334dffdf23",", 
    ""name": "": "Fat Freddy'sFreddy's Cat"" 
  } 
} 

假设你正在编写一个针对先前请求的客户端,你需要在你能够读取和处理返回的小猫之前,在你的应用程序中添加逻辑来检查响应中的状态节点。

现在考虑一些更糟糕的情况:

更糟糕的失败例子:

POST /kittens 
RESPONSE HTTP 200 OK 
{ 
  ""status":": 400, 
  ""statusMessage": "": "Bad Request"" 
} 

更糟糕的成功例子:

POST /kittens 
RESPONSE HTTP 200 OK 
{ 
  ""id": "": "123434jhjh3433",", 
  ""name": "": "Fat Freddy'sFreddy's Cat"" 
} 

如果你的 API 作者做了像前面例子中的事情,你需要检查返回的响应是错误还是你期望的小猫。在编写这个 API 客户端的过程中,你每分钟会说出多少个 WTF,这不会让你对它的作者产生好感。这些可能看起来像是极端的例子,但野外确实有类似的情况,在我的职业生涯中,我相当确信我犯过这样的错误,但那时我没有读过这本书。

作者在最好的意图下所做的尝试是过于字面地理解 HTTP 状态码。W3C RFC2616 指出,HTTP 状态码与尝试理解和满足请求有关(www.w3.org/Protocols/rfc2616/rfc2616-sec6.html#sec6.1.1);然而,当你查看一些单独的状态码时,这有点模糊。现代共识是,使用 HTTP 状态码来指示 API 请求的处理状态,而不仅仅是服务器的处理能力是可以接受的。考虑一下,如果我们通过实施这种方法,我们如何使这些请求变得更好。

一个失败的例子:

POST /kittens 
RESPONSE HTTP 400 BAD REQUEST 
{ 
  ""errorMessage": "": "Name should be between 1 and 256 characters in 
  length and only contain [A-Z] - ['-.]"'-.]" 
} 

一个成功的例子:

POST /kittens 
RESPONSE HTTP 201 CREATED 
{ 
  ""id": "": "123hjhjh2322",", 
  ""name": "": "Fat Freddy'sFreddy's cat"" 
} 

这更加语义化;在失败的情况下,如果用户需要更多信息,他们只需要阅读响应。除此之外,我们还可以提供一个标准错误对象,该对象用于我们 API 的所有端点,它提供了进一步但非必需的信息,以确定请求失败的原因。我们稍后会看看错误对象,但现在让我们更深入地看看 HTTP 状态码。

2xx 成功

2xx 状态码表示客户端的请求已被成功接收并理解。

200 OK

这是一个通用的响应码,表示请求已成功。伴随此代码的响应通常是:

  • GET:一个对应于请求资源的实体

  • HEAD:请求的资源对应的头字段,没有消息体

  • POST:一个描述或包含操作结果的实体

201 已创建

当请求成功且结果是一个新实体被创建时,会发送创建的响应。除了响应之外,API 通常还会返回一个Location头,其中包含新创建实体的位置:

201 Created 
Location: https://api.kittens.com/v1/kittens/123dfdf111 

返回对象体是否可选取决于此响应类型。

204 无内容

此状态通知客户端请求已成功处理;然而,响应中不会有消息体。例如,如果用户对集合发出DELETE请求,则响应可能返回 204 状态。

3xx 重定向

3xx 状态码类表示客户端必须采取额外操作以完成请求。许多这些状态码由 CDN 和其他内容重定向技术使用,然而,代码 304 在为我们的 API 设计提供语义反馈给客户端时非常有用。

301 永久移动

这告诉客户端他们请求的资源已被永久移动到不同的位置。虽然这传统上用于将页面或资源从 Web 服务器重定向,但它在我们构建 API 时也可能很有用。如果我们重命名一个集合,我们可以使用 301 重定向将客户端发送到正确的位置。然而,这应该被视为例外而不是常规做法。一些客户端不会隐式遵循 301 重定向,实现此功能会增加消费者额外的复杂性。

304 未修改

此响应通常由 CDN 或缓存服务器使用,并设置为指示自上次调用 API 以来响应未修改。这是为了节省带宽,请求将不会返回一个体,但将返回Content-LocationExpires头。

4xx 客户端错误

如果错误是由客户端而非服务器引起的,服务器将返回 4xx 响应,并且总是返回一个实体,其中包含有关错误的更多详细信息。

400 错误请求

此响应表示客户端由于请求格式错误或域验证失败(数据缺失或会导致无效状态的操作)而无法理解请求。

401 未授权

这表示请求需要用户身份验证,并将包含一个包含适用于请求资源的挑战的WWW-Authenticate头。如果用户已在WWW-Authenticate头中包含了所需的凭据,则响应应包含一个可能包含相关诊断信息的错误对象。

403 禁止

服务器已理解请求,但拒绝执行。这可能是因为对资源的访问级别不正确,而不是用户未认证。

如果服务器不希望公开请求无法访问资源的事实,那么可以返回404 Not found状态码而不是此响应。

404 未找到

此响应表示服务器未找到与请求 URI 匹配的内容。没有给出关于条件是暂时性还是永久性的指示。

客户端可以多次向此端点发送请求,因为状态可能不是永久的。

405 方法不允许

请求中指定的方法不允许对 URI 指示的资源进行操作。这可能是当客户端尝试通过向仅提供文档检索功能的集合发送POSTPUTPATCH来修改集合时。

408 请求超时

客户端没有在服务器准备等待的时间内产生请求。客户端可以在稍后时间重复请求,无需修改。

5xx 服务器错误

范围在 500 之间的响应状态码表示服务器发生了“Bang”,服务器知道这一点,并为这种情况感到抱歉。

RFC 建议在响应中返回一个错误实体,说明这是永久性的还是暂时性的,并包含错误解释。当我们查看关于安全性的章节时,我们会看到关于在错误信息中不要透露太多信息的建议,因为这种状态可能是用户试图破坏您的系统而人为制造的。通过返回诸如堆栈跟踪或其他内部信息之类的 5xx 错误,实际上可能会帮助破坏您的系统。因此,目前通常的做法是,500 错误只会返回一个非常通用的信息。

500 内部服务器错误

一个通用的错误信息,表明事情并没有按计划进行。

503 服务不可用

服务器当前因临时过载或维护而不可用。在微服务发生故障或过载的情况下,有一个非常有用的模式可以实现,该模式可以避免级联故障。在这种情况下,微服务将监控其内部状态,在发生故障或过载时,将拒绝接受请求,并立即向客户端发出信号。我们将在第 xx 章中更详细地探讨这个模式;然而,这个实例可能是您想要返回 503 状态码的地方。这也可以作为您的健康检查的一部分使用。

HTTP 头

请求头是 HTTP 请求和响应过程中的一个非常重要的部分,实施标准方法有助于您的用户从一个 API 过渡到另一个 API。在本节中,我们不会涵盖您可以在 API 中使用的所有可能的头,但我们将查看最常见的头,以获取有关 HTTP 协议的完整信息,请参阅 RFC 7231 tools.ietf.org/html/rfc7231。该文档包含了对当前标准的全面概述。

标准请求头

请求头为 API 的请求和响应提供额外的信息。把它们想象成操作的元数据。它们可以用来增强响应中的其他数据,这些数据本身不属于主体,例如内容编码。它们也可以被客户端利用,提供有助于服务器处理响应的信息。在可能的情况下,我们应该始终使用标准头,因为这为你的用户提供了一致性,并为他们提供了多个端点从多个不同供应商的通用标准。

Authorization - 字符串

授权是使用最广泛的请求头之一,即使你有公共只读 API,我也建议你要求用户授权他们的请求。通过要求用户授权请求,你就有能力执行用户级日志记录和速率限制等操作。通常,你可能会看到使用自定义请求头(如“X-API-Authorization”)进行授权。我建议你不要使用这种方法,因为 W3C RFC 2616 中指定的标准授权头(www.w3.org/Protocols/rfc2616/rfc2616-sec14.html)具有我们所需的所有功能。许多公司,如 Twitter 和 PayPal,使用此头进行请求认证。让我们看看 Twitter 开发者文档中的一个简单示例,看看这是如何实现的:

Authorization:  
        OAuth oauth_consumer_key="="xvz1evFS4wEEPTGEFPHBog",",  
              oauth_nonce="="kYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg",",  
              oauth_signature="="tnnArxj06cWHq44gCs1OSKk%2FjLY%3D",",  
              oauth_signature_method="="HMAC-SHA1",",  
              oauth_timestamp="="1318622958",",  
              oauth_token="="370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb",",  
              oauth_version="="1.0"" 

头部格式为[Authorization method] [逗号分隔的 URL 编码值]。这清楚地告知服务器授权类型是 OAuth,并且此授权的各个组件以逗号分隔的格式跟随。通过遵循这种标准方法,你可以让你的消费者使用实现此标准的第三方库,从而节省他们构建定制实现的工作。

日期

请求的 RFC 3339 格式的时间戳。

Accept - 内容类型

响应请求的内容类型,例如:

  • application/xml

  • text/xml

  • application/json

  • text/javascript(用于 JSONP)

Accept-Encoding - gzip, deflate

当适用时,REST 端点应始终支持 gzip 和 deflate 编码。

在 Go 中实现 gzip 支持相对简单;我们在第一章,“微服务简介”中展示了如何将中间件实现到你的微服务中。在下面的示例中,我们将使用这项技术来创建 gzip 响应 writer。

在 gzip 格式中编写响应的核心是compress/gzip包,它是标准库的一部分。它允许你创建一个实现ioWriteCloser接口的Writer,该接口包装现有的io.Writer,使用 gzip 压缩将数据写入给定的 writer:

func NewWriter(w io.Writer) *Writer 

要创建我们的处理程序,我们将编写NewGzipHandler函数,这个函数返回一个新的http.Handler,它将包装我们的标准输出处理程序。

我们首先需要做的是创建自己的ResponseWriter,它嵌入http.ResponseWriter

示例 2.1 chapter2/gzip/gzip_deflate.go

68 type GzipResponseWriter struct { 
69   gw *gzip.Writer 
70   http.ResponseWriter 
71} 

核心方法是实现Write方法:

73 func (w GzipResponseWriter) Write(b []byte) (int, error) { 
74   if _, ok := w.Header()["()["Content-Type"];"]; !ok { 
75     // If content type is not set, infer it from the uncompressed body. 
76   w.Header().Set("("Content-Type",", http.DetectContentType(b)) 
77   } 
78   return w.gw.Write(b) 
79 } 

如果你查看标准http.Response结构体中Write方法的实现,那里有很多事情在进行,我们既不想丢失也不想重新实现,因为当我们对gzip.Writer对象调用Write时,它会反过来调用http.Responsewrite方法,我们不会丢失任何复杂性。

在我们的NewGzipHandler内部,我们的处理程序会检查客户端是否发送了Accept-Encoding头,如果是的话,我们将使用GzipResponseWriter方法来写入响应;如果客户端请求未压缩的内容,我们则只调用带有标准ResponseWriterServeHttp

40 type GZipHandler struct { 
41  next http.Handler 
42 } 
43 
44 func (h *GZipHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 
45  encodings := r.Header.Get("("Accept-Encoding")") 
46 
47  if strings.Contains(encodings, ""gzip")") { 
48      h.serveGzipped(w, r) 
49  } else if strings.Contains(encodings, ""deflate")") { 
50      panic("("Deflate not implemented")") 
51  } else { 
52      h.servePlain(w, r) 
53  } 
54 } 
55 
56 func (h *GZipHandler) serveGzipped(w http.ResponseWriter, r *http.Request) { 
57  gzw := gzip.NewWriter(w) 
58  defer gzw.Close() 
59 
60  w.Header().Set("("Content-Encoding", "", "gzip")") 
61  h.next.ServeHTTP(GzipResponseWriter{gzw, w}, r) 
62 } 

63 func (h *GZipHandler) servePlain(w http.ResponseWriter, r *http.Request) 64 { 
65  h.next.ServeHTTP(w, r) 
66 } 

这绝对不是一个全面的示例,而且有许多开源包,例如来自《纽约时报》团队的那个(github.com/NYTimes/gziphandler),它为你管理这些。

作为一个小型的编程测试,为什么不尝试修改这个示例来实现DEFLATE

标准响应头

所有服务都应该返回以下头信息。

  • Date: 请求被处理的日期,格式为 RFC 3339。

  • Content-Type: 响应的内容类型。

  • Content-Encoding: gzip 或 deflate。

  • X-Request-ID/X-Correlation-ID:虽然你可能不会直接要求你的客户端实现这个头,但它可能是你在调用下游服务时添加到请求中的内容。当你试图调试在生产环境中运行的服务时,能够根据单个事务 ID 分组所有请求可以非常有用。当我们查看日志和监控时,我们会看到的一种常见做法是将所有日志存储在公共数据库中,例如 Elastic Search。通过在构建许多相互连接的微服务时设置标准的工作方式,它们在每个下游调用中传递关联 ID,你将能够使用 Kibana 或其他日志查询工具查询你的日志并将它们分组到单个事务中:

        X-Request-ID: f058ebd6-02f7-4d3f-942e-904344e8cde 

返回错误

在失败的情况下,你的 API 用户应该能够编写一段代码来处理不同端点的错误。一个标准的错误实体将帮助你的消费者,使他们能够在客户端或服务器发生错误时编写 DRY(Don't Repeat Yourself)代码。

微软 API 指南推荐以下格式来处理这些实体:

{ 
  ""error":": { 
    ""code": "": "BadArgument",", 
    ""message": "": "Previous passwords may not be reused",", 
    ""target": "": "password",", 
    ""innererror":": a { 
      ""code": "": "PasswordError",", 
  ""innererror":": { 
    ""code": "": "PasswordDoesNotMeetPolicy",", 
    ""minLength": "": "6",", 
    ""maxLength": "": "64",", 
    ""characterTypes": ["": ["lowerCase","","upperCase","","number","","symbol"],"], 
    ""minDistinctCharacterTypes": "": "2",", 
    ""innererror":": { 
      ""code": "": "PasswordReuseNotAllowed"" 
    } 
      } 
    } 
  } 
} 

错误响应对象

ErrorResponse是返回响应的最高级对象,它包含以下字段:

属性 类型 必需 描述
错误 错误 错误对象。

错误:对象

Error对象是我们错误响应的详情;它提供了错误发生原因的完整详情:

属性 类型 必需 描述
Code String (枚举) 图片 服务器定义的错误代码集合中的一个。
message String 图片 错误的可读表示形式。
Target String - 错误的目标。
Details Error[] - 导致此报告错误的具体错误详情数组。
innererror InnerError - 包含比当前对象更具体错误信息的对象。

InnerError对象

属性 类型 必需 描述
Code String - 比包含错误提供的错误代码更具体的错误代码。
innererror InnerError - 包含比当前对象更具体错误信息的对象。

微软提供了一个优秀的 API 指南资源,你可以通过以下链接了解更多关于返回错误的信息:

github.com/Microsoft/api-guidelines/blob/master/Guidelines.md#51-errors

从 JavaScript 访问 API

网络浏览器实现了一个沙盒机制,该机制限制了一个域中的资源访问另一个域中的资源。例如,你可能有一个允许修改和检索用户数据的 API,以及一个提供此 API 接口的网站。如果浏览器没有实现“同源策略”,并且假设用户没有注销他们的会话,那么恶意页面就可以向 API 发送请求并修改它,而你却不知道。

为了解决这个问题,你的微服务可以实现两种方法来允许这种访问,JSONP(代表带有填充的 JSON)和CORS跨源资源共享)。

JSONP

JSONP 基本上是一个漏洞,并且大多数没有实现后续 CORS 标准的浏览器都实现了它。它仅限于GET请求,并且通过绕过问题来工作,即虽然XMLHTTPRequest被阻止向第三方服务器发出请求,但 HTML 脚本元素没有限制。

JSONP 请求将一个<script src="img/...">元素插入到浏览器的 DOM 中,API 的 URI 作为src目标。此组件返回一个带有 JSON 数据的函数调用,当它加载时,该函数执行并将数据传递给回调。

JavaScript 回调在代码中定义:

function success(data) { 
  alert(data.message); 
} 

这是 API 调用的响应:

success({"({"message":"":"Hello World"})"}) 

要表示请求返回 JSONP 格式的数据,通常会在 URI 中添加callback=functionName参数,在我们的例子中,这将是在/helloworld?callback=success。实现这一点特别简单,让我们看看我们的简单 Go helloworld示例,看看我们如何修改它以实现 JSONP。

有一个需要注意的事项是我们返回的Content-Type标题。我们不再返回application/json,因为我们不是返回 JSON,实际上我们在返回 JavaScript,因此我们必须相应地设置Content-Type标题:

Content-Type: application/javascript 

示例 chapter2/jsonp/jsonp.go

让我们快速看一下如何使用 Go 发送 JSONP 的示例,我们的响应对象将完全与第一章,微服务简介中的那些相同:

18 type helloWorldResponse struct { 
19  Message string `json:":"message"`"` 
20 } 

差异全在于处理器,如果我们查看第30行,我们正在检查查询字符串中是否有回调参数。这将由客户端提供,并指示当响应返回时他们期望被调用的函数:

23 func helloWorldHandler(w http.ResponseWriter, r *http.Request) { 
24  response := helloWorldResponse{Message: ""HelloWorld"}"} 
25  data, err := json.Marshal(response) 
26  if err != nil { 
27    panic("("Ooops")") 
28 } 
29 
30  callback := r.URL.Query().Get("("callback")") 
31  if callback != """" { 
32    r.Headers().Add("("Content-Type", "", "application/javascript")") 
33    fmt.Fprintf(w, "%"%s(%s)",)", callback, string(data)) 
34  } else { 
35    fmt.Fprint(w, string(data)) 
36  } 
37 } 

要以 JSONP 格式返回我们的响应,我们只需要将标准响应包装成 JavaScript 函数调用。在第33行,我们正在获取客户端传递的回调函数名称,并将我们通常要发送的响应封装起来。结果输出将类似于以下这样:

请求

GET /helloworld?callback=hello 

响应

hello({"message":"Hello World"})  

CORS

假设您的用户正在使用过去五年内发布的桌面浏览器,或者 iOS 9 或 Android 4.2+等移动浏览器,那么实现 CORS 将绰绰有余。caniuse.com/#feat=cors 表示这超过了所有互联网用户的 92%。我期待着批评 IE 因未完全采用而受到的缺乏;然而,由于这自 IE8 以来就已经得到支持,我不得不抱怨移动用户。

CORS 是 W3C 的一个提案,旨在标准化浏览器中的跨源请求。它是通过浏览器内置的HTTP客户端在真实请求之前向 URI 发送一个OPTIONS请求来工作的。

如果另一端的服务器返回一个包含从该脚本加载的域的源头的标题,那么浏览器将信任该服务器,并允许进行跨站请求:

Access-Control-Allow-Origin: origin.com 

在 Go 中实现这一点相当简单,我们可以创建一个中间件来全局管理这一点。为了简单起见,在我们的例子中,我们将其硬编码到处理器中:

示例 2.2 chapter2/cors/cors.go

25 if r.Method == ""OPTIONS"" { 
26  w.Header().Add("("Access-Control-Allow-Origin", "*")", "*") 
27  w.Header().Add("("Access-Control-Allow-Methods", "", "GET")") 
28  w.WriteHeader(http.StatusNoContent) 
29  return 
30 }  

25行,我们检测到请求方法是OPTIONS,而不是返回响应,我们返回客户端期望的Access-Control-Allow-Origin报头。在我们的示例中,我们简单地返回\*,这意味着所有域名都可以与此 API 交互。这不是最安全的实现,而且通常你会要求你的 API 用户注册将与 API 交互的域名,并将Allow-Origin限制只包括那些域名。除了Allow-Origin报头外,我们还返回以下内容:

Access-Control-Allow-Methods: GET 

这告诉浏览器它只能对此 URI 发起GET请求,并且禁止发起POSTPUT等请求。这是一个可选的报头,但可以在与 API 交互时增强用户的安全性。需要注意的是,我们不是发送回200 OK响应,而是使用204 No Content,因为在OPTIONS请求中返回正文是不合法的。

RPC API

RPC 代表远程过程调用;它是在远程机器上执行函数或方法的一种方法。RPC 自诞生以来一直存在,并且有众多不同类型的 RPC 技术,其中一些依赖于存在接口定义(如 SOAP、Thrift 协议缓冲区)。这种接口定义可以使得为不同的技术栈生成客户端和服务器存根变得更加容易。通常,接口是用领域特定语言(DSL)定义的,生成器程序将使用它来创建应用程序客户端和服务器。

与 REST 需要使用 HTTP 作为传输层不同,RPC 不受此限制,虽然可以将 RPC 调用发送到 HTTP,但如果选择,你也可以使用 TCP 或甚至 UDP 套接字的轻量级。

最近,RPC 的使用有所回升,许多由 Uber、Google、Netflix 等公司构建的大规模系统都在使用 RPC。这得益于不使用 HTTP 所带来的低延迟速度和性能,以及通过实现二进制消息格式而不是 JSON 或 XML 所获得的更小的消息大小。

RPC 的批评者提到,客户端和服务器之间可能会出现紧密耦合,即如果你更新了服务器上的合约,那么所有客户端也需要更新。在许多现代 RPC 实现中,这个问题已经不那么严重了,实际上,与 RESTful API 相比,这个问题并不更严重。虽然像 JMI 这样的旧技术紧密耦合,要求客户端和服务器共享相同的接口,但现代实现如 Protocol Buffers 合理地封装了对象,即使存在细微的差异也不会抛出错误。因此,通过遵循版本化 API部分的标准指南,你遇到的问题并不比实现 RESTful API 时更严重。

RPC 的一个好处是您可以快速为您的用户生成客户端,这允许从传输和消息类型中抽象出来,并使他们依赖于接口。作为创建者,您可以更改应用程序的底层实现,例如从 Thrift 迁移到 Proto buffers,而无需要求客户端做任何事情,只需使用您提供的最新版本的客户端。版本化还允许您保留与 REST 相同的前向兼容性。

RPC API 设计

我们刚才讨论的创建良好 RESTful API 的一些原则也可以应用于 RPC。然而,主要区别之一是您可能不会使用 HTTP 作为传输协议;因此,您并不总是能够使用 HTTP 状态码作为成功或失败的指示。RPC代表远程过程调用,其历史可以追溯到互联网出现之前。最初,它被构想为执行可以在同一台机器上运行的独立应用程序中的过程,甚至可能在网络上。虽然我们现在认为这是理所当然的,但在 20 世纪 90 年代,这可是前沿技术。不幸的是,像 CORBA 和 Java RMI 这样的框架给 RPC 带来了坏名声,即使现在,如果您与 RPC 的反对者交谈,他们很可能会提到这两个框架。然而,好处是性能,使用二进制序列化在网络上非常高效,我们不再有 RMI 和 CORBA 强制执行的紧密耦合。我们也不再试图做任何太聪明的事情;我们不再尝试在两个进程之间共享对象,我们采取了一种更功能性的方法,即返回不可变对象的方法。这让我们拥有了两者之优;交互的简单性和二进制消息的速度与负载小。

RPC 消息框架

这些天我们不再需要在客户端和服务器上使用相同的接口实现,这不符合我们独立可版本化和可部署的口号。幸运的是,框架更加灵活,我们可以采取与 REST 相同的方法,添加元素是可以的,但是删除元素或更改方法签名必须触发版本更新。

Gob

我们已经在上一章中讨论了 gob,但作为一个快速回顾,gob 格式是专门为促进 Go 到 Go 通信而设计的,并且围绕着一个更容易使用且可能比类似协议缓冲区更高效的想法构建,但这牺牲了跨语言通信。

gob 对象定义:

type HelloWorldRequest struct {
  Name string
}

关于 gob 的更多信息可以在 Go 文档中找到,网址为golang.org/pkg/encoding/gob/

Thrift

Facebook 创建了 Thrift 框架,并于 2007 年开源。目前由 Apache 软件基金会维护。Thrift 的主要目标是:

  • 简单性:Thrift 代码简单易懂,没有不必要的依赖

  • 透明性:Thrift 符合所有语言中最常见的习惯用法

  • 一致性:特定于语言的特性属于扩展,而不是核心库

  • 性能:首先追求性能,其次追求优雅

这是一个 Thrift 服务定义:

struct User { 
  1: string name, 
  2: i32 id, 
  3: string email 
} 

struct Error { 
  1: i32 code, 
  2: string detail 
} 

service Users { 
  Error createUser(1: User user) 
} 

thrift.apache.org上找到更多关于 Apache Thrift 的信息。

协议缓冲

协议缓冲是谷歌的产品,它们刚刚进入第三个版本。协议缓冲采用提供一种 DSL 的方法,该生成器(用 C 编写)读取并可以生成超过十种语言的客户端和服务器存根,其中主要的前十种由谷歌维护,包括:Go、Java、C、NodeJS 的 JavaScript。

协议缓冲是一个可插拔的架构,因此可以编写自己的插件来生成各种端点,而不仅仅是 RPC;然而,RPC 是主要用例,因为它们与 gRPC 框架耦合。

gRPC 是由谷歌设计的一个快速且语言无关的 RPC 框架,它起源于一个内部项目,在该项目中延迟和速度在谷歌的架构中至关重要。默认情况下,gRPC 使用协议缓冲作为序列化和反序列化结构化数据的方法。以下示例展示了这种 DSL 的一个例子。

协议缓冲服务定义:

service Users { 
  rpc CreateUser (User) returns (Error) {} 
} 

message User { 
  required string name = 1; 
  required int32 id = 2; 
  optional string email = 3; 
} 

message Error { 
  optional code int32 = 1 
  optional detail string = 2 
} 

developers.google.com/protocol-buffers/上找到更多关于协议缓冲的信息。

JSON-RPC

JSON-RPC 是尝试使用 JSON 表示对象以用于 RPC 的标准方式。这消除了解码任何专有二进制协议的需要,但以传输速度为代价。没有要求任何特定的客户端或服务器提供此数据格式,TCP 套接字,以及能够编写大多数所有编程语言都能管理的字符串的能力,这些都是您所需的所有。

与 Thrift 和协议缓冲不同,JSON-RPC 为消息序列化设定了标准。

JSON-RPC 实现了一些很好的功能,允许批量处理请求;每个请求都包含一个id参数,由客户端建立。当服务器响应时,它将返回相同的标识符,使客户端能够理解响应与哪个请求相关。

这是一个 JSON-RPC 序列化请求:

{
  "jsonrpc": "2.0", 
  "method": "": "Users.v1.CreateUser",
  "params": {
    "name": "Nic Jackson", 
    "id": 12335432434
  }, 
  "id": 1
} 

这是一个 JSON-RPC 序列化响应:

{
  "jsonrpc": "2.0", 
  "result": {...}, 
  "id":": 1
} 

www.jsonrpc.org/specification上找到更多关于 JSON-RPC 2.0 的信息。

过滤

当我们查看 RESTful API 时,我们讨论了使用查询字符串执行过滤操作的概念,例如:

  • 分页

  • 过滤

  • 排序

显然,如果我们正在编写 RPC API,我们没有查询字符串的便利;然而,实现这些概念非常有用。只要我们保持一致性,就没有任何理由我们不能在我们的请求对象上定义用于过滤条件的参数:

{
 "jsonrpc": "2.0", 
 "method": "": "Users.v1.GetUserLog",
 "params": {
   "name": "Nic Jackson", 
   "id": 12335432434,
   "filter": { 
     "page_start":": 1,  //optional 
     "page_size"" : 10,  //optional 
     "sort": "name DESC" //optional 
   },
 "id": 1
}

这只是一个例子,你可能会选择根据自己特定的需求实现,然而,关键在于一致性。如果我们为每个方法使用相同的对象,我们可以合理地确信我们的用户会对此感到满意。

API 版本控制

API 版本控制是你从一开始就应该考虑的事情,并且尽量避免。一般来说,你将需要修改你的 API,然而,维护n个不同版本可能会非常痛苦,所以一开始就进行前期设计思考可以节省你很多麻烦。

在我们查看您如何可以版本控制 API 之前,这相当直接,让我们看看您应该在何时进行版本控制。

当您引入重大变更时,您将增加 API 版本号。

重大变更包括:

  • 删除或重命名 API 或 API 参数

  • 改变 API 参数的类型,例如,从整数更改为字符串

  • 修改响应代码、错误代码或故障合同

  • 改变现有 API 的行为

不涉及重大变更的事情包括:

  • 向返回实体添加参数

  • 添加额外的端点或功能

  • 修复错误或其他不包含在重大变更列表中的维护工作

语义版本控制

微服务应该实施主版本号方案。通常,设计者会选择只实现主版本号,并暗示次要版本为.0,根据语义版本控制原则semver.org,次要版本通常表示以向后兼容的方式实现的功能添加。这可能是向您的 API 添加额外的端点。可以争辩说,由于这不会影响客户端与您的 API 交互的能力,因此您不必担心次要版本,只需关注主版本即可,因为客户端不需要请求特定的版本才能正常工作。

当对 API 进行版本控制时,我认为删除次要版本并只关注主版本会更简洁。我们会采取这种方法的两个原因:

  • URI 变得更加易读,点号仅用作网络位置分隔符。当使用 RPC API 时,点号仅用于分隔API.VERSION.METHOD,使一切更容易阅读。

  • 我们应该通过我们的 API 版本控制推断出变化是一件大事,并且对客户端的功能有影响。内部我们仍然可以使用Major.Minor;然而,这不需要对客户端来说是一个需要考虑的事情,因为他们将没有能力选择使用 API 的次要版本。

REST API 的版本控制格式

为了允许客户端请求特定的 API 版本,有三种常见的方法可以实现。

这也可以作为 URI 的一部分来完成:

https://myserver.com/v1/helloworld 

也可以作为查询字符串参数来完成:

https://myserver.com/helloworld?api-version=1 

最后,可以通过使用自定义 HTTP 头来完成:

GET https://myserver.com/helloworld
api-version: 2

无论你选择哪种方式来实现版本控制,这都取决于你和你团队,但它在你的前期设计思考中应该扮演重要角色。一旦你决定了一个选项,坚持使用它,确保为你的消费者提供一致且优秀的体验应该是你的主要目标之一。

RPC API 的版本控制格式

RPC 的版本控制可能稍微困难一些,因为你很可能没有使用 HTTP 作为传输。然而,这仍然是可能的。处理这种情况的最佳方式是处理程序的命名空间。

在 go 基础包中,你可以给你的处理程序命名,例如Greet.v1.HelloWorld

RPC 的命名

使用 RPC 时,你没有使用 HTTP 动词来传达 API 意图的奢侈,例如,你有用户集合。在使用 HTTP API 的情况下,你可以通过GETPOSTDELETE等来分割各种操作。这在 RPC API 中是不可能的,你需要像编写 Go 代码中的方法一样思考,所以例如:

GET /v1/users 

前面的代码也可以写成如下 RPC 方法:

Users.v1.Users 
GET /v1/users/123434 

或者,它也可以写成如下 RPC 方法:

Users.v1.User 

子集合在语义上变得稍微少一些,而在 RESTful API 中,你可以做以下操作:

GET /v1/users/12343/permissions/1232 

你不能使用 RPC API 来做这件事,你必须明确指定方法作为一个单独的实体:

Permissions.v1.Permission 

方法名也需要推断 API 将要执行的操作;你不能依赖于 HTTP 动词的使用,所以如果你有一个可以删除用户的方法,你必须将删除动词添加到方法调用中,例如:

DELETE /v1/users/123123 

前面的代码将变为:

Users.v1.DeleteUser 

对象类型标准化

无论你使用的是自定义二进制序列化、JSON 还是 JSON-RPC,你都需要考虑你的用户将如何处理交易另一端的对象。许多使用 stub 生成客户端代码的序列化包,如 Protocol Buffers 和 Thrift,会愉快地处理简单类型如日期的序列化到本地类型,这使得消费者可以轻松使用和操作这些对象。然而,如果你使用 JSON 或 JSON-RPC,没有日期作为本地类型的概念,因此回退到 ISO 标准可能是有用的,客户端用户可以轻松反序列化。微软 API 设计指南提供了一些关于如何处理日期和持续时间的良好建议。

日期

当返回日期时,你应该始终使用DateLiteral格式,最好是Iso8601Literal。如果你需要以除Iso8601Literal之外的其他格式发送日期,则可以使用StructuredDateLiteral格式,这允许你在返回的实体中指定类型。

非正式的 Iso8601Literal 格式是使用最简单的方法,并且几乎任何消费您 API 的客户端都应该能够理解:

{"date": "2016-07-14T16:00Z"} 

更正式的 StucturedDateLiteral 不返回字符串,而是一个包含两个属性 kindvalue 的实体:

{"date": {"kind": "U", "value": 1471186826}} 

允许的种类有:

  • C: CLR;自 2000 年 1 月 1 日午夜以来的毫秒数

  • E: ECMAScript;自 1970 年 1 月 1 日午夜以来的毫秒数

  • I: ISO 8601;一个限于 ECMAScript 子集的字符串

  • O: OLE 日期;整数部分是自 1899 年 12 月 31 日午夜以来的天数,小数部分是当天的时间(0.5 = 中午)

  • T: 刻度;自 1601 年 1 月 1 日午夜以来的刻度(100 纳秒间隔)数

  • U: UNIX;自 1970 年 1 月 1 日午夜以来的秒数

  • W: Windows;自 1601 年 1 月 1 日午夜以来的毫秒数

  • X: Excel;与 O 相同,但 1900 年被错误地视为闰年,且天数为 "January 0 (零)"

持续时间

持续时间序列化为符合 ISO 8601,并以下列格式表示:

P[n]Y[n]M[n]DT[n]H[n]M[n]S 

  • P: 这是持续时间标识符(历史上称为"周期"),放置在持续时间表示的开始处

  • Y: 这是跟随年数值的年标识符

  • M: 这是跟随月数值的月标识符

  • W: 这是跟随周数值的周标识符

  • D: 这是跟随天数值的日标识符

  • T: 这是时间表示中的时间组件之前的时间标识符

  • H: 这是跟随小时数值的时标识符

  • M: 这是跟随分钟数值的分钟标识符

  • S: 这是跟随秒数值的秒标识符

例如,P3Y6M4DT12H30M5S 表示 "三年,六个月,四天,十二小时,三十分钟和五秒" 的持续时间。

间隔

再次,ISO 8601 规范的一部分是,如果您需要接收或发送一个间隔,您可以使用以下格式:

  • 开始和结束,例如 2007-03-01T13:00:00Z/2008-05-11T15:30:00Z

  • 开始和持续时间,例如 2007-03-01T13:00:00Z/P1Y2M10DT2H30M

  • 持续时间和结束,例如 P1Y2M10DT2H30M/2008-05-11T15:30:00Z

  • 仅持续时间,例如 P1Y2M10DT2H30M,带有额外的上下文信息

github.com/Microsoft/api-guidelines/blob/master/Guidelines.md#113-json-serialization-of-dates-and-times 查找有关日期和时间的 JSON 序列化的更多信息。

记录 API

记录 API 非常有用,无论你打算让 API 被公司内部的其他团队、外部用户,甚至只是你自己使用。你会感谢自己花时间记录 API 操作并保持文档更新。保持文档更新不应是一项艰巨的任务。有许多应用程序可以从你的源代码自动生成文档,所以你只需要在构建工作流程中运行此应用程序即可。

基于 REST 的 API

目前有三个主要标准正在争夺成为 REST API 文档的皇后:

  • Swagger

  • API Blueprint

  • RAML

Swagger

Swagger 是由 SmartBear 设计的,并被选为 Open API 创新计划的一部分;这可能会给它带来最大的机会,成为文档化 RESTful API 的标准。然而,Open API 创新计划 (openapis.org) 是一个行业机构,它是否能够获得 W3C 在网络标准方面的认可,可能取决于更多知名企业的加入。

文档是用 YAML 编写的,各种代码生成工具既可以从源代码生成 Swagger 文档,也可以生成客户端 SDK。该标准在功能列表上非常全面,并且相对简单易写,同时被开发社区广泛理解。

Swagger 的代码示例如下所示:

/pets: 
  get: 
    description: Returns all pets from the system that the user has access to 
    produces: 
      - application/json 
    responses: 
      '200''200': 
        description: A list of pets. 
  schema: 
    type: array 
    items: 
      $ref: ''#/definitions/Pet'Pet' 

definitions: 
  Pet: 
    type: object 
    properties: 
      name: 
  type: string 
    description: name of the pet 

swagger.io 查找有关 Swagger 的更多信息。

API Blueprint

API Blueprint 是由 Apiary 设计的开放标准,并按照 MIT 许可证发布。它与 Apiary 的产品紧密相连。然而,它也可以独立使用,并且有各种开源工具可以读取和写入该格式。

文档是用 Markdown 编写的,这使得编写文档感觉更加自然,而不是处理嵌套的对象层。

API Blueprint 的代码示例如下所示:

FORMAT: 1A 

# Data Structures 

## Pet (object) 
+ name: Jason (string) - Name of the pet. 

# Pets [/pets] 

Returns all pets from the system that the user has access to'to' 

## Retrieve all pets [GET] 
+ Response 200 (application/json) 
+ Attributes (array[Pet]) 

apiblueprint.org 查找有关 API Blueprint 的更多信息。

RAML

RAML 代表 RESTful API Modelling Language,并以 YAML 格式编写。它的目标是允许定义一种人类可读的格式,用于描述资源、方法、参数、响应、媒体类型以及其他构成你 API 基础的 HTTP 构造。

RAML 的代码示例如下所示:

#%RAML 1.0 
title: Pets API 
mediaType: [ application/json] 
types: 
  Pet: 
    type: object 
    properties: 
      name: 
        type: string 
        description: name of the pet 
/pets: 
  description: Returns all pets from the system that the user has access to 
  get: 
    responses: 
      200: 
        body: Pet[] 

raml.org 查找有关 RAML 的更多信息。

基于 RPC 的 API

在 RPC API 中,有一种观点认为你的合同就是你的文档,在以下示例中,我们使用协议缓冲区 DSL 定义接口,并根据需要添加任何必要的注释来帮助消费者。主要遵循的理论是自文档化代码,你的方法和参数名称应该推断意图并提供足够的描述,以消除注释的使用。

协议缓冲区示例:

// The greeting service definition. 
service Users { 
  // Create user creates a user in the system with the given User details, 
  // it returns an Error message which will be nil on a successful operation 
  rpc CreateUser (User) returns (Error) {} 
} 

// Person describes a user entity 
message User { 
  // name is a required field and represents the name of 
  required string name = 1; 
  // id is the unique identifier for the user in the sytem 
  required int32 id = 2; 
  // email is the users email address and is an optional field  
  optional string email = 3; 
} 

message Error { 
  optional code int32 = 1 
  optional detail string = 2 
} 

你选择哪种标准完全取决于你,你的工作流程,你的团队标准,以及你的用户。然而,一旦你选择了与命名约定相同的方法,你应该坚持在你的所有 API 中保持一致的风格。

摘要

在本章中,我们没有花太多时间查看代码;然而,我们已经研究了编写优秀 API 的某些基本概念,这和能够编写代码一样重要。

本章的大部分内容都关注于 RESTful API,因为与 RPC 不同,我们需要在它们的用法上更加描述性。我们还有能力利用 HATEOAS 的原则,这是在使用 RPC 时所不具备的。

在下一章中,我们将开始探讨 Go 社区中存在的一些令人惊叹的框架,这样我们就可以开始应用这些原则,并进一步深化我们对微服务精通的进步。

第三章:介绍 Docker

在我们继续本书的内容之前,我们需要先了解一下被称为 Docker 的这个小东西,在我们开始之前,别忘了克隆示例代码仓库 github.com/building-microservices-with-go/chapter3.git

使用 Docker 介绍容器

Docker 是一个在过去三年中崛起的平台;它诞生于简化构建、运输和运行应用程序过程的愿望。Docker 不是容器的发明者,Jacques Gélinas 在 2001 年创建了 VServer 项目,从那时起,其他主要项目还包括 IBM 的 LXC 和 CoreOS 的 rkt。

如果你想了解更多关于历史的信息,我推荐阅读 Redhat 的这篇优秀的博客文章:rhelblog.redhat.com/2015/08/28/the-history-of-containers,本节将重点介绍 Docker,这是目前最受欢迎的技术。

容器的概念是进程隔离和应用程序打包。引用 Docker 的话:

容器镜像是一个轻量级、独立、可执行的软件包,它包含了运行该软件所需的一切:代码、运行时、系统工具、系统库、设置。

...

容器将软件与其环境隔离开来,例如,开发环境和预发布环境之间的差异,并有助于减少在相同基础设施上运行不同软件的团队之间的冲突。

它们在应用开发中的好处是,在部署这些应用程序时,我们可以利用这一点,因为它允许我们将它们打包得更紧密,节省硬件资源。

从开发和测试生命周期来看,容器使我们能够在开发机器上运行生产代码,而无需复杂的设置;它还允许我们创建一个“洁净室”环境,而无需安装不同实例的同数据库来测试新软件。

容器已成为打包微服务的首选方案,随着我们在本书中的示例进展,你将了解到这对你的工作流程是多么宝贵。

容器通过隔离进程和文件系统来工作。除非明确指定,否则容器无法访问彼此的文件系统。除非再次指定,否则它们也不能通过 TCP 或 UDP 套接字相互交互。

Docker 由许多部分组成;然而,其核心是 Docker 引擎,这是一个轻量级的应用程序运行时,具有编排、调度、网络和安全功能。Docker 引擎可以安装在物理或虚拟主机上的任何位置,并且支持 Windows 和 Linux。容器允许开发者将大量或少量代码及其依赖项打包到一个隔离的包中。

我们还可以从大量的预先创建的镜像中获取资源,几乎所有的软件供应商,从 MySQL 到 IBM 的 WebSphere,都有官方镜像可供我们使用。

Docker 也使用 Go 语言,实际上,几乎所有的 Docker Engine 和其他应用程序的代码都是用 Go 编写的。

我们不如通过实例来检查每个功能,而不是写一篇关于 Docker 如何工作的论文。到本章结束时,我们将使用在第一章,“微服务简介”中创建的一个简单示例,并为它创建一个 Docker 镜像。

安装 Docker

前往docs.docker.com/engine/installation/,并在你的机器上安装正确的 Docker 版本。你将找到适用于 Mac、Windows 和 Linux 的版本。

运行我们的第一个容器

为了验证 Docker 是否正确安装,让我们运行我们的第一个容器,hello-world实际上是一个镜像,镜像是一个不可变的容器快照。一旦我们用以下命令启动它们,它们就变成了容器,把它想象成类型和实例,类型定义了构成行为的字段和方法。实例是这个类型的活生生的实例化,你可以将其他类型分配给字段并调用方法来执行操作。

$ docker run --rm hello-world  

你首先应该看到的是:

Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
c04b14da8d14: Pull complete 
Digest: sha256:0256e8a36e2070f7bf2d0b0763dbabdd67798512411de4cdcf9431a1feb60fd9
Status: Downloaded newer image for hello-world:latest

当你执行docker run时,引擎首先会检查你是否已经安装了镜像。如果没有,它会连接到默认的注册表,在这种情况下,hub.docker.com/,以检索它。

一旦镜像被下载,守护进程可以从下载的镜像创建一个容器,所有输出都会流到你的终端上:

Hello from Docker!
This message shows that your installation appears to be working correctly.  

--rm标志告诉 Docker 引擎在退出时删除容器并删除它所使用的任何资源,如卷。除非我们想在某个时候重新启动容器,否则使用--rm标志来保持我们的文件系统干净是一个好习惯,否则,所有创建的临时卷都会存在并占用空间。让我们尝试一个稍微复杂一些的例子,这次,我们将启动一个容器并在其中创建一个 shell 来展示如何导航到内部文件系统。在你的终端中执行以下命令:

$ docker run -it --rm alpine:latest sh  

Alpine 是 Linux 的一个轻量级版本,非常适合运行 Go 应用程序。-it标志代表交互式终端,它将你的终端的标准输入映射到正在运行的容器的输入。在我们要运行的镜像名称之后的sh语句是我们希望在容器启动时执行的命令的名称。

如果一切顺利,你现在应该已经在一个容器的 shell 中了。如果你通过执行ls命令检查当前目录,你会看到以下内容,希望这并不是你在运行命令之前的目录:

bin      etc      lib      media    proc     run      srv      tmp      var
dev      home     linuxrc  mnt      root     sbin     sys      usr

这是新启动的容器的根文件夹,容器是不可变的,所以你对运行中的容器文件系统所做的任何更改,在容器停止时都会被丢弃。虽然这看起来可能是一个问题,但有一些持久化数据的方法,我们稍后会看看,但现在,重要的是要记住:

“容器是不可变的镜像实例,并且默认情况下数据卷是非持久的”

在设计你的服务时,你需要记住这一点,为了说明这是如何工作的,请看这个简单的例子。

打开另一个终端并执行以下命令:

$ docker ps  

你应该看到以下输出:

CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
43a1bea0009e        alpine:latest       "sh"                6 minutes ago       Up 6 minutes                            tiny_galileo

docker ps 命令查询引擎并返回一个容器列表,默认情况下这仅显示正在运行的容器,然而,如果我们添加 -a 标志,我们也可以看到停止的容器。

我们之前启动的 Alpine Linux 容器目前正在运行,所以回到你之前的终端窗口,在根文件系统中创建一个文件:

$ touch mytestfile.txt  

如果我们再次列出目录结构,我们可以看到在文件系统的根目录下创建了一个文件:

bin             lib             mytestfile.txt  sbin            usr
dev             linuxrc         proc            srv             var
etc             media           root            sys
home            mnt             run             tmp  

现在使用 exit 命令退出容器,并再次运行 docker ps,你应该看到以下输出:

CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES  

如果我们添加 -a 标志命令来查看停止的容器,我们也应该看到我们之前启动的容器:

CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS                     PORTS               NAMES
518c8ae7fc94        alpine:latest       "sh"                5 seconds ago       Exited (0) 2 seconds ago                       pensive_perlman  

现在,再次使用 docker run 命令启动另一个容器,并列出根文件夹中的目录内容。

没有 mytestfile.txt 吧?这个文件不存在的原因是因为我们之前讨论的原则,我认为再次提到这一点很重要,因为如果你是第一次使用 Docker,这可能会让你感到意外:

“容器是不可变的镜像实例,并且默认情况下数据卷是非持久的。”

然而,有一点值得注意,除非你明确删除容器,否则它将保持在 Docker 主机上的停止状态。

删除容器有两个重要原因;第一个是如果你不记得这一点,你很快就会填满主机的磁盘,因为每次你创建一个容器,Docker 都会在主机上为容器卷分配空间。第二个原因是容器可以被重启。

重启听起来很酷,实际上,这是一个方便的功能,不是你应该在生产环境中使用的东西,为此你需要记住黄金法则并相应地设计你的应用程序:

“容器是不可变的镜像实例,并且默认情况下数据卷是非持久的。”

然而,Docker 的使用远远超出了仅仅为你的微服务运行应用程序。这是一个管理你的开发依赖项而不会让你的开发机器变得杂乱无章的绝佳方式。我们稍后会看看这一点,但现在,我们感兴趣的是我们如何重启一个停止的容器。

如果我们执行docker ps -a命令,我们会看到现在有两个停止的容器。最老的一个是我们第一次启动并添加了mytestfile.txt的容器。这是我们想要重新启动的容器,所以获取容器的 ID 并执行以下命令:

$ docker start -it [container_id] sh  

再次强调,如果你检查容器的目录内容,你应该在容器的根目录下的 shell 中,你认为你会找到什么?

没错,mytestfile.txt;这是因为当你重新启动容器时,引擎重新挂载了你在第一次运行命令时附加的卷。这些就是之前提到的你修改以添加文件的相同卷。

因此我们可以重新启动我们的容器;然而,我只想最后一次重复黄金法则:

"容器是镜像的不变实例,数据卷默认是非持久的。"

在生产环境中运行时,你不能确保可以重新启动一个容器。这里有成千上万的原因,其中一个主要的原因是我们将在查看编排时更深入地探讨,那就是容器通常运行在一组主机上。由于无法保证容器将在哪个主机上重新启动,甚至无法保证容器之前运行的主机实际上存在。有许多项目试图解决这个问题,但最好的方法是完全避免这种复杂性。如果你需要持久化文件,那么将它们存储在为这项工作设计的某些东西中,比如 Amazon S3 或 Google Cloud Storage。围绕这个原则设计你的应用程序,这样当不可避免的事情发生时,你将花更少的时间惊慌,而且你的超级敏感数据容器不会消失。

好的,在我们更深入地了解 Docker 卷之前,让我们清理一下。

退出你的容器并回到 Docker 主机上的 shell。如果我们运行docker ps -a,我们会看到有两个停止的容器。为了删除这些容器,我们可以使用docker rm containerid命令。

现在使用你列表中的第一个containerid运行此命令,如果成功,你请求删除的容器 ID 将被回显给你,并且容器将被删除。

如果你想要删除所有停止的容器,可以使用以下命令:

$ docker rm -v $(docker ps -a -q)  

docker ps -a -q-a标志将列出所有容器,包括停止的容器,-q将返回容器 ID 的列表而不是完整详情。我们将此作为参数列表传递给docker rm,它将删除列表中的所有容器。

为了避免需要删除容器,我们可以在启动新容器时使用--rm标志。此标志告诉 Docker 在容器停止时删除它。

Docker 卷

我们已经看到了 Docker 容器是不可变的;然而,在某些情况下,您可能希望将一些文件写入磁盘,或者当您想要从磁盘读取数据,例如在开发环境中。Docker 有卷的概念,可以从运行 Docker 机器的主机或另一个 Docker 容器挂载。

联合文件系统

为了保持我们的镜像高效且紧凑,Docker 使用了联合文件系统的概念。联合文件系统允许我们通过将不同的目录和或文件组合在一起来表示一个逻辑文件系统。它使用写时复制技术,在我们修改文件系统时复制层,这样我们在创建新镜像时只使用大约 1MB 的空间。当数据写入文件系统时,Docker 会复制层并将其放在栈的顶部。在构建镜像和扩展现有镜像时,我们利用了这项技术,同样,在启动镜像和创建容器时,唯一的区别就是这个可写层,这意味着我们不需要每次都复制所有层并填满我们的磁盘。

挂载卷

-v--volume 参数允许您指定一对值,这对值对应于您希望在主机上挂载的文件系统以及您希望在容器内部挂载卷的路径。

让我们尝试之前的示例,但这次是在本地文件系统上挂载一个卷:

$ docker run -it -v $(pwd):/host alpine:latest /bin/sh  

如果您切换到主机文件夹,您会看到从您运行docker run命令的位置可以访问相同的文件夹。-v参数值的语法是hostfolder:destinationfolder,我认为需要指出的一点是,这些路径必须是绝对路径,您不能使用相对路径,如./../foldername。您刚刚挂载的卷具有读写访问权限,您所做的任何更改都将同步到主机上的文件夹,所以请小心不要执行rm -rf *。在生产环境中创建卷应该非常谨慎使用,我建议在可能的情况下完全避免这样做,因为在生产环境中,没有保证如果容器死亡并被重新创建,它将替换为之前所在的主机。这意味着您对卷所做的任何更改都将丢失。

Docker 端口

当在容器内运行 Web 应用时,我们通常会需要将一些端口暴露给外部世界。默认情况下,Docker 容器是完全隔离的,如果您在容器内启动一个运行在端口8080的服务器,除非您明确指定该端口可以从外部访问,否则它将不可访问。

从安全角度来看,映射端口是一件好事,因为我们遵循的是不信任的原则。同时,暴露这些端口也毫不费力。使用我们在第一章“微服务简介”中创建的示例,让我们看看这有多简单。

移动到您检出示例代码的文件夹,并运行以下 Docker 命令:

$ docker run -it --rm -v $(pwd):/src -p 8080:8080 -w /src golang:alpine /bin/sh  

我们传递的-w标志是用来设置工作目录的,这意味着我们在容器中运行的任何命令都将在这个文件夹内运行。当我们启动 shell 时,你会看到,我们不需要切换到我们在卷挂载的第二部分指定的文件夹,我们已经在那个文件夹里,可以运行我们的应用程序。我们这次也使用了一个稍微不同的镜像。我们不是使用alpine:latest,这是一个轻量级的 Linux 版本,我们使用的是golang:alpine,这是一个安装了最新 Go 工具的 Alpine 版本。

如果我们使用go run main.go命令启动我们的应用程序;我们应该会看到以下输出:

2016/09/02 05:53:13 Server starting on port 8080  

现在切换到另一个 shell,并尝试 curl API 端点:

$ curl -XPOST localhost:8080/helloworld -d '{"name":"Nic"}'  

你应该会看到类似以下的消息返回:

{"message":"Hello Nic"}  

如果我们运行docker ps命令来检查正在运行的容器,我们会看到没有端口被暴露。回到你之前的终端窗口,终止命令并退出容器。

这次,当我们启动它时,我们将添加-p参数来指定端口。就像卷一样,这需要一对由冒号(:)分隔的值。第一个是我们希望绑定到主机上的目标端口,第二个是我们应用程序绑定的 Docker 容器上的源端口。

因为这绑定到了主机上的端口,就像你因为端口绑定而无法在本地两次启动程序一样,你也不能在 Docker 的主机端口映射中这样做。当然,你可以在不同的容器中启动你的代码的多个实例,并将它们绑定到不同的端口,我们将在稍后看到如何做到这一点。

但首先让我们看看那个端口命令,而不是启动一个容器并创建一个 shell 来运行我们的应用程序,我们可以通过将/bin/sh命令替换为我们的go run命令,用一条命令来完成这个操作。试一试,看看你能否运行你的应用程序。

明白了?

你应该输入了类似以下的内容:

$ docker run -it --rm -v $(pwd):/src -w /src -p 8080:8080 golang:alpine go run reading_writing_json_8.go  

现在再次尝试使用curl向 API 发送一些数据,你应该会看到以下输出:

{"message":"Hello Nic"}  

就像卷一样,你可以指定多个-p参数实例,这使你能够为多个端口设置绑定。

删除以显式名称开始的容器

以名称参数开始的容器即使指定了--rm参数也不会自动删除。要删除以这种方式启动的容器,我们必须手动使用docker rm命令。如果我们向命令中添加-v选项,我们还可以删除与其关联的卷。我们真的应该现在就做这件事,或者当我们试图在本章的后面重新创建容器时,你可能会感到有些困惑:

$ docker rm -v server  

Docker 网络

我从未打算让这一章成为官方 Docker 文档的完整复制;我只是试图解释一些关键概念,这些概念将帮助您在阅读本书的其余部分时进步。

Docker 网络是一个有趣的话题,默认情况下,Docker 支持以下网络模式:

  • bridge

  • host

  • none

  • overlay

桥接网络

桥接网络是当你启动容器时它们将连接到的默认网络;这是我们能够在上一个示例中将容器连接在一起的原因。为了实现这一点,Docker 使用了一些核心 Linux 功能,如网络命名空间和虚拟以太网接口(或veth接口)。

当 Docker 引擎启动时,它会在主机机器上创建docker0虚拟接口。docker0接口是一个虚拟以太网桥,它自动将数据包转发到连接到它的任何其他网络接口。当容器启动时,它会创建一个veth对,它将一个分配给容器,这成为它的eth0,另一个连接到docker0桥。

主机网络

主网络实际上是与 Docker 引擎运行在同一个网络。当你将容器连接到主网络时,容器暴露的所有端口都会自动映射到主机上,它还共享主机的 IP 地址。虽然这看起来像是一种方便,但 Docker 始终被设计为能够在引擎上运行同一容器的多个实例,并且由于在 Linux 中使用host network只能将套接字绑定到一个端口,这限制了这一功能。

主网络也可能对您的容器构成安全风险,因为它不再受“不信任”原则的保护,您也不再能够明确控制端口是否暴露。话虽如此,由于主机网络的效率,在某些情况下,如果您预计容器将大量使用网络,将容器连接到主机网络可能是合适的。API 网关可能就是这样一个例子,这个容器仍然可以将请求路由到位于桥接网络上的其他 API 容器。

无网络

在某些情况下,您可能希望从任何网络中移除您的容器。考虑这种情况:您有一个只处理存储在文件中的数据的应用程序。利用“不信任”原则,我们可能确定最安全的事情是不将其连接到任何容器,并且只允许它写入挂载在主机上的卷。将您的容器连接到none网络正好提供了这种能力,尽管用例可能有些有限,但它确实存在,了解这一点是很好的。

覆盖网络

Docker 的覆盖网络是一种独特的 Docker 网络,用于连接运行在不同主机上的容器。正如我们之前所学的,使用桥接网络时,网络通信被限制在 Docker 主机上,这在开发软件时通常是可行的。然而,当你将代码部署到生产环境中时,所有这些都会改变,因为你通常会在多个主机上运行多个容器,作为你的高可用性配置的一部分。容器仍然需要相互通信,虽然我们可以通过ESB(企业服务总线)路由所有流量,但在微服务世界中这有点反模式。正如我们将在后面的章节中看到的,推荐的方法是服务负责自己的发现和负载均衡客户端调用。Docker 覆盖网络解决了这个问题,实际上它是在机器之间创建一个网络隧道,将流量无修改地通过物理网络传递。覆盖网络的问题是你不能再依赖 Docker 为你更新etc/hosts文件,你必须依赖于动态服务注册。

自定义网络驱动程序

Docker 还支持基于其开源libnetwork项目的网络插件,你可以编写自定义网络插件来替换 Docker 引擎的网络子系统。它们还提供了将非 Docker 应用程序连接到容器网络的能力,例如物理数据库服务器。

Weaveworks

Weaveworks 是最受欢迎的插件之一,它使你能够安全地链接你的 Docker 主机,并提供一系列额外的工具,如 weavedns 的服务发现和 weavescope 的可视化,这样你可以看到你的网络是如何连接在一起的。

www.weave.works

Project Calico

Project Calico 试图解决使用虚拟局域网、桥接和隧道可能引起的速度和效率问题。它通过将你的容器连接到 vRouter 来实现这一点,然后直接在 L3 网络上路由流量。当你需要在多个数据中心之间发送数据时,这可以带来巨大的优势,因为没有依赖 NAT,较小的数据包大小减少了 CPU 利用率。

www.projectcalico.org

创建自定义桥接网络

实现自定义覆盖网络超出了本书的范围,然而,了解如何创建自定义桥接网络是我们应该关注的,因为 Docker-Compose,我们将在本章后面介绍,利用了这些概念。

与许多 Docker 工具一样,创建桥接网络相当简单。要查看 Docker 引擎上当前正在运行的网络,我们可以执行以下命令:

$ docker network ls  

输出应该类似于以下内容:

NETWORK ID          NAME                DRIVER              SCOPE
8e8c0cc84f66        bridge              bridge              local 
0c2ecf158a3e        host                host                local 
951b3fde8001        none                null                local       

你会发现默认创建了三个网络,这是我们之前讨论的三个之一。因为这些是默认网络,我们无法删除它们,Docker 需要这些网络才能正确运行,允许你删除它们确实是不好的。

创建一个桥接网络

要创建一个桥接网络,我们可以使用以下命令:

$ docker network create testnetwork  

现在在你的终端中运行这个命令,再次列出网络以查看结果。

你会看到现在在你的列表中有一个使用桥接驱动程序并且名称是你指定的一个参数的网络。默认情况下,当你创建一个网络时,它使用bridge作为默认驱动程序,当然,你可以创建一个使用自定义驱动程序的网络,这可以通过指定额外的参数-d drivername来轻松实现。

将容器连接到自定义网络

要将容器连接到自定义网络,让我们再次使用我们在第一章中创建的示例应用程序:微服务简介

$ docker run -it --rm -v $(pwd):/src -w /src --name server --network=testnetwork golang:alpine go run main.go  

你是否收到了错误信息,说名称已被使用,因为你忘记在早期部分移除容器?如果是这样,可能需要回翻几页。

假设一切顺利,你应该看到服务器启动的消息,现在让我们尝试使用我们之前执行的相同命令来 curl 容器:

$ docker run --rm appropriate/curl:latest curl -i -XPOST server:8080/helloworld -d '{"name":"Nic"}'  

您应该已经收到了以下错误信息:

curl: (6) Couldn't resolve host 'server'

这是预期的,尝试更新docker run命令,使其与我们的 API 容器一起工作。

明白了?

如果没有,这里有一个添加了网络参数的修改后的命令:

$ docker run --rm --network=testnetwork appropriate/curl:latest curl -i -XPOST server:8080/helloworld -d '{"name":"Nic"}'  

这个命令应该第二次运行得很好,你应该看到预期的输出。现在移除服务器容器,我们将看看如何编写你自己的 Docker 文件。

编写 Dockerfile

Dockerfile 是我们的镜像配方;它们定义了基本镜像、要安装的软件,并赋予我们设置应用程序所需的各种结构的权限。

在本节中,我们将探讨如何为我们的示例 API 创建 Dockerfile。再次强调,这不会是 Dockerfile 如何工作的全面概述,因为有许多书籍和在线资源专门为此目的存在。我们将查看关键点,这将给我们基础知识。

我们将要做的第一件事是构建我们的应用程序代码,因为当我们将其打包到 Dockerfile 中时,我们将执行一个二进制文件,而不是使用go run命令。我们将创建的镜像将只包含运行我们的应用程序所需的软件。在创建镜像时限制安装的软件是 Docker 的最佳实践,因为它通过仅包含必要的软件来减少攻击面。

为 Docker 构建应用程序代码

我们将执行一个与通常的go build略有不同的命令来创建我们的文件:

$ CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -a -installsuffix cgo -ldflags '-s' -o server  

在前面的命令中,我们传递了参数 -ldflags '-s',这个参数在构建应用程序时将 -s 参数传递给链接器,并告诉它静态链接所有依赖项。当我们使用流行的 Scratch 容器作为基础时,这非常有用;Scratch 是最轻的基础之一,它没有任何应用程序框架或应用程序,这与占用约 150MB 的 Ubuntu 相反。Scratch 和 Ubuntu 之间的区别在于 Scratch 没有访问标准 C 库 GLibC 的权限。

如果我们不构建静态二进制文件,那么如果我们尝试在 Scratch 容器中运行它,它将不会执行。这是因为虽然您可能认为您的 Go 应用程序是一个静态二进制文件,但它仍然依赖于 GLibCnetos/user 包都链接到 GLibC,因此如果我们要在 Scratch 基础镜像上运行我们的应用程序,我们需要静态链接这个库。然而,好处是图像非常小,我们最终得到的图像大小大约为 4MB,正好是我们编译的 Go 应用程序的大小。

因为 Docker 引擎是在 Linux 上运行的,所以我们还需要为 Linux 架构构建我们的 Go 可执行文件。即使您使用 Docker for Mac 或 Docker for Windows,底层发生的事情是 Docker 引擎在 HyperV 或 Mac 的 xhyve 虚拟机上运行一个轻量级的虚拟机。

如果您不是使用 Linux 运行 go build 命令,并且由于 Go 具有出色的跨平台编译能力,您不需要做太多。您只需要像我们在前面的示例中做的那样,在 go build 命令前加上架构变量 GOOS=linux GOARCH=386

现在我们已经为我们的应用程序创建了一个二进制文件,让我们来看看 Dockerfile:

1 FROM scratch
2 MAINTAINER jackson.nic@gmail.com
3
4 EXPOSE 8080
5
6 COPY ./server ./
7 
8 ENTRYPOINT ./server  

FROM

FROM 指令为后续指令设置基础镜像。您可以使用存储在远程注册表或本地 Docker 引擎上的任何镜像。当您执行 docker build 时,如果您还没有这个镜像,Docker 将在构建过程的第一个步骤中从注册表中拉取它。FROM 命令的格式与您在发出 docker run 命令时使用的格式相同,它可以是:

  • FROM image // 假设为最新版本

  • FROM image:tag // 其中您可以指定要使用的标签

第 1 行,我们使用的是 image 名称 scratch,这是一种特殊的镜像,基本上是一个空白画布。我们可以使用 Ubuntu、Debian、Alpine 或几乎所有其他东西,但由于我们只需要运行我们的 Go 应用程序本身,因此我们可以使用 scratch 来生成可能最小的镜像。

维护者

MAINTAINER 指令允许您设置生成的镜像的作者。这是一个可选指令;然而,即使您不打算将您的镜像发布到公共注册表,包含这个指令也是一个好的实践。

EXPOSE

EXPOSE 指令通知 Docker 容器在运行时监听指定的网络端口。Expose 不会使端口对主机可访问;此功能仍需要使用 -p 映射来执行。

COPY

COPY 指令将文件从指令第一部分的源复制到第二部分指定的目标:

  • COPY <src> <dest>

  • COPY ["<src>", "<dest>"] // 当路径包含空格时很有用

COPY 指令中的 <src> 可以包含通配符,匹配规则使用 Go 的 filepath.Match 规则。

注意:

  • <src> 必须是构建上下文的一部分,您不能指定相对文件夹,例如 ../;

  • <src> 中指定的根 / 将是上下文的根

  • <dest> 中指定的根 / 将映射到容器的根文件系统

  • 指定没有目标的 COPY 指令将文件或文件夹复制到与原始文件同名的 WORKDIR

ENTRYPOINT

ENTRYPOINT 允许您配置在容器启动时您希望运行的可执行文件。使用 ENTRYPOINT 可以在 docker run 命令中指定参数,这些参数将附加到 ENTRYPOINT

ENTRYPOINT 有两种形式:

  • ENTRYPOINT ["executable", "param1", "param2"] // 优先形式

  • ENTRYPOINT command param1 param2 // shell 形式

例如,在我们的 Docker 文件中,我们指定了 ENTRYPOINT ./server。这是我们想要运行的 Go 可执行文件。当我们使用以下 docker run helloworld 命令启动容器时,我们不需要明确告诉容器执行二进制文件并启动服务器。然而,我们可以通过 docker run 命令的参数传递额外的参数给应用程序;这些参数将在应用程序运行之前附加到 ENTRYPOINT。例如:

$ docker run --rm helloworld --config=/configfile.json  

前一个命令将参数附加到入口点中定义的执行语句,这相当于执行以下 shell 命令:

$ ./server --config=configfile.json  

CMD

CMD 指令有三种形式:

  • CMD ["executable", "param1", "param2"] // exec 形式

  • CMD ["param1", "param2"] // 将默认参数附加到 ENTRYPOINT

  • CMD command param1 param2 // shell 形式

当使用 CMDENTRYPOINT 指令提供默认参数时,应使用 JSON 数组格式指定 CMDENTRYPOINT 指令。

如果我们为 CMD 指定默认值,我们仍然可以通过传递命令参数到 docker run 命令来覆盖它。

Dockerfile 中只允许有一个 CMD 指令。

创建 Dockerfile 的良好实践

考虑到所有这些,我们需要记住 Docker 中的联合文件系统是如何工作的,以及我们如何利用它来创建小型和紧凑的镜像。每次我们在 Dockerfile 中发出命令时,Docker 都会创建一个新的层。当我们修改这个命令时,这个层必须完全重新创建,甚至可能所有后续的层也需要重新创建,这可能会显著减慢你的构建速度。因此,建议你尽可能紧密地分组你的命令,以减少这种情况发生的可能性。

很常见,你会看到 Dockerfile,而不是为每个我们想要执行的命令单独有一个RUN命令,我们使用标准的 bash 格式来链式执行这些命令。

例如,考虑以下内容,它将从包管理器安装软件。

不良实践:

RUN apt-get update
RUN apt-get install -y wget
RUN apt-get install -y curl
RUN apt-get install -y nginx  

良好实践:

RUN apt-get update && \
 apt-get install -y wget curl nginx

第二个示例只会创建一层,这又会进一步创建一个更小、更紧凑的图像,将更改最少的语句放在 Dockerfile 的上方也是一种良好的实践,这样即使这些层没有变化,也可以避免后续层的无效化。

从 Dockerfile 构建镜像

要从我们的 Dockerfile 构建一个镜像,我们可以执行一个简单的命令:

$ docker build -t testserver .  

分解这个-t参数是我们想要赋予容器的标签,它采用 name:tag 的形式,如果我们像示例命令中那样省略了tag部分,那么将自动分配标签latest

如果你运行docker images,你会看到我们的testserver镜像已经被赋予了这个标签。

最后一个参数是我们想要发送给 Docker Engine 的上下文。当你运行 Docker 构建时,上下文会自动转发到服务器。这听起来可能有些奇怪,但你要记住,Docker Engine 可能不会在你的本地机器上运行,因此它将无法访问你的本地文件系统。因此,我们应该小心设置上下文的位置,因为这可能意味着大量数据被发送到引擎,这会减慢速度。上下文然后成为你的COPY命令的根。

现在我们有了正在运行的容器,让我们来测试它。为什么不从一个新构建的镜像中启动一个容器,并通过 curl 端点来检查 API 呢:

$ docker run --rm -p 8080:8080 testserver
$ curl -XPOST localhost:8080/helloworld -d '{"name":"Nic"}'

Docker 构建上下文

当我们运行 Docker 构建命令时,我们将上下文路径设置为最后一个参数。当命令执行时实际上发生的情况是上下文被传输到服务器。如果你有一个大的源文件夹,这可能会导致问题,因此只发送需要打包到容器内或构建容器时需要的文件是一种良好的实践。我们可以通过两种方式来减轻这个问题。第一种是确保我们的上下文只包含我们需要的文件。由于这并不总是可能的,所以我们还有一个次要选项,即使用 .dockerignore 文件。

Docker 忽略文件

在 CLI 将上下文发送到引擎之前,.dockerignore 文件类似于 git 忽略文件,它排除了与 .dockerignore 文件中匹配模式的文件和目录。它使用 Go 的 filepath.Match 规则定义的模式,你可以在以下 Go 文档中找到更多关于它们的信息:godoc.org/path/filepath#Match

规则 行为
# comment 被忽略。
*/temp* 排除根目录任何直接子目录中以 temp 开头的文件和目录。例如,普通文件 /somedir/temporary.txt 被排除,同样 /somedir/temp 目录也被排除。
*/*/temp* 排除根目录下两级以下子目录中以 temp 开头的文件和目录。例如,/somedir/subdir/temporary.txt 被排除。
temp? 排除根目录中名称为 temp 的一字符扩展名的文件和目录。例如,/tempa/tempb 被排除。

docs.docker.com/engine/reference/builder/#/dockerignore-file



在容器中运行守护进程

当你在虚拟机或物理服务器上部署应用程序时,你可能习惯于使用 initdsystemd 这样的守护进程运行器来确保应用程序在后台启动并继续运行,即使它崩溃了。当你使用 Docker 容器时,这是一个反模式,因为 Docker 要成功停止应用程序,它将尝试杀死 PID 为 1 的进程。守护进程通常以 PID 1 启动,并使用另一个进程 ID 启动你的应用程序,这意味着当停止 Docker 容器时,它们不会被杀死。这可能导致在执行 docker stop 命令时容器挂起。

如果你需要确保应用程序即使在崩溃后也能继续运行,那么你可以将这个责任委托给启动你的 Docker 容器的编排器。我们将在稍后的章节中学习更多关于编排的内容。

Docker Compose

这一切都很简单,现在让我们看看 Docker 的一个引人注目的特性,它允许您通过存储在方便的 YAML 文件中的堆栈定义一次性启动多个容器。

在 Linux 上安装 Docker Compose

如果您已经安装了 Docker for Mac 或 Docker for Windows,那么docker-compose已经捆绑在其中了;然而,如果您正在使用 Linux,那么您可能需要自己安装它,因为它不是默认 Docker 包的一部分。

要在 Linux 上安装 Docker Compose,请在您的终端中执行以下命令:

$ curl -L https://github.com/docker/compose/releases/download/1.8.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose  && chmod +x /usr/local/bin/docker-compose  

在我们看看如何使用docker-compose运行我们的应用程序之前,让我们看看我们将要运行的文件以及它的一些重要方面:

1 version: '2' 
2 services: 
3   testserver: 
4     image: testserver 
5   curl: 
6     image: appropriate/curl 
7     entrypoint: sh -c  "sleep 3 && curl -XPOST testserver:8080/helloworld -d '{\"name\":\"Nic\"}'" 

Docker Compose 文件是用 YAML 编写的,在这个文件中,您可以定义将构成您应用程序的服务。在我们的简单示例中,我们只描述了两个服务。第一个是我们刚刚构建的示例代码,第二个是一个简单的服务,它会向这个 API 发送 curl 请求。我承认,作为一个生产示例,这并不特别有用,但它只是用来展示如何设置这些文件。随着我们进入后面的章节,我们将大量依赖这些文件来创建我们的数据库和其他数据存储,这些数据存储构成了我们的应用程序。

第 1 行定义了我们使用的 Docker Compose 文件的版本,版本 2 是最新版本,与版本 1 相比是一个重大变化,--link指令现在已弃用,将在未来的版本中删除。

第 2 行中我们定义了服务。服务是您希望与堆栈一起启动的容器。每个服务都必须在 compose 文件中有一个唯一的名称,但不必与在您的 Docker Engine 上运行的容器名称相同。为了避免在启动堆栈时发生冲突,我们可以将-p projectname传递给docker-compose up命令;这将给我们的任何容器的名称前加上指定的项目名称。

您需要为服务指定的最小信息是镜像,这是您希望从中启动容器的镜像。与docker run的工作方式相同,这可以是 Docker Engine 上的本地镜像,也可以是远程注册表中镜像的引用。当您启动堆栈时,compose 将检查镜像是否本地可用,如果不可用,它将自动从注册表中拉取。

第 6 行定义了我们的第二个服务;这只是一个简单地执行命令来向第一个服务暴露的 API 发送 curl 请求。

在这个服务定义块中,我们既指定了镜像又指定了入口点。

服务启动

之前的命令看起来有点奇怪,但 Docker Compose 有一个陷阱,很多人都会犯这个错误,那就是 compose 没有真正的方法知道应用程序何时正在运行。即使我们使用了 depends-on 配置,我们只是在通知 compose 存在依赖关系,并且它应该控制服务的启动顺序。

sh -c  "sleep 3 && curl -XPOST testserver:8080/helloworld -d '{\"name\":\"Nic\"}'"

Compose 所做的只是检查容器是否已启动。一般问题发生在对容器启动等于它准备好接收请求的误解。通常情况下并非如此,应用程序启动并准备好接受请求可能需要一些时间。如果你有一个像我们在 curl 入口点指定的那样依赖另一个服务的端点,那么我们不能假设在执行我们的命令之前,依赖的服务已经准备好接收请求。我们将在 第六章 “微服务框架”中介绍处理这种模式的方法,但现在我们可以意识到:

"容器已启动,服务已准备好并不等同于它已经准备好接收请求。"

在我们的简单示例中,我们知道服务启动大概需要一秒钟左右的时间,所以我们只需等待三秒钟,给服务足够的时间准备,然后再执行我们的命令。这种方法并不是一个好的实践,它只是为了说明我们如何使用 compose 来连接服务。实际上,你很可能不会在你的 compose 文件中启动单个命令,就像我们在这里做的那样。

当你使用 Docker 网络,Docker 会自动将映射添加到容器的 resolve.conf 文件中,指向内置的 Docker DNS 服务器,然后我们可以通过名称引用连接到同一网络的其他容器。查看我们的 curl 命令,这个 DNS 功能正是允许我们使用主机名 testserver 的原因。

好的,是时候测试一下了,从您的终端运行以下命令:

$ docker-compose up  

如果一切顺利,你应该在输出中看到以下消息:

{"message":"Hello Nic"}  

Ctrl + C 将退出 compose,然而,由于我们使用 docker run 命令并传递了 --rm 参数来删除容器,我们需要确保自己清理。要删除使用 docker-compose 启动的任何已停止容器,我们可以使用特定的 compose 命令 rm 并传递 -v 参数来删除任何相关卷:

$ docker-compose rm -v  

指定 compose 文件的存储位置

每次运行 docker-compose 时,它都会在当前文件夹中查找名为 docker-compose.yml 的文件作为默认文件。要指定一个不同的文件,我们可以向 compose 传递 -f 参数,并指定我们想要加载的 compose 文件路径:

$ docker-compose -f ./docker-compose.yml up  

指定项目名称

正如我们之前在启动docker-compose时讨论的那样,它将创建在您的 Compose 文件中给定名称的服务,并在其后面附加项目名称default。如果我们需要运行多个此 Compose 文件实例,那么docker-compose不会启动另一个实例,因为它会先检查是否有任何服务以给定名称运行。为了覆盖这一点,我们可以指定项目名称,以替换默认的default名称。为此,我们只需在命令中指定-p projectname参数,如下所示:

$ docker-compose -p testproject up  

这将创建两个容器:

  • testproject_testserver

  • testproject_curl

总结

总结来说,在本章中我们学习了如何使用 Docker,虽然这只是一个简要概述,但我建议您查阅文档,更深入地了解 Dockerfile、Composefile、Docker Engine 和 Docker Compose 的概念。Docker 是开发、测试和生产中不可或缺的工具,随着我们进入接下来的章节,我们将广泛使用这些概念。在下一章,我们将探讨测试,这将建立在您迄今为止所学的一切之上。

第四章:测试

当你试图定义什么是测试时,你会得到各种各样的答案,我们中的许多人直到被有缺陷的软件烧伤或试图更改没有测试的复杂代码库时,才真正理解测试的全部好处。

当我试图定义测试时,我得到了以下定义:

“良好的夜晚睡眠的艺术是知道你不会因为支持电话而被吵醒,以及在不断变化的市场中能够自信地更改你的软件所带来的安心感。”

好吧,所以我试图讲一个笑话,但这个概念是正确的。没有人喜欢调试糟糕的代码,确实,没有人喜欢系统失败时带来的压力。从质量第一的咒语开始可以缓解许多这些问题。

在过去的 20 年里,像 TDD 这样的技术已经变得司空见惯。在某些情况下,它并不像我希望的那样普遍,但至少人们现在在谈论测试了。在某种程度上,我们要感谢敏捷联盟:

逐步释放的原则提供了显著的商业效益;然而,频繁地逐步释放的缺点(或从你的观点来看是好处)是,你不能再在发布到市场之前花上三个月的时间运行回归测试套件。

在我的办公室里,上下文切换是最大的抱怨之一。没有人喜欢放下手头的工作去调查可能是由他们或甚至是一个同事几个月或几年前完成的工作中的问题。我们希望向前推进;为了确保我们能这样做,我们必须确保我们之前交付的内容符合规格,并且质量足够高以满足客户的要求。

我还提到了定义上的变化,而变化的最大问题之一是担心你正在进行的更改可能会对系统的另一部分产生不良影响。这种影响适用于微服务,也适用于大型单体系统。

如果我告诉你,易于测试的代码的副作用可能是编写得很好的代码,这种代码松散耦合并且具有正确的抽象?

然而,测试并不仅仅是关于开发者的:确实需要有人从代码库中分离出来进行手动测试。这种探索性测试可以揭示缺失的需求或错误的假设。本身,这是一个专业领域,远远超出了本书的范围,所以我们将会集中讨论你应该进行的测试类型。

测试金字塔

Mike Cohn 因在其书籍《成功实施敏捷》中创造了测试金字塔的概念而受到赞誉。这个概念是,你运行成本最低(最快)的测试,即单元测试,位于金字塔的底部;服务级别集成测试位于此之上,而在金字塔的顶端,你放置完整的端到端测试,这是成本最高的元素。因为这是一个金字塔,随着你向上移动金字塔,测试的数量会减少。

图片

在自动化测试的早期,所有的测试都是在金字塔的顶端完成的。虽然从质量角度来看这确实有效,但它意味着调试有问题的区域的过程将会极其复杂且耗时。如果你很幸运,可能会出现完全失败的情况,这可以通过堆栈跟踪来追踪。如果你不幸,那么问题可能是行为上的;即使你完全了解系统,你也可能需要翻阅数千行代码并手动重复操作来重现失败。

从外向内开发

在编写测试时,我喜欢遵循一个称为从外向内开发的过程。在从外向内开发中,你首先几乎在金字塔的顶端编写测试,确定你正在工作的故事的功能,然后为这个故事编写一些失败的测试。然后你开始实现单元测试和代码,这开始让行为测试的各个步骤通过。

图片

这个初始规范也成为了你系统的活文档。我们将在本章后面更详细地介绍如何创建它,但大多数情况下,它是以Gherkin这样的语言编写的,并且是由一个包含领域专家(如产品所有者、开发人员和测试专家)的团队共同定义的。Gherkin 背后的意图是创建一个每个人都理解的全局语言。这种普遍使用的语言使用具有特殊意义的动词和名词,几乎总是特定于领域,但也应该对外部人员是可理解的。

Feature: As a user when I call the search endpoint, I would like to receive a list of kittens

功能是敏捷环境中由产品所有者拥有的故事。然后功能被分解为场景,这些场景更详细地说明了代码必须具备的、可接受的特性。

Scenario: Invalid query
 Given I have no search criteria 
 When I call the search endpoint 
 Then I should receive a bad request message 

当我们稍后进入 BDD 部分时,我们将更深入地探讨这个问题;我们还将查看一个用于编写和执行 Cucumber 规范的 Go 框架。现在,然而,我将通过向您展示如何在 Go 中编写优秀的单元测试来打破“自外向内”开发规则。我们即将学习的概念在我们开始研究 BDD 时将非常有用,因此我认为最好我们先了解它们。像前面的章节一样,在阅读本章时,手头有源代码会很有用;您可以从以下位置克隆代码:github.com/building-microservices-with-go/chapter4.git

单元测试

我们的单元测试直接到底层的金字塔。这本书从未打算成为 TDD 的教程,有太多更好的地方可以学习。然而,我们将查看 Go 中内置的测试框架。在我们这样做之前,让我们先提醒自己由 Bob Martin 叔叔在他的书《Clean Code》中定义的三个测试定律:

  • 第一定律:在你编写失败的单元测试之前,你不得编写生产代码

  • 第二定律:你不得编写超过足够失败的单元测试,不编译即视为失败

  • 第三定律:你不得编写超过足够通过当前失败的测试的生产代码

在 Go 中对微服务进行测试的最有效方法之一不是陷入通过 HTTP 接口执行所有测试的陷阱。我们需要开发一种模式,避免为测试我们的处理程序创建物理 Web 服务器,创建这种测试的代码运行速度慢,编写起来极其繁琐。我们需要做的是将我们的处理程序及其内部的代码作为单元测试来测试。这些测试将比通过 Web 服务器进行测试运行得更快,如果我们考虑覆盖率,我们将在执行请求到运行服务器的 Cucumber 测试中测试处理程序的连接,这总体上为我们提供了代码的 100%覆盖率。

main.go

  10 func main() { 
  11   err := http.ListenAndServe(":2323", &handlers.SearchHandler{}) 
  12   if err != nil { 
  13     log.Fatal(err) 
  14   } 
  15 } 

你将在主函数中看到我们将处理程序拆分到一个单独的包中。以这种方式拆分代码允许我们单独测试这些处理程序,所以让我们继续为我们的SearchHandler编写一些单元测试。

习惯上,我们将在与它们所属的包相同的文件夹中定义我们的测试文件,并将它们命名为它们要测试的文件名,后面跟_test。在我们的例子中,我们将为位于handlers/search.go文件中的SearchHandler编写一些测试;因此,我们的测试文件将被命名为handlers/search_test.go

测试方法的签名看起来像这样:

func TestXxx(*testing.T) 

测试名称必须有一个特定的名称,以Test开头,然后紧接着是一个大写字母或数字。因此,我们不能将我们的测试命名为TestmyHandler,但我们可以将其命名为Test1HandlerTestMyHandler

再次,借鉴 Uncle Bob 的智慧,我们需要像对待生产代码中的方法名称一样仔细考虑我们的测试名称。

我们将要编写的第一个测试是验证请求中是否已发送搜索条件,其实现将如下所示:

   9 func TestSearchHandlerReturnsBadRequestWhenNoSearchCriteriaIsSent(t *testing.T) { 
  10   handler := SearchHandler{} 
  11   request := httptest.NewRequest("GET", "/search", nil) 
  12   response := httptest.NewRecorder() 
  13 
  14   handler.ServeHTTP(response, request) 
  15 
  16   if response.Code != http.StatusBadRequest { 
  17     t.Errorf("Expected BadRequest got %v", response.Code) 
  18   } 
  19 } 

net/http/httptest包为我们提供了两个出色的方便方法NewRequestNewResponse,如果你熟悉单元测试的概念,那么其中一个基本原理是隔离依赖。我们经常用 Mock 或 Spies 替换依赖项,这样我们就可以在不执行依赖项中的代码的情况下测试我们的代码的行为。这两个函数使我们能够做到这一点;它们生成依赖对象的 Mock 版本http.Requesthttp.ResponseWriter

httptest.NewRequest

我们需要特别注意的第一行是第11行:net/http/httptest包为我们提供了一些方便的方法。NewRequest方法返回一个传入的服务器请求,然后我们可以将其传递给我们的http.Handler

func NewRequest(method, target string, body io.Reader) *http.Request 

我们可以向方法和方法的目标传递参数,目标可以是路径或绝对 URL。如果我们只传递一个路径,那么example.com将用作我们的主机设置。最后,我们可以提供一个io.Reader文件,它将对应于请求的主体;如果我们不传递 nil 值,则Request.ContentLength被设置。

httptest.NewRecorder

在第12行,我们正在创建一个ResponseRecorder类型:这将是我们传递给处理器的ResponseWriter实例。因为处理器没有返回函数来验证正确操作,我们需要检查已经写入输出的内容。ResponseRecorder类型是http.ResponseWriter的一个实现,它正是这样做的:它记录我们所做的所有更改,以便稍后可以对它进行断言。

type ResponseRecorder struct { 
         Code      int           // the HTTP response code from WriteHeader 
         HeaderMap http.Header   // the HTTP response headers 
         Body      *bytes.Buffer // if non-nil, the bytes.Buffer to append written data to 
         Flushed   bool 
         // contains filtered or unexported fields 
 } 

我们需要做的只是调用ServeHTTP方法,并带上我们的模拟请求和响应,然后断言我们得到了正确的结果。

Go 没有像 RSpec 或 JUnit 那样的断言库。我们将在本章后面部分查看第三方框架,但到目前为止,让我们专注于标准包。

在第16行,我们正在检查从处理器返回的响应代码是否等于预期的代码http.BadRequest。如果不是,那么我们就调用测试框架上的 Errorf 方法。

ErrorF

func (c *T) Errorf(format string, args ...interface{}) 

Errorf函数接受一个格式字符串的参数和一个可变数量的参数列表;在调用Fail之前,它内部调用Logf方法。

如果我们通过运行命令go test -v -race ./...来运行我们的测试,我们应该看到以下输出:

=== RUN   TestSearchHandlerReturnsBadRequestWhenNoSearchCriteriaIsSent
--- FAIL: TestSearchHandlerReturnsBadRequestWhenNoSearchCriteriaIsSent (0.00s)
 search_test.go:17: Expected BadRequest got 200
 FAIL
 exit status 1
 FAIL    github.com/nicholasjackson/building-microservices-in-
 go/chapter5/handlers    0.016s

-v 标志将以详细模式打印输出,并且它还会打印出应用程序写入输出的所有文本,即使测试成功。

-race 标志启用了 Go 的竞态检测器,它可以检测并发问题中的错误。当两个 Go 线程同时访问同一个变量,并且至少有一个访问是写入时,就会发生数据竞争。竞态标志会给你的测试运行增加一点开销,所以我建议你将其添加到所有执行中。

使用 -./... 作为我们的最终参数允许我们在当前文件夹以及子文件夹中运行所有测试,这使我们免去了手动构建要测试的包或文件列表的麻烦。

现在我们有一个失败的测试,我们可以继续编写实现来使测试通过:

 18   decoder := json.NewDecoder(r.Body) 
 19   defer r.Body.Close() 
 20 
 21   request := new(searchRequest) 
 22   err := decoder.Decode(request) 
 23   if err != nil { 
 24     http.Error(rw, "Bad Request", http.StatusBadRequest) 
 25     return 
 26   } 

当我们重新运行测试时,我们可以看到它们已经成功:

=== RUN   TestSearchHandlerReturnsBadRequestWhenNoSearchCriteriaIsSent
--- PASS: TestSearchHandlerReturnsBadRequestWhenNoSearchCriteriaIsSent (0.00s)
PASS
ok      github.com/nicholasjackson/building-microservices-in-go/chapter5/handlers    1.022s

这个输出很棒;但如果将空字符串传递给请求被视为失败呢?是时候写另一个测试了:

  23 func TestSearchHandlerReturnsBadRequestWhenBlankSearchCriteriaIsSent(t *testing.T) { 
  24   handler := SearchHandler{} 
  25   data, _ := json.Marshal(searchRequest{}) 
  26   request := httptest.NewRequest("POST", "/search", bytes.NewReader(data)) 
  27   response := httptest.NewRecorder() 
  28 
  29   handler.ServeHTTP(response, request) 
  30 
  31   if response.Code != http.StatusBadRequest { 
  32     t.Errorf("Expected BadRequest got %v", response.Code) 
  33   } 
  34 } 

这个测试与上一个非常相似;唯一的区别是我们正在将一些 JSON 传递到请求体中。虽然这个测试会正确失败,但我们应该从 Uncle Bob 那里吸取教训,对这个测试进行重构以使其更易于阅读:

  21 func TestSearchHandlerReturnsBadRequestWhenBlankSearchCriteriaIsSent(t *testing.T) { 
  22   r, rw, handler := setupTest(&searchRequest{}) 
  23 
  24   handler.ServeHTTP(rw, r) 
  25 
  26   if rw.Code != http.StatusBadRequest { 
  27     t.Errorf("Expected BadRequest got %v", rw.Code) 
  28   } 
  29 } 

我们已经重构了测试,添加了一个共享于两个测试的设置方法,其背后的意图是保持我们的测试专注于三个核心领域:

  • 设置

  • 执行

  • 断言

重复代码的糟糕测试可能比糟糕的代码更糟糕:你的测试应该是清晰、易于理解的,并且包含你会在生产代码中添加的相同关注。

现在,如果测试失败,我们可以继续更新我们的代码来实现这个功能:

23 if err != nil || len(request.Query) < 1 { 
24     http.Error(rw, "Bad Request", http.StatusBadRequest) 
25     return 
26   } 

我们需要做的只是对 if 语句进行简单的修改。随着我们的系统复杂性增加,我们发现更多关于构成无效搜索查询的情况,我们将将其重构为单独的方法;但,目前,这是我们使测试通过所需的最小修改。

依赖注入和模拟

为了使返回 Search 处理器项的测试通过,我们需要一个数据存储。无论我们是在数据库中实现数据存储还是在简单的内存存储中实现,我们都不想在实际的数据存储上运行我们的测试,因为我们将会检查数据存储和我们的处理器。因此,我们需要管理处理器上的依赖,以便在测试中替换它们。为此,我们将使用一种称为依赖注入的技术,我们将通过传递依赖项到处理器而不是在内部创建它们来实现这一点。

这个方法允许我们在测试处理器时用存根或模拟来替换这些依赖,这样我们就可以控制依赖的行为并检查调用代码对此的反应。

在我们做任何事情之前,我们需要创建我们的依赖。在我们的简单示例中,我们将创建一个内存数据存储,它有一个单独的方法:

Search(string) []Kitten 

要用模拟替换类型,我们需要将我们的处理器修改为依赖于代表我们的数据存储的接口。然后我们可以用实际的数据存储或存储的模拟实例来交换,而无需更改底层代码:

 type Store interface { 
     Search(name string) []Kitten 
 } 

我们现在可以继续创建这个实现的代码。由于这是一个简单的示例,我们将将小猫的列表硬编码为一个切片,并且搜索方法将只从匹配作为参数给出的标准的小猫名称的切片中进行选择。

好的,太棒了;我们现在已经创建了数据存储,所以让我们看看我们将如何修改我们的处理器以接受这个依赖项。这很简单:因为我们创建了一个实现了 ServeHTTP 方法的结构体,我们只需将我们的依赖项添加到这个结构体上:

 Search { 
     Store data.Store 
 } 

注意我们是如何使用接口的引用而不是具体类型,这允许我们将这个对象与实现存储接口的任何东西进行交换。

现在,回到我们的单元测试:我们希望确保,当我们用搜索字符串调用 ServeHTTP 方法时,我们正在查询数据存储并返回其中的小猫。

要做到这一点,我们将创建数据存储的模拟实例。我们可以自己创建模拟;然而,Matt Ryer 有一个非常好的包,他碰巧也是 Packt 的作者。Testify (github.com/stretchr/testify.git) 有一个功能齐全的模拟框架,带有断言。它还有一个用于测试测试中对象相等性的优秀包,并消除了我们不得不编写的许多样板代码。

在数据包中,我们将创建一个名为 mockstore.go 的新文件。这个结构将是我们的数据存储模拟实现:

5 // MockStore is a mock implementation of a datastore for testing purposes 
6 type MockStore struct { 
7   mock.Mock 
8 } 
9 
10 //Search returns the object which was passed to the mock on setup 
11 func (m *MockStore) Search(name string) []Kitten { 
12   args := m.Mock.Called(name) 
13 
14   return args.Get(0).([]Kitten) 
15 } 

在第 6 行,我们定义了我们的 MockStore 对象。这没有什么不寻常的,除了你会注意到它嵌入了 mock.Mock 类型。嵌入 Mock 将给我们 mock.Mock 结构体上的所有方法。

当我们编写搜索方法的实现时,我们首先调用 Called 方法,并传递发送到 Search 的参数。内部,模拟包正在记录这个方法被调用以及传递了什么参数,这样我们就可以稍后针对它编写断言:

 args := m.Mock.Called(name) 

最后,我们返回 args.Get(0).(Kitten)。当我们调用 Called 方法时,模拟返回我们设置中提供的参数列表。我们将这些参数转换为我们的输出类型,并返回给调用者。让我们快速看一下测试方法,看看它是如何工作的。

57 行是测试设置的开始。我们首先要做的是创建我们的 MockStore 实例。然后,我们将这个实例设置为 Search 处理器的依赖项。如果我们回溯到文件的第 38 行,你会看到我们在 mockStore 上调用 On 方法。On 方法是模拟的设置方法,其签名如下:

func (c *Call) On(methodName string, arguments ...interface{}) *Call 

如果我们不使用参数Search调用On方法,那么当我们调用代码中的Search方法时,测试将抛出一个异常,表示Search已被调用但尚未设置。我喜欢使用模拟而不是简单的存根的一个原因就是这种能力,可以断言一个方法已被调用,并且我们可以明确地规定代码被测试可以表现出的行为。这样我们可以确保我们没有做那些输出未经测试的工作。

在我们的例子中,我们设置的条件是,当Search方法使用参数Fat Freddy's Cat被调用时,我们希望返回一个包含小猫的数组。

假设我们在数据存储上调用Search方法,并传递 HTTP 响应中发送的查询。以这种方式使用断言是一种方便的技术,因为它允许我们测试不愉快的路径,例如当数据存储可能由于内部错误或其他原因而无法返回数据时。如果我们试图使用集成测试来测试这个,可能会很难说服数据库按需失败。

为什么不花五分钟作为一个小练习,然后继续编写这段代码来完成?

所有这些都工作了吗?如果没有,别担心,你只需查看示例代码,看看你哪里出了错,但我希望这个过程是有用的。你可以看到,你可以通过两层测试来采取一种有节制的做法,从而产生一个可工作的应用程序。这些测试现在是你的安全网:无论何时你更改代码以添加新功能,你都可以确信你没有无意中破坏了某些东西。

代码覆盖率

代码覆盖率是一个很好的指标,可以确保你编写的代码有足够的覆盖率。

获取测试覆盖率的最简单方法是在执行测试时使用-cover选项:

go test -cover ./...  

如果我们在示例代码根目录中运行这段代码,我们会看到以下输出:

$go test -cover ./...
? github.com/building-microservices-with-go/chapter4 [no test files]
ok github.com/building-microservices-with-go/chapter4/data 0.017s coverage: 20.0% of statements
ok github.com/building-microservices-with-go/chapter4/features 0.018s coverage: 0.0% of statements [no tests to run]
ok github.com/building-microservices-with-go/chapter4/handlers 0.018s coverage: 100.0% of statements

现在我们处理器的样子看起来很漂亮:我们对这个包的覆盖率达到了 100%。然而,我们的数据包只报告了 20%的覆盖率。在我们对此过于担忧之前,让我们看看我们正在尝试测试的内容。

如果我们首先检查datastore.go文件,这只是一个接口,因此没有测试文件;然而,memorystore.go有。这个文件对这个文件的测试覆盖率达到了 100%。让我们失望的文件是我们的模拟类和我们的 MongoDB 实现。

现在我对模拟类型不太关心,但 Mongo 存储是一个有趣的问题。

由于依赖于 MongoDB,这种类型将非常难以测试。我们可以创建 Mongo 包的模拟实现来测试我们的代码,但这可能比实现更复杂。然而,在这个类中,我们有一些关键区域可能会出错。考虑第26行:

c := s.DB("kittenserver").C("kittens") 

这行代码从数据库kittenserver中检索集合kittens。如果我们在这里犯了一个简单的拼写错误,那么我们的应用程序将无法工作。我们不希望等到代码进入生产环境后才发现这个问题。我们也不想手动测试这个,因为在更大的应用程序中,这可能会非常耗时。集成测试确实是我们的 Cucumber 测试大放异彩的地方。如果你还记得,我们正在编写一些非常高级的端到端测试,以确保我们的 API 输入产生正确的输出。因为这是针对实际数据库运行的,如果我们犯了这样的错误,那么它就会被捕捉到。所以,虽然 Go 覆盖率报告显示我们没有覆盖,但这是因为我们有更高层次的测试,Go 测试没有查看,所以我们是有覆盖的。我们可能遇到问题的核心区域是省略了第23行。

如果我们在打开数据库后没有关闭连接,我们将泄漏连接;过一段时间后,我们可能会发现我们无法再打开另一个,因为连接池已经耗尽。没有简单的方法来测试这个问题,但是,然而,有一种方法可以在部署后捕捉到这个问题。当我们查看第七章中的日志和监控 Chapter 7,日志和 监控时,我们将看到我们如何将这些信息暴露出来,以帮助我们确保我们的生产系统正常运行。

行为驱动开发

行为驱动开发BDD)是一种通常由名为Cucumber的应用框架执行的技术。它是由丹·诺斯(Dan North)开发的,旨在在开发人员和产品所有者之间建立一个共同的基础。从理论上讲,应该可以使用 BDD 对系统的完整覆盖进行测试;然而,由于这将创建大量运行缓慢的测试,这不是最佳方法。我们应该做的是定义我们系统的边界,并且我们可以将粒度留给我们的单元测试。

在我们的“三个朋友”小组中,我们讨论特性的各个方面以及它的基本质量,并开始编写场景。

悲伤路径

Scenario: User passes no search criteria    
  Given I have no search criteria 
  When I call the search endpoint 
  Then I should receive a bad request message 

快乐路径

Scenario: User passes valid search criteria    
  Given I have valid search criteria 
  When I call the search endpoint 
  Then I should receive a list of kittens 

这些场景是一个非常简单的例子,但我认为你可以理解,当与非技术人员使用这种语言时,提出这些描述将会非常直接。从自动化的角度来看,我们接下来要做的就是编写与每个GivenWhenThen语句相对应的步骤。

对于这本书,我们将探讨 GoDog 框架,它允许我们使用 Go 实现步骤定义。我们首先需要安装应用程序,你可以通过运行以下命令来完成:fgo get github.com/DATA-DOG/godog/cmd/godog

如果我们查看features/search.feature,我们可以看到我们已经实现了这个功能和场景。

如果我们在创建特性之前运行 godog ./ 命令来运行这些测试,我们应该看到以下错误信息:

Feature: As a user when I call the search endpoint, I would like to receive a list of kittens

Scenario: Invalid query                       
# features/search.feature:4
 Given I have no search criteria
 When I call the search endpoint
 Then I should receive a bad request message

Scenario: Valid query                     
# features/search.feature:9
 Given I have valid search criteria
 When I call the search endpoint
 Then I should receive a list of kittens

2 scenarios (2 undefined)
6 steps (6 undefined)
321.121µs

您可以使用这些片段为未定义的步骤实现步骤定义:

func iHaveNoSearchCriteria() error { 
    return godog.ErrPending 
} 

func iCallTheSearchEndpoint() error { 
    return godog.ErrPending 
} 

func iShouldReceiveABadRequestMessage() error { 
    return godog.ErrPending 
} 

func iHaveAValidSearchCriteria() error { 
    return godog.ErrPending 
} 

func iShouldReceiveAListOfKittens() error { 
    return godog.ErrPending 
} 

func FeatureContext(s *godog.Suite) { 
    s.Step(`^I have no search criteria$`, iHaveNoSearchCriteria) 
    s.Step(`^I call the search endpoint$`, iCallTheSearchEndpoint) 
    s.Step(`^I should receive a bad request message$`, iShouldReceiveABadRequestMessage) 
    s.Step(`^I have a valid search criteria$`, iHaveAValidSearchCriteria) 
    s.Step(`^I should receive a list of kittens$`, iShouldReceiveAListOfKittens) 
} 

有用的是,这为我们执行步骤提供了模板;一旦我们实现这一点并重新运行命令,我们会得到不同的消息:

Feature: As a user when I call the search endpoint, I would like to receive a list of kittens

 Scenario: Invalid query                       # search.feature:4
 Given I have no search criteria             # search_test.go:6 -> github.com/nicholasjackson/building-microservices-in-go/chapter5/features.iHaveNoSearchCriteria
 TODO: write pending definition
 When I call the search endpoint
 Then I should receive a bad request message

 Scenario: Valid query                     # search.feature:9
 Given I have a valid search criteria    # search_test.go:18 -> github.com/nicholasjackson/building-microservices-in-go/chapter5/features.iHaveAValidSearchCriteria
 TODO: write pending definition
 When I call the search endpoint
 Then I should receive a list of kittens

 2 scenarios (2 pending)
 6 steps (2 pending, 4 skipped)
 548.978µs  

我们现在可以开始填写那些应该失败的步骤的详细信息,因为我们还没有编写代码。

我们可以使用纯 Go 实现我们的代码,这使我们能够使用任何接口和包。看看与 iCallTheSearchEndpoint 方法对应的示例:

23 func iCallTheSearchEndpoint() error { 
24   var request []byte 
25 
26   response, err = http.Post("http://localhost:2323", "application/json", bytes.NewReader(request)) 
27   return err 
28 } 
29 
30 func iShouldReceiveABadRequestMessage() error { 
31   if response.StatusCode != http.StatusBadRequest { 
32     return fmt.Errorf("Should have recieved a bad response") 
33   } 
34 
35   return nil 
36 } 

现在我们实现了一些测试,我们应该运行 Cucumber 测试,因为一些步骤应该通过。为了测试系统,我们需要启动我们的主要应用程序;我们可以将主函数拆分出来成为一个 StartServer 函数,这个函数可以直接从 Cucumber 中调用。然而,这忽略了我们在主函数中忘记调用 StartServer 的事实。因此,最好的方法是使用 Cucumber 测试从外部测试整个应用程序。

要做到这一点,我们将在 features/search_test.go 文件中添加几个新的函数:

59 func FeatureContext(s *godog.Suite) { 
60   s.Step(`^I have no search criteria$`, iHaveNoSearchCriteria) 
61   s.Step(`^I call the search endpoint$`, iCallTheSearchEndpoint) 
62   s.Step(`^I should receive a bad request message$`, iShouldReceiveABadRequestMessage) 
63   s.Step(`^I have a valid search criteria$`, iHaveAValidSearchCriteria) 
64   s.Step(`^I should receive a list of kittens$`, iShouldReceiveAListOfKittens) 
65 
66   s.BeforeScenario(func(interface{}) { 
67     startServer() 
68     fmt.Printf("Server running with pid: %v", server.Process.Pid) 
69   }) 
70 
71   s.AfterScenario(func(interface{}, error) { 
72     server.Process.Kill() 
73   }) 
74 } 
75 
76 var server *exec.Cmd 
77 
78 func startServer() { 
79   server = exec.Command("go", "run", "../main.go") 
80   go server.Run() 
81   time.Sleep(3 * time.Second) 
82 } 

在第 66 行,我们正在使用 godog 上的 BeforeScenario 方法:这允许我们在场景开始之前运行一个函数。我们会用这个来清理数据存储中的任何数据,但在我们的简单示例中,我们只是将要启动应用程序服务器。在本章的后面部分,我们将查看一个更复杂的示例,该示例使用 Docker Compose 启动包含我们的服务器和数据库的容器堆栈。

startServer 函数会启动一个新的进程来运行 go run ../main.go。我们必须在 gofunc 中运行这个命令,因为我们不希望测试被阻塞。第 81 行包含一个短暂的暂停,以查看我们的服务器是否已启动。实际上,我们应该检查 API 的健康端点,但现在这已经足够了。

71 将在场景结束后执行并拆解我们的服务器。如果我们不这样做,那么下次我们尝试启动服务器时将会失败,因为进程已经运行并且绑定到了端口。

让我们继续运行我们的 Cucumber 测试,输出应该看起来像这样:

Feature: As a user when I call the search endpoint, I would like to receive a list of kittens
 Server running with pid: 91535
 Scenario: Invalid query                       # search.feature:4
 Given I have no search criteria             # search_test.go:17 -> github.com/building-microservices-with-go/chapter4/features.iHaveNoSearchCriteria
 When I call the search endpoint             # search_test.go:25 -> github.com/building-microservices-with-go/chapter4/features.iCallTheSearchEndpoint
 Then I should receive a bad request message # search_test.go:32 -> github.com/building-microservices-with-go/chapter4/features.iShouldReceiveABadRequestMessage
 Server running with pid: 91615
 Scenario: Valid query                     # search.feature:9
 Given I have a valid search criteria    # search_test.go:40 -> github.com/building-microservices-with-go/chapter4/features.iHaveAValidSearchCriteria
 Do not have a valid criteria
 When I call the search endpoint
 Then I should receive a list of kittens

 --- Failed scenarios:

 search.feature:10

 2 scenarios (1 passed, 1 failed)
 6 steps (3 passed, 1 failed, 2 skipped)
 6.010954682s
 make: *** [cucumber] Error 1  

完美!我们正在取得进展,一些步骤现在正在通过,其中一个特性正在通过。我们现在可以继续完成这些测试,但首先,我们需要看看我们如何使用 Docker Compose 来测试真实数据库。

使用 Docker Compose 进行测试

到目前为止,这是一个相对简单的实现,但作为一个现实世界的例子,它并不特别有用。你找到自己只包含三个项目的内存数据存储的情况将会非常罕见。通常情况下,你将使用一个功能数据库。当然,真实数据库和我们的代码之间的集成需要测试;我们需要确保数据存储的连接是正确的,并且我们发送给它的查询是有效的。

要做到这一点,我们需要启动一个真实数据库,而为了做到这一点,我们可以使用 Docker-Compose,因为它是一种启动依赖项的绝佳方式。

在我们的示例文件 docker-compose.yml 中,我们有以下内容:

version: '2' 
services: 
  mongodb: 
    image: mongo 
    ports: 
      - 27017:27017 

当我们运行 docker-compose up 命令时,我们将下载 MongoDB 的镜像并启动一个实例,在本地主机上暴露这些端口。

现在,我们需要在我们的项目中创建一个新的结构体,该结构体将实现存储接口。然后我们可以对真实数据库执行命令,而不是使用模拟或简单的内存存储。

MongoStore 的实现相当直接。查看 data/monogstore.go 文件,你会看到我们有两个未在接口中定义的额外方法,即:

DelleteAllKittens 
InsertKittens 

这些内容在这里是因为我们需要它们来设置我们的功能测试。

如果我们查看我们的文件 features/search_test.go,你会看到我们在设置中添加了对 FeatureContext 方法的几个额外调用。

我们首先要做的是调用 waitForDB 方法:因为我们无法控制我们的 Mongo 实例何时准备好接受连接,所以在启动测试之前我们需要等待它。这个过程是这样的:我们将尝试使用便利方法 NewMongoStore 创建一个 MongoStore 实例,内部它执行以下工作:

10 // NewMongoStore creates an instance of MongoStore with the given connection string 
11 func NewMongoStore(connection string) (*MongoStore, error) { 
12   session, err := mgo.Dial(connection) 
13   if err != nil { 
14     return nil, err 
15   } 
16 
17   return &MongoStore{session: session}, nil 
18 } 

Dial 方法尝试连接到连接字符串中指定的 MongoDB 实例。如果连接失败,则返回错误。在我们的代码中,如果我们收到错误,我们将这个错误返回给 NewMongoStore 的调用者,并返回我们结构体的 nil 实例。waitForDB 方法通过重复尝试创建此连接直到不再收到错误来工作。为了避免在数据库尝试启动时进行垃圾邮件发送,我们每次失败尝试后都会暂停一秒钟,最多暂停 10 秒。这个方法将阻塞主 Go 线程,但这是由设计决定的,因为我们不希望在确保我们有了这个连接之前执行测试:

 98 func waitForDB() { 
 99   var err error 
100 
101   for i := 0; i < 10; i++ { 
102     store, err = data.NewMongoStore("localhost") 
103     if err == nil { 
104       break 
105     } 
106     time.Sleep(1 * time.Second) 
107   } 
108 } 

我们还在 BeforeScenario 设置中添加了一些代码:我们首先要做的是清除我们的数据库,删除任何之前的测试数据。清除数据是一个极其重要的步骤,因为如果我们有任何修改数据的函数,那么在每次运行后,我们不会得到可预测的测试结果。

最后,我们将 setupData 方法插入到测试数据中,然后我们将继续执行测试。

在我们可以测试我们的代码之前,我们有很多事情要做,我们需要运行 docker-compose,执行我们的测试,然后停止 docker-compose。一种高效地编写此过程的方法是使用 Makefiles,Makefiles 已经存在很长时间了,并且仍然是许多应用程序的主要软件构建机制。它们允许我们在简单的文本文件中定义命令,然后通过执行make [command]来运行这些命令。

如果我们查看示例存储库中的 Makefile 中的 Cucumber 命令,我们可以看到我们如何编写执行测试所需的步骤。我们使用docker-compose启动 Mongo 实例,运行 Cucumber 测试,然后再次拆毁数据库:

cucumber: 
    docker-compose up -d 
    cd features && godog ./ 
    docker-compose stop 

当我们在运行测试之前启动数据库时,你可能想知道为什么我们还需要waitForDB方法,记住 Docker 只知道主进程何时执行。进程开始和准备好接受连接之间可能会有相当大的延迟。为了运行这个,我们从命令行运行make cucumber,结果应该是通过 cucumber 测试:

$make cucumber
docker-compose up -d
chapter4_mongodb_1 is up-to-date
cd features && godog ./
Feature: As a user when I call the search endpoint, I would like to receive a list of kittens
Server running with pid: 88200
  Scenario: Invalid query # search.feature:4
    Given I have no search criteria # search_test.go:21 -> github.com/building-microservices-with-go/chapter4/features.iHaveNoSearchCriteria
    When I call the search endpoint # search_test.go:29 -> github.com/building-microservices-with-go/chapter4/features.iCallTheSearchEndpoint
    Then I should receive a bad request message # search_test.go:40 -> github.com/building-microservices-with-go/chapter4/features.iShouldReceiveABadRequestMessage
Server running with pid: 88468
  Scenario: Valid query # search.feature:9
    Given I have a valid search criteria # search_test.go:48 -> github.com/building-microservices-with-go/chapter4/features.iHaveAValidSearchCriteria
    When I call the search endpoint # search_test.go:29 -> github.com/building-microservices-with-go/chapter4/features.iCallTheSearchEndpoint
    Then I should receive a list of kittens # search_test.go:54 -> github.com/building-microservices-with-go/chapter4/features.iShouldReceiveAListOfKittens

2 scenarios (2 passed)
6 steps (6 passed)
7.028664s
docker-compose stop
Stopping chapter4_mongodb_1 ... done

这部分就到这里了,我们了解到,通过一些放置得当的模式,编写一个健壮的测试套件很容易,这将让我们安心地睡个好觉,而不是半夜起床诊断一个损坏的系统。在下一节中,我们将探讨 Go 的一些出色功能,以确保我们的代码快速且优化。

基准测试和性能分析

Go 有两种出色的方法来分析你的代码性能。我们有基准测试和出色的 pprof。

基准测试

基准测试是通过多次以固定工作量执行代码来衡量代码性能的一种方法。我们在第一章中简要介绍了这种方法,微服务简介,我们确定json.Marshal方法比json.Encode方法慢。虽然这是一个有用的功能,但我发现很难确定我应该基准测试什么。如果我在编写算法,那么这相对简单。然而,当编写主要与数据库交互的微服务时,这要困难得多。

为了展示在 Go 中执行基准测试是多么容易,请查看chandlers/search_bench_test.go

  11 func BenchmarkSearchHandler(b *testing.B) { 
  12   mockStore = &data.MockStore{} 
  13   mockStore.On("Search", "Fat Freddy's Cat").Return([]data.Kitten{ 
  14     data.Kitten{ 
  15       Name: "Fat Freddy's Cat", 
  16     }, 
  17   }) 
  18 
  19   search := Search{DataStore: mockStore} 
  20 
  21   for i := 0; i < b.N; i++ { 
  22     r := httptest.NewRequest("POST", "/search", 
         bytes.NewReader([]byte(`{"query":"Fat Freddy's Cat"}`))) 
  23     rr := httptest.NewRecorder() 
  24     search.ServeHTTP(rr, r) 
  25   } 
  26 } 

这段代码最重要的部分隐藏在第21行:

for n := 0; n < b.N; n++ 

当运行基准测试时,Go 需要多次运行以获得准确的结果。基准测试将运行的次数是基准结构体上的字段N。在设置这个数字之前,Go 将执行你的代码的一些迭代以获得执行时间的近似测量。

我们将使用go test-bench -benchmem命令执行基准测试:

go test -bench=. -benchmem
BenchmarkSearchHandler-8          50000         43183 ns/op       49142 B/op          68 allocs/op
PASS
ok      github.com/building-microservices-with-go/chapter4/handlers    2.495s  

在这里,我们传递一个额外的标志来查看每次执行的内存分配情况。我们知道,当使用模拟运行时,我们的处理程序执行需要 43,183 纳秒或 0.043183 毫秒,并执行了 68 次内存分配。如果代码在实际运行中也能这么快,那就太好了,但我们可能需要等待几年才能看到与数据库连接的 API 达到这种速度级别。

基准测试的另一个优点是,我们可以运行它们,并输出配置文件,这些配置文件可以与 pprof 一起使用:

go test -bench=. -cpuprofile=cpu.prof -blockprofile=block.prof -memprofile=mem.prof  

此命令的输出将给我们更多关于时间和内存消耗位置的信息,并有助于我们正确优化代码。

分析

当我们想查看我们程序的速度时,我们可以采用的最佳技术是分析。分析在应用程序执行时自动采样运行中的应用程序;然后我们可以将数据,如特定函数的运行时间,计算到称为配置文件的统计摘要中。

Go 支持三种不同类型的分析:

  • CPU:标识需要最多 CPU 时间的任务

  • Heap:标识负责分配最多内存的语句

  • Blocking:标识负责最长时间阻塞 Go 例程的操作

如果我们想在我们的应用程序中启用分析,我们可以做两件事之一:

  • 在启动文件中添加import "net/http/pprof"

  • 手动启动分析

第一个选项是最直接的。您只需将其添加到主 Go 文件的开头,如果您还没有运行 HTTP 网络服务器,就启动一个:

import _ "net/http/pprof" 

go func() { 
     log.Println(http.ListenAndServe("localhost:6060", nil)) 
 }() 

此方法随后在您的 HTTP 服务器上的/debug/pprof/路径下公开各种路径,然后可以通过 URL 访问。然而,这种做法的副作用是,当这个导入语句在您的 go 文件中时,您将进行性能分析,这不仅可能会减慢您的应用程序,而且您也不希望公开这些信息供公众消费。

另一种分析方法是,在启动应用程序时通过传递一些额外的命令行标志来启动分析器:

19 var cpuprofile = flag.String("cpuprofile", "", "write cpu profile to 
   file") 
20 var memprofile = flag.String("memprofile", "", "write memory profile  
   to file") 
21 var store *data.MongoStore 
22 
23 func main() { 
24   flag.Parse() 
25 
26   if *cpuprofile != "" { 
27     fmt.Println("Running with CPU profile") 
28     f, err := os.Create(*cpuprofile) 
29     if err != nil { 
30       log.Fatal(err) 
31     } 
32     pprof.StartCPUProfile(f) 
33   } 
34 
35   sigs := make(chan os.Signal, 1) 
36   signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) 
37 
38   go func() { 
39     <-sigs 
40     fmt.Println("Finished") 
41     if *memprofile != "" { 
42       f, err := os.Create(*memprofile) 
43       if err != nil { 
44         log.Fatal(err) 
45       } 
46       runtime.GC() 
47       pprof.Lookup("heap").WriteTo(f, 0) 
48       defer f.Close() 
49     } 
50     if *cpuprofile != "" { 
51       pprof.StopCPUProfile() 
52     } 
53 
54     os.Exit(0) 
55   }() 

在第26行,我们检查是否指定了用于 CPU 分析的输出文件,如果是,我们就创建文件,然后使用pprof.StartCPUProfile(f)启动分析器,并传递文件引用:

func StartCPUProfile(w io.Writer) error 

StartCPUProfile函数为当前进程启用 CPU 分析,并将输出缓冲到w。在运行时,CPU 分析器大约每秒停止应用程序 100 次并记录数据。

要分析堆分配,我们使用一个稍微不同的命令:

pprof.Lookup("heap").WriteTo(f, 0) 
func Lookup(name string) *Profile 

Lookup()函数返回具有给定名称的配置文件,如果没有这样的配置文件,则可用的预定义配置文件是:

goroutine    - stack traces of all current goroutines 
heap         - a sampling of all heap allocations 
threadcreate - stack traces that led to the creation of new OS threads 
block        - stack traces that led to blocking on synchronization primitives 

fuc (p *Profile) WriteTo(w io.Writer, debug int) error 

WriteTo 将配置文件输出到指定的写入位置,以 pprof 格式。如果我们设置调试标志为 1,那么 WriteTo 将在函数名称和行号上添加注释,而不是像 pprof 使用的仅十六进制地址。这些注释是为了让你能够阅读文件而无需任何特殊工具。

如果你查看 benchmark 文件夹中的示例代码,你会找到一个示例配置文件以及生成它的二进制文件。

我们现在可以运行 pprof 工具来检查发生了什么。为此,我们需要在命令行上运行工具,并提供一个指向执行配置文件的二进制文件的引用,以及配置文件本身:

go tool pprof ./kittenserver ./cpu.prof  

我们可以运行的 simplest 命令是 top。Top 将显示在我们的应用程序执行期间消耗了最多 CPU 的函数:

Entering interactive mode (type "help" for commands) 
 (pprof) top 
 24460ms of 42630ms total (57.38%) 
 Dropped 456 nodes (cum <= 213.15ms) 
 Showing top 10 nodes out of 163 (cum >= 790ms) 
       flat  flat%   sum%        cum   cum% 
    16110ms 37.79% 37.79%    16790ms 39.39%  syscall.Syscall 
     2670ms  6.26% 44.05%     2670ms  6.26%  runtime._ExternalCode 
     1440ms  3.38% 47.43%     1560ms  3.66%  syscall.Syscall6 
      900ms  2.11% 49.54%      900ms  2.11%  runtime.epollctl 
      830ms  1.95% 51.49%     2370ms  5.56%  runtime.mallocgc 
      610ms  1.43% 52.92%     1470ms  3.45%  runtime.pcvalue 
      510ms  1.20% 54.12%      510ms  1.20%  runtime.heapBitsSetType 
      470ms  1.10% 55.22%     2810ms  6.59%  runtime.gentraceback 
      470ms  1.10% 56.32%      470ms  1.10%  runtime.memmove 
      450ms  1.06% 57.38%      790ms  1.85%  runtime.deferreturn 
 (pprof) 

在这种情况下,主要的违规者是 syscall.Syscall。如果我们查阅文档,我们会发现 syscall 包包含对低级操作系统原语的接口。

单独来看,这个输出并不特别有用,所以让我们生成一个调用图,它将显示更多细节。我们可以再次使用 pprof 工具来做这件事。然而,我们首先需要安装 Graphviz。如果你使用 macOS,可以使用 brew 来安装:

brew install graphviz  

如果你使用基于 Linux 的系统并且有 apt 软件包管理器,你可以使用它:

apt-get install graphviz  

这个输出的样子看起来是这样的 benchmark/cpu.png:

这相当令人印象深刻!然而,我们可以看到 syscall.Syscall 以最大的字体显示,因为它消耗了最多的 CPU。如果我们从这里开始,从底部开始回溯,我们可以看到这个问题的根源似乎是我们数据存储的搜索函数。

为了更详细地查看这是在哪里发生的,我们可以使用 list 命令,然后传递我们想要进一步调查的对象或方法的名称:

Entering interactive mode (type "help" for commands)
(pprof) list Search
Total: 42.63s
ROUTINE ======================== github.com//building-microservices-with-go/chapter4/data.(*MongoStore).Search in github.com/building-microservices-with-go/chapter4/data/mongostore.go
 40ms      7.92s (flat, cum) 18.58% of Total
 .         .    16:
 .         .    17:    return &MongoStore{session: session}, nil
 .         .    18:}
 .         .    19:
 .         .    20:// Search returns Kittens from the MongoDB 
                      instance which have the name name
 40ms       40ms     21:func (m *MongoStore) Search(name string) 
                        []Kitten {
 .     270ms    22:    s := m.session.Clone()
 .      10ms    23:    defer s.Close()
 .         .    24:
 .      20ms    25:    var results []Kitten
 .      70ms    26:    c := s.DB("kittenserver").C("kittens")
 .     7.30s    27:    err := c.Find(Kitten{Name: 
                              name}).All(&results)
 .         .    28:    if err != nil {
 .         .    29:        return nil
 .         .    30:    }
 .         .    31:
 .     210ms    32:    return results
 .         .    33:}
 .         .    34:
 .         .    35:// DeleteAllKittens deletes all the kittens 
                      from the datastore
 .         .    36:func (m *MongoStore) DeleteAllKittens() {
 .         .    37:    s := m.session.Clone() 

当我们使用搜索方法做这件事时,我们可以看到我们的大部分 CPU 周期都花在了执行对 MongoDB 的查询上。如果我们查看来自 syscall.Syscall 的其他路由,它显示另一个大消费者是 http.ResponseWriter 接口。

这个输出都是有意义的,因为我们没有在我们的 API 中做任何特别聪明的事情;它只是从数据库中检索一些数据。pprof 的好处是我们可以使用相同的命令来查询堆的使用情况。

摘要

在本章中,你学习了一些在 Go 中测试微服务的最佳实践方法。我们查看测试包,包括一些处理请求和响应的特殊功能。我们还探讨了使用 Cucumber 编写集成测试。

然而,确保你的代码无故障运行只是工作的一部分,我们还需要确保我们的代码性能良好,Go 也提供了一些出色的工具来管理这一点。

我总是会建议你测试你的代码,并且要虔诚地这样做。至于性能优化,这无疑是一个值得讨论的话题,你无疑已经听到过这样的评论:过早的优化是万恶之源。然而,唐纳德·克努特(Donald Knuth)的这句话被误解了很多:他并不是说在你遇到问题之前永远不要优化;他说的是你应该只优化那些真正重要的东西。有了 pprof,我们有一个简单的方法来确定什么,如果有的话,实际上才是重要的。将性能分析实践纳入你的开发流程,你将拥有更快、更高效的应用程序,性能分析也是当你试图追踪那些棘手的错误时,更好地理解你的应用程序的绝佳技术。

“程序员们浪费了大量的时间在思考或担心他们程序中非关键部分的运行速度上,当考虑到调试和维护时,这些对效率的追求有着强烈的负面影响。我们应该忘记那些小的效率,比如说 97%的时间:过早的优化是万恶之源。然而,我们不应该错过那关键的 3%的机会。”

—— 唐纳德·克努特

第五章:常见模式

在我们查看一些可以帮助你在 Go 中构建微服务的框架之前,我们应该首先看看一些可以帮助你避免失败的设计模式。

我不是在谈论像工厂或外观这样的软件设计模式,而是在谈论像负载均衡和服务发现这样的架构设计。如果你以前从未使用过微服务架构,那么你可能不会理解为什么需要这些,但我希望到本章结束时,你将有一个坚实的理解,为什么这些模式很重要,以及如何正确地应用它们。如果你已经成功部署了微服务架构,那么本章将为你提供更多关于使你的系统运行的基础模式的知识。如果你在微服务方面没有取得太多成功,那么可能你没有理解你需要我即将描述的模式。

通常,每个人都有适合的东西,我们将不仅查看核心模式,还会查看一些可以为我们做大部分繁重工作的出色开源软件。

本章中引用的示例可以在以下链接找到:github.com/building-microservices-with-go/chapter5.git

失败设计

凡是可能出错的事情都会出错。

当我们构建微服务时,我们始终应该为失败做好准备。这有很多原因,但主要原因是云计算网络可能会出现故障,你失去了调整切换和路由的能力,如果你在数据中心运行它们,这将为你提供一个优化的系统。此外,我们倾向于构建微服务架构来自动扩展,这种扩展会导致服务以不可预测的方式启动和停止。

这对我们软件的意义在于,在讨论即将推出的功能时,我们需要提前考虑这种失败。然后我们需要从软件开始设计它,并且作为工程师,我们需要理解这些问题。

在他的书《设计数据密集型应用》中,马丁·克莱普曼提出了以下评论:

系统越大,其组件出现故障的可能性就越大。随着时间的推移,故障会被修复,新的东西会出问题,但在拥有数千个节点的系统中,合理地假设总会有东西是出故障的。如果错误处理策略只是放弃这样一个庞大的系统,那么这种方法永远不会成功。

虽然这适用于更重大的系统,但我认为,当你需要开始考虑由于连接性和依赖性导致的失败时,你的系统规模一旦达到n+1,这种情况就开始了。这个数字可能看起来很小,但它的相关性却非常强。考虑以下简单的系统:

您有一个简单的网站,允许您的用户(他们都是猫爱好者)注册接收其他猫爱好者的更新。更新以简单的每日电子邮件的形式提供,您希望在用户提交表单并将数据保存到数据库后发送欢迎邮件。因为您是一位优秀的微服务实践者,您已经意识到发送电子邮件不应是注册系统的责任,相反,您希望将这项任务委托给外部系统。在此期间,该服务越来越受欢迎;您确定可以通过利用 MailCo 的电子邮件 API 服务来节省时间和精力。这个服务有一个简单的 RESTful 协议,可以满足您当前的所有需求,这使您能够更快地进入市场。

下面的图表示了那个简单的微服务:

图片

作为一位优秀的软件架构师,您为这个邮件功能定义了一个接口,它将作为实际实现的抽象。这个概念将允许您在以后快速替换 MailCo。发送电子邮件很快,所以没有必要做任何聪明的事情。我们可以在注册请求期间同步调用 MailCo。

这个应用程序看起来像是一个简单的问题,您以创纪录的时间完成了工作。该网站托管在 AWS 上,并配置了 ElasticScale,所以无论您得到什么负载,您都会安心地睡觉,不用担心网站会崩溃。

一天晚上,您的 CEO 参加了一个由新闻网络 CNN 报道的科技初创公司活动。她与记者交谈,这位记者也是一个猫爱好者,决定在明天的晚间特别报道中介绍这项服务。

兴奋得难以置信;这正是将服务推向太空的东西。您和其他工程师只是为了安心检查系统,确保自动扩展配置正确,然后吃些披萨和啤酒,观看节目。

当节目播出,您的产品向全国展示时,您可以在 Google Analytics 中看到网站上的用户数量。看起来很棒:请求时间短,云基础设施正在正常工作,这是一次完全的成功。当然,直到它不是。几分钟之后,请求排队开始上升,服务仍在扩展,但现在由于错误数量众多和交易处理时间过长,警报开始响起。越来越多的用户进入网站并尝试注册,但成功的人很少,这是您可能希望发生的最糟糕的灾难。

我不会提及 CEO 脸上的表情;你们中的许多人可能在自己的职业生涯中见过这种表情;如果没有,当你看到的时候,你就会知道我在说什么。那是一种介于愤怒、仇恨和困惑之间的表情,他们怎么会雇佣这样的笨蛋。

你不是白痴;软件是复杂的,而复杂性很容易导致错误。

因此,你开始调查这个问题,很快你就看到,尽管你的服务和数据库一直在正常运行,但瓶颈是 MailCo 的电子邮件 API。这导致了阻塞,因为你正在执行一个同步请求,所以你的服务也开始阻塞。

因此,你的辉煌时刻被一个第三方 API 的单个瓶颈击垮了。现在你理解了为什么你需要为故障做计划。让我们看看你如何可以实施故障驱动的设计模式。

模式

关于微服务的真相是它们并不难,你只需要理解核心软件架构模式,这些模式将帮助你成功。在本节中,我们将探讨一些这些模式以及我们如何在 Go 中实现它们。

事件处理

在我们的案例研究中,我们因为下游同步过程失败而失败,这阻碍了上游。我们应该问自己的第一个问题是“这个调用需要同步吗?”在发送电子邮件的情况下,答案几乎总是“不”。处理这个问题最好的方式是采取“点火后忘记”的方法;我们只需将包含邮件所有详细信息的请求添加到一个高可用队列中,这样可以保证至少一次的投递,然后继续。会有一个单独的工人在处理队列记录并将这些发送到第三方 API。

如果第三方开始出现问题,我们可以愉快地停止处理队列,而不会对我们的注册服务造成任何问题。

关于用户体验,这可能会意味着当用户点击注册按钮时,他们不会立即收到欢迎邮件。然而,电子邮件不是一个即时系统,所以一些延迟是可以预料的。你可以进一步改善用户体验:如果将项目添加到队列中返回队列的大致长度给调用系统,会怎样?当你设计故障时,你可能采取的决策是,如果队列中有超过n个项目,你可以向用户展示一个友好的消息,告诉他们你现在很忙,但请放心,你的欢迎邮件正在路上。

我们将在第九章“事件驱动架构”中进一步探讨这个模式的实现,但到目前为止,有几个关键概念我们需要讨论。

至少一次投递的事件处理

事件处理是一个模型,它允许你通过使用消息队列来解耦你的微服务。而不是直接连接到一个可能或可能不在已知位置的服务,你广播并监听存在于队列上的事件,例如 Redis、Amazon SQS、NATS.io、Rabbit、Kafka 以及一大堆其他来源。

以发送欢迎电子邮件的例子来说,我们不会直接调用下游服务的 REST 或 RPC 接口,而是将包含接收者处理此消息所需的所有详细信息的活动添加到一个队列中。

我们的消息可能看起来像:

{ 
  "id": "ABCDERE2342323SDSD", 
  "queue" "registration.welcome_email", 
  "dispatch_date": "2016-03-04 T12:23:12:232", 
  "payload": { 
    "name": "Nic Jackson", 
    "email": "mail@nicholasjackson.io" 
  } 
} 

我们将消息加入队列,然后等待队列的确认(ACK),以告知我们消息已被接收。当然,我们不知道消息是否已送达,但收到确认应该足以让我们通知用户并继续操作。

消息队列是一个高度分布式和可扩展的系统,它应该能够处理数百万条消息,所以我们不需要担心它不可用。在队列的另一端,将有一个工作者正在监听与其相关的新的消息。当它收到这样的消息时,它会处理该消息,然后将其从队列中移除。

图片

当然,接收服务可能无法处理消息,这可能是由于电子邮件服务直接失败或存在错误,或者可能是加入队列的消息格式不适合电子邮件服务读取。我们需要独立处理这两个问题,让我们从处理错误开始。

处理错误

在分布式系统中出现错误并不罕见,我们应该在软件设计中考虑这一点。在有效消息无法被处理的情况下,一个标准的方法是重试处理该消息,通常会有一个延迟。我们可以将消息连同当时发生的错误信息重新加入队列,如下面的示例所示:

{ 
 "id": "ABCDERE2342323SDSD", 
 "queue" "registration.welcome_email", 
 "dispatch_date": "2016-03-04 T12:23:12:232", 
 "payload": { 
 "name": "Nic Jackson", 
 "email": "mail@nicholasjackson.io" 
 }, 
 "error": [{
   "status_code": 3343234,
   "message": "Message rejected from mail API, quota exceeded",
   "stack_trace": "mail_handler.go line 32 ...",
   "date": "2016-03-04 T12:24:01:132"
 }]
} 

每次我们无法处理消息时,都重要地附加错误信息,因为它提供了错误的历史记录,它还提供了我们理解尝试处理消息次数的能力,因为当我们超过这个阈值后,我们不想继续重试,我们需要将此消息移动到第二个队列,在那里我们可以用它作为诊断信息。

死信队列

这个第二个队列通常被称为死信队列,死信队列是特定于消息原始队列的,如果我们有一个名为order_service_emails的队列,那么我们会创建一个名为order_service_emails_deadletter的第二个队列。这样做的目的是为了我们可以检查这个队列上的失败消息,以帮助我们调试系统,如果我们不知道错误是什么,那么知道发生了错误就没有意义,因为我们已经将错误详情直接附加到消息正文中,所以我们需要的这个历史记录就在我们需要的地方。

我们可以通过邮件 API 超出配额的情况看到消息失败,我们也有错误发生的时间和日期。在这种情况下,因为我们已经超过了电子邮件提供者的配额,一旦我们解决了电子邮件提供者的问题,我们就可以将这些消息从死信队列移回到主队列,并且它们应该会正确处理。错误信息以机器可读的格式存在,使我们能够以编程方式处理死信队列,我们可以在特定时间窗口内明确选择与配额问题相关的消息。

如果由于消息负载不良,电子邮件服务无法处理消息,我们通常不会重试处理该消息,而是直接将其添加到死信队列。再次拥有这些信息使我们能够诊断为什么可能发生这个问题,这可能是由于上游服务中的合同变更尚未反映在下游服务中。如果这是失败的原因,我们就有了纠正消耗消息的电子邮件服务中合同问题的知识,然后将消息再次移回到主队列以进行处理。

幂等事务和消息顺序

虽然现在许多消息队列除了提供至少一次投递外,还提供了最多一次投递,但对于大量消息的高吞吐量来说,后者仍然是最佳选择。为了处理下游服务可能会接收到消息两次的事实,它需要能够在其自身逻辑中处理这种情况。确保相同消息不会被处理两次的一种方法是在事务表中记录消息 ID。当我们收到一条消息时,我们会插入一个包含消息 ID 的行,然后我们可以在收到消息时检查它是否已经被处理,以及是否需要处理该消息。

消息传递可能出现的另一个问题是,如果由于某种原因,两个相互替代的消息以错误的顺序接收,那么你可能会在数据库中得到不一致的数据。考虑这个简单的例子,前端服务允许更新用户信息的一部分,这部分信息被转发到第二个微服务。用户快速更新他们的信息两次,导致向第二个服务发送了两条消息。如果这两条消息按照发送的顺序到达,那么第二个服务将处理这两条消息,数据将处于一致状态。然而,如果它们没有按照正确的顺序到达,那么第二个服务将相对于第一个服务不一致,因为它会将旧数据保存为最新数据。一种避免这种问题的潜在方法是通过再次利用事务表,并存储消息的发送日期,除了 id 之外。当第二个服务接收到一条消息时,它不仅可以检查当前消息是否已被处理,还可以检查它是否是最新的消息,如果不是,则丢弃它。

很遗憾,没有一种解决方案可以适用于所有消息传递需求,我们需要根据服务的运行条件定制解决方案。对于你作为微服务实践者来说,你需要意识到这些条件可能存在,并将它们纳入你的解决方案设计中。

原子事务

在存储数据时,数据库可以是原子的:也就是说,所有操作要么都发生,要么都不发生。在微服务的分布式事务中,我们不能这样说。当我们大约十年前使用 SOAP 作为我们的消息协议时,有一个名为Web 服务-事务WS-T)的标准的提案。这个标准旨在提供与数据库事务相同的功能,但在分布式系统中。幸运的是,SOAP 已经消失了,除非你在金融或其他处理遗留系统的行业中工作,但问题仍然存在。在我们之前的例子中,我们探讨了如何通过使用至少一次投递的消息队列来解耦数据的保存和电子邮件的发送。如果我们能够以同样的方式解决原子性问题,考虑这个例子:

图片

我们将订单处理的两部分都分配到队列中,一个工作服务将数据持久化到数据库,另一个负责发送确认电子邮件的服务。这两个服务都会订阅相同的new_order消息,并在收到该消息时采取行动。分布式事务并不提供数据库中存在的同类型事务。当数据库事务的一部分失败时,我们可以回滚事务的其他部分。使用这种模式,我们只有在处理成功的情况下才会从队列中删除消息,因此当出现失败时,我们会持续重试。这给我们带来了一种最终一致的事务。我对分布式事务的看法是,如果可能的话,尽量避免使用;尽量保持你的行为简单。然而,当这种情况不可能时,这种模式可能就是适用的正确选择。

超时

超时是在与其他服务或数据存储进行通信时一个非常实用的模式。其理念是,你为服务器的响应设置一个限制,如果在给定时间内没有收到响应,那么你将编写业务逻辑来处理这种失败,例如重试或向上游服务发送失败消息。

超时可能是检测下游服务故障的唯一方式。然而,没有回复并不意味着服务器没有收到并处理了消息,或者它可能不存在。超时的关键特性是快速失败并通知调用者这种失败。

这是一种很好的做法,不仅从尽早向客户端返回并避免他们无限期等待的角度来看,而且从负载和容量的角度来看也是如此。你服务当前拥有的每个活动连接都是一个不能为活动客户提供服务的关系。此外,你系统的容量是无限的,维护一个连接需要许多资源,这也适用于向你发起调用的上游服务。超时是大规模分布式系统中的一个有效卫生因素,在这些系统中,许多服务的小实例通常被集群在一起以实现高吞吐量和冗余。如果这些实例中的一个出现故障,不幸的是你连接到了它,那么这可能会阻塞一个完全正常的服务。正确的方法是等待一定时间的响应,如果在这一时期内没有响应,我们应该取消调用,并尝试列表中的下一个服务。关于你的超时设置多长时间的问题没有简单的答案。我们还需要考虑网络请求中可能发生的不同类型的超时,例如,你有:

  • 连接超时 - 打开到服务器的网络连接所需的时间

  • 请求超时 - 服务器处理请求所需的时间

请求超时几乎总是两个中最长的时间,我建议在服务的配置中定义超时。虽然你最初可能将其设置为任意值,比如 10 秒,但在系统在生产环境中运行并积累了一定的交易时间数据集后,你可以修改这个值。

我们将使用 eapache 的deadline包(github.com/eapache/go-resiliency/tree/master/deadline),这是由 go-kit 工具包(gokit.io)推荐的。

我们将要运行的方法从 0-100 循环,并在每次循环后暂停。如果我们让函数继续到末尾,它将需要 100 秒。

使用deadline包,我们可以设置自己的超时,在两秒后取消长时间运行的操作:

timeout/main.go

 24 func makeTimeoutRequest() { 
 25   dl := deadline.New(1 * time.Second) 
 26   err := dl.Run(func(stopper <-chan struct{}) error { 
 27     slowFunction() 
 28     return nil 
 29   }) 
 30 
 31   switch err { 
 32   case deadline.ErrTimedOut: 
 33     fmt.Println("Timeout") 
 34   default: 
 35     fmt.Println(err) 
 36   } 
 37 } 

退避

通常,一旦连接失败,你不想立即重试,以避免用请求淹没网络或服务器。为了允许这样做,有必要在重试策略中实现退避方法。退避算法在第一次失败后等待设定的时间,然后随着后续失败的增加而增加,直到达到最大持续时间。

在客户端调用的 API 中使用这种策略可能不是最佳选择,因为它违反了快速失败的要求。然而,如果我们有一个仅处理消息队列的工作进程,那么这可能是为您的系统添加一点保护的最佳策略。

我们将探讨go-resiliency包和retrier包。

要创建一个新的重试器,我们使用New函数,其签名如下:

func New(backoff []time.Duration, class Classifier) *Retrier 

第一个参数是Duration数组。我们不必手动计算这个值,可以使用两个内置方法来生成这个值:

func ConstantBackoff(n int, amount time.Duration) []time.Duration 

ConstantBackoff函数生成一个简单的退避策略,重试n次,并在每次重试之间等待给定的时间:

func ExponentialBackoff(n int, initialAmount time.Duration) []time.Duration 

ExponentialBackoff函数生成一个简单的退避策略,重试n次,并在每次重试之间加倍时间。

第二个参数是Classifier。这允许我们对允许重试的错误类型和立即失败的错误类型有更多的控制。

type DefaultClassifier struct{} 

DefaultClassifier类型是最简单形式:如果没有返回错误,则成功;如果有任何错误返回,则重试器进入重试状态。

type BlacklistClassifier []error 

BlacklistClassifier类型根据黑名单对错误进行分类。如果错误在给定的黑名单中,它将立即失败;否则,它将重试。

type WhitelistClassifier []error 

WhitelistClassifier类型与黑名单相反,它只有在给定的白名单中存在错误时才会重试。任何其他错误都将失败。

WhitelistClassifier可能看起来稍微复杂一些。然而,每种情况都需要不同的实现。你实施的策略与你的用例紧密相关。

电路断开

我们已经研究了像超时和退避这样的模式,这些模式有助于保护我们的系统在出现故障时免受级联失败的影响。然而,现在是时候介绍另一个与这对互补的模式了。断路器完全是关于快速失败,Michael Nygard 在他的书《Release It》中说:

“断路器是一种在系统处于压力下自动降低功能的方法。”

一个这样的例子可能是我们的前端示例网络应用程序。它依赖于下游服务提供与当前查看的小猫匹配的小猫搞笑图片推荐。因为这个调用与主页加载同步,所以直到成功返回推荐,服务器不会返回数据。现在你已经为失败设计了,并为这个调用引入了五秒的超时。然而,由于推荐系统存在问题,原本只需 20 毫秒的调用现在需要 5000 毫秒才能失败。每个查看小猫个人资料的用户都比平时多等了五秒;你的应用程序没有像正常那样快速处理请求和释放资源,其容量显著减少。此外,由于处理单个页面请求所需时间较长,主网站的并发连接数增加了;这正在给前端增加负载,使其开始变慢。最终的效果将是,如果推荐服务不开始响应,整个网站将面临中断。

对于这个问题,有一个简单的解决方案:你应该停止尝试调用推荐服务,将网站恢复到正常操作速度,并稍微降低个人资料页面的功能。这将产生三个效果:

  • 你将恢复网站上其他用户的浏览体验。

  • 你在某个领域稍微降低了用户体验。

  • 在实施此功能之前,你需要与你的利益相关者进行对话,因为它对系统的业务有直接影响。

现在这个例子应该相对简单。假设推荐可以提高转化率 1%;然而,缓慢的页面加载会减少 90%。那么,降低 1%而不是 90%不是更好吗?这个例子很明确,但如果下游服务是股票检查系统,你应该接受一个可能没有库存来履行的订单吗?

错误行为不是软件工程可以独自回答的问题;业务利益相关者需要参与这个决策。事实上,我建议在规划系统设计时,将失败作为非功能性需求的一部分进行讨论,并提前决定当下游服务失败时将采取什么措施。

那么它们是如何工作的呢?

在正常操作中,就像你家里的电闸箱中的断路器一样,断路器是闭合的,流量正常流动。然而,一旦预定的错误阈值被超过,断路器进入开启状态,所有请求立即失败,甚至没有尝试。经过一段时间后,会允许进一步的请求,此时电路进入半开启状态,在这个状态下,任何失败都会立即返回到开启状态,无论errorThreshold是多少。一旦处理了一些请求且没有错误,电路再次返回到闭合状态,只有当失败次数超过错误阈值时,电路才会再次开启。

这为我们提供了更多关于为什么我们需要断路器的背景信息,但我们在 Go 中如何实现它们呢?

图片

我们再次转向go-resilience包。创建断路器很简单,断路器的签名如下:

func New(errorThreshold, successThreshold int, timeout time.Duration) *Breaker 

我们构建我们的断路器有三个参数:

  • 第一个errorThreshold,是电路开启之前请求可以失败的最大次数

  • successThreshold,是在我们回到开启状态之前,在半开启状态下需要成功的请求次数

  • timeout,是电路在变为半开启状态之前保持开启状态的时间

运行以下代码:

 11   b := breaker.New(3, 1, 5*time.Second) 
 12 
 13   for { 
 14     result := b.Run(func() error { 
 15       // Call some service 
 16       time.Sleep(2 * time.Second) 
 17       return fmt.Errorf("Timeout") 
 18     }) 
 19 
 20     switch result { 
 21     case nil: 
 22       // success! 
 23     case breaker.ErrBreakerOpen: 
 24       // our function wasn't run because the breaker was open 
 25       fmt.Println("Breaker open") 
 26     default: 
 27       fmt.Println(result) 
 28     } 
 29 
 30     time.Sleep(500 * time.Millisecond) 
 31   } 

如果你运行这段代码,你应该看到以下输出。在三次失败请求后,断路器进入开启状态,然后在我们五秒的间隔后,我们进入半开启状态,并被允许再次发起请求。不幸的是,这次请求失败了,我们再次进入完全开启状态,并且我们不再尝试发起调用:

Timeout
Timeout
Timeout
Breaker open
Breaker open
Breaker open
...
Breaker open
Breaker open
Timeout
Breaker open
Breaker open  

电路断路和超时的一种更现代的实现是 Netflix 的 Hystrix 库;Netflix 当然因生产高质量的微服务架构而闻名,Hystrix 客户端也是被一次次复制的对象。

Hystrix 被描述为“一个设计用于隔离对远程系统、服务和第三方库访问点的延迟和容错库,阻止级联故障,并在故障不可避免的复杂分布式系统中实现弹性。”

(github.com/Netflix/Hystrix)

对于在 Golang 中的实现,请查看优秀的包github.com/afex/hystrix-go。这是一个很好的干净实现,比实现go-resiliency要干净一些。hystrix-go的另一个好处是它会自动将指标导出到 Hystrix 仪表板或 StatsD。在第七章,日志和监控中,我们将了解到这一点的重要性。

我希望你能看到为什么这是一个极其简单但非常有用的模式。然而,应该提出一些问题,即当你失败时你将做什么。这些是微服务,你很少只有一个服务实例,所以为什么不重试调用,为此我们可以使用负载均衡器模式。

健康检查

健康检查应该是你的微服务设置的一个基本部分。每个服务都应该暴露一个健康检查端点,该端点可以被 consul 或其他服务器监控器访问。健康检查很重要,因为它们允许负责运行应用程序的过程在应用程序开始出现异常或失败时重启或终止它。当然,你必须非常小心,不要过于激进地设置这个。

你在健康检查中记录的内容完全由你决定。然而,我建议你考虑实现以下功能:

  • 数据存储连接状态(一般连接状态,连接池状态)

  • 当前响应时间(滚动平均)

  • 当前连接

  • 坏请求(运行平均)

你如何确定什么会导致不健康状态,需要成为你在设计服务时讨论的一部分。例如,无法连接到数据库意味着服务完全不可操作,它会报告不健康,并允许编排器回收容器。耗尽的连接池可能只是意味着服务在高负载下运行,虽然它不是完全不可操作,但它可能正在遭受性能下降,应该只发出警告。

对于当前的响应时间也是如此。我认为这一点很有趣:当你将服务部署到生产环境后进行负载测试,你可以构建出操作健康阈值的图像。这些数字可以存储在配置文件中,并由健康检查使用。例如,如果你知道你的服务将以 50 毫秒的延迟处理平均服务请求,对于 4,000 个并发用户;然而,当达到 5,000 个用户时,这个时间增长到 500 毫秒,因为你已经耗尽了连接池。你可以将你的服务级别协议(SLA)的上限设置为 100 毫秒;然后你将从健康检查开始报告性能下降。然而,这应该是一个基于正态分布的滚动平均值。总有可能有一个或两个请求会大大超出正常操作的标准差,你不想让这种情况扭曲你的平均值,从而导致服务报告不健康,而实际上缓慢的响应是由于上游服务网络连接缓慢,而不是你的内部状态。

当讨论健康检查时,Michael Nygard 考虑了握手模式,其中每个客户端在连接到下游服务之前都会发送一个握手请求以检查其是否能够接收请求。在正常操作条件下,这会在你的应用程序中添加大量的冗余,我认为这可能有些过度。这也意味着你正在使用客户端负载均衡,因为如果使用服务器端方法,你无法保证你握手的那个服务就是你要连接的服务。尽管如此,《Release It》这本书是在 10 年前写的,技术已经发生了很大的变化。然而,下游服务决定它是否能够处理请求的概念是有效的。为什么不先调用你的内部健康检查,然后再处理请求呢?这样,你就可以立即失败,并给客户端尝试集群中另一个端点的机会。这个调用几乎不会增加你的处理时间,因为你只是在读取健康端点的状态,而不是处理任何数据。

让我们通过查看health/main.go中的示例代码来了解如何实现这一点:

18 func main() {
19   ma = ewma.NewMovingAverage()
20
21   http.HandleFunc("/", mainHandler)
22   http.HandleFunc("/health", healthHandler)
23
24   http.ListenAndServe(":8080", nil)
25 }

我们定义了两个处理器,一个用于处理路径/上的主要请求,另一个用于检查路径/health上的健康状态。

处理器实现了一个简单的移动平均值,它记录了处理器执行所需的时间。我们不是允许任何请求被处理,而是在第30行首先检查服务是否当前健康,这是检查当前移动平均值是否大于定义的阈值;如果服务不健康,我们返回状态码StatusServiceUnavailable

 27 func mainHandler(rw http.ResponseWriter, r *http.Request) {
 28   startTime := time.Now()
 29
 30   if !isHealthy() {
 31     respondServiceUnhealthy(rw)
 32     return
 33   }
 34
 35   rw.WriteHeader(http.StatusOK)
 36   fmt.Fprintf(rw, "Average request time: %f (ms)\n", ma.Value()/1000000)
 37
 38   duration := time.Now().Sub(startTime)
 39   ma.Add(float64(duration))
 40 }

深入查看respondServiceUnhealty函数,我们可以看到它不仅仅只是返回 HTTP 状态码。

55 func respondServiceUnhealthy(rw http.ResponseWriter) {
56   rw.WriteHeader(http.StatusServiceUnavailable)
57
58   resetMutex.RLock()
59   defer resetMutex.RUnlock()
60
61   if !resetting {
62     go sleepAndResetAverage()
63   }
64 }

58行和第59行正在获取对resetMutex的锁,我们需要这个锁,因为当服务不健康时,我们需要等待一段时间以给服务恢复的机会,然后重置平均值。然而,我们不希望在每次调用处理器或服务被标记为不健康时都调用这个操作,因为这可能会导致服务永远无法恢复。第61行的检查和变量确保了这种情况不会发生,然而,除非用互斥锁标记,否则这个变量并不安全,因为我们有多个 goroutine。

63 func sleepAndResetAverage() {
64   resetMutex.Lock()
65   resetting = true
66   resetMutex.Unlock()
67
68   time.Sleep(timeout)
69   ma = ewma.NewMovingAverage()
70
71   resetMutex.Lock()
72   resetting = false
73   resetMutex.Unlock()
74 }

sleepAndResetAverage函数在重置移动平均值之前会等待一个预定的时间长度,在这段时间内,服务将不会执行任何工作,这有望给过载的服务提供恢复的时间。同样,在交互重置变量之前,我们需要获取对resetMutex的锁,以避免多个 goroutine 尝试访问这个变量时的竞争条件。第69行将移动平均值重置为 0,这意味着服务将再次能够处理工作。

这个例子只是一个简单的实现,如前所述,我们可以添加服务可用的任何指标,例如 CPU 内存、数据库连接状态,如果我们使用数据库的话。

限流

限流是一种模式,其中你限制一个服务可以处理的连接数,当这个阈值被超过时,返回一个 HTTP 错误代码。这个例子的完整源代码可以在文件throttling/limit_handler.go中找到。Go 的中间件模式在这里非常有用:我们将要做的是包装我们想要调用的处理器,但在调用处理器本身之前,我们将检查服务器是否能够满足请求。在这个例子中,为了简单起见,我们只限制处理器可以服务的并发请求数量,我们可以通过一个简单的带缓冲的通道来实现这一点。

我们的LimitHandler是一个非常简单的对象:

  9 type LimitHandler struct { 
 10   connections chan struct{} 
 11   handler     http.Handler 
 12 } 

我们有两个私有字段:一个字段持有连接数,作为一个带缓冲的通道,另一个是在我们检查系统健康后将要调用的处理器。为了创建这个对象的实例,我们将使用NewLimitHandler函数。这个函数接受参数连接,这是我们允许在任何时候处理的连接数,以及如果成功将被调用的处理器:

16 func NewLimitHandler(connections int, next http.Handler) 
   *LimitHandler { 
17   cons := make(chan struct{}, connections) 
18   for i := 0; i < connections; i++ { 
19     cons <- struct{}{} 
20   } 
21 
22   return &LimitHandler{ 
23     connections: cons, 
24     handler:     next, 
25   } 
26 } 

这非常直接:我们创建一个大小等于并发连接数的带缓冲通道,然后将其填充以供使用:

28 func (l *LimitHandler) ServeHTTP(rw http.ResponseWriter, r 
   *http.Request) { 
29   select { 
30   case <-l.connections: 
31     l.handler.ServeHTTP(rw, r) 
32     l.connections <- struct{}{} // release the lock 
32   default: 
33     http.Error(rw, "Busy", http.StatusTooManyRequests) 
34   } 
35 } 

如果我们查看从第29行开始的ServeHTTP方法,我们有一个select语句。通道的优点在于我们可以写出这样的语句:如果我们无法从通道中检索到项目,那么我们应该向客户端返回一个忙碌的错误信息。

在这个例子中,另一个值得关注的点是测试,在对应这个例子的测试文件throttling/limit_handler_test.go中,我们有一个相当复杂的测试设置,用于检查当我们达到限制时,多个并发请求会返回一个错误:

 14 func newTestHandler(ctx context.Context) http.Handler { 
 15   return http.HandlerFunc(func(rw http.ResponseWriter, r 
      *http.Request) { 
 16     rw.WriteHeader(http.StatusOK) 
 17     <-r.Context().Done() 
 18   }) 
 19 } 

 84 func TestReturnsBusyWhenConnectionsExhausted(t *testing.T) { 
 85   ctx, cancel := context.WithCancel(context.Background()) 
 86   ctx2, cancel2 := context.WithCancel(context.Background()) 
 87   handler := NewLimitHandler(1, newTestHandler(ctx)) 
 88   rw, r := setup(ctx) 
 89   rw2, r2 := setup(ctx2) 
 90 
 91   time.AfterFunc(10*time.Millisecond, func() { 
 92     cancel() 
 93     cancel2() 
 94   }) 
 95 
 96   waitGroup := sync.WaitGroup{} 
 97   waitGroup.Add(2) 
 98 
 99   go func() { 
100     handler.ServeHTTP(rw, r) 
101     waitGroup.Done() 
102   }() 
103 
104   go func() { 
105     handler.ServeHTTP(rw2, r2) 
106     waitGroup.Done() 
107   }() 
108 
109   waitGroup.Wait() 
110 
111   if rw.Code == http.StatusOK && rw2.Code == http.StatusOK { 
112     t.Fatalf("One request should have been busy, request 1: %v, 
        request 2: %v", rw.Code, rw2.Code) 
113   } 
114 } 

如果我们查看第 87 行,我们可以看到我们正在构建我们的新 LimitHandler 并传递一个模拟处理程序,如果服务器能够接受请求,这个处理程序将被调用。您可以看到,在这个处理程序的 17 行,我们将阻塞,直到上下文的完成通道上有项目,并且这个上下文是一个 WithCancel 上下文。我们需要这样做的原因是,为了测试我们的请求之一将被调用而另一个则不会,但 LimitHandler 将返回 TooManyRequests,我们需要阻塞第一个请求。为了确保我们的测试最终完成,我们在计时器块中调用上下文的取消方法,该计时器块将在十毫秒后触发。随着我们需要在 Go 线程中调用我们的处理程序以确保它们并发执行,事情开始变得有些复杂。然而,在我们做出断言之前,我们需要确保它们已经完成。这就是为什么我们在第 96 行设置 WaitGroup 的原因,并在每个处理程序完成后递减这个组。最后,我们只需在第 109 行阻塞,直到一切完成,然后我们可以做出断言。让我们更仔细地看看通过这个测试的流程:

  1. 在第 109 行阻塞。

  2. 同时调用 handler.ServeHTTP 两次。

  3. 一个 ServeHTTP 方法立即返回 http.TooManyRequests 并递减等待组。

  4. 调用取消上下文,允许阻塞的 ServeHTTP 调用返回并递减等待组。

  5. 执行断言。

这个流程与从上到下线性阅读代码的顺序不同。有三个并发线程正在执行,执行流程与代码中语句的顺序不同。不幸的是,测试并发 Go 线程始终是一个复杂的问题。然而,通过执行这些步骤,我们已经为我们的 LimitHandler 实现了 100% 的覆盖率:

PASS 
coverage: 100.0% of statements 
ok      github.com/nicholasjackson/building-microservices-in-go/chapter5/health 0.033s  

与仅仅限制这个处理程序中的连接数不同,我们可以实现任何我们喜欢的东西:实现记录平均执行时间或 CPU 消耗并在条件超过我们的要求时快速失败是非常简单的。确定这些要求的确切内容本身就是一个复杂的话题,您的第一个猜测很可能会出错。我们需要运行我们系统的多个负载测试,并花费时间查看端点的日志和性能统计,然后我们才能处于做出明智猜测的情况。然而,这个动作可能只是让您免于级联故障,这确实是一件好事。

服务发现

在单体应用中,服务通过语言级别的函数或过程调用相互调用。这种行为相对简单且可预测。然而,一旦我们意识到单体应用不适合现代软件的规模和需求,我们就转向了 SOA 或面向服务的架构。我们将这个单体分解成更小的块,这些块通常服务于特定的目的。为了解决服务间调用的问题,SOA 服务在已知固定的位置运行,因为服务器很大,通常托管在你的数据中心或数据中心租赁的机架上。这意味着它们的位置不会经常改变,IP 地址通常是静态的,即使服务器需要移动,重新配置 IP 地址也总是部署过程的一部分。

在微服务中,所有这些都发生了变化,应用程序通常在虚拟化或容器化环境中运行,其中服务的实例数量和位置可以每分钟动态变化。这使我们能够根据动态施加于它的力量来扩展我们的应用程序,但这种灵活性并非没有问题。主要问题之一是知道你的服务在哪里以便联系它们。我的一个好朋友,一位出色的软件架构师,也是这本书前言的作者,在一次他的演讲中说过:

"微服务本身很简单;构建微服务系统却很困难。"

没有合适的模式,几乎可以说是无法实现的,你很可能会在服务上线生产之前就遇到第一个问题:服务发现。

假设你有一个这样的设置:你有三个相同的服务实例 A、B 和 C。实例 A 和 B 在同一台硬件上运行,但服务 C 在一个完全不同的数据中心运行。由于 A 和 B 在同一台机器上运行,它们可以通过相同的 IP 地址访问。然而,正因为如此,它们都不能绑定到相同的端口。你的客户端应该如何弄清楚所有这些信息,以便进行简单的调用?

解决方案是服务发现和动态服务注册表的使用,如 Consul 或 Etcd。这些系统具有高度的可扩展性,并且有强一致性方法来存储服务的位置。服务在启动时向动态服务注册表注册,除了它们运行的 IP 地址和端口外,通常还会提供元数据,如服务版本或其他环境参数,这些参数可以在客户端查询注册表时使用。此外,Consul 还具有对服务进行健康检查的能力,以确保其可用性。如果服务未通过健康检查,则它在注册表中标记为不可用,并且不会被任何查询返回。

服务发现有两种主要模式:

  • 服务器端发现

  • 客户端发现

服务器端服务发现

在同一应用程序内部进行服务间调用时,服务器端服务发现,在我看来,是一种微服务反模式。这是我们用于 SOA 环境中调用服务的方法。通常,将有一个反向代理作为您服务的网关。它联系动态服务注册表,并将您的请求转发到后端服务。客户端将访问后端服务,使用子域或路径作为区分器来实现已知的 URI。

这种方法的缺点是,反向代理开始成为瓶颈。您可能能够快速扩展后端服务,但现在您需要监控和观察这些服务器。此外,这种模式引入了延迟,即使只有 20 毫秒的跳跃,这也可能轻易地消耗您 10%的容量,这意味着您在运行和维护这些服务的成本之外,还要增加 10%的成本。那么一致性如何呢?您可能会在代码中遇到两种不同的故障模式,一种是针对下游调用的内部服务,另一种是针对外部服务。这只会增加混乱。

然而,对我来说,最大的问题是您必须集中化这个故障逻辑。在本章稍后,我们将深入探讨这些模式,但我们已经指出,您的服务在某个时刻会出错,您将希望处理这个故障。如果您将这个逻辑放入反向代理,那么所有想要访问服务 A 的服务都将被同等对待,无论调用是否对成功至关重要。

在我看来,这种模式的最大问题是它从客户端抽象出所有这些知识,内部重试,并且直到成功或灾难性故障,从不让调用客户端知道发生了什么。

图片

客户端服务发现

当服务器端服务发现可能适合您公开 API 的任何内部服务间通信时,我更倾向于客户端模式。这使您能够更好地控制发生故障时的情况。您可以根据具体情况在失败重试时实现业务逻辑,这也可以保护您免受级联故障的影响。

从本质上讲,这种模式与其服务器端伙伴类似。然而,客户端负责服务发现和负载均衡。您仍然需要连接到动态服务注册表以获取将要调用的服务的相关信息。这种逻辑在每个客户端本地化,因此可以针对具体情况处理故障逻辑。

图片

负载均衡

当我们讨论服务发现时,我们考察了服务器端和客户端发现的概念。我个人的偏好是查看客户端进行任何内部调用,因为它允许你根据具体情况对重试逻辑有更大的控制。为什么我喜欢客户端负载均衡?多年来,服务器端发现是唯一的选择,由于性能问题,也倾向于在负载均衡器上进行 SSL 终止。这现在已经不再是必然的,当我们查看安全章节时,我们会看到这一点。在内部使用 TLS 加密连接是一个好主意。然而,关于能够进行复杂的流量分配,这只能在你有一个知识中心的情况下实现。我不确定这是否必要:理论上,随机分配最终会达到相同的效果。然而,只向特定主机发送一定数量的连接可能会有好处;但那么如何衡量健康状态呢?你可以使用第 6 层或第 7 层,但正如我们所看到的,通过使用智能健康检查,如果服务太忙,它可能会拒绝连接。

从查看断路器的示例来看,我希望你现在可以开始看到这可以为你的系统带来的潜力。那么我们如何在 Go 中实现负载均衡?

如果我们查看loadbalancing/main.go,我创建了一个简单的负载均衡器实现。我们通过调用NewLoadBalancer来创建它,该函数具有以下签名:

func NewLoadBalancer(strategy Strategy, endpoints []url.URL) *LoadBalancer 

此函数接受两个参数:一个strategy,它是一个包含端点选择逻辑的接口,以及一个端点列表。

为了能够实现负载均衡器的多种策略,例如轮询、随机或更复杂的策略,如分布式统计,跨多个实例,你可以定义自己的策略,该策略具有以下接口:

 10 // Strategy is an interface to be implemented by loadbalancing 
 11 // strategies like round robin or random. 
 12 type Strategy interface { 
 13   NextEndpoint() url.URL 
 14   SetEndpoints([]url.URL) 
 15 } 

NextEndpoint() url.URL 

这是返回特定端点的方法。它不是直接调用的,而是在你调用GetEndpoint方法时,LoadBalancer包内部调用的。这必须是一个公开方法,以便允许策略包含在LoadBalancer包之外的包中:

SetEndpoints([]url.URL) 

此方法将更新Strategy类型,包含当前可用的端点列表。同样,这不是直接调用的,而是在你调用UpdateEndpoints方法时,LoadBalancer包内部调用的。

要使用LoadBalancer包,你只需用你选择的策略和端点列表初始化它,然后通过调用GetEndpoint,你将收到列表中的下一个端点:

 56 func main() { 
 57   endpoints := []url.URL{ 
 58     url.URL{Host: "www.google.com"}, 
 59     url.URL{Host: "www.google.co.uk"}, 
 60   } 
 61 
 62   lb := NewLoadBalancer(&RandomStrategy{}, endpoints) 
 63 
 64   fmt.Println(lb.GetEndpoint()) 
 65 } 

在示例代码中,我们已经实现了一个简单的RandomStrategy。为什么不尝试构建一个应用RoundRobinStrategy的策略呢?

缓存

提高您服务性能的一种方法是通过在内存缓存或类似 Redis 的旁路缓存中缓存数据库和其他下游调用的结果,而不是每次都直接击中数据库。

缓存的设计是为了通过在快速访问的数据存储中存储预编译对象来提供巨大的吞吐量,通常基于哈希键的概念。从查看算法性能我们知道,哈希表的平均性能为 O(1);也就是说,这是最快的。不深入探讨大 O 符号,这意味着只需要一次迭代就能在集合中找到你想要的项目。

这对您的意义是,您不仅可以减少数据库的负载,还可以减少您的基础设施成本。通常,数据库受限于可以从磁盘读取和写入的数据量以及 CPU 处理这些信息所需的时间。使用内存缓存,通过使用预聚合数据,这些数据存储在快速内存中,而不是存储在像磁盘这样的状态设备上,可以消除这种限制。您还消除了许多数据库中存在的锁定问题:一次写入可以阻塞许多读取信息。这以一致性为代价,因为您无法保证所有客户端将同时拥有相同的信息。然而,更多情况下,强一致性是数据库中被高估的属性:

图片

考虑我们的猫咪列表。如果我们正在接收大量用户检索猫咪列表,并且每次都必须调用数据库以确保列表始终是最新的,那么这将非常昂贵,并且当数据库已经承受高负载时,可能会迅速超出其处理能力。我们首先需要问自己,是否所有这些客户端都必须同时接收更新信息,或者一秒钟的延迟是否可以接受。通常情况下,一秒钟的延迟是可以接受的,并且你从中获得的速度和成本效益远远超过了连接客户端无法在信息写入数据库后立即获得最新信息的潜在成本。

缓存策略可以根据您对一致性的要求来计算。理论上,您的缓存过期时间越长,您的成本节约就越大,但您的系统速度会降低,这是以牺牲一致性为代价的。我们已经讨论了如何设计故障以及如何实现系统的优雅降级。同样,当您规划一个功能时,您应该讨论一致性以及与性能和成本的权衡,并记录这个决定,因为这些决定将极大地帮助创建一个更成功的实现。

过早的优化

你可能听说过这个短语,那么这意味着你不需要在需要时才实现缓存吗?不;这意味着你应该在设计时尝试预测系统将承受的初始负载,以及随着时间的推移容量增长,正如你在考虑应用程序生命周期时一样。在创建这个设计时,你将汇集这些数据,并且你无法可靠地预测服务将运行的速度。然而,你知道缓存将比数据存储便宜;所以,如果可能的话,你应该设计使用可能的最小和最便宜的数据存储,并做出安排,以便在以后引入缓存来扩展你的服务。这样,你只做必要的实际工作来推出服务,但你在前端已经完成了设计,以便在需要扩展时扩展服务。

数据库或下游服务故障时的陈旧缓存

缓存通常会有一个结束日期。然而,如果你以这种方式实现缓存,即代码决定使其失效,那么如果下游服务或数据库消失,你可以避免潜在的问题。再次强调,这是回到考虑故障状态并思考什么更好:用户看到稍微过时的信息,还是错误页面?如果你的缓存已过期,对下游服务的调用将失败。但是,你总是可以选择将陈旧的缓存返回给调用客户端。在某些情况下,这比返回 50x 错误要好。

摘要

我们已经看到了如何使用一些相当酷的模式来使我们的微服务更加健壮,并处理不可避免的故障。我们也探讨了如何引入一个薄弱环节可以挽救整个系统免于级联故障。你应该如何以及在哪里应用这些模式应该从有根据的猜测开始,但你需要不断地查看日志和监控来确保你的观点仍然相关。在下一章中,我们将探讨一些用于在 Go 中构建微服务的出色框架,然后在第七章,日志和监控中,我们将探讨一些日志和监控你服务的选项和最佳实践。

第六章:微服务框架

在本章中,我们将探讨一些构建微服务的最流行框架,并查看一个示例项目以了解其实施情况。我们将检查基于 RESTful 和 RPC 的微服务,并且为了增加一些变化,我们还将查看一个提供构建高度分布式系统所需大部分粘合剂的商业框架。

伴随本章的源代码可以在github.com/building-microservices-with-go/chapter6找到

什么构成了一个好的微服务框架?

什么构成了一个微服务框架是一个非常好且具有许多不同观点的问题。为了减少主观性,我们将分解优秀框架的特性,并尝试以一致的方式为这些功能中的每一个分配一个分数。以下图表是我认为必要的特性的层级思维导图。当你评估最适合你和你项目的框架时,可以使用这个框架,添加或删除可能相关的任何属性:

图片

在选择一个好的框架时,以下是一些需要考虑的功能:

  • 与其他框架的接口能力:必须能够通过未使用相同框架构建的客户端与使用该框架构建的任何服务进行交互。

    • 实施标准:应使用标准消息协议以最大化交互,例如:

      • JSON-RPC

      • Thrift

      • Protocol Buffers

      • JSON

    • 开放性:框架在源代码和路线图上都应该保持开放。

  • 模式:框架必须实现微服务架构的标准模式:

    • 断路器:对下游服务的客户端调用必须实现断路器。

    • 服务发现:必须能够向动态服务注册表注册,并且能够查询同一注册表以定位连接的服务

    • 专有:专有服务注册表必须对未实现框架或其 SDK 的其他客户端开放和可用。

    • 超时设置:下游客户端调用应可配置用户定义的超时时间。

    • 健康检查:框架必须创建一个自动的健康检查端点。

    • 路由:框架必须支持易于使用的基于模式的多个路由。

    • 中间件:框架必须支持中间件,以便用户为处理器创建共享代码。

    • 负载均衡:下游客户端连接应能够进行负载均衡。

    • 语言无关性:框架需要是语言无关的,以实现跨团队的多语言工作流程。至少,应该能够在多种语言中创建客户端 SDK。

  • 通信协议:服务应支持以下通信协议中的良好标准:

    • REST:如果框架实现 REST,它必须充分利用语义 API 设计,并适当使用 HTTP 动词和状态码。

    • RPC:如果框架基于 RPC,它必须使用标准且开放的消息协议。

  • 可维护性:框架必须以最小的努力进行维护:

    • 易于更新:它必须易于更新,代码更改量最小。

    • 良好版本控制:框架必须具有良好的版本控制,API 的主要破坏性更改主要限于主要版本更新。

  • 工具:必须有足够的工具来适应现代开发实践:

    • CI/CD:它必须与持续集成和持续部署管道集成并良好工作;工具必须是可脚本化的。

    • 跨平台:工具必须跨平台工作,至少包括 OSX 和 Linux。

  • 代码生成:它应该支持代码生成模板,以构建服务,并可能扩展服务。

  • 快速设置:框架应该快速设置,步骤和依赖项最少。

  • 易用性:任何好的框架都应该易于使用;你不会为选择了一个难以工作的框架而感到高兴。这个类别已经被分解为以下子类别:

  • 可扩展性:当需要时,用户应该能够通过以下方式扩展框架:

    • 插件:一个可插拔的软件架构,以便能够创建生成器和模板。

    • 中间件:通过处理中间件进行扩展。

  • 支持:在整个服务生命周期中,一个良好的支持网络至关重要。

    • 维护:框架必须得到良好的维护,包括:

      • 定期更新:框架定期更新和发布。

      • 接受拉取请求:作者接受来自社区贡献者的拉取请求。

      • 企业赞助商:虽然这个选项不是必需的,但企业赞助商可以延长框架的生命周期,因为出现 leftpad 情况的可能性较小。(www.theregister.co.uk/2016/03/23/npm_left_pad_chaos/

    • 文档:框架应该有良好的文档,包括清晰简洁的示例和全面的 API 文档。

      • 易于理解:文档应该是可访问的,并且易于阅读。

      • 代码示例:应提供足够的代码示例以支持使用框架的开发者。

    • 教程:框架理想情况下应该有社区贡献的教程,包括博客和视频格式。

    • 社区:应该有一个健康的社区在使用和支持框架,至少有一个以下沟通渠道:

      • Slack

      • Gitter

      • Github

      • 邮件列表

      • Stack Overflow

  • 安全性:框架应该是安全的,并实施最新的行业标准:

    • TLS:使用 TLS 保护框架的端点应该是可能的。

    • OWASP:框架应该实施 OWASP 咨询。

    • 验证:请求应根据消息注解中实现的规则自动验证。

    • 修补良好:应定期评估和修补安全漏洞。

    • 认证/授权:框架应实现认证和授权的方法,例如 OAuth 标准。

  • 开源:框架应该是开源的,并发布在允许分叉和修改的许可证下:

    • 社区:应该有一个良好的开源社区关注和为项目做出贡献。

    • 流行:框架应该是流行的,并且商业上被使用。

  • 质量:框架的代码质量应该是可见的,并且达到高标准。社区贡献应遵循已发布的流程和标准。

    • 高测试覆盖率:测试覆盖率应该高,并受到监控;拉取请求应确保遵守编码标准。

      • 单元测试:快速运行的单元测试是必不可少的。

      • 行为/功能:理想情况下,框架应实现关于生成代码和构建过程的行为和功能测试:

    • 自动化构建:应存在并可见的源代码自动化构建。拉取请求应运行自动化构建,并在请求上报告状态。

    • 代码质量:应使用自动化的代码质量工具,并且结果应该是可见的,例如:

    • 标准语言模式:考虑语言级别的惯用法的编写代码的标准方法是必不可少的。

    • 高效:框架必须生成在运行时高效的代码。

    • 快速:代码必须快速执行,并且设计用于性能。

    • 低延迟:请求应具有低延迟。

    • 内存低:服务应该内存效率高。

    • 支持大量连接:它应该支持大量的并发连接。

在类似的基础上比较各种框架很难,因为每个框架都提供了一套不同的功能,所有这些功能都会影响性能。然而,我认为尝试对每个框架运行一些性能测试是有用的。为此,我们将在具有两个 CPU 核心和 2GB RAM 的小型 Digital Ocean 主机上运行我们的示例服务。然后,我们将使用相同大小的另一台服务器来执行基准测试应用程序。

我们的策略是运行一个 5 分钟的测试,有 400 个连接和 5 秒的超时。连接将在 90 秒的间隔内逐步增加。

该过程不是一个科学测试,但它将给我们提供响应时间的指示,并确定服务器能否处理合理的并发连接数量。

作为基准测试,我创建了一个使用 JSON 作为消息协议的纯 HTTP 服务器。该服务的结果将在以下章节中概述,并与其他框架进行比较,以形成基准效率。

然而,需要注意的是,一些框架具有一些高级功能,如开箱即用的请求验证、断路器。这些功能的存在数量将影响服务的延迟,因此不可能进行真正的类似比较。

结果:

线程数 400
总请求数: 1546084
平均请求时间 51.50ms
总成功数 1546049
总超时数 35
总失败数 35

随时间变化的请求:

Micro

我们将要查看的第一个框架是由 Asim Aslam 开发的 Micro。在过去几年中,它一直在积极开发,并在汽车租赁公司 Sixt 的实际使用中获得了生产资质。Micro 是一个可插拔的 RPC 微服务框架,支持服务发现、负载均衡、同步和异步通信以及多种消息编码格式。要深入了解 Micro 的功能和查看其源代码,请访问 GitHub 上的以下位置:github.com/micro/go-micro

设置

Micro 的安装很简单;嗯,它是 Go,所以应该是这样的。你需要安装 protoc,这是生成源代码的应用程序,它是 Google 的 Protocol Buffers 包的一部分。作为消息协议,protobufs 正在迅速发展,你将在我们将在本章中查看的许多框架中找到这个消息协议。

代码生成

protoc 应用程序用于从 proto 文件定义中生成我们的 Go 代码。实际上,protoc 的美妙之处在于它可以生成大约 10 种不同语言的代码。Micro 还具有使用 protoc 插件自动生成客户端和服务器代码的能力。这是一个很好的功能,确实可以节省一些按键。

让我们看看如何使用 protoc 生成我们的 Go 代码,这些代码定义了我们的消息协议。在 20 min 内就能深入理解知识点,而且记忆深刻,难以遗忘

gomicro/proto/kittens.proto

  1 syntax = "proto3"; 
  2 
  3 package bmigo.micro; 
  4 
  5 message RequestEnvelope { 
  6     string service_method = 1; 
  7     fixed64 seq = 2; 
  8 } 
  9 
 10 message ResponseEnvelope { 
 11     string service_method = 1; 
 12     fixed64 seq = 2; 
 13     string error = 3; 
 14 } 
 15 
 16 message Request { 
 17     string name = 1; 
 18 } 
 19 
 20 message Response { 
 21     string msg = 1; 
 22 } 
 23 
 24 service Kittens { 
 25     rpc Hello(Request) returns (Response) {} 
 26 } 

当你运行 protoc 命令时,它会处理 proto DSL 文件并输出本地语言源文件。在我们的例子中,该代码片段看起来如下所示:

gomicro/proto/kittens.pb.go

 32 type Request struct { 
 33   Name string `protobuf:"bytes,1,opt,name=name" 
      json:"name,omitempty"` 
 34 } 
 35 
 36 func (m *Request) Reset()                    { *m = Request{} } 
 37 func (m *Request) String() string            { return 
    proto.CompactTextString(m) } 
 38 func (*Request) ProtoMessage()               {} 
 39 func (*Request) Descriptor() ([]byte, []int) { return 
    fileDescriptor0, []int{0} } 

我们从不手动编辑此文件,因此代码看起来如何并不重要。这一切只是在允许使用 Protocol Buffers 规定的二进制标准序列化结构体。

要与 Micro 一起使用,我们实际上不需要做很多事情。让我们看看主函数,看看设置有多简单:

gomicro/server/main.go

 20 func main() { 
 21   cmd.Init() 
 22 
 23   server.Init( 
 24     server.Name("bmigo.micro.Kittens"), 
 25     server.Version("1.0.0"), 
 26     server.Address(":8091"), 
 27   ) 
 28 
 29   // Register Handlers 
 30   server.Handle( 
 31     server.NewHandler( 
 32       new(Kittens), 
 33     ), 
 34   ) 
 35 
 36   // Run server 
 37   if err := server.Run(); err != nil { 
 38     log.Fatal(err) 
 39   } 
 40 } 

在第24行,我们正在初始化 micro 服务器,传递一些选项。就像我们可以传递基本 HTTP 服务器一个地址来配置服务器将绑定到的 IP 和端口一样,我们在第27行做了同样的事情。

从第31行开始的处理器部分也应该对你很熟悉;Micro 使用与net/rpc包中相同的签名。创建一个处理器就像定义一个struct并向它添加方法一样简单。Micro 会自动将这些注册为你的服务上的路由:

 12 type Kittens struct{} 
 13 
 14 func (s *Kittens) Hello(ctx context.Context, req *kittens.Request, 
    rsp *kittens.Response) error { 
 15   rsp.Msg = server.DefaultId + ": Hello " + req.Name 
 16 
 17   return nil 
 18 } 

处理器的形式看起来与net/http包中的非常相似;我们可以看到我们在第一章中查看过的相同上下文对象。如果你还记得,上下文是一个安全的方法,用于访问请求作用域的数据,可以从多个 Goroutines 中访问。请求和响应对象是我们定义在 proto 文件中的那些。在这个处理器中,我们不是将响应写入ResponseWriter,而是将我们希望返回的值设置到传递给函数的响应引用中。关于返回,如果我们遇到错误并希望通知调用者,我们有返回错误选项。

工具(CI/CD,跨平台)

由于 Micro 是用纯 Go 编写的,唯一的依赖项是protoc,它创建了一个非常轻量级的框架,可以轻松地在 Linux、Mac 和 Windows 上使用。它也容易设置到 CI 服务器上;主要复杂性是protoc的安装,但这个应用程序得到了 Google 的极大支持,并且适用于所有主要操作系统和架构。

可维护性

Micro 的构建方式对现代企业中微服务的更新和维护问题非常具有同情心。版本控制被纳入框架中,在我们的示例中,我们在server.Init方法中设置版本。多个服务可以通过它们的版本号区分开来而共存。在请求服务时,可以通过版本进行筛选,这样就可以部署新版本的服务,而不会对其他服务造成干扰。

格式(REST/RPC)

在其核心,Micro 使用 Google 的 Protocol Buffers 作为其核心消息协议。然而,这并不是与服务通信的唯一方法。Micro 还实现了边车模式,这是一个 RPC 代理。这为将任何应用程序集成到 Micro 生态系统提供了一个非常简单的方法。边车可以用作 API 网关,这是多个下游服务的单一入口点。在 Micro 的情况下,网关处理 HTTP 请求并将它们转换为 RPC;它还具备提供反向代理功能的能力。这是一个非常灵活的模式,边车可以与主要服务不同地扩展,允许你将其作为面向非 Micro 消费者的公开端点暴露出来。

更多关于 Micro 架构的信息可以在 Micro 网站上找到,地址为blog.micro.mu/2016/03/20/micro.htmlblog.micro.mu/2016/04/18/micro-architecture.html。我强烈推荐任何考虑使用 Micro 的人阅读这些文章,因为它们提供了关于 Micro 能够做什么以及它使用的令人惊叹的模式概览。

Micro 还实现了编码和解码消息的编解码器接口,所以默认情况下,它支持proto-rpcjson-rpc,但应用你选择的任何消息协议将变得极其容易。

模式

通常,Micro 架构得非常好,并且考虑到生产使用。Micro 的创建者 Asim,同时也是主要维护者,作为软件架构师和软件工程师有着令人难以置信的背景。我们将在本章讨论的大多数常见模式已经在 Micro 中实现,并且还有许多作为社区插件可用。它包括完整的 PubSub 支持,并且再次支持包括 Redis 和 NATS 在内的广泛的后端服务器。由于架构模型,如果你选择的后端不是标准包的一部分,编写自己的插件相对容易。

语言无关性

多亏了两个很好的设计选择,即使用 Protocol Buffers 作为消息格式以及边车的功能,我们几乎可以从任何支持 HTTP 传输的语言中与微服务进行接口。

让我们快速看一下如何使用 Ruby 发送和接收消息。这个例子可能比仅仅做一个简单的 REST 调用要复杂一些,而且大多数使用 Micro 的人可能会选择从边车使用 JSON-RPC。然而,看到我们如何使用 RPC 接口进行接口是很有趣的。虽然看起来可能比使用 Micro 的 Go 客户端进行调用有更多的样板代码,但这些可以封装成一个库并作为 gem 分发;这段代码只是为了说明可能性:

gomicro/client/client.rb

 82 def main() 
 83 
 84   puts "Connecting to Kittenserver" 
 85 
 86   request = Bmigo::Micro::Request.new(name: 'Nic') 
 87   body = send_request('kittenserver_kittenserver_1', '8091', 
      'Kittens.Hello', request).body 
 88   envelope, message = read_response(body, Bmigo::Micro::Response) 
 89 
 90   puts envelope.inspect 
 91   puts message.inspect 
 92 end 

正如我们从 proto 文件中生成了一些原生 Go 代码一样,我们也可以为 Ruby 做同样的事情。这使得共享这些服务定义并节省消费者手动编写的麻烦成为可能。在86行,我们正在创建一个请求,该请求被发送到 Micro 服务。尽管 Micro 是一个 RPC 服务,但它仍然使用 HTTP 作为传输协议,这使得使用 Ruby 的标准的NET::HTTP库来发送请求变得容易。然而,在我们能够这样做之前,我们需要了解 Micro 的消息协议是如何工作的。以下就是 Micro 的消息格式:

信封大小(4 字节) 信封(n 字节) 消息大小(4 字节) 消息(n 字节)

消息体中的前 4 个字节是信封的长度。信封本身是 Micro 确定消息发送目的的方法;它类似于你在 RESTful API 中使用 URI 的方式。信封使用 Protocol Buffer 的二进制序列化方法通过 encode 方法写入到消息体中。幸运的是,所有这些工作都由protobuf包为我们完成。然后我们再次使用正好 4 个字节写入消息大小,随后是消息,这同样使用 Protocol Buffers 包编码成二进制表示。

消息可以随后作为 HTTP POST 发送。为了让 Micro 知道我们发送的是二进制消息而不是 JSON-RPC,我们必须指定Content-Type头并将其设置为application/octet-stream

Micro 返回的响应将与请求格式相同。

与其他框架的接口能力

由于其语言无关的接口,可以将 Micro 与许多不同的框架集成。理论上,你甚至可以编写一个与 Micro 兼容的服务,利用所有服务发现和注册功能,而使用的语言不是 Go。然而,我们为什么要这样做呢?毕竟,Go 语言非常出色。

效率

Micro 表现优异,能够管理 400 个连接,响应时间大约为 125 毫秒。响应时间使我们几乎每秒可以处理 3,000 个请求。虽然这并不是直接反映你的服务器在生产中的表现,但我们将使用相同的设置来测试本章中所有框架。在负载测试 Micro 时,内存消耗效率高,仅消耗服务器上大约 10%的可用内存。CPU 负载,像所有测试一样,都在最大值运行,但这是在系统处理如此多的并发请求时可以预料的。

结果:

线程数 400
总请求数 806011
平均请求时间 125.58ms
总成功数 806011
总超时数 0
总失败数 0

请求随时间变化:

质量性

Micro 框架的质量非常高,具有自动构建、所需的地方有适当的代码覆盖率,并且实现许多标准 Go 编程习惯应该非常直接。

开源

该框架采用 Apache 许可证开源。至于流行度,Micro 在 GitHub 上有超过 2,000 颗星,并接受社区的贡献。

安全性

默认情况下,Micro 没有明确的身份验证或授权层,我认为这是好事。使用 JWT 或如果你真的需要,使用你自己的格式将身份验证集成到服务框架中相对简单。请求验证部分由 Protocol Buffers 处理。然而,要执行更复杂的验证,可能需要使用类似 govalidator (github.com/asaskevich/govalidator) 的工具。然而,由于你不能直接修改请求对象来添加所需的字段标签,你可能不得不跳过几个步骤。然而,验证的问题更多是与 Protocol Buffers 框架有关,而不是 Micro。

从安全通信的角度来看,Micro 使用 net/http 作为基础传输,因此引入 TLS 加密将是一件非常简单的事情,不仅适用于面向公众的服务,也适用于私有服务。当我们更深入地了解安全性时,你会看到这为什么很重要。

支持

对框架的支持相当出色;有很多代码示例,还有一个使用得很好的 Slack 群组,所以你可能会有的任何问题都可以由其他用户或 Asim 本身回答,Asim 在群组中非常活跃,提供支持。

扩展性

Micro 的一个很好的特性是它为了扩展性而设计的架构方式。服务发现、消息传递和传输的所有标准依赖项都遵循接口驱动的抽象。如果你需要实现特定的用例,或者当可能由于 etcd 版本更新等引入破坏性更改时进行升级,编写一个特定于此的插件并在你的服务中使用它将不成问题。对于一个这样的框架来说,客户端和服务器接口都支持中间件,这将使从身份验证和授权到请求验证的广泛功能成为可能。

我们从 Micro 中学到的东西

通常来说,Micro 是一个很好的框架,几乎涵盖了构建高度可扩展的分布式系统时所需的所有需求。Asim 在创建和维护这个框架方面都做得非常出色,他的技能和经验在他的实现模式中得到了体现。

Kite

Kite 是由负责 Koding(基于浏览器的 IDE)的团队开发的框架。该框架被 Koding 团队使用,并且由于他们自己面临了许多问题,因此将其开源,他们认为这对其他微服务实践者会有所帮助。

框架背后的概念是,一切都是风筝,无论是服务器还是客户端,它们都通过 WebSocket 和基于 RPC 的协议进行双向通信。WebSocket 使得服务间通信非常高效,因为它消除了不断进行握手连接的开销,这个过程可能需要的时间与消息传递本身一样长。Kite 还内置了服务发现功能,允许你调用风筝而无需知道具体的端点。

设置

Kite 的安装相对简单;有一些服务发现的依赖项,例如 etcd,但创建风筝所需的所有代码都位于 Go 包中。如果我们使用 go get 命令安装此包,我们就可以开始编写我们的第一个风筝了。

go get github.com/koding/kite  

Kite 的工作方式是有一个与你的应用程序风筝一起运行的服务,称为 kontrol。这个服务处理服务发现,你的所有应用程序服务都注册到这个服务上,以便客户端可以查询服务目录以获取服务端点。kontrol 风筝包含在主包中,为了方便,我创建了一个 Docker Compose 文件,它启动了这个服务以及 etcd,它用作服务注册。

如果我们看一下我们的服务器实现,我们可以看到我们需要添加的注册新服务的各个步骤:

kite/server/main.go

 13 func main() { 
 14 
 15   k := kite.New("math", "1.0.0") 
 16   c := config.MustGet() 
 17   k.Config = c 
 18   k.Config.KontrolURL = "http://kontrol:6000/kite" 
 19 
 20   k.RegisterForever(&url.URL{Scheme: "http", Host: "127.0.0.1:8091", Path: "/kite"}) 
 21 
 22   // Add our handler method with the name "square" 
 23   k.HandleFunc("Hello", func(r *kite.Request) (interface{}, error) { 
 24     name, _ := r.Args.One().String() 
 25 
 26     return fmt.Sprintf("Hello %v", name), nil 
 27   }).DisableAuthentication() 
 28 
 29   // Attach to a server with port8091 and run it 
 30   k.Config.Port = 8091 
 31   k.Run() 
 32 
 33 } 

在第 15 行,我们正在创建我们的新风筝。我们向 New 方法传递两个参数:我们的风筝名称和服务版本。然后我们获取配置的引用并将其设置到我们的风筝中。为了能够将我们的风筝注册到服务发现中,我们必须将 KontrolURL 设置为我们 kontrol 服务器的正确 URI:

k.Config.KontrolURL = "http://kontrol:6000/kite 

如果你看看我们传递的 URL;我们使用的是 Docker 在我们将一些容器链接在一起时提供的名称。

在下一行,我们正在将我们的容器注册到 kontrol 服务器。我们需要传递我们正在使用的 URL 方案。在这个例子中,HTTP 是主机名;这需要是应用程序的可访问名称。我们在主机名上有点作弊,因为我们正在将端口暴露给 Docker 主机;如果我们的客户端应用程序已经链接到这个容器,我们本可以传递内部名称。

现在有趣的部分开始了,我们定义了风筝将可用的方法。如果我们看一下第 25 行,我们会看到一个应该看起来相当熟悉的模式:

HandleFunc(route string, function func(*kite.Request) (interface{}, error)) 

HandleFunc 的签名与标准 HTTP 库非常相似;我们设置一个路由并传递一个负责执行该请求的函数。你会看到请求和响应都没有类型。嗯,对于 Request 方法来说,这并不完全正确,但确实没有明确定义的合同。

要获取通过 Request 方法传递的参数,我们使用 Args 对象,它是一个 dnode 消息。与其他我们考虑过的框架不同,dnode 消息没有可以在消息的消费者和生成者之间共享的合约,因此每个都必须实现自己的解释。dnode 消息本身是一个以换行符终止的 JSON 消息,并且被 kite 框架高度抽象化,对于好奇的人来说,协议定义可以在以下文档中找到:github.com/substack/dnode-protocol/blob/master/doc/protocol.markdown#the-protocol

我们的 HandleFunc 输出是标准的 Go 模式 interface{} error,这里的 interface{} 是我们希望发送给调用者的响应。这不是强类型,它很可能只是一个可以序列化到 dnode 有效载荷的 struct,其表示形式是 JSON。

Kite 的一个优点是内置了身份验证,在我们的实例中,我们已禁用此功能。根据调用者的权限限制特定服务调用的操作相当常见。在底层,Kite 使用 JWT 将这些权限分解成一系列声明。原则是密钥被签名,因此接收服务只需验证密钥的签名即可信任其有效载荷,而不是必须调用下游服务。我们最后调用的行是 k.Run();这启动了我们的 Kite 并阻塞了主函数。

代码生成

使用 Kite,无需代码生成或模板来帮助设置您的服务器和客户端。话虽如此,创建服务器的简单性并不需要这种需求。

工具

除了 Go 语言之外,您几乎不需要设置 Kite。用于服务发现的 etcd 和 Kite 都可以轻松地打包进 Docker 容器,这允许标准的测试和部署工作流程。

框架的跨平台元素仅限于可以使用 Go 框架编译的领域,在我撰写本文时,这是一个相当令人印象深刻的列表。

可维护性

Kite 是一个相对成熟的框架,在三年多的时间里一直处于活跃开发状态。它还被 Koding 服务积极使用,该服务于 2016 年被亚马逊收购。

由于路由是通过注册处理程序来工作的,因此可以干净地将您的实现与主 Kite 包分开,这允许在上游更改时轻松更新主包。

我对dnode消息周围的合同缺失有一些保留。如果不妥善管理,这可能会引起维护问题,因为消费者有责任发现协议实现,而供应商服务必须记录此协议并确保其正确版本化以避免破坏性更改。据我所知,没有从代码源自动生成文档的能力。由于 dnode 底层使用 JSON,可能有一个想法是在有效负载中包含一个 JSON 对象,其类型已知并且可以很容易地使用标准包序列化到结构体中。

格式

Kite 使用 dnode 作为其消息协议。虽然如果你正在进行风筝到风筝的通信或如果你使用 Kite 的 JavaScript 框架,这不会成为问题,但如果你想从你的堆栈中的另一种语言进行接口,这可能会成为一个问题。协议定义列在 GitHub 项目github.com/substack/dnode-protocol/blob/master/doc/protocol.markdown#the-protocol中,并且它是基于 JSON 的。查看 dnode 的文档,似乎消息协议和执行框架从未打算松散耦合。我个人的建议是在选择消息协议时,你应该确保已经为你的选择语言编写了编码器和解码器。如果没有包,那么你需要评估协议是否有足够大的用户基础,以至于编写这个包的行动是合理的。

模式

服务发现是 Kite 应用程序控制的一部分。kontrol 的后端存储不是专有的,但它使用插件架构并支持 etcd、consul 等。

如果我们查看我们的客户端应用程序,我们可以看到这个功能是如何实际工作的。

在第19行,我们调用GetKites方法,并传递一个KontrolQuery作为参数。查询包含用户名、环境和我们要引用的服务名称。

这个调用的返回类型是 Kites 的切片。在我们的简单示例中,我们只是获取列表中的第一个元素的引用。这个过程确实意味着我们必须自己实现负载均衡和断路器;如果 kontrol 内置了这个功能会更好。

要连接到 Kite,我们有两种方法可供选择:

Dial() 
DialForever() 

Dial()方法接受一个超时时间,无论是否成功连接到下游服务,超时后该方法都会返回。DialForever()方法,正如其名称所暗示的,不会超时。在这两种情况下,都会返回一个通道,我们使用它来暂停执行,直到我们获得连接。

调用服务现在就像执行 Tell 一样简单,只需传递你希望运行的方法名称和参数即可。在我看来,风筝在这里减分了。服务调用的合约非常宽松,为消费者创建实现将不会没有困难。

语言独立性

作为框架,风筝主要基于 Go 和 JavaScript。JavaScript 包 github.com/koding/kite.js 允许你用 JavaScript 编写风筝,它可以在 NodeJS 服务器上运行,或者你也可以直接从浏览器使用插件,这将使你能够构建丰富的用户界面。

从 Ruby 等语言与风筝通信需要一定的努力。需要编写自定义代码来与 kontrol 交互,并执行对风筝的查询。这当然是可以实现的,如果你构建了这个框架,请将其推回开源社区。

效率

风筝(Kite)速度快。得益于其使用的 WebSocket 技术,一旦连接成功,似乎几乎没有额外的开销。在我测试系统时,确实遇到了一些创建多个风筝连接的问题;这些问题并非出现在服务器端,而是在客户端。说实话,我并没有深入挖掘这个问题,但客户端使用共享风筝的性能相当令人印象深刻。在 CPU 和内存消耗方面,风筝是所有评估框架中消耗最多的。在 400 个连接测试中,风筝消耗了客户端 2 GB RAM 中的 1.8 GB;服务器消耗了 1.6 GB。客户端和服务器都是 CPU 的重度使用者:

结果表:

线程数 400
总请求数: 1649754
平均请求时间 33.55ms
总成功数 1649754
总超时数 0
总失败数 0

请求随时间的变化:

质量

风筝框架使用 Travis CI,并为每个构建执行单元和集成测试。代码覆盖率不是很大;然而,它似乎覆盖了复杂性,查看 GitHub 上的问题,似乎没有突出的问题。

开源

该项目在 GitHub 上相当受欢迎,拥有超过 1,200 个星标。然而,没有 Slack 社区或论坛。不过,当问题发布在 GitHub 问题上时,作者们非常擅长回答问题。

安全性

在安全性方面,风筝使用自己的基于 JWT 的身份验证层,并支持标准的 Go TLS 配置来安全地连接两个风筝。然而,请求验证似乎并不存在,我认为这是由于 dnode 协议非常动态所致。应该相对简单地将此实现,因为处理程序可以像使用 net/http 构建中间件模式一样链式连接。

支持

由于 Kite 在 Koding 的商业环境中使用,它得到了非常好的维护和成熟,并定期更新。然而,文档却有些不足,在我编写示例代码时,我花了很多时间来理解各个部分。作者们意识到了文档的问题,并计划改进这一方面。Google 在提供 Kite 的帮助方面也做得很少。在搜索问题时,通常你最终会回到 GitHub 仓库。如果你是一个相对有经验的开发者,这并不是一个巨大的问题,因为你可以直接阅读代码并逆向工程它。然而,如果你是初学者,这可能会成为一个问题,因为你可能没有牢固掌握底层概念。

有代码级别的文档,代码本身具有描述性;然而,有一些元素可能需要进一步的解释。

可扩展性

Kite 没有正式的插件或中间件格式。然而,由于它的设计方式,扩展框架应该是可能的。但是,你可能会遇到问题。例如,如果你希望为 kontrol 存储添加不同的后端,选项被硬编码到 kontrol 应用程序中,因此即使存储是从一个接口派生出来的,也需要对 kontrol 的主函数进行修改以启用这一功能。

总结 Kite

Kite 是一个编写得很好的框架,如果你只是用 Go 语言构建需要从浏览器访问的微服务,那么它可能是一个合适的选择。在我看来,文档和教程需要更多的改进;然而,我怀疑这是由于 Kite 是一个小型公司的内部框架,而不是出于生产社区开源框架的意图而被开源的。Kite 由于框架中缺少标准模式而失去了很多分数。由于消息协议 dnode,跨框架集成也受到影响,而且文档可以大幅改进。

gRPC

我们在第二章“设计一个优秀的 API”中查看 API 设计时,已经了解过 Google 的 Protocol Buffers 消息协议。gRPC 是一个跨平台框架,它使用 HTTP/2 作为传输协议,并使用 Protocol Buffers 作为消息协议。Google 开发它作为他们内部使用了多年的 Stubby 框架的替代品。

这个项目的目的是构建一个框架,它促进良好的微服务设计,专注于消息而不是分布式对象。gRPC 也针对我们在微服务架构中面临的许多网络问题进行了优化,例如脆弱的网络、有限的带宽、传输成本等等。gRPC 的另一个令人喜爱的特性是其能够在客户端和服务器之间流式传输数据。这在某些应用程序类型中可以带来巨大的好处,并且作为标准组件内置到框架中。此外,对于微服务之间的通信,还有一个纯 JavaScript 实现,旨在使浏览器客户端能够访问 gRPC 服务器。在撰写本文时,这尚未发布,预计发布日期为 2017 年第三季度。

设置

设置 gRPC 项目的主要问题是安装 protoc 应用程序和从以下 URL 获得的各个插件:

github.com/google/protobuf/releases

我们接下来需要安装 gRPC 的 Go 包和 protoc 的代码生成插件:

$ go get google.golang.org/grpc
$ go get -u github.com/golang/protobuf/{proto,protoc-gen-go}  

为了方便,我创建了一个包含所有这些包的 Docker 容器 (nicholasjackson/building-microservices-in-go)。如果你查看示例代码中的 chapter4/grpc/Makefile,你会看到我们正在使用 Docker 的力量来避免安装任何应用程序的麻烦。

代码生成

gRPC 的美妙之处在于代码生成。从我们在第二章中查看的简单 proto 文件,我们可以生成所有客户端和服务器代码。然后我们只需要连接我们的处理程序,它们将处理业务逻辑。

如果我们查看 chapter4/grpc/proto/kittens.proto 中的 proto 文件,我们可以看到这个文件与我们在上一章中审查的文件有些相似。

主要区别在于从第 13 行开始的以下代码块:

 13 service Kittens { 
 14     rpc Hello(Request) returns (Response) {} 
 15 } 

这是我们的服务定义,它包含了我们处理程序的合约。尽管它是用 proto DSL 编写的,但它的语义很好,非常易于阅读。

要生成我们的 Go 代码,我们只需要调用 protoc 命令,并告诉它使用 Go 插件:

protoc -I /proto /proto/kittens.proto --go_out=plugins=grpc:/proto  

这将创建我们的消息和我们的服务定义,并将它们输出到 kittens.pb.go 文件。即使代码是自动生成的,查看这个文件也是非常有趣的,可以看到框架的一些内部工作原理。

现在让我们看看,如果我们查看 grpc/server/main.go,使用这个框架有多简单。

我们可以看到,我们首先做的事情是设置我们的处理程序代码:

 15 type kittenServer struct{} 
 16 
 17 func (k *kittenServer) Hello(ctx context.Context, request 
    *proto.Request) (*proto.Response, error) { 
 18   response := &proto.Response{}
 19   response.Msg = fmt.Sprintf("Hello %v", request.Name) 
 20 
 21   return response, nil 
 22 } 

在第 15 行,我们创建了一个结构体,其方法将与自动由 protoc 命令为我们生成的 KittenServer 接口相对应:

type KittensServer interface { 
    Hello(context.Context, *Request) (*Response, error) 
} 

17 行是我们定义我们的处理器的地方,同样,这个模式应该与我们在 第一章 中考察的模式相似,微服务介绍。我们有上下文和一个与我们在 protos 文件中定义的请求消息相对应的对象,以及响应和错误的返回元组。

这个方法是我们将进行请求工作的地方,您可以看到在第 18 行我们创建了一个响应对象,然后设置了将返回给客户端的消息。

将服务器连接起来也非常简单。我们只需要创建一个监听器,然后创建一个新的服务器实例,这个实例是由 protoc 命令为我们自动生成的:

 24 func main() { 
 25   lis, err := net.Listen("tcp", fmt.Sprintf(":%d", 9000)) 
 26   if err != nil { 
 27     log.Fatalf("failed to listen: %v", err) 
 28   } 
 29   grpcServer := grpc.NewServer() 
 30   proto.RegisterKittensServer(grpcServer, &kittenServer{}) 
 31   grpcServer.Serve(lis) 
 32 } 

客户端代码同样简单。如果我们查看 grpc/client/client.go,我们可以看到我们正在创建到我们服务器的连接,然后发起请求:

 12 func main() { 
 13   conn, err := grpc.Dial("127.0.0.1:9000", grpc.WithInsecure()) 
 14   if err != nil { 
 15     log.Fatal("Unable to create connection to server: ", err) 
 16   } 
 17 
 18   client := proto.NewKittensClient(conn) 
 19   response, err := client.Hello(context.Background(), 
      &proto.Request{Name: "Nic"}) 
 20 
 21   if err != nil { 
 22     log.Fatal("Error calling service: ", err) 
 23   } 
 24 
 25   fmt.Println(response.Msg) 
 26 } 

grpc.Dial 方法具有以下签名:

func Dial(target string, opts ...DialOption) (*ClientConn, error) 

目标是一个字符串,它对应于服务器的网络位置和端口,而 opts 是一个可变数量的 DialOptions 列表。在我们的例子中,我们只使用了 WithInsecure,这禁用了客户端的传输安全;默认情况下,传输安全是设置的,所以在我们这个简单的例子中,我们需要这个选项。

选项列表非常全面,您可以指定配置,如超时和使用负载均衡器。有关完整列表,请参阅文档,可在 godoc.org/google.golang.org/grpc#WithInsecure 找到。

18 行是我们创建客户端的地方。这是一个定义在我们自动生成的代码文件中的类型,而不是基础包。我们传递给它我们之前创建的连接,然后我们可以像第 19 行所示的那样调用服务器上的方法。

工具

gRPC 的工具相当令人印象深刻。支持的平台和语言数量庞大,仅使用 Go 和 protoc 应用程序,就可以相对容易地设置自动化构建。在我们的简单例子中,我已经配置了构建在 Docker 容器中运行,这进一步限制了在持续部署机器上安装任何软件的要求。通过这样做,我们可以限制所有构建中使用的依赖项。我们将在后面的章节中了解更多关于这种技术的信息。

可维护性

更新 gRPC 也非常容易。谷歌投入了大量工作,使新的 v3 规范与 v2 的 Protocol Buffers 兼容,根据文档,谷歌希望随着 gRPC 和 Protocol Buffers 的发展,继续保持这种兼容性。

格式

虽然我们可能没有 REST 的语义特性,但我们确实有一个用 Protocol Buffers 定义的非常清晰的消息协议。定义易于理解,并且允许客户端连接使用我们定义的 proto 文件并重新使用它们来创建自己的客户端,这是一个非常棒的功能。

模式

框架中实现的模式集合非常全面,支持健康检查和超时。没有显式支持中间件;然而,许多中间件的需求,如身份验证和请求验证,我们都可以免费获得框架内置的支持。我们也没有断路器,但均衡器可以配置以添加此功能。在官方文档中,有一项声明称这是一个实验性 API,未来可能会更改或扩展。因此,我们可以期待从这个功能中获得许多重大更新。

客户端本身有配置来处理退避算法。这种节流机制可以在高负载情况下保护您的服务器,避免因压力下的服务器可能承受数千个连接而洪水般涌入。

从服务发现的角度来看,框架内部没有隐式处理这一点;然而,扩展点存在,可以用来与您选择的任何后端进行此操作。

语言独立性

目前 gRPC 支持的语言数量相当令人印象深刻,有 10 种语言得到官方支持,还有许多更多由不断增长的用户社区支持。使用protoc命令在多种语言中生成和分发客户端 SDK 的能力非常出色。为了展示如何从除 Go 以外的语言实现这一点,我们创建了一个简单的 Ruby 示例,展示了连接到 gRPC 服务是多么简单。

在我们的示例中,grpc/client/client.rb,我们可以看到要初始化连接并执行对用 Go 编写的 gRPC 端点的请求,所需的代码行数非常少:

  6 require 'kittens_services_pb' 
  7 
  8 service = 
     Bmigo::Grpc::Kittens::Stub.new('kittenserver_kittenserver_1:9000', 
     :this_channel_is_insecure) 
  9 
 10 request = Bmigo::Grpc::Request.new 
 11 request.name = 'Nic' 
 12 
 13 response = service.hello(request) 
 14 
 15 puts response.msg 

对于非 Ruby 程序员来说,在第 6 行,我们包含了自动生成的代码,该代码是用protoc命令和 Ruby gRPC 插件生成的。

第 8 行创建了一个到我们服务器的实例,再次传递了与 Go 客户端相同的非安全通道选项。

然后,我们在第 10 行创建一个请求对象,设置此请求的参数,并执行它。请求和响应的所有对象都为我们定义好了,并且使用起来非常简单。

Google 目前正在开发一个框架版本,该版本将允许从网络浏览器连接到 gRPC 服务。当这个版本到来时,将非常容易创建由 gRPC 微服务支持的交互式 Web 应用程序。

效率

由于使用了 HTTP/2 和二进制消息,gRPC 非常快速,能够支持巨大的吞吐量。向客户端流式传输数据是一个极好的功能。从移动端的角度来看,客户端只需要维护到服务器的单个连接,这是高效的,服务器也可以向这个开放连接推送数据更新。以下是一个例子,看看我为 GoLang UK 会议创建的代码,它实现了一个简单的服务器和 Android 客户端,该客户端接收流式更新。而不是在 Android 上使用本地的 gRPC 客户端,我使用 GoMobile 编译了一个用 Go 编写的本地框架,然后在 Android 应用中使用它:

github.com/gokitter

结果:

线程数 400
总请求: 2949094
平均请求时间 23.81ms
总成功 2949094
总超时 0
总失败 0

请求随时间变化:

质量

如你所期望的,从谷歌的角度来看,项目的质量非常高,框架实现了许多令人惊叹的架构模式和标准语言模式。所有代码都是使用持续集成构建的,测试覆盖率也非常出色。

开源

该框架越来越受欢迎,并且由谷歌和社区提交者持续开发。一切都是开源的,如果你想要深入研究代码,GitHub 仓库中所有内容都对你开放:

github.com/grpc

安全性

从安全性的角度来看,我们拥有所需的所有功能。gRPC 支持 TLS 加密、身份验证和请求验证。因为底层传输是net/http,我们也可以确信我们在服务器层接收到了最高质量的服务。

支持

文档再次表现出色,gRPC 网站上提供了优秀的示例和源代码文档。社区资源列表正在增长,博客作者提供了更多示例,同时谷歌群组和 Stack overflow 上也有支持。

可扩展性

从可扩展性的角度来看,可以为 protoc 编写自定义插件,就像 Micro 框架所做的那样,而且框架编写得很好,也是可扩展的。作为一个刚刚达到版本 1 的新框架,当前选项非常令人印象深刻,我只看到这些在未来的版本中会继续增长。

关于 gRPC 的几行描述

我对 gRPC 框架及其日益增长的选项和社区支持印象深刻,这些支持似乎每天都在增长。协议缓冲区消息格式似乎也在增长,例如苹果公司贡献了它们的实现,我可以看到这将成为客户端-服务器通信的非常接近的非官方标准,取代 JSON。

摘要

在评估中,Micro 和 gRPC 都名列前茅,但原因略有不同。如果你的大部分系统都是 Go 语言,Micro 可以直接在生产系统中使用。Micro 的开发仍在继续,目前的重点在于性能,坦白说,这已经相当令人印象深刻。尽管如此,通过解决微服务开发中缺失的必要元素,gRPC 真的是一个有力的竞争者。其多语言特性和吞吐量都非常出色,并且它还在持续改进中。

在本章中,我们探讨了几个不同的框架,并希望它们能让你对在必要时自己做出决策所需的关键特性有所了解。在下一章,我们将探讨日志和度量,这些是运行生产中微服务的基本技术。

第七章:日志和监控

日志和监控不是高级话题。然而,它们是那些你直到没有它们才意识到它们有多重要的事情之一。关于你的服务的有用数据对于理解你的服务正在运行的负载和环境至关重要,这样你就可以确保它被精细调整以提供最佳性能。

考虑这个例子:当你首次推出你的服务时,你有一个返回小猫列表的端点。最初,这个服务响应迅速,响应时间为 20 毫秒;然而,随着人们开始向服务中添加项目,速度减慢到 200 毫秒。这个问题的第一部分是你需要了解这种减速。如果你从事电子商务,处理请求或页面加载所需的时间与客户购买东西的可能性之间存在直接关联。

确定速度的传统方法之一一直是查看边缘的情况;你使用像谷歌分析这样的工具,并测量最终用户所体验到的页面加载速度。

这个问题的第一个问题是,你不知道减速是从哪里开始的。当我们构建单体应用时,这很简单;减速要么是添加到 HTML 中的额外冗余,要么是单体应用服务器。因此,应用服务器可能将一些指标输出到日志文件;然而,由于应用程序只有一个附加的数据存储,你不需要查看很多地方就能找到源头。

微服务出现后,一切都会改变;你可能有 1000 个应用,而不是一个;你可能有一个数据存储,而不是 100 个,还有数十个其他相关服务,如云存储队列和消息路由器。你可以采取相同的猜测和测试方法,但最终你可能会对你自己和所有构建该系统的同事产生深深的厌恶。

第 2 个问题:使用谷歌分析并不能轻易告诉你当网站在负载下时是否会变慢。当你遇到故障时,你将如何知道?如果页面没有加载是因为后端服务器没有响应,那么向谷歌分析发送数据的 JavaScript 将不会触发。你甚至能在谷歌分析中设置一个警报,当平均加载时间低于某个阈值时发出警报吗?

第 3 个问题:你只有一个 API,没有网站;再见了,谷歌分析。

现在,我并不是说你不应该使用谷歌分析;我想要说的是,它应该成为更大策略的一部分。

堆栈跟踪和其他有助于诊断问题的应用程序输出可以分为三类:

  • 指标:这些是诸如时间序列数据(例如,交易或单个组件的计时)之类的东西。

  • 基于文本的日志:基于文本的记录是真正的老式日志,由 Nginx 或其他应用程序软件的文本日志等生成。

  • 异常:异常可能属于前两个类别之一;然而,我喜欢将这些内容分开成单独的类别,因为异常应该是,嗯,异常的。

如往常一样,本章的源代码可在 GitHub 上找到,你可以在github.com/building-microservices-with-go/chapter7找到。

日志最佳实践

在免费的电子书《实用日志手册》中,Jon Gifford(Loggly,www.loggly.com)提出了以下八个最佳实践,这些实践适用于确定你的日志策略:

  • 将应用程序日志视为一个持续迭代的流程。先进行高层次记录,然后添加更深入的仪表化。

  • 总是记录任何超出流程范围的内容,因为分布式系统的问题表现不佳。

  • 总是记录不可接受的性能。记录任何超出你期望系统性能范围的任何内容。

  • 如果可能,始终记录足够多的上下文,以便从单个日志事件中获得完整的事件画面。

  • 将机器视为你的最终消费者,而不是人类。创建你的日志管理解决方案可以解释的记录。

  • 趋势比数据点更能讲述故事。

  • 仪表化不能替代分析,反之亦然。

  • 慢速飞行比盲目飞行要好。因此,争论的焦点不是是否要进行仪表化,而是仪表化的程度。

我认为其中有一个观点需要更多的解释,那就是“仪表化不能替代分析,反之亦然。”乔恩指的是,虽然你的应用程序可能有高水平的日志和监控,但你仍然应该运行一个预发布流程来分析应用程序代码。我们研究了 Go 的剖析工具,并且我们也使用 bench 工具进行了一些基本的性能测试。然而,对于生产服务,应该采取更彻底的方法,本书的范围不包括深入探讨性能测试,但我鼓励你阅读 Packt 出版的 Bayo Erinle 所著的《使用 JMeter 3 进行性能测试》以获取更多关于这个主题的信息。

指标

在我看来,指标是日常运营中最有用的日志形式。指标之所以有用,是因为我们有简单的数值数据。我们可以将这些数据绘制到时间序列仪表板上,并且可以相当快速地从输出中设置警报,因为数据处理和收集的成本极低。

无论你存储什么,指标的高效性在于你使用唯一键作为标识符,在时间序列数据库中存储数值数据。数值数据允许数据的计算和比较非常高效。它还允许数据存储在时间推移时降低数据的分辨率,使你在需要时能够拥有细粒度数据,同时保留历史参考数据,而无需需要 PB 级的数据存储。

指标最适合表示的数据类型

这相当简单:这是通过简单的数字表达时具有意义的数值数据,例如请求定时和计数。你希望你的指标有多细取决于你的需求;通常,当我构建微服务时,我会从顶线指标开始,例如处理器的请求定时、成功和失败计数,如果我在使用数据存储,那么我也会包括这些。随着服务的发展,我开始进行性能测试,我将会开始添加帮助我诊断服务性能问题的新的项目。

命名规范

定义命名规范非常重要,因为一旦你开始收集数据,就会有一个需要分析数据的时候。对我来说,关键不是为你的服务定义一个规范,而是一个对你的整个环境有用的规范。当你开始调查你的服务问题时,往往你会发现问题并不一定出在你的服务上,而可能是由于许多其他因素:

  • 主机服务器上的 CPU 耗尽

  • 内存耗尽

  • 网络延迟

  • 慢速数据存储查询

  • 由于任何前面的因素导致的下游服务的延迟

我建议你使用点符号如以下方式拆分你的服务名称:

environment.host.service.group.segment.outcome 

  • environment: 这是工作环境;例如:生产,预发布

  • host: 这是运行应用程序的服务器的主机名

  • service: 你的服务名称

  • group: 这是顶级分组;对于 API,这可能是处理器

  • segment: 该组的孩子级信息;这通常是在 API 实例中处理器的名称

  • outcome: 这表示操作的结果,在 API 中你可能已经调用,成功,或者你可能选择使用 HTTP 状态码

下面是一个如何使用以下点符号的示例:

prod.server1.kittenserver.handlers.list.ok 
prod.server1.kittenserver.mysql.select_kittens.timing 

如果你的监控解决方案支持事件名称之外的标签,那么我建议你使用标签来表示环境和主机,这将使查询数据存储变得更容易。例如,如果我有一个处理器,它列出了在我的生产服务器上运行的猫咪,那么我可能会选择在处理器被调用时发出以下事件:

func (h *list) ServeHTTP(rw http.ResponseWriter, r *http.Request) { 
  event := startTimingEvent("kittens.handlers.list.timing", ["production", "192.168.2.2"]) 
  defer event.Complete() 

  dispatchIncrementEvent("kittens.handlers.list.called", ["production", "192.168.2.2"]) 

... 

  if err != nil { 
    dispatchIncrementEvent("kittens.handlers.list.failed", ["production", 192.168.2.2"]) 
   return` 
  } 

  dispatchIncrementEvent("kittens.handlers.list.success", ["production", 192.168.2.2"]) 
} 

这是一个伪代码,但你可以看到我们从该处理器派发了三个事件:

  1. 第一件事是我们将要发送一些定时信息。

  2. 在接下来的操作中,我们只是将要发送一个增量计数,这仅仅表明处理程序已被调用。

  3. 最后,我们将检查操作是否成功。如果不成功,我们将增加我们的处理失败指标;如果成功,我们将增加我们的成功指标。

以这种方式命名我们的指标允许我们在粒度级别或更高层次上绘制错误。例如,我们可能对整个服务的总失败请求数量感兴趣,而不仅仅是这个端点。使用这种命名约定,我们可以使用通配符进行查询;因此,为了查询这个服务的所有失败,我们可以编写如下代码:

kittens.handlers.*.failed 

如果我们对所有服务的处理程序的所有失败请求都感兴趣,我们可以编写以下查询:

*.handlers.*.failed 

为指标保持一致的命名约定是至关重要的。在构建服务时,将此添加到你的前期设计中,并作为公司范围内的标准实现,而不仅仅是团队层面的标准。让我们看看一些示例代码,看看实现 statsD 是多么简单。如果我们查看 chapter7/main.go,我们可以在第 19 行看到我们初始化了我们的 statsD 客户端:

statsd, err := createStatsDClient(os.Getenv("STATSD") 
  if err != nil { 
    log.Fatal("Unable to create statsD client") 
  } 
... 

func createStatsDClient(address string) (*statsd.Client, error){ 
  return statsd.New(statsd.Address(address)) 
} 

我们使用由 Alex Cesaro 开发的开源包(github.com/alexcesaro/statsd)。这个接口非常简单;为了创建我们的客户端,我们调用新函数并传递一个选项列表。在这个例子中,我们只传递 statsD 服务器的地址,该地址已由环境变量设置:

func New(opts ...Option) (*Client, error) 

如果我们查看文件 cserver/handlers/helloworld.go 中的第 27 行,我们是在处理程序完成之前延迟发送定时数据:

defer h.statsd.NewTiming().Send(helloworldTiming) 

开始时间将是延迟语句执行的时间,因此这应该是你文件的第一行;结束时间将在延迟语句执行后。如果你在这个链中调用下游,并且这个处理程序是中间件,那么请记住,所有下游调用的执行时间也将包含在这个指标中。为了排除这一点,我们可以在第 27 行创建一个新的 Timing,然后在执行链中的下一个中间件之前手动调用发送方法:

func (c *Client) NewTiming() Timing 

如果你查看第 35 行,你会看到我们在请求成功完成时调用增量方法:

h.statsd.Increment(helloworldSuccess)    

Increment 函数将给定的桶的计数增加一个,这些是在你的应用程序中非常有吸引力的指标,因为它们为你提供了一个关于健康和状态的真正有趣的画面:

func (c *Client) Increment(bucket string) 

statsD 客户端不是同步工作的,每次调用客户端时都会发送每个指标;相反,它将所有调用缓冲起来,并且有一个内部 goroutine 会在预定的时间间隔发送数据。这使得操作非常高效,你不需要担心任何应用程序的减速。

存储和查询

存储和查询度量数据有多种选择;你可以选择自托管,或者你可以利用软件即服务。如何管理这取决于你公司的规模和对你数据的安全要求。

软件即服务

对于软件即服务(SaaS),我建议查看 Datadog。要将度量标准发送到 Datadog,你有两种选择:一种是与 API 直接通信;另一种是在你的集群内部运行 Datadog 收集器作为容器。Datadog 收集器允许你使用StatsD作为数据格式,并且它支持一些标准的StatsD不支持的扩展,例如添加额外的标签或元数据到你的度量标准。标签允许你通过用户定义的标签对数据进行分类,这允许你保持度量标准名称与它们所监控的内容相关,而无需添加环境信息。

自托管

虽然使用 SaaS 服务来处理生产数据可能是可取的,但始终能够本地运行服务器进行本地开发是有用的。有多个后端数据存储选项,如 Graphite、Prometheus、InfluxDB 和 ElasticSearch;然而,当涉及到图形化时,Grafana 是领先的选择。

让我们为我们的列表,kittenservice,启动一个 Docker Compose 堆栈,这样我们就可以通过 Docker Compose 来运行设置 Prometheus 与 Grafana 的简单步骤。

如果我们查看 Docker compose 文件,我们可以看到我们有三个条目:

  • statsD

  • grafana

  • prometheus

StatsD 不是一个statsD服务器,而是一个statsD导出器;它暴露了一个端点,Prometheus 可以使用它来收集统计数据。与将度量标准推送到 Graphite 不同,Prometheus 是拉取统计数据。

Prometheus 是用于收集数据的数据库服务器。

Grafana 是我们将用于图形化我们的数据。

如果我们查看位于我们源代码库根目录的 Docker Compose 文件docker-compose.yml,我们会看到 Prometheus 部分需要一些特定的配置:

prometheus: 
   image: prom/prometheus 
   links: 
     - statsd 
   volumes: 
     - ./prometheus.yml:/etc/prometheus/prometheus.yml 
   ports: 
     - 9090:9090 

我们正在挂载一个包含 Prometheus 配置的卷。让我们看看它:

global: 
   scrape_interval:     15s 

 scrape_configs: 
   - job_name: 'statsd' 
     static_configs: 
       - targets: ['statsd:9102'] 

   - job_name: 'prometheus' 
     static_configs: 
       - targets: ['localhost:9090'] 

此配置的第一部分设置了从我们的数据源获取数据的时间间隔以及它们将被评估的时间间隔。抓取间隔的默认值为一分钟。在我们的示例中,我们将其减少,因为我们没有耐心,并且希望在向服务器发出请求后几乎立即看到我们的指标更新。然而,在实践中,我们并不真正对实时数据感兴趣。一分钟的时间延迟是可以接受的。下一部分是抓取配置;这些是我们希望导入 Prometheus 的数据的设置。第一个元素是我们的statsD收集器;我们将它指向在docker-compose文件中定义的收集器。由于我们在这两个容器之间使用了一个链接,我们可以在配置中使用链接名称。下一个项目是 Prometheus 性能指标的配置。我们不必启用它;然而,指标是至关重要的,因此监控我们的指标数据库的健康状况是有意义的。

Grafana

为了显示这些指标,我们将使用 Grafana。如果我们通过使用make runserver命令启动我们的堆栈并等待服务器启动几分钟,然后我们可以执行几个 curl 到端点以开始向系统中填充数据:

curl [docker host ip]:8091/helloworld -d '{"name": "Nic"}'  

让我们登录到 Grafana,查看我们收集的一些数据。将您的浏览器指向[docker host ip]:3000,您应该会看到一个登录界面。默认用户名和密码是admin

登录后,我们首先想做的就是配置我们的数据源。不幸的是,似乎没有方法可以通过配置文件自动设置。如果您需要在本地机器之外的环境中配置,有一个 API;如果您需要使用这个 API 来同步数据,应该很容易编写一些代码:

docs.grafana.org/http_api/data_source/

配置数据源相对简单。我们所需做的只是选择 Prometheus 作为我们的数据类型,然后填写连接细节。您需要确保您选择代理而不是直接连接。代理会从 Grafana 服务器调用数据;直接连接将使用您的浏览器。一旦我们完成这些,让我们添加 Prometheus 服务器的默认仪表板:

如果你点击仪表板选项卡,你会看到你可以导入预创建的仪表板。这很有用,但我们想从我们的服务器创建自己的仪表板。为此,点击仪表板链接,然后选择新的仪表板。这将带你去仪表板创建页面。我们将添加一个请求的图表。所以让我们选择图形选项。在底部面板中,我们有添加我们想要显示的指标的能力;如果我们已经知道仪表板的名称,那么我们只需要在框中输入表达式:

指标查找允许我们根据名称的一部分搜索指标。如果我们在这个框中输入kitten,那么所有标记为 kitten 的简单 API 指标都会显示在这个框中。目前,让我们选择验证成功指标。默认情况下,这个指标是给定时间间隔内指标报告次数的总数。这就是为什么你会看到图表。虽然这可能在某些情况下很有用,但我们想看到的是显示给定期间成功的良好条形图。为此,我们可以使用许多表达式之一来分组这些数据:

increase(kittenserver_helloworld_success_counter{}[30s]) 

这个表达式会将数据分组到 30 秒的桶中,并返回当前桶与上一个桶之间的差异。实际上,这给我们的是一个每 30 秒显示成功次数的图表。为了展示信息,条形图可能更合适,因此我们可以在显示选项卡中更改此选项。将步长设置与我们在增加表达式中设置的持续时间相同的间隔,会使图表看起来更易于阅读。现在添加第二个查询以获取 hello world 处理程序的计时。这次我们不需要将数据聚合到桶中,因为我们可以在图表上直接显示它。计时指标显示三条线,平均(四分位数,0.5),顶部 10%(四分位数,0.9),以及顶部 1%(四分位数,0.99)。一般来说,我们希望这些线非常紧密地聚集在一起,这表明我们的服务调用变化很小。尽管我们一次又一次地执行相同的操作,但我们没有在图表中看到这一点,这是由于代码中的第 149 行:

time.Sleep(time.Duration(rand.Intn(200)) * time.Millisecond) 

我们的处理程序运行得太快,以至于无法测量< 1 ms,所以我添加了一点点随机等待,使图表更有趣:

这就是简单指标的基本知识;要记录更详细的信息,我们需要回到可靠的日志文件。从服务器中提取数据的日子已经一去不复返了,在我们高度分布的世界里,这将是一场噩梦。幸运的是,我们有像 Elasticsearch 和 Kibana 这样的工具。

记录

当与高度分布式的容器一起工作时,你可能会有 100 个你的应用程序实例在运行,而不是一个或两个。这意味着,如果你需要 grep 你的日志文件,你将需要在数百个文件上执行此操作,而不是仅仅在几个文件上。此外,基于 Docker 的应用程序应该是无状态的,调度器可能会在多个主机之间移动它们。这增加了管理复杂性的一层。为了避免麻烦,最好的解决方法是从一开始就不将日志写入磁盘。一个分布式的日志存储,如 ELK 堆栈,或者软件即服务平台,如 Logmatic 或 Loggly,为我们解决了这个问题,并为我们提供了关于系统健康和运行状况的绝佳洞察。至于成本,你很可能会发现,SaaS 提供商中有一个比运行和维护你的 ELK 堆栈更便宜。然而,你的安全需求可能并不总是允许这样做。在查看日志时,保留也是一个有趣的问题。我个人的偏好是只存储日志数据短时间,例如 30 天;这允许你维护诊断跟踪,这对于故障排除可能是有用的,而不必承担维护历史数据的成本。对于历史数据,一个度量平台是最好的,因为你可以以低廉的成本存储这些数据数年,这可以用来比较当前性能与历史事件。

使用关联 ID 进行分布式跟踪

在第二章“设计一个优秀的 API”中,我们探讨了头部X-Request-ID,它允许我们使用相同的 ID 标记所有针对单个请求的服务调用,这样我们就可以稍后查询它们。当涉及到调试请求时,这是一个极其重要的概念,因为它可以极大地帮助你通过查看请求树和传递给它们的参数来理解为什么一个服务可能会失败或行为异常。如果你查看handlers/correlation.go文件,我们可以非常简单地实现这一点:

func (c *correlationHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { 
  if r.Header.Get("X-Request-ID") == "" { 
    r.Header.Set("X-Request-ID", uuid.New().String()) 
  }

  c.next.ServeHTTP(rw, r)
}

当我们希望使用处理器时,它是通过中间件模式实现的,我们只需要像这样包装实际的处理器即可:

http.Handle("/helloworld", handlers.NewCorrelationHandler(validation))

现在每次向/helloworld端点发出请求时,如果请求中尚未包含,X-Request-ID头部将附加一个随机 UUID。这是一种非常简单的方法将分布式跟踪添加到你的应用程序中,根据你的需求,你可能想了解一下 Zipkin,这是一个设计用来解决延迟问题的分布式跟踪系统,它正变得越来越流行zipkin.io.。还有来自 DataDog、NewRelic 和 AWS X-Ray 的工具,不过深入探讨这些应用程序可能有些过于复杂。然而,请花一个小时熟悉它们的特性,因为你永远不知道何时会需要它们。

Elasticsearch、Logstash 和 Kibana(ELK)

当涉及到日志详细数据时,Elasticsearch、Logstash 和 Kibana 几乎是行业标准。所有传统上会流式传输到日志文件的输出都存储在中央位置,你可以使用图形界面工具 Kibana 进行查询。

如果我们查看我们的 Docker Compose 文件,你会看到三个条目用于我们的 ELK 堆栈:

elasticsearch: 
   image: elasticsearch:2.4.2 
   ports: 
     - 9200:9200 
     - 9300:9300 
   environment: 
     ES_JAVA_OPTS: "-Xms1g -Xmx1g" 
 kibana: 
   image: kibana:4.6.3 
   ports: 
     - 5601:5601 
   environment: 
     - ELASTICSEARCH_URL=http://elasticsearch:9200 
   links: 
     - elasticsearch 
 logstash: 
   image: logstash 
   command: -f /etc/logstash/conf.d/ 
   ports: 
     - 5000:5000 
   volumes: 
     - ./logstash.conf:/etc/logstash/conf.d/logstash.conf 
   links: 
     - elasticsearch 

Elasticsearch 是我们日志数据的存储库,Kibana 是我们将用于查询这些数据的应用程序,Logstash 用于从应用程序日志中读取数据并将其存储在 Elasticsearch 中。除了几个环境变量之外,唯一的配置是 logstash 配置:

input { 
   tcp { 
     port => 5000 
     codec => "json" 
     type => "json" 
   } 
 } 

## Add your filters / logstash plugins configuration here 
output { 
  elasticsearch { 
    hosts => "elasticsearch:9200" 
  } 
} 

输入配置允许我们直接通过 TCP 将日志发送到 Logstash 服务器。这避免了写入磁盘的问题,并且 Logstash 需要读取这些文件。一般来说,TCP 可能会更快,磁盘 I/O 不是免费的,并且顺序写入日志文件引起的竞争可能会减慢你的应用程序。根据你对风险的承受能力,你可能选择使用 UDP 作为日志的传输协议。这比 TCP 快,然而,这种速度是以你不会收到数据已接收的确认,并且可能会丢失一些日志为代价的。

“我要给你讲一个关于 UDP 的笑话,但你可能听不懂。”

通常情况下,这不会造成太大的问题,除非你需要日志进行安全审计。在这种情况下,你可以为不同的日志类型配置多个输入。Logstash 有能力 grep 许多常见的日志输出格式,并将这些转换为 JSON 格式,以便由 Elasticsearch 索引。由于我们示例应用程序区域的日志已经以 JSON 格式存在,我们可以将类型设置为 JSON,Logstash 不会应用任何转换。在输出部分,我们定义了我们的数据存储库;同样,就像 Prometheus 配置一样,我们可以使用 Docker 提供的链接地址作为我们的 URI:

www.elastic.co/guide/en/logstash/current/configuration.html

Kibana

如果堆栈尚未运行,请启动它并向 Elasticsearch 发送少量数据:

curl $(docker-machine ip):8091/helloworld -d '{"name": "Nic"}'  

现在,将你的浏览器指向 http://192.168.165.129:5601。如果你是第一次设置,你应该看到的第一个屏幕是提示你在 Elasticsearch 中创建新索引的屏幕。使用默认设置创建这个索引;现在,你应该会看到 Elasticsearch 可以从你的日志中索引的字段列表:

图片

如果需要,你可以更改这些设置。然而,通常情况下,默认设置就足够了。Kibana 的屏幕相对简单。如果你切换到“发现”标签,你将能够看到一些已收集的日志:

图片

展开其中一个条目将显示索引字段的更多详细信息:

图片

要通过这些字段之一过滤日志,你可以在窗口顶部的搜索栏中输入过滤器。搜索条件必须以 Lucene 格式编写,因此要按状态码过滤我们的列表,我们可以输入以下查询:

status: 200 

这个过滤器通过包含数字值200status字段进行过滤。虽然搜索索引字段相对简单,但我们已经将大部分数据添加到message字段中,该字段以 JSON 字符串的形式存储:

status:200 and message:/.*"Method":"POST"/ 

要将我们的列表过滤为仅显示POST操作,我们可以使用包含正则表达式搜索的查询:

图片

正则表达式搜索项将比索引查询慢,因为每个项目都必须被评估。如果我们发现有一个特定的字段我们总是引用并且希望加快这些过滤速度,那么我们有两个选项。第一个也是最尴尬的选项是向我们的 Logstash 配置中添加一个*grok*部分:

www.elastic.co/guide/en/logstash/current/plugins-filters-grok.html#plugins-filters-grok-add\_field

另一个选项是在我们准备记录数据时指定这些字段。如果你查看示例代码,你会看到我们正在提取方法,并且虽然这也会进入message字段,但我们使用WithFields方法来记录这个,这将允许 Logstash 对其进行索引。如果你查看chandlers/helloworld.go文件的第 37 行,你可以看到这个操作的实际应用:

serializedRequest := serializeRequest(r) 
message, _ := json.Marshal(serializedRequest) 
h.logger.WithFields(logrus.Fields{ 
  "handler": "HelloWorld", 
  "status":  http.StatusOK, 
  "method":  serializedRequest.Method, 
}).Info(string(message)) 

在我们的示例中,我们使用 Logrus 日志记录器。Logrus 是 Go 的一个结构化日志记录器,支持许多不同的插件。在我们的示例中,我们使用 Logstash 插件,该插件允许你将日志直接发送到 Logstash 端点,而不是将它们写入文件然后让 Logstash 拾取:

56 func createLogger(address string) (*logrus.Logger, error) { 
57  retryCount := 0 
58 
59  l := logrus.New() 
60  hostname, _ := os.Hostname() 
61  var err error 
62 
63  // Retry connection to logstash incase the server has not yet come up 
64  for ; retryCount < 10; retryCount++ { 
65    hook, err := logstash.NewHookWithFields( 
66     "tcp", 
67  address, 
68  "kittenserver", 
69  logrus.Fields{"hostname": hostname}, 
70    ) 
71 
72    if err == nil { 
73      l.Hooks.Add(hook) 
74      return l, err 
75    } 
76 
77    log.Println("Unable to connect to logstash, retrying") 
78    time.Sleep(1 * time.Second) 
79  } 
80 
81  return nil, err 
82 } 

向 Logrus 添加插件非常简单。我们定义一个钩子,该钩子位于一个单独的包中,指定连接协议、地址、应用程序名称以及一个始终发送到日志记录器的字段集合:

func NewHookWithFields(protocol, address, appName string, alwaysSentFields logrus.Fields) (*Hook, error) 

然后我们使用钩子方法将插件与日志记录器注册:

func AddHook(hook Hook) 

Logrus 有许多可配置的选项,标准的 Log、Info、Debug 和 Error 日志级别将使你能够记录任何对象。然而,除非有特定的实现,否则它将使用 Go 的内置ToString方法。为了解决这个问题,并能够在我们的日志文件中拥有更多可解析的数据,我添加了一个简单的序列化方法,该方法将http.Request中的相关方法转换为 JSON 对象:

type SerialzableRequest struct { 
  *http.Request 
} 

func (sr *SerialzableRequest) ToJSON() string 

这个示例的完整源代码可以在chapter7/httputil/request.go的示例代码中找到。这目前只是一个简单的实现,但如果需要可以扩展。

异常

Go 语言的一大优点是,标准的模式是当错误发生时应该始终处理它们,而不是将它们向上冒泡并展示给用户。话虽如此,总有意外发生的情况。这个秘诀就是了解它,并在它发生时解决问题。市场上有很多异常日志平台。然而,在我看来,我们讨论的两种技术对于追踪我们希望在 Web 应用程序中找到的少量错误已经足够了。

恐慌和恢复

Go 语言处理意外错误有两种优秀的方法:

  • 恐慌

  • 恢复

恐慌

内置的 panic 函数停止当前 goroutine 的正常执行。然后以正常方式运行所有延迟函数,然后程序终止:

func panic(v interface{}) 

恢复

recover 函数允许应用程序管理恐慌 goroutine 的行为。当在延迟函数内部调用时,recover 停止 panic 的执行并返回传递给 panic 调用的错误:

func recover() interface{} 

如果我们的处理程序由于某种原因发生恐慌,HTTP 服务器将恢复这个恐慌并将输出写入std错误。虽然如果我们本地运行应用程序,这是可以的,但它不允许我们在应用程序分布到许多远程服务器时管理错误。由于我们已经登录到 ELK 堆栈设置,我们可以编写一个简单的处理程序,它将包装我们的主要处理程序并允许捕获任何恐慌并将其转发到记录器:

18 func (p *panicHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { 
19  defer func() { 
20    if err := recover(); err != nil { 
21  p.logger.WithFields( 
22  logrus.Fields{ 
23      "handler": "panic", 
24      "status":  http.StatusInternalServerError, 
25      "method":  r.Method, 
26      "path":    r.URL.Path, 
27      "query":   r.URL.RawQuery, 
28      }, 
29  ).Error(fmt.Sprintf("Error: %v\n%s", err, debug.Stack())) 
30 
31  rw.WriteHeader(http.StatusInternalServerError) 
32   } 
33  }() 
34 
35  p.next.ServeHTTP(rw, r) 
36 }   

这相对简单。在行19中,我们延迟调用 recover。当它运行时,如果我们有一个错误消息,也就是说,有东西发生了恐慌,我们希望记录这个。就像在先前的例子中一样,我们正在向日志条目添加字段,以便 Elasticsearch 可以索引这些字段,但我们不是记录请求,而是写入错误消息。这个消息可能不会提供足够的信息,使我们能够调试应用程序,因此为了获取上下文,我们调用debug.Stack()

func Stack() []byte 

栈是 runtime/debug 包的一部分,并返回调用它的 goroutine 的格式化堆栈跟踪。你可以通过运行本章的示例代码并 curl bang端点来测试它:

curl -i [docker host ip]:8091/bang  

当我们查询 Kibana 时,我们将此消息与错误信息一起写入 Elasticsearch。对于此消息,我们将看到捕获的详细信息,如下所示:

最后,我们向客户端返回状态码 500,不包含消息体。

消息应该提供足够的信息,使我们能够理解问题区域在哪里。然而,导致异常的输入将丢失,所以如果我们无法重现错误,那么可能是时候向我们的服务添加更多监控工具并重新运行了。

作为您服务应用生命周期的一部分,您应该始终努力跟踪异常。这将大大提高您在出现问题时做出反应的能力。通常情况下,我看到异常跟踪器中充满了问题,以至于团队失去了清理它们的希望并停止了尝试。当出现新的异常时,不要让您的服务以这种方式发展,而应该修复它。这样,您可以在异常上设置警报,因为您将非常有信心存在问题。

摘要

本章内容到此结束。日志记录和监控是一个可以根据您的特定用例和环境进行调整的话题,但我希望您已经学会了设置它的简便性。使用软件即服务(SaaS),例如 Datadog 或 Logmatic,是一种快速启动和运行的好方法,与 OpsGenie 或 PagerDuty 的警报集成将允许您在出现问题时立即收到警报。

第八章:安全性

在微服务中的安全性可能感觉像是一片雷区,在某些方面确实如此。本章主要旨在探讨一些你可以做的事情来提高你的 Go 代码的安全性;然而,我认为讨论一些更广泛的问题也很重要。对于像防火墙配置这样的主题,最好留给专业书籍来深入讨论;然而,我们将介绍一些你面临的概念和问题,以便你为更深入的阅读做好准备。

加密和签名

当我们探讨保护数据的方法,无论是静态还是传输中的数据,我们讨论的许多方法都将涉及密码学保护数据。

“密码学是使用数学来加密和解密数据的科学。密码学使你能够存储敏感信息或通过不安全的网络(如互联网)传输信息,这样除了预期的接收者外,任何人都无法读取。”

  • 《密码学简介》,网络合作伙伴公司

作为本章我们将讨论的内容的基础,我们首先必须了解密码学是如何工作的,不是我们需要一个数学学位,而是要了解涉及的各个部分。密码学的安全性取决于所涉及的密钥的安全性,我们需要了解哪些密钥可以自由分发,哪些密钥需要我们用生命来保护。

对称密钥加密

对称密钥加密也称为密钥加密或传统密码学:一个密钥用于数据的加密和解密。为了远程端能够解密这些信息,它必须首先拥有这个密钥,并且这个密钥必须安全地保存,因为一个服务器的密钥泄露将导致所有共享这个密钥的服务器被泄露。它也可能使密钥管理更加复杂,因为当你需要更改密钥,而你应该经常更改它时,你需要将这个更改在整个环境中推广。

公钥密码学

公钥密码学是由 Whitfield Diffie 和 Martin Hellman 于 1975 年引入的,以避免双方都需要知道秘密的需要。实际上,他们并不是第一个发明这种技术的;它是由英国情报机构多年前开发的,但被当作军事机密保密。

公钥密码学使用一对密钥进行加密;你也会听到它被称为非对称加密。公钥用于加密信息,而私钥只能用于解密。因为无法从公钥中确定私钥,所以通常公钥会被公之于众。

数字签名

公钥加密也赋予我们使用数字签名的功能。数字签名通过使用私钥加密消息然后传输已签名的消息来实现。如果消息可以用公钥解密,那么消息一定是从私钥持有者那里发出的。由于加密消息的计算时间和有效载荷大小的增加,一个标准的方法是创建消息的单向哈希,然后使用私钥加密这个哈希。接收者将使用公钥解密哈希,并从消息中生成相同的哈希;然后,可以认为消息来自一个可信的来源。

X.509 数字证书

公钥的一个问题是您必须小心,您认为属于接收者的密钥确实属于接收者。如果密钥在公共网络上传输,总有可能发生中间人攻击。攻击者可以冒充一个假公钥,作为您认为的可信接收者;然而,他们可以用自己的密钥替换它。这意味着您认为已经安全传输的消息实际上可能被恶意第三方解密和读取。

为了避免这些问题,存在数字证书,简化了确定公钥是否属于报告的所有者的任务。

一个数字证书包含三样东西:

  • 一个公钥

  • 证书信息,如所有者的名称或 ID

  • 一个或多个数字签名

使证书可信的是数字签名。证书由一个受信任的第三方或证书授权机构(CA)签署,该机构证明您的身份以及您的公钥属于您。任何人都可以创建 CA 根证书并签署他们的证书,对于非公开访问系统,如微服务之间的通信,这是一种相当常见的做法。然而,对于公开证书,您需要支付 CA 来签署您的证书。定价的目的是让 CA 确保您确实是您所说的那个人;目前,最受欢迎的 CA 是 Comodo、Symantec(在收购之前是 Verisign)和 GoDaddy。您在浏览器中看到的原因不仅仅是您正在使用安全通信,而是您的浏览器已经验证了证书的签名,这是与它捆绑在一起的 100 多个受信任的第三方之一。

TLS/SSL

SSL,即两个系统之间安全传输数据的通用术语,是指由 Mozilla 在 1995 年首次开发的一个已弃用的标准。自那时起,它已被 2008 年 8 月发布的 TLS 1.2 所取代;尽管 SSL 3.0 在技术上仍然可行,但在 2015 年 6 月由于POODLE在降级旧加密中摆动预言机)攻击的漏洞而被弃用。2014 年由一支谷歌安全研究人员团队发现的 POODLE 攻击是通过攻击者向服务器发送多个请求来工作的;然后分析这些数据并使用它们,这使得他们能够解密传输中的数据。平均而言,只需要进行 256 次 SSL 3.0 调用,就可以解密 1 字节的信息。

这意味着这个漏洞在公开披露之前已经存在了 18 年;你可能会问,为什么在更强的 TLS 1.0 发布 15 年后,人们还在使用 SSL 3.0?这起因于一些浏览器和服务器不支持 TLS 1.0 的问题,因此存在一个回退机制,允许降级到较低级别的加密。尽管在发现时几乎没有人还在使用 SSL 3.0,但回退机制仍然存在于协议中,因此可以被黑客利用。解决这个问题的方法相当简单:在服务器的配置中禁用低于 TLS 1.0 的所有内容。我们有一些 TLS 和 SSL 的历史,但它是如何保护你的数据安全的呢?

TLS 使用对称加密,客户端和服务器都拥有一个用于加密和解密的密钥。如果你还记得上一节,我们介绍了对称加密和密钥分发的问题。TLS 通过在握手的第一部分使用非对称加密来解决这个问题。客户端从服务器检索包含公钥的证书并生成一个随机数;它使用公钥来加密这个随机数并将其发送回服务器。现在双方都有了随机数,他们使用这个随机数来生成对称密钥,这些密钥用于在传输过程中加密和解密数据。

外部安全

这是保护您系统安全的第一道防线,通常由第 2 层或第 3 层防火墙、DDoS 保护、Web 应用防火墙以及其他软件和硬件组成。在攻击者能够破坏您的应用程序之前,他们必须首先通过这些硬件和软件层,这些层不是应用程序代码的一部分,而是一个共享的基础设施层,应用程序中的许多组件可能都会共享。在本节中,我们将探讨一些外部安全措施以及可能针对您发起的攻击。通常,保护服务边界是由运维人员完成的任务,然而作为开发者,我们需要了解这些流程和风险,因为这极大地增强了我们加固应用程序代码的能力。在本节中,我们将探讨外部安全的一些常见方法,以及黑客可能利用系统的一些方式。

第 2 层或第 3 层防火墙

第 2 层更常用于路由,因为它只处理 MAC 地址,而不处理 IP 地址,而第 3 层则是 IP 地址感知的。传统上,第 2 层是唯一真正的方式,因为它不会增加延迟,其速度与电缆大致相同。随着处理能力和内存的增加,第 3 层现在可以以电缆速度运行,通常,当我们查看边缘防火墙时,这些防火墙通常是进入您系统的第一个入口点,现在它们通常是第 3 层。这给我们带来了什么?首先,它阻止了边缘的不必要流量:我们限制了对外部世界的可访问端口,并阻止了流量到达不允许的目标,这些流量在防火墙处被阻止,没有机会对源头执行攻击。除此之外,它还允许我们限制对某些端口的访问。例如,如果您正在运行服务器,您很可能需要某种形式的远程访问,如 SSH。2015 年出现的 Heartbleed 漏洞利用了 OpenSSH 中的漏洞,直接暴露在互联网上的 SSH 服务器容易受到这种攻击。有效地使用防火墙意味着私有端口,如 SSH,将被锁定到 IP 地址或 IP 地址范围,这可能是您的 VPN、办公 IP 或公共 IP。这大大减少了攻击向量,因此即使您正在运行一个容易受到 Heartbleed 影响的 OpenSSH 版本,攻击者要利用这一点,他们也需要在您的受保护区域内。

Heartbleed 漏洞利用了执行缓冲区溢出攻击的能力。例如,你要求服务器返回一个 4 个字母的单词,但指定长度为 500;你得到的是 4 个字母的单词,剩下的 496 个字符是跟随初始分配内存地址的内存块。在实践中,这允许黑客随机访问服务器中的内存块;这可能包含诸如更改密码请求这样的项目,这些项目给了他们访问服务器的凭证。如果你运行的是一个全球可用的 SSH 服务器,那么你可能发现你有一个问题:

图片

网络应用防火墙

网络应用防火墙(WAF)在系统中配置为第二或第三道防线。为了理解什么是 WAF,让我们看看来自开放网络应用安全项目(OWASP)的定义:

“网络应用防火墙(WAF)是针对 HTTP 应用的防火墙。它将一系列规则应用于 HTTP 会话。这些规则涵盖了常见的攻击,如跨站脚本(XSS)和 SQL 注入。

当代理保护客户端时,WAF 保护服务器。WAF 被部署以保护特定的网络应用或一组网络应用。WAF 可以被视为反向代理。

WAF 可能以设备、服务器插件或过滤器的形式出现,并且可以根据应用程序进行定制。进行这种定制的努力可能非常显著,并且需要随着应用程序的修改而维护。”

OWASP 是一个非常有用的资源,实际上已经为 ModSecurity 提供了一套核心规则集,ModSecurity 可以防止诸如 SQL 注入、XSS、Shellshock 等攻击。作为最低要求,设置一个如 ModSecurity 和 OWASP CRS 的 WAF 应该是你的基本需求。在 Docker 容器内托管这应该相对简单,这可以形成在第二层防火墙之后的第二道防线。

另外还有一个选择:一些 CDN 公司,如 Cloudflare,提供托管 WAF 服务。这是在网络的边缘提供保护,得益于像 Cloudflare 这样的企业专业知识,你无需担心配置问题。实际上,Cloudflare 支持 OWASP CRS(www.modsecurity.org/crs/)。

API 网关

除了 WAF(Web 应用防火墙)之外,API 网关也是一个非常有用的工具;它可以同时起到将您的公共 API 路由到后端服务以及一些附加功能的作用,例如在边缘进行令牌验证和输入验证与转换。当我们讨论到困惑代理问题,即防火墙后面的攻击者可以执行他们无权执行的命令时,我们探讨了加密 Web 令牌的可能性;但问题是,用于解密这些令牌的私钥需要分布到多个后端服务中。这使得密钥管理比应有的要复杂得多。API 网关可以通过成为唯一可以解密消息的层来简化这种情况;其他服务使用公钥来验证签名。API 网关通常实现许多其他一线功能,例如但不限于以下内容:

  • 请求验证

  • 授权

  • 速率限制

  • 记录

  • 缓存

  • 请求和响应转换

WAF 和 API 网关之间存在一定的交叉;然而,这两个应该被视为您基础设施中两个截然不同的部分。关于 API 网关的提供商,这似乎是一个正在发展的领域;如果您已经购买了 AWS PaS 环境,可以使用 AWS 的高级 API 网关。对于独立部署,Kong(getkong.org/)、Tyk(tyk.io/)、Apigee(apigee.com/api-management/#/homepage)、Mashery(www.mashery.com/)以及 Mulesoft 的 Anypoint 平台(www.mulesoft.com/)是该领域的领导者。当然,您也可以使用 Nginx 或 HAProxy 构建自己的 API 网关;然而,我建议您在着手构建自己的之前先检查一下这些特定的平台。

DDoS 保护

2016 年 10 月 21 日,一次大规模的互联网中断是由攻击者针对 DYN 的 DNS 服务器使用 Mirai 僵尸网络造成的。Mirai 漏洞利用了由名为 XionMai Technologies 的中国公司制造的 IP 摄像头和 DVR 中的漏洞。攻击者没有攻击目标,而是决定摧毁互联网基础设施的主要部分,使美国东海岸和西海岸的大部分地区瘫痪。Mirai 漏洞仅利用了 60 个用户名和密码来尝试更新易受攻击设备的固件。一旦恶意软件被安装,设备就被僵尸网络控制。剩下的只是告诉机器人对 DYN 的名称服务器发起 DNS 攻击。

Mirai 的代码已经在网上公布;你只需稍加努力,用 Google 就可以找到。我希望你看到这段代码时感到惊讶的是它的简单性。现在,我不想从设计这种攻击的复杂性中减去任何东西;我只是在谈论实施。相当大的一部分代码是用 Go 编写的,因此非常易于阅读。其中有一些对通道的出色使用。如果你查看代码,尝试找出可以用信号量改进的区域。

Akamai 发布的一份报告称,今年所有攻击中有 98.34% 是针对基础设施的,只有 1.66% 是针对应用层的。在这 98.34% 中,许多可以通过一点网络卫生来避免。让我们来看看主要的威胁以及它们是如何工作的。

DDoS 攻击的类型

以下是一些 DDoS 攻击的类型:

  • UDP 分片

  • DNS

  • NTP

  • Chargen

  • UDP

  • SYN

  • SSDP

  • ACK

UDP 分片攻击

UDP 分片攻击是攻击者利用网络中数据报分片的方式。每个网络都有一个称为最大传输单元(MTU)的限制。如果一个发送到网络的数据报大于 MTU,它将被分片以成功传输。

UDP 分片攻击通过创建包含伪造数据包的数据报来实现;当服务器尝试重新组装这些数据包时,它无法做到,资源很快就会被耗尽。

UDP 洪水

UDP 洪水攻击通过向一个 IP 地址发送大量带有伪造源地址的 UDP 数据包来实现。服务器将对这些请求做出响应,向伪造的地址发送回复。由于攻击的高强度,路由器将超过每秒 UDP 数据报的限制,并在一段时间内停止向同一安全区域内的所有地址发送数据。

这通常还会利用一种称为反射攻击的技术。当源 IP 地址被伪造时,返回的数据包不会发送回真实源地址,而是发送到伪造的 IP 地址。使用这种技术的理由是,它允许发送者通过仅消耗出站数据包的资源来放大攻击。

DNS

DNS 攻击利用 UDP 洪水来攻击 DNS 服务器;会发出许多请求来查询 DNS 服务器。这些请求被设计成从一个小请求返回一个非常大的回复,以最大化攻击效率,因为通常发送者不会收到回复。

我们之前讨论过的攻击,它针对的是 Dyn 的基础设施,在 2016 年 10 月导致美国东海岸和西海岸的许多网站瘫痪,这种攻击就是以这种方式进行的。与大多数 DNS 攻击不同,Miraia 网络没有使用反射,它允许响应返回给发送者,这是由于大量被入侵的设备才成为可能。

NTP

NTP(网络时间协议)是另一种利用 NTP 服务器内置功能的放大攻击,该功能返回与它交互的最后 600 台机器。这种攻击利用了支持 MONLIST 命令且未打补丁的开放 NTP 服务器。该项目openntpproject.org/旨在识别未打补丁的服务器,以鼓励移除这种漏洞。不幸的是,NSFOCUS 在 2014 年进行的研究发现,全球有超过 17,000 台服务器容易受到这种漏洞的攻击。假设所有这些服务器都可以被利用,并使用 2014 年 CloudFlare 遭受的 NTP 攻击的负载大小,我们有能力进行 1.4 Tbps 的 DDoS 攻击。这种流量将是今天已知最大攻击的两倍。NTP 提供了一个强大的应用程序攻击平台,仅因服务器打补丁不当而存在。

CHARGEN

CHARGEN(字符生成协议)攻击是另一种反射放大攻击。攻击利用了开放的 CHARGEN 服务器,这些服务器在端口19上运行,每次从连接的主机接收数据报时,都会返回 0 到 512 个字符长度的随机字符数。CHARGEN 被设计为用于调试 TCP 网络代码和带宽测量的字节流源。CHARGEN 攻击通过滥用已启用在网络连接打印机上的 CHARGEN 服务器来工作。

SYN 洪水

SYN 洪水是一种经典的 DDoS 攻击,向一台机器发送大量数据包,试图阻止连接被关闭。最终,服务器端的连接会超时;然而,目的是反复攻击服务器,消耗所有可用资源,以便真正的连接无法通过。

SSDP

SSDP(简单服务发现协议)通常用于发现即插即用(UPnP)设备。这正是您的家庭路由器实现的协议,所以下次您抱怨您最喜欢的游戏网络离线时,为什么不首先检查您是否无意中暴露了 SSDP 到互联网?

ACK

ACK 洪水利用了客户端连接到服务器时存在的三次握手。第一步是客户端发送一个 SYN 数据包,服务器回复一个 SYN-ACK 数据包。然后客户端最终回复一个 ACK 数据包,然后连接就为数据开放。ACK 洪水有两种形式:

  • 攻击者向服务器发送一个伪造的 SYN 数据包,然后跟随一个伪造的 SYN-ACK 数据包。然后服务器打开并保持连接。如果打开的连接足够多,那么服务器最终会耗尽资源。

  • 第二种方法只是发送 ACK 数据包。由于服务器没有开放连接,这个数据包将被丢弃;然而,它仍然消耗资源来处理这些数据包。

这种攻击类似于 SYN 攻击;然而,由于它通过欺骗 DDoS 过滤器将数据包传递到服务器的方式,它可能更加高效。

避免这些攻击并不简单:你需要检测和过滤你网络边缘的这种活动。你还需要大量的带宽来吸收系统进入的流量,在我看来,这不是一个可以或应该由内部解决方案解决的问题。

避免 DDoS 攻击的第一道防线是确保你没有启用它们。配置防火墙以确保你没有暴露易受攻击的服务,并修补你的服务意味着攻击者无法利用你的网络基础设施来攻击他人。第二道防线是利用 Cloudflare、Akamai、Imperva 或其他专家的力量,他们拥有基础设施和网络清洗过滤器,以确保流量永远不会到达你的服务器。

应用程序安全

现在,我们希望理解一些加密工作的方式以及我们基础设施的一些脆弱性,但我们的应用程序呢?完全有可能有人想闯入你的系统。虽然 DDoS 攻击可能会让你在一天或两天内感到不便,但一个绕过你的防火墙并进入你的应用程序服务器的黑客可能会造成严重的财务或声誉损害。我们首先需要做的是基于不信任的原则。David Strauss 在他的演讲《不要构建“死亡之星”安全》(2016 年 O'Reilly 软件架构会议)中,研究了维基解密网站,并得出结论,不是第一道防线失败了,而是攻击者能够访问各种后端系统。

在同一场会议上,Sam Newman,他写了优秀的《微服务》一书(如果还没有读过,我鼓励大家阅读),也在谈论《应用安全和微服务》。Sam 表示,微服务为我们提供了多个边界的功能;虽然这可能是一个好处,但也可能引起问题。他提出了一种 ThoughtWorks 使用的微服务安全模型;这建议你遵循以下四个步骤:

  • 预防

  • 检测

  • 响应

  • 恢复

预防

预防是你应该投入最多努力的地方,本章的剩余部分将专注于这一点。这是实施安全通信、授权和认证的技术。

检测

检测与你的应用程序日志和 ModSecurity 日志(如果你使用它)有关。我们在上一章讨论了系统中的日志记录方法,我建议你考虑你需要检测恶意意图的日志类型,而不仅仅是用于故障排除。当你规划一个功能时,这应该成为你的非功能性要求的一部分。

响应

应对措施是指你如何处理安全漏洞:如果发生事件,你需要立即处理。这不仅包括将攻击者排除在系统之外,还要确定被窃取的内容,在个人信息或信用卡丢失的情况下,联系你的客户并对问题保持透明。想想如果你的公司遇到火灾,你们会如何处理消防演习。你进行练习是为了确保在发生火灾的情况下,每个人都知道该做什么以及如何快速反应。对于一些公司来说,游戏日是标准实践,他们会演练灾难恢复情况。如果你打算练习你的应对流程,你需要确保整个业务都参与其中;虽然技术部门将包括在问题的诊断和修复中,但为了真正有用,还需要在业务层面、法律、公关和沟通方面的参与。

恢复

恢复过程应该是最简单的步骤,假设你的基础设施已经得到了良好的备份和自动化。山姆建议不要冒险,彻底摧毁它,使用新的密钥和密码重新构建,以避免进一步的攻击。

混淆代理

混淆代理问题是指一个系统可以滥用另一个系统对其的信任,并执行它通常不允许执行的命令。考虑一个在你的系统中发放退款系统的例子;你认为这个系统是安全的,因为它是一个位于防火墙后的私有 API,但如果你防火墙被攻击者攻破了呢?如果他们能够检测到向服务器发送带有有效负载的POST请求会导致银行或 PayPal 账户退款,那么他们甚至不需要进一步攻击你的基础设施来获得收益。这种情况非常普遍;在构建系统时,我们过度依赖外部防御,并对防火墙后的任何事物都实行信任原则。你也可能假设攻击者实际上在你的组织外部;如果他们合法地访问了服务器怎么办?在美国,内部欺诈占金融损失的 XXX%;我们需要构建使这种情况变得困难的系统,并确保我们有完整的访问和操作审计记录。这并不需要是一个难以解决的问题;我们将看到两种非常简单的方法来解决这个问题,当实施时,既不会增加你的开发时间,也不会增加你的运营时间。

攻击者如何绕过防火墙

攻击者拥有多种工具来绕过你的安全系统。我们不是在谈论试图利用互联网上找到的工具利用现有漏洞的人。我们谈论的是复杂且聪明的黑客,无论出于什么原因,都决心对你的公司造成伤害。

场景

你是一个利用最新的微服务架构模式构建的电子商务平台。你的应用程序代码正在 Docker 容器中运行,你使用 Kubernetes 在 AWS 上托管一切。系统的前端是一个简单的 Node.js 应用程序,它与各种私有 API 通信,以提供网站上许多交易功能。应用程序本身没有数据库,容器中也没有存储任何机密。

攻击

攻击者发现了一个用于前端展示的模板引擎中的远程代码执行漏洞。他们发现系统运行在 Kubenettes 上,并且控制 API 在受损害的容器内部可用。他们使用这个 API 来在你的网络上启动一个恶意容器,该容器以特权模式运行,启动一个反向 SSH 隧道到攻击者的远程服务器,完全绕过了防火墙,并使他们获得了容器的 root 访问权限。从这里,他们嗅探网络流量,并确定支付网关有 POST 端点 v1/refunds;通过向此端点发送 JSON 负载数据,可以将大量资金退还到离岸银行账户。

即使防火墙正在保护入站流量,并且只允许端口 80443 的入站,攻击者还是利用了应用程序内部的一个漏洞,为自己创建了一个后门。在生产环境中启动应用程序周围的不存在的安全性和服务之间开放的未加密通信,给了他们他们需要的一切,以清空公司的银行账户。

这是一个非常真实的威胁,但幸运的是,Go 语言有许多优秀的工具可以帮助我们使攻击者难以得逞。

输入验证

在我们的场景中,攻击者使用远程代码执行漏洞来获取对我们环境的访问权限。WAF 之后的第一道防线是输入验证。所有数据都应该进行验证以设置边界;实现它并不需要花费大量时间,并且可以帮助你保护免受此类攻击。Go 语言中有一个优秀的库,它是 go-playground 包的一部分(github.com/go-playground/validator)。

看看这个简单的代码示例,看看实现起来有多容易:

validation/main.go

3 // Request defines the input structure received by a http handler 
4 type Request struct { 
5  Name  string `json:"name"` 
6  Email string `json:"email" validate:"email"` 
7  URL   string `json:"url" validate:"url"` 
8 } 

验证器包的好处是它与字段标签一起工作,这是一种保持代码整洁的无侵入方式。通过添加验证标签,我们可以为字段指定许多不同的验证函数,包括电子邮件、URL、IP 地址、最小和最大长度以及正则表达式。同一字段上也可以有多个验证器。例如,如果我想验证我的输入是一个电子邮件并且长度至少为三个,我可以添加以下内容:

validate: "email,min=3" 

验证器按照列表中的顺序处理,所以检查字段是否包含电子邮件的验证函数会在检查长度之前进行验证。

使用这个包也非常简单:如果我们看看测试中的示例,我们可以看到验证实际上只是一个方法调用:

 9 func TestErrorWhenRequestEmailNotPresent(t *testing.T) { 
10  validate := validator.New() 
11  request := Request{ 
12    URL: "http://nicholasjackson.io", 
13  } 
14 
15  if err := validate.Struct(&request); err == nil { 
16    t.Error("Should have raised an error") 
17  } 
18 } 

在最简单的形式中,我们只需要对请求进行验证的两个方法调用。首先,我们使用New函数创建一个新的验证器,就像第 10 行那样:

func New() *Validate 

New函数返回一个具有合理默认值的validate新实例。

然后,我们可以调用validate方法来检查我们的结构是否有效:

func (v *Validate) Struct(s interface{}) (err error) 

Struct函数验证结构体的公开字段,并自动验证嵌套结构体,除非另有说明。

对于传递的错误值,它返回InvalidValidationError,否则返回 nil 或ValidationErrors作为错误。如果您需要断言错误,它不为 nil,例如,err.(validator.ValidationErrors)来访问错误数组。

如果结构体有验证错误,Struct将返回一个错误;要获取错误详细消息,我们可以将错误转换为ValidationErrors对象,它是一个FieldError集合。要查看FieldError对象的全部可用方法,请查看 godoc(godoc.org/gopkg.in/go-playground/validator.v9#FieldError)。

模糊测试

当然,我们也应该加强我们的测试技术。一种非常有效的方法是在测试中使用模糊器来测试输入验证的边界;这仅仅扩大了我们测试的范围,以确保我们覆盖了所有边缘情况。潜在的攻击者很可能会使用这种技术来测试你 API 的边界,为什么不先发制人,确保所有输入都得到正确处理呢?

在 Go 中实现 fuzzer 的最流行实现之一是出色的包 github.com/dvyukov/go-fuzz/go-fuzzgo-fuzz 是一个覆盖率引导的 fuzzer,它使用您的应用程序代码的仪器化构建,暴露出它使用的代码覆盖率,以确保最大化的代码路径被覆盖。fuzzer 生成随机输入,其目的是使应用程序崩溃或产生意外的输出。尽管 fuzzing 是一个高级主题,但在本章的代码示例 validation/fuzzer 中,您可以找到一个如何对刚刚覆盖的验证处理程序进行 fuzz 的示例。

TLS

我们的攻击者利用的另一个漏洞是,防火墙后面的所有流量都没有加密,通过嗅探服务之间的流量,他们发现了一种伪造对支付网关的调用以向远程银行账户发送退款的方法。另一个问题可能是,您正在将敏感信息,如银行详情或信用卡号码,在您的前端服务和支付服务之间传递。即使您没有在您的系统上存储信用卡号码,如果您不小心,您可能会通过假设防火墙后面的所有内容都是安全的,将此流量暴露给攻击者。由于现在服务器可用的处理能力不断提高,TLS 或传输层安全性不再增加任何开销。除此之外,防火墙内部的服务通常只有有限数量的连接;因此,为了减少 TLS 握手的丢失时间,您可以在您的服务中使用持久可重用连接来最小化这个问题。让我们看看我们如何在 Go 中快速实现 TLS。

生成私钥

在我们进行任何操作之前,我们需要生成一个密钥和证书。Go 实际上有一个非常棒的实用工具,可以仅使用 Go 生成密钥和证书,但在我们查看这个之前,让我们看看我们如何传统地使用 openssl 生成证书:

openssl genrsa -aes256 -out key.pem 4096  

这将为我们生成一个 PEM 格式的密钥,它使用 4096 位大小的 RSA 算法;密钥将使用 aes256 格式加密,并提示您输入密码。然而,我们还需要一个 X.509 证书,该证书将与这个密钥一起使用;为了生成这个证书,我们还可以再次使用 openssl 并执行以下命令:

openssl req -new -x509 -sha256 -key key.pem -out certificate.pem -days 365  

此命令将使用密钥再次以 PEM 格式生成证书,有效期为一年。在实践中,我们不应该为我们的内部服务生成寿命如此长的证书。因为我们控制服务的部署,我们应该尽可能频繁地轮换密钥。关于此证书的另一件事是,虽然它是有效且安全的,但客户端不会自动信任它。这是因为根是自动生成的,而不是来自受信任的权威机构。这对于内部使用来说是完全可以的;然而,如果我们需要服务面向公众,我们就需要请求一个受信任的来源为我们生成证书。

现在我们知道了如何使用openssl命令行工具来完成这个操作,接下来让我们看看如何仅使用 Go 的 crypto 库实现相同的功能。示例应用程序可以在golang.org/src/crypto/tls/generate_cert.go找到,它为我们提供了这个操作的详细信息。现在让我们一步一步地查看这个过程。

如果我们查看tls/generate_keys中的示例,我们可以看到我们正在使用来自crypto/edcsa包的GenerateKey方法:

120 func generatePrivateKey() *rsa.PrivateKey { 
121  key, _ := rsa.GenerateKey(rand.Reader, 4096) 
122  return key 
123 } 

120行的GenerateKey方法的签名如下:

func GenerateKey(rand io.Reader, bits int) (*PrivateKey, error) 

第一个参数是一个 I/O 读取器,它将返回随机数;为此,我们使用rand.Reader方法,这是一个全局共享的加密强伪随机生成器的实例。在 Linux 上,这将使用/dev/urandom,在 Windows 上使用CryptGenRandomAPI。第二个参数是要使用的位数大小:位数越大越安全,但会导致加密和解密操作变慢。

为了将密钥序列化到文件,我们需要执行几个不同的操作:

191 func savePrivateKey(key *rsa.PrivateKey, path string, password []byte) error { 
192  b := x509.MarshalPKCS1PrivateKey(key) 
193  var block *pem.Block 
194  var err error 
195 
196  if len(password) > 3 { 
197    block, err = x509.EncryptPEMBlock(rand.Reader, "RSA PRIVATE  
KEY", b, password, x509.PEMCipherAES256) 
198    if err != nil { 
199      return fmt.Errorf("Unable to encrypt key: %s", err) 
200    } 
201  } else { 
202    block = &pem.Block{Type: "RSA PRIVATE KEY", Bytes: b} 
203  } 
204 
205  keyOut, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) 
206  if err != nil { 
207    return fmt.Errorf("failed to open key.pem for writing: %v", err) 
208  } 
209 
210  pem.Encode(keyOut, block) 
211  keyOut.Close() 
212 
213  return nil 
214 } 

在第192行,我们正在获取从GenerateKey函数返回的PrivateKey引用,并需要将其转换为字节数组,以便将其序列化到磁盘。crypto/x509包有许多有用的函数,可以启用此类操作;我们需要使用的函数是MarshalPKCS1PrivateKey,它将我们的基于 RSA 的私钥序列化为 ASN.1,DER 格式:

func MarshalPKCS1PrivateKey(key *rsa.PrivateKey) ([]byte, error) 

一旦我们有了字节的密钥格式,我们就可以准备将其写入文件;然而,仅仅将字节写入文件是不够的;我们需要能够以 PEM 格式写入,如下面的示例所示:

-----BEGIN RSA PRIVATE KEY----- 
Proc-Type: 4,ENCRYPTED 
DEK-Info: AES-256-CBC,c4e4be9d17fcd2f44ed4c7f0f6a9b7a8 

cfsmkm4ejLN2hi99TgxXNBfxsSQz6Pz8plQ2HJ1XToo8uXGALFlA+5y9ZLzBLGRj 
... 
zVYQvWh5NytrP9wnNogRsXqAufxf4ZLehosx0eUK4R4PsMy/VTDvcNo9P3uq2T32 

-----END RSA PRIVATE KEY----- 

此文件的格式如下所示,虽然我们可以手动创建此文件,但 Go 的 crypto 库已经为我们提供了支持:

-----BEGIN Type----- 
Headers 
base64-encoded Bytes 
-----END Type----- 

我们还需要确保我们的私钥安全,所以如果指定了密码,我们将像使用命令行选项一样加密密钥。在第196行,我们检查是否指定了密码,如果是的话,我们将调用该方法:

func EncryptPEMBlock(rand io.Reader, blockType string, data, password []byte, alg PEMCipher) (*pem.Block, error) 

此方法返回一个用于给定 DER 编码数据的 PEM 块,该数据使用给定的密码加密。我们在示例中使用的是 AES256 算法;然而,Go 也支持以下加密方式:

const ( 
        PEMCipherDES PEMCipher 
        PEMCipher3DES 
        PEMCipherAES128 
        PEMCipherAES192 
        PEMCipherAES256 
) 

如果我们不想用密码加密密钥,我们需要做一些稍微不同的事情。在第 202 行,我们需要自己创建 PEM 块;pem包通过以下结构体为我们提供了这个功能:

type Block struct { 
        Type    string            // The type, taken from the preamble (i.e. "RSA PRIVATE KEY"). 
        Headers map[string]string // Optional headers. 
        Bytes   []byte            // The decoded bytes of the contents. Typically a DER encoded ASN.1 structure. 
} 

无论我们使用加密的 PEM 块还是未加密的,我们都使用同一包中的Encode函数,它将我们的数据转换为正确的格式:

func Encode(out io.Writer, b *Block) error 

生成 X.509 证书

现在我们有了私钥,我们可以继续生成我们的证书。我们已经看到使用openssl创建它有多容易,在 Go 中也是如此:

125 func generateX509Certificate( 
126  key *rsa.PrivateKey, 
127  template *x509.Certificate, 
128  duration time.Duration, 
129  parentKey *rsa.PrivateKey, 
130  parentCert *x509.Certificate) []byte { 
131 
132  notBefore := time.Now() 
133   notAfter := notBefore.Add(duration) 
134 
135  template.NotBefore = notBefore 
136  template.NotAfter = notAfter 
137 
138  serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) 
139  serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) 
140  if err != nil { 
141    panic(fmt.Errorf("failed to generate serial number: %s", err)) 
142  } 
143 
144  template.SerialNumber = serialNumber 
145 
146  subjectKey, err := getSubjectKey(key) 
147  if err != nil { 
148    panic(fmt.Errorf("unable to get subject key: %s", err)) 
149  } 
150 
151  template.SubjectKeyId = subjectKey 
152 
153  if parentKey == nil { 
154    parentKey = key 
155  } 
156 
157  if parentCert == nil { 
158    parentCert = template 
159  } 
160 
161  cert, err := x509.CreateCertificate(rand.Reader, template, parentCert, &key.PublicKey, parentKey) 
162  if err != nil { 
163    panic(err) 
164  } 
165 
166  return cert 
167 } 

我们将一些参数传递给这个方法。第一个参数可能有点奇怪,那就是模板。因为我们需要生成不同类型的证书,例如那些可以签署其他证书以创建信任链的证书,我们需要创建一个模板来使用,其中包含一些默认值。如果我们查看定义在第 22 行的rootTemplate,我们可以检查一些这些选项:

22 var rootTemplate = x509.Certificate{ 
23  Subject: pkix.Name{ 
24    Country:            []string{"UK"}, 
25    Organization:       []string{"Acme Co"}, 
26    OrganizationalUnit: []string{"Tech"}, 
27    CommonName:         "Root", 
28  }, 
29 
30  KeyUsage: x509.KeyUsageKeyEncipherment | 
31    x509.KeyUsageDigitalSignature | 
32    x509.KeyUsageCertSign | 
33    x509.KeyUsageCRLSign, 
34  BasicConstraintsValid: true, 
35  IsCA: true, 
36 } 

主题,是pkix.Name结构体的一个实例,具有以下字段:

type Name struct { 
        Country, Organization, OrganizationalUnit []string 
        Locality, Province                        []string 
        StreetAddress, PostalCode                 []string 
        SerialNumber, CommonName                  string 

        Names      []AttributeTypeAndValue 
        ExtraNames []AttributeTypeAndValue 
} 

这些是 X.509 区分名称的常见元素;这些元素中的大多数都很直接,代表了证书所有者的详细信息。SerialNumber是最重要的之一。序列号对于证书链必须是唯一的;然而,它不需要是顺序的。如果我们查看第 138 行的示例,我们正在生成一个 128 位长的大随机整数,但你可以将它改为任何你喜欢的。

我们证书生成的下一个有趣的部分是SubjectKey;这是为了使信任链正确工作所必需的。如果一个证书由另一个证书签署,那么权限密钥标识符将与父证书的主题密钥标识符匹配:

X509v3 Subject Key Identifier: 
                5E:18:F9:33:BB:7B:E0:73:70:A5:3B:13:A8:40:38:3E:C9:4C:B4:17 
X509v3 Authority Key Identifier: 
                keyid:72:38:FD:0F:68:5C:66:77:C0:AF:CB:43:C7:91:4C:5A:DD:DC:4D:D8 

要生成主题密钥,我们需要将密钥的公共版本序列化为 DER 格式,然后提取仅包含密钥部分的字节:

174 func getSubjectKey(key *rsa.PrivateKey) ([]byte, error) { 
175  publicKey, err := x509.MarshalPKIXPublicKey(&key.PublicKey) 
176  if err != nil { 
177    return nil, fmt.Errorf("failed to marshal public key: %s", err) 
178  } 
179 
200  var subPKI subjectPublicKeyInfo 
201  _, err = asn1.Unmarshal(publicKey, &subPKI) 
202  if err != nil { 
203    return nil, fmt.Errorf("failed to unmarshal public key: %s", err) 
204  } 
205 
206  h := sha1.New() 
207  h.Write(subPKI.SubjectPublicKey.Bytes) 
208  return h.Sum(nil), nil 
209 } 

在第 174 行,我们使用x509包中的MarshalPKIXPublicKey函数将公钥转换为字节数组:

func MarshalPKIXPublicKey(pub interface{}) ([]byte, error) 
MarshalPKIXPublicKey serialises a public key to DER-encoded PKIX format. 

这返回一个表示 ASN.1 数据结构的字节数组;为了获取密钥的底层数据,我们需要将其解包到定义在第 169 行的结构体格式中:

169 type subjectPublicKeyInfo struct { 
170  Algorithm        pkix.AlgorithmIdentifier 
171  SubjectPublicKey asn1.BitString 
172 } 

为了执行此转换,我们可以使用Unmarshal函数,该函数位于encoding/asn1包中。此方法尝试将 ASN.1 数据格式转换为:

func Unmarshal(b []byte, val interface{}) (rest []byte, err error) 
Unmarshal parses the DER-encoded ASN.1 data structure b and uses the reflect package to fill in an arbitrary value pointed at by val. Because Unmarshal uses the reflect package, the structs being written to must use upper case field names. 

最后,在第161行,我们可以使用crypto/x509包上的CreateCertificate方法创建证书。此方法接受一个父证书,该证书将用于签名子证书。对于我们的根证书,我们希望它是自签名的,因此我们将父证书和私钥都设置为根证书的私钥和模板。对于中间和叶证书,我们会使用父证书的详细信息:

func CreateCertificate(rand io.Reader, template, parent *Certificate, pub, priv interface{}) (cert []byte, err error) 

CreateCertificate函数基于模板创建一个新的证书。以下模板成员被使用:SerialNumberSubjectNotBeforeNotAfterKeyUsageExtKeyUsageUnknownExtKeyUsageBasicConstraintsValidIsCAMaxPathLenSubjectKeyIdDNSNamesPermittedDNSDomainsCriticalPermittedDNSDomainsSignatureAlgorithm

证书由父证书签名。如果父证书等于模板,则证书是自签名的。参数pub是签发者的公钥,priv是签发者的私钥。

现在我们有了证书,让我们看看如何使用 TLS 来保护一个网络服务器。在第一章,“微服务简介”中,你可能还记得介绍了来自标准 HTTP 包的http.ListenAndServe,它启动了一个 HTTP 网络服务器。当然,Go 有一个同样出色的包用于创建一个使用 TLS 加密的网络服务器。实际上,它只需要比标准的ListenAndServe多两个参数:

func ListenAndServeTLS(addr, certFile, keyFile string, handler Handler) error 

我们需要做的只是传递我们的证书和相应的私钥的路径以及服务器启动时将使用 TLS 来处理流量。如果我们使用自签名证书,在我们的例子中就是这样,那么我们需要为我们的客户端编写一些额外的代码,否则当我们尝试连接到服务器时,我们会收到如下错误信息:

2017/03/19 14:29:03 Get https://localhost:8433: x509: certificate signed by unknown authority 
exit status 1 

为了避免这种情况,我们需要创建一个新的证书池并将它传递给客户端的 TLS 设置。默认情况下,Go 将使用主机的根 CA 集合,它将不包括我们的自签名证书:

13 roots := x509.NewCertPool() 
14 
15 rootCert, err := ioutil.ReadFile("../generate_keys/root_cert.pem") 
16 if err != nil { 
17  log.Fatal(err) 
18 } 
19 
20 ok := roots.AppendCertsFromPEM(rootCert) 
21 if !ok { 
22   panic("failed to parse root certificate") 
23 } 
24 
25 applicationCert, err := ioutil.ReadFile("../generate_keys/application_cert.pem") 
26 if err != nil { 
27  log.Fatal(err) 
28 } 
29 
30 ok = roots.AppendCertsFromPEM(applicationCert) 
31 if !ok { 
32  panic("failed to parse root certificate") 
33 } 
34 
35 tlsConf := &tls.Config{RootCAs: roots} 
36 
37 tr := &http.Transport{TLSClientConfig: tlsConf} 
38 client := &http.Client{Transport: tr} 

在第13行,我们创建了一个新的证书池,然后读取证书,这些证书被 PEM 编码成字节数组。在第20行,我们可以将这些证书添加到新的证书池中;为了使证书被识别为有效,我们需要中间证书和根证书。然后我们可以创建一个新的 TLS 配置并添加证书;然后将其添加到传输中,最终在第38行添加到客户端。

当我们现在运行客户端时,它没有任何问题地连接,我们将看到服务器正确返回的Hello World响应。

保护静态数据

假设我们的系统已经连接到数据库来存储诸如用户账户之类的信息,攻击者将能够访问完整的密码数据库。当我们把数据存储在数据库中时,我们应该考虑的一个问题是我们的数据加密。毫无疑问,加密数据比不加密数据更昂贵,有时很难确定我们应该加密哪些字段或表,哪些应该保持未加密状态。

微服务给我们带来的许多好处之一是我们可以在系统中分离功能和数据。这使得决定要加密哪些数据变得更加容易,因为与其试图理解数据存储中哪些数据需要加密,你只需做出一个更简单的决定:在这个数据存储中是否有任何需要加密的数据?如果有,那么只需简单地加密所有数据。在应用层而不是数据存储中执行此加密可能更有益,因为应用通常比数据存储扩展得更好,你必须考虑缓存可能引入的边缘情况。如果你为了减轻数据存储的压力,使用 Elasticache 或其他技术添加一个中间缓存层,你需要考虑你数据的安全性。如果数据在数据库中加密,那么你需要确保缓存也应用相同级别的加密。

物理机器访问

当我说“物理上”时,我的意思是人类可以访问;代码可能运行在虚拟机上。然而,问题是一样的:我常常发现,公司给开发者访问在生产环境中运行的数据库和其他信息源。即使他们没有访问数据库密码,他们可能有权访问配置存储或能够通过 SSH 连接到应用程序服务器并从应用程序中读取配置。有一个名为最小权限的安全原则;这建议账户和服务应具有执行其业务功能所需的最小权限。即使你已经确保了机器到机器的通信是安全的,并且你的防火墙有适当的保护措施,攻击者总有通过后门访问你的系统的机会。考虑以下场景。你公司的一名非技术员工打开了一封电子邮件或下载了一些软件,这些软件在他们的笔记本电脑上安装了恶意软件。攻击者利用这一点来获取对他们的机器的访问权限,并从那里成功地在网络中横向移动,最终到达你的笔记本电脑。现在,由于你登录并忙于工作,并通过 VPN 连接到生产环境,他们设法在你的机器上安装了一个键盘记录器,这使他们能够访问你的密码,他们从你的磁盘中检索你的 SSH 密钥,因为你几乎可以完全访问生产环境,现在他们也一样。虽然这听起来像是科幻小说,但这是完全可能的。当然,你可以保护你的内部网络,但避免这种攻击的最佳方法是严格限制对生产环境和数据的访问。你永远不需要这种级别的访问权限;在我的代码中进行稳健的测试时,我常常发现,当一项服务表现不佳时,生产访问并不能帮助我解决问题。我应该能够在预演环境中重现几乎任何错误,并且该服务发出的日志和度量数据应该足以让我诊断任何问题。我并不是说我从未在生产环境中实时调试过,但幸运的是,在过去的十年中我没有这样做。工具和实践如此之先进,以至于我们永远不需要回到那些行为。

OWASP

无论何时你在寻找关于安全的实际网络安全建议,OWASP 几乎总是你的首选。

对于 API 的帮助,OWASP 也可以提供帮助:他们已经发布了REST 安全速查表(www.owasp.org/index.php/REST_Security_Cheat_Sheet)。

当然,正如我们在本书中已经讨论过的,构建 API 有许多不同的标准,REST 只是其中之一;然而,我们可以从本指南中利用一些有用的通用技巧。

永远不要在 URL 中存储会话令牌

JWT(JSON Web Token),可能是您在 API 中最常见的会话令牌,编码成 URL 安全的格式。然而,将令牌存储或传递到 URL 中并不推荐,并且它始终应存储在 cookie 中或作为POST变量。这样做的原因是,如果您在 URL 中传递会话令牌,这些令牌可能会泄漏到您的服务器日志中,并且根据您如何管理令牌的持续时间,如果攻击者访问了您的日志文件,他们也可能能够获得完全访问权限来执行用户的命令。

跨站脚本(XSS)和跨站请求伪造(CRSF)

XSS(跨站脚本)和 CRSF(跨站请求伪造)仅在 API 将从网络浏览器中使用时适用,例如在单页应用或直接 JavaScript 调用中。然而,为了防止攻击者注入恶意 JavaScript 以检索您的会话令牌,您应确保它存储在标记为 HTTP-only 的 cookie 中,并且您始终通过 HTTPS 发送它们以防止它们在传输中被捕获。除此之外,我们还可以添加一层安全措施,该措施检查浏览器发送的 HTTP 引用与预期域名是否匹配。虽然使用类似 cURL 的工具可以伪造 HTTP 引用,但在浏览器中的 JavaScript 中这样做是不可能的或极其困难的。

不安全直接对象引用

当您构建 API 时,您需要确保您正在检查认证用户是否可以修改请求中的对象。这将由服务器端执行;我们不希望给我们的攻击者创建一个真正的登录并能够操纵请求以代表其他用户执行操作的能力。

OWASP 文档会定期更新,因为发现了新的攻击和漏洞;经常检查网站并保持自己最新。

认证和授权

认证是检查某事是否为真的过程或行为,例如:此用户名是否与该密码匹配?授权是指定访问权限或关于用户的策略的功能。

认证是一个被广泛理解的概念;然而,我们需要理解一些概念以确保此操作不会被破坏,例如永远不要在数据存储中以纯文本形式存储密码,并防止通过将活动令牌转移到第三方来劫持登录会话。然而,授权同样重要;我们之前讨论了困惑代理问题:即使用户已通过认证,我们仍然必须控制他们在系统上可以执行的操作。在自身之间建立信任原则且不独立验证用户权利的服务,如果攻击者破坏了您的防火墙,就会很容易受到滥用。在本节中,我们将探讨解决这两个问题有多么容易,并提供模式,以确保您的服务永远不会暴露。

密码散列

哈希是一种单向加密:你取一系列字母和数字,通过运行哈希算法,你得到一个序列,虽然可以通过相同的原始输入重新生成,但无法从数学上逆向。那么,为什么你会使用哈希而不是直接加密数据呢?首先,哈希不需要任何密钥,因此它们不会因为私钥的丢失而受到威胁,它们非常适合存储不需要逆向的数据。考虑密码:你的系统永远不需要知道用户的密码是什么,它只需要知道用户传递给你的值与存储的值匹配。哈希密码是完美的安全方法:你哈希用户的输入,并将这个哈希值与你的数据存储中的值进行比较。如果数据库被破坏,攻击者将无法解码密码。当然,攻击者可以尝试暴力破解密码,但截至目前,地球上没有足够的计算能力来解码一个合理的哈希值。这意味着哈希是不可攻破的吗?不。事实上,许多人认为 MD5 哈希是不可逆的;然而,这个算法已经被破坏。可以在几秒钟内找到碰撞。2011 年有一个案例,攻击者利用这个漏洞创建了假的 SSL 证书,这允许他们利用用户的信任。幸运的是,我们不再使用 MD5 或 SHA-1 进行加密目的。你仍然可以在像 git 提交这样的签名中找到它,那里的碰撞可能性被计算速度所抵消,但对于安全性,我们需要使用更现代的算法。

添加一点调味料

虽然单独的哈希值可以提供相当的安全级别,但我们还可以添加盐和胡椒。盐与加密数据一起存储在数据库中。这样做背后的意图是使暴力破解数据变得更加计算密集。这阻止了攻击者使用彩虹表来攻击你的数据。彩虹表是预先计算好的哈希值表,因此,你不需要在每次尝试时都计算哈希值,只需在表中查找加密字符串并返回原始值即可。为了应对这种情况,我们添加了一个盐,这个盐是随机生成的,并在哈希之前附加到数据上。尽管我们需要将它与哈希值一起存储在数据库中以便稍后检查值,但它阻止了彩虹表的使用,因为每个表都必须为每个盐值重新计算,这非常计算密集。为了进一步增强安全性,我们通常还会添加一个胡椒,这是一个存储在盐和哈希值之外的预计算值。

常规做法是预先生成一个胡椒列表并将其存储在配置存储中。当你第一次哈希密码或其他值时,你会随机选择一个胡椒并将其附加到值上,就像你为盐做的那样。然后,当你检查提供的值是否与哈希匹配时,你会遍历胡椒列表并为每个胡椒生成一个哈希值进行比较。这会在你的服务中检查密码时增加一点计算时间;然而,这远不如它会给试图暴力破解你的值的攻击者带来的工作量。让我们看看我们如何使用盐和胡椒来哈希一个值。

如果我们查看hashing/hash.go中的源代码,我们可以使用以下GenerateHash方法从一个输入字符串创建一个哈希值。GenerateHash方法具有以下签名,并且给定一个输入字符串,它使用 sha512 算法返回一个随机盐和哈希字符串:

func GenerateHash(input string) (hash string, salt string) 

要使用这个功能,我们只需用我们的字符串来调用方法,然后我们会得到以下输出:

h:= New(peppers) 
hash, salt := h.GenerateHash("HelloWorld1") 

fmt.Println("Salt: ", salt) 
fmt.Println("Hash: ", hash) 

---Output 
Salt:  15f42f8b4f1c71dc6183c822fcf28b3c34564c32339509c2c02fa3c4dda0ed4f 
Hash:  b16a89d3c41c9fe045a7c1b892d5aa15aee805d64b753e003f7050851ef4d374e3e16ce23500020746174f7b7d8aeaffebf52939f33c4fda505a5c4e38cdd0e1 

让我们更深入地看看这个函数在做什么:

22 // GenerateHash hashes the input string using sha512 with a salt and pepper. 
23 // returns the hash and the salt 
24 func (h *Hash) GenerateHash(input string) (hash string, salt string) { 
25  pepper := h.getRandomPepper() 
26  salt = h.generateRandomSalt() 
27  hash = h.createHash(input, salt, pepper) 
28 
29  return 
30 } 

我们首先做的事情,在第25行,是从传递给结构体的胡椒切片中随机获取一个胡椒。胡椒不需要存储在数据库中;这纯粹是为了通过要求攻击者将他们的暴力破解尝试次数增加五倍来减缓潜在的攻击者。在我们的例子中,我们可以增加胡椒的数量,正如你从下面的基准测试中可以看到,即使有 1,000 个胡椒,我们仍然能够在 1 毫秒内比较一个哈希值。但是,为了获得这种额外的安全性,可能并不值得。生成一个哈希值需要 4,634 纳秒,由于字符串的长度,我们需要生成最大为 6.2e19 或 62 千万亿种排列组合。这是假设有 63 个允许的字符和一个 11 个字符长度的密码。这是一个相当大的数字,要生成这么多的哈希值,对于一个单核 CPU 来说,大概需要 9 百万年的时间来暴力破解。

字典攻击

然而,并非所有密码都复杂,许多密码容易受到一种称为字典攻击的攻击。字典攻击不会尝试 62 千万亿种排列组合,而是集中在最有可能成功的那些上。这些字典本身通常是从之前被利用的密码数据库中派生出来的,由于人类的行为有一定的可预测性,我们经常使用相同的密码。因为我们的密码HelloWorld1已经在包含 1400 万个其他密码的字典中,当我尝试使用John the Ripper破解加盐的哈希时,只用了 2.4 秒就检索到了密码。

添加胡椒

在阻止用户使用简单密码方面,我们能够做的事情之间有一条很细的界限。我们应该始终有一个政策来定义什么是一个好的密码--最小长度、大小写混合、添加符号等等--但是随着密码的复杂度增加,可用性可能会受到影响。然而,添加 pepper 可以帮助减缓攻击者的速度:pepper 或 peppers 为系统所知,但不会与密码和盐一起存储。它们可以硬编码到应用程序代码中,作为启动配置存储,或者在运行时从安全保险库中存储。同样,我们在用户的密码中附加了盐,我们也对 pepper 做同样的事情。如果数据库表因 SQL 注入攻击而受损,除非攻击者能够检索到 peppers,否则数据库将毫无用处。当然,攻击者可能能够获取到你的 peppers;然而,在安全领域,几乎一切都是关于使事情变得困难并减缓某人的速度。

bcrypt

bcrypt 是另一种流行的密码散列方法,它使用可变次数的轮次来生成散列,这既减缓了暴力破解攻击的能力,也减缓了生成散列的时间。Go 有一个 bcrypt 的实现,由实验性包提供,位于godoc.org/golang.org/x/crypto/bcrypt。要使用 bcrypt 散列密码,我们使用GenerateFromPassword方法:

func GenerateFromPassword(password []byte, cost int) ([]byte, error)

GenerateFromPassword方法返回给定成本的密码的 bcrypt 散列。成本是一个变量,允许你在生成散列时增加处理时间以换取返回散列的安全性。

要检查 bcrypt 散列的相等性,我们不能再次使用给定的密码调用GenerateFromPassword并比较输出与存储的散列,因为GenerateFromPassword每次运行都会创建不同的散列。为了比较相等性,我们需要使用CompareHashAndPassword方法:

func CompareHashAndPassword(hashedPassword, password []byte) error

CompareHashAndPassword方法比较 bcrypt 散列密码与其可能的纯文本等效密码。bcrypt 是保护密码的一种安全方法,但它很慢,让我们更深入地看看生成散列的成本。

基准测试

下表说明了使用我们迄今为止讨论的方法生成和比较散列字符串的大致时间。即使有 1,000 个 peppers,我们也会看到大约 1.5 毫秒的处理时间来运行比较。这可能看起来不是很多时间;然而,我们需要带着一点盐来对待这些基准,因为它们正在运行单个操作,而你的服务器将同时处理多个请求。我们知道的是,与 100 个列表相比,比较 1,000 个 peppers 的列表需要 10 倍的时间,而这比 10 个列表的时间还要长 10 倍:

即使有 1,000 个辣椒,我们运行比较的时间大约为 1.5 毫秒。这可能看起来不是很多时间;然而,我们需要带着一点盐来对待这些基准,因为它们正在运行一个单一操作,而你的服务器将同时处理多个请求。我们知道的是,与 1,000 个辣椒的哈希比较需要比 100 个的哈希比较长 10 倍,而这比 10 个的哈希比较长 10 倍:

BenchmarkGeneratePlainHash-8             30000000     1069 ns/op
BenchmarkGenerateHashWithSaltAndPepper-8  5000000     5223 ns/op
BenchmarkGenerateBcrypt-8                     500 68126630 ns/op
BenchmarkCompareSaltedHash-8             20000000     1276 ns/op
BenchmarkComparePlainHash-8              20000000     1174 ns/op
BenchmarkCompareHash5Peppers-8           20000000     4980 ns/op
BenchmarkCompareHash10Peppers-8          10000000     4669 ns/op
BenchmarkCompareHash100Peppers-8          1000000    22150 ns/op
BenchmarkCompareHash1000Peppers-8           20000  1492037 ns/op
BenchmarkCompareBCrypt-8                      500 70942742 ns/op

根据这些信息,我们可以平衡我们的服务在速度和安全性之间的平衡;然而,我们应该始终倾向于更安全的选择。要了解 Facebook 如何管理哈希,我建议您观看 Alec Muffett 的演讲 Facebook:密码哈希和身份验证www.youtube.com/watch?v=NQDo2e3gj1A)。

JWTs

JSON Web Token(JWT)是一种在环境中安全传递与用户相关的声明或数据的标准。这是一个极其流行的标准,几乎适用于所有主要的语言和框架,当然也包括 Go。JWT 有两个主要优势。一是声明的一个标准格式,这使得可靠框架的可用性成为可能。另一个是使用非对称加密,这意味着因为令牌是经过签名的,所以接收者只需要签名人公钥来验证令牌确实来自可信源,这允许我们将对私钥的访问锁定到授权服务器。

JWT 的格式

JWT 被分成三个不同的部分,这些部分被编码为 Base64-URL。像标准 Base64 一样,Base64-URL 用-_替换了+/,并移除了所有填充。这使得令牌可以在 URL 中安全地传输。

结果是一个看起来像以下示例的令牌:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3NMZXZlbCI6InVzZXIiLCJleHAiOjE4MDc3MDEyNDYsInVzZXJJRCI6ImFiY3NkMjMyamZqZiJ9.iQxUbQuEy4Jh4oTkkz0OPGvS86xOWJjdzxHHDBeAolv0982pXKPBMWskSJDF3F8zd8a8nMIlQ5m9tzePoJWe_E5B9PRJEvYAUuSp6bGm7-IQEum8EzHq2tMvYtPl9uzXgOU4C_pIjZh5CjFUeZLk5tWKwOOo8pW4NUSxsV2ZRQ_CGfIrBqEQgKRodeLTcQ4wJkLBILBzmAqTVl-5sLgBEoZ76C_gcvS6l5HAwEAhmiCqtDMX46o8pA72Oa6NiVRsgxrhrKX9rDUBdJAxNwFAwCjTv6su0jTZvkYD80Li9aXiMuM9NX7q5gncbEhfko_byTYryLsmmaUSXNBlnvC_nQ 

令牌的三个不同部分是头部、载荷和签名。头部声明了编码对象的类型和加密签名的算法:

{ 
  "alg": "RS256", 
  "typ": "JWT" 
} 

第二个对象载荷,包含与令牌相关的声明的详细信息:

{ 
  "userID": "abcsd232fjfj", 
  "accessLevel": "user" 
} 

最后,第三部分是签名,这是一个可选元素,在解码状态下如下所示:

Tm 
  <a=<kNX[d\1k$H_3w5C7NAIR1b 
                           Hy 
1TyՊ5D]Ehuq0&B s 
V_{@! 39Tl5t17@(ӿ.پF5~ H_6+&\[1m% 

JWT 中的每个元素都是 base64URL 编码的(en.wikipedia.org/wiki/Base64#URL_applications);表示为其二进制形式的签名是以下格式的消息的 sha256:

Base64URL(header).Base64URL(payload) 

签名的格式可以是对称的(HS256),使用共享密钥,或者非对称的(RS256),使用公钥和私钥。对于 JWT,最佳选项是非对称选项,因为对于需要验证 JWT 的服务,它只需要密钥的公钥部分。

我们可以使用命令行仅验证我们的 JWT。首先,我们需要将我们的 base64URL 编码的签名转换为标准 base64 编码,通过将 _ 替换为 /- 替换为 +。然后,我们可以将此通过管道传递到 base64 命令行应用程序,并传递 -D 标志以解码输入;然后将其输出到一个文件中:

cat signature.txt | sed -e 's/_/\//g' -e 's/-/+/g' | base64 -D > signature.sha256 

下一步是验证签名是否由正确的密钥签名,通过将其与公钥进行验证:

openssl dgst -sha256 -verify ../keys/sample_key.pub -signature signature.sha256 data.txt 

使用 Go 生成 JWT 非常简单,多亏了一些出色的社区包。在示例代码中我们将使用的包名为 jose,由 Eric Largergren 创建(github.com/SermoDigital/jose)。根据 jwt.io 上的列表,此包实现了标准中定义的所有功能,并且在写作时是一个显而易见的选择。

如果我们查看文件 chapter8/jwt/jwt.go 并查看 GenerateJWT 方法,我们可以看到使用 jose 创建 JWT 是多么简单:

30 // GenerateJWT creates a new JWT and signs it with the private key 
31 func GenerateJWT() []byte { 
32  claims := jws.Claims{} 
33  claims.SetExpiration(time.Now().Add(2880 * time.Minute)) 
34  claims.Set("userID", "abcsd232jfjf") 
35  claims.Set("accessLevel", "user") 
36 
37  jwt := jws.NewJWT(claims, crypto.SigningMethodRS256) 
38 
39  b, _ := jwt.Serialize(rsaPrivate) 
40 
41  return b 
42 } 

我们首先需要生成一个声明列表并设置一个过期时间;我们将过期时间设置为两周。然后我们可以使用 Set 函数设置一个声明列表:

func (c Claims) Set(key string, val interface{}) 

最后,在行 39 中,我们可以通过将声明和签名方法传递给 NewJWT 函数来创建一个新的 JWT:

func NewJWT(claims Claims, method crypto.SigningMethod) jwt.JWT 

然后,我们可以调用 Serialize 方法,该方法以私钥作为参数——在我们的例子中是一个 rsa.PrivateKey 的实例——并返回一个编码格式的字节数组:

func (j *jws) Serialize(key interface{}) ([]byte, error) 

使用 jose 验证 JWT 与创建 JWT 一样简单:

46 func ValidateJWT(token []byte) error { 
47  jwt, err := jws.ParseJWT(token) 
48  if err != nil { 
49    return fmt.Errorf("Unable to parse token: %v", err) 
50  } 
51 
52  if err = jwt.Validate(rsaPublic, crypto.SigningMethodRS256); err != nil { 
54    return fmt.Errorf("Unable to validate token: %v", err) 
55  } 
56 
57  return nil 
58 } 

我们首先需要做的是使用 ParseJWT 函数将我们的 JWT 从字节数组解析到 jwt 结构体中:

func ParseJWT(encoded []byte) (jwt.JWT, error) 

然后,我们可以调用 Validate 方法,传递与私钥对应的公钥以及签名方法。可选地,我们可以提供一个自定义验证函数;默认验证将仅检查签名以及令牌是否未过期:

func (j *jws) Validate(key interface{}, m crypto.SigningMethod, v ...*jwt.Validator) error 

当验证失败时,将返回一个错误;如果错误为 nil,则令牌有效,其中的声明可以信赖。

安全消息

当我们需要发送加密消息时,最好的方法之一是使用非对称加密,其中我们使用公开信息加密消息,这些信息可以轻松分发,然后使用由单个方安全持有的私钥解密它。

Go 中的加密包包含了我们保护数据所需的所有功能。如果我们查看示例文件 chapter8/asymmetric/asymmetric.goEncryptDataWithPublicKey 方法是 rsa 包公钥加密的一个简单实现:

func EncryptOAEP(hash hash.Hash, random io.Reader, pub *PublicKey, msg []byte, label []byte) ([]byte, error) 

第一个参数是一个加密哈希,用作加密前处理消息的随机预言机。这个函数在加密和解密时必须相同,文档建议使用 sha256。下一个参数是随机数生成器;它用作熵的来源,以确保如果你两次加密相同的消息,不会返回相同的密文。pub 是我们想要用来加密消息的 rsa.PublicKey;消息本身作为字节数组切片传递。最后一个参数是可选的,并且在结果密文中不加密;它可以用来帮助接收者理解信息,例如使用了哪个密钥来加密消息,但必须极端小心,不要向标签中添加可能危及加密消息安全性的数据:

41 // EncryptMessageWithPublicKey encrypts the given string and retuns the encrypted 
42 // result base64 encoded 
43 func EncryptMessageWithPublicKey(message string) (string, error) { 
44 
45  modulus := rsaPublic.N.BitLen() / 8 
46  hashLength := 256 / 4 
47  maxLength := modulus - (hashLength * 2) - 2 
48 
49  if len(message) > maxLength { 
50    return "", fmt.Errorf("The maximum message size must not exceed: %d", maxLength) 
51  } 
52 
53  data, err := EncryptDataWithPublicKey([]byte(message)) 
54  return base64.StdEncoding.EncodeToString(data), err 
55 }  

在这种方法中,我们首先要检查消息是否短于这种加密方法允许的最大长度。最大长度必须不大于公模数减去两倍的哈希长度再减去两个。由于公钥密码学中涉及的数学,我们只能允许加密短消息。我们稍后会看看如何解决这个问题。在第 53 行,我们调用另一个内部函数,该函数简单地调用 rsa 包中的 EncryptOAEP 函数。然后我们将数据编码为 base64 并返回结果。

解密数据很简单:

57 // DecryptMessageWithPrivateKey decrypts the given base64 encoded ciphertext with 
58// the private key and returns plain text 
59 func DecryptMessageWithPrivateKey(message string) (string, error) { 
60  data, err := base64.StdEncoding.DecodeString(message) 
61  if err != nil { 
62    return "", err 
63  } 
64 
65  data, err = DecryptDataWithPrivateKey(data) 
66  return string(data), err 
67 } 

由于我们的加密方法实现返回了一个 base64 编码的字符串,我们在解密消息之前首先要做的是将其解码回二进制形式。然后我们调用内部方法 DecryptDataWithPrivateKey;这是一个 rsa.DecryptOAEP 方法的包装器:

func DecryptOAEP(hash hash.Hash, random io.Reader, priv *PrivateKey, ciphertext []byte, label []byte) ([]byte, error) 

这种方法与加密方法的参数相同,只是这次我们使用的是私钥。如果我们回顾非对称加密的工作原理,我们可以用公钥加密,但不能用公钥解密消息。必须使用私钥来完成这个目的。

共享密钥

对称加密也有其用途:一方面,它更快,另一方面,它可以处理任何大小的消息。在 Go 中实现对称加密,正如你所期望的,相当简单:我们有优秀的 crypto/aes 包,它为我们处理所有繁重的工作。让我们看看如何使用 AES 加密消息。查看示例文件 symmetric/symmetric.go

12 func EncryptData(data []byte, key []byte) ([]byte, error) { 
13  if err := validateKey(key); err != nil { 
14    return make([]byte, 0), err 
15  } 
16 
17  c, err := aes.NewCipher(key) 
18  if err != nil { 
19    return make([]byte, 0), err 
20  } 
21 
22  gcm, err := cipher.NewGCM(c) 
23  if err != nil { 
24    return make([]byte, 0), err 
25  } 
26 
27  nonce := make([]byte, gcm.NonceSize()) 
28  if _, err = io.ReadFull(rand.Reader, nonce); err != nil { 
29    return make([]byte, 0), err 
30  } 
31 
32  return gcm.Seal(nil, nonce, data, nil), nil 
33 } 

在第 13 行,我们首先需要做的是验证密钥的长度。密钥的长度决定了加密的强度;16 字节密钥将使用 AES-128 加密,24 字节 AES-192,32 字节 AES-256。然后我们创建一个新的 GCMGalois/Counter Mode)加密器,并将我们的 AES 加密器的引用传递给它:

func NewGCM(cipher Block) (AEAD, error) 

然后,我们需要创建一个用于防止重放攻击的 nonce,最后我们可以调用Seal方法来加密我们的数据:

Seal(dst, nonce, plaintext, additionalData []byte) []byte 

与 RSA 公钥加密不同,AES 可以处理的消息大小几乎是无限的;然而,问题在于密钥必须由作者和读者共享,这引入了向双方分配密钥的问题。

解密的工作方式与加密方法相反,一个例子可以在下一个代码块中看到:

35 // DecryptData decrypts the given data with the given key 
36 func DecryptData(data []byte, key []byte) ([]byte, error) { 
37  c, err := aes.NewCipher(key) 
38  if err != nil { 
39    return make([]byte, 0), err 
40  } 
41 
42  gcm, err := cipher.NewGCM(c) 
43  if err != nil { 
44    return make([]byte, 0), err 
45  } 
46 
47  nonceSize := gcm.NonceSize() 
48  if len(data) < nonceSize { 
49    return make([]byte, 0), fmt.Errorf("ciphertext too short") 
50  } 
51 
52  nonce, ciphertext := data[:nonceSize], data[nonceSize:] 
53  return gcm.Open(nil, nonce, ciphertext, nil) 
54 } 

在这个代码块中,我们需要注意的主要是gcm.Open方法:

Open(dst, nonce, ciphertext, additionalData []byte) ([]byte, error) 

我们将要用来解密消息的 nonce 需要与加密消息时使用的相同。当我们调用Seal时,从方法返回的字节切片是加密消息和 nonce,因此要检索它,我们只需要计算 nonce 的大小,然后将字节切片分成两部分。

处理大消息的非对称加密

我们已经讨论了非对称加密的问题,即它只能用于相对较小的消息;然而,不需要处理密钥分配的好处相对于对称加密来说是非常有优势的。对此问题有一个常见的解决方案;该解决方案是创建一个随机密钥,然后对称加密一个消息,然后非对称加密密钥并将两部分都分发给接收者。只有私钥的持有者才能解密对称密钥,只有当对称密钥被解密后,接收者才能解密主消息:

69 // EncryptLargeMessageWithPublicKey encrypts the given message by randomly generating 
70 // a cipher. 
71 // Returns the ciphertext for the given message base64 encoded and the key 
72 // used to encypt the message which is encrypted with the public key 
73 func EncryptLargeMessageWithPublicKey(message string) (ciphertext string, cipherkey string, err error) { 
74  key := utils.GenerateRandomString(16) // 16, 24, 32 keysize, random string is 2 bytes per char so 16 chars returns 32 bytes 
75  cipherData, err := symetric.EncryptData([]byte(message), []byte(key)) 
76  if err != nil { 
77    return "", "", err 
78  } 
79 
80  cipherkey, err = EncryptMessageWithPublicKey(key) 
81  if err != nil { 
82    return "", "", err 
83  } 
84 
85  return base64.StdEncoding.EncodeToString(cipherData), cipherkey, nil 
86 } 

查看asymmetric/asymmetric.go中的示例,我们可以看到我们正在做的是确切这样。这个函数简单地封装了我们在本章前面看到的对称和非对称加密的两种方法。解密很简单:

 88 // DecryptLargeMessageWithPrivateKey decrypts the given base64 encoded message by 
 89 // decrypting the base64 encoded key with the rsa private key and then using 
 90 // the result to decrupt the ciphertext 
 91 func DecryptLargeMessageWithPrivateKey(message, key string) (string, error) { 
 92   keystring, err := DecryptMessageWithPrivateKey(key) 
 93  if err != nil { 
 94    return "", fmt.Errorf("Unable to decrypt key with private key: %s", err) 
 95  } 
 96 
 97  messageData, err := base64.StdEncoding.DecodeString(message) 
 98  if err != nil { 
 99    return "", err 
100  } 
101 
102  data, err := symetric.DecryptData(messageData, []byte(keystring)) 
103 
104  return string(data), err 
105 } 

维护

保持你的系统安全的一个重要元素是确保你使用所有最新的安全补丁来更新它。这种方法需要应用于你的应用程序代码和服务器操作系统以及应用程序,如果你使用 Docker,你还需要确保你的容器是最新的,以确保你免受漏洞的侵害。

修补容器

确保你的容器安全的最简单方法之一是定期构建和部署它们。很多时候,如果一个服务没有处于活跃开发状态,那么它可能连续数月都不会被部署到生产环境中。正因为这个问题,你可能需要修补主机级别的应用程序库,比如 OpenSSL,但由于容器提供的应用隔离,你可能在容器级别有易受攻击的二进制文件。保持事物更新的最简单方法是定期运行构建和部署,即使应用程序代码没有变化。你还需要确保,如果你在 Dockerfile 中使用基础容器,这个容器也需要构建和更新。

Docker Hub、quay.io 和其他几个软件即服务注册表具有在链接容器发生变化时自动重建容器的功能。如果你正在构建基于 golang:latest 的镜像,你可以在上游镜像推送到注册表时自动触发构建。你还可以运行自动化的安全扫描,该扫描检查你的镜像层,并扫描任何 CVE 漏洞。它会告诉你漏洞存在于哪一层,通常你会发现这通常是在基础层,例如 Ubuntu 或 Debian。

软件更新

在你的主机和 Docker 镜像上修补软件可以帮助你免受诸如 Heartbleed 这样的漏洞的侵害,该漏洞在 OpenSSL 中被发现。修补软件更新相对简单。你可以配置你的主机自动更新自己;另一个选项,我更喜欢,是确保你的基础设施是自动化的,这样你就可以将其烧毁并重建。

修补应用程序代码

就像主机上的软件需要更新一样,你也必须更新你的应用程序代码,以确保你总是拥有最新的更新。通常,人们会锁定应用程序依赖项到某个版本,并且随着 Go 1.5 中引入的 vendoring 支持功能,这个过程在社区中越来越流行。这个问题的一个主要原因是,在 go 1.5 之前的版本中,vendoring 并未出现,这是为了鼓励你针对最新的包构建应用程序代码,并且尽早而不是晚些时候修复任何由于破坏性 API 变化而产生的问题。如果你确实使用了 vendoring,我当然不会建议你不使用它,那么你应该运行一个夜间构建,将所有库更新到最新版本。你不必一定将此部署到生产环境中;然而,如果测试通过,为什么不呢?如果测试失败,那么,即使这是一个未处于积极开发状态的服务,这也应该成为你进行一些维护的触发器。

日志记录

即使我们已经保护了密码并实施了良好的安全措施,我们仍然需要知道我们何时受到威胁。在前一章中,我们介绍了日志记录,日志记录可以作为你安全策略的一部分的有用工具。考虑有人试图暴力破解你的应用程序登录;当需要对此类威胁做出反应时,跟踪高水平的身份验证错误以及源 IP 地址可能是有用的。攻击者的 IP 地址可以被防火墙阻止。

日志文件的内容需要考虑以下属性:

  • 谁在执行操作

  • 什么失败了或成功了

  • 行动发生的时间

  • 为什么会失败或成功

  • 你如何处理这个问题

以下示例包含的信息远远不足以有用,实际上,除了让您知道可能存在故障外,您甚至可以不占用此类日志的空间:

Aug 18 11:00:57 [AuthController] Authentication failed. 

以下示例要好得多;它更深入地展示了问题,并详细描述了用户为访问系统所采取的事件。现代日志评估工具,如 Kibana,允许过滤和分组此类日志文件,这使您能够构建仪表板或事件列表:

Aug 18 11:00:57 [AuthController] Authentication failure 
for nicj@example.com by 127.0.0.1 - user unknown - 
/user/login /user/myaccount 
Aug 18 11:01:18 [AuthController] Authentication failure 
for nicj@example.com by 127.0.0.1 - invalid password - 
/user/login?err=1 /user/login 
Aug 18 11:02:01 [AuthController] Authentication failure 
for nicj@example.com by 127.0.0.1 - incorrect 2FA code 
- /user/login?err=2 /user/login 

例如,您可以创建一个仪表板,查看来自单个 IP 地址的失败尝试,如果超过某个阈值,这可能表明恶意尝试暴力破解系统访问。通常可以在此类事件上设置警报,让您能够主动识别威胁并阻止访问。

摘要

在本章中,我们学习了您的服务可能面临的来自入侵者的攻击。我们希望有一个关于加密工作原理以及如何利用 Go 的标准包来实现这些功能以保护我们的服务的介绍。您几乎无法完全保护自己免受决心攻击者的攻击;然而,使用本章中描述的简单技术应该形成您标准的工作实践。实施许多这些技术不会显著减慢您的开发周期;然而,这将为您提供一个保持安全的优势。

第九章:事件驱动架构

在过去的几章中,我们探讨了稳定性和性能方面的问题,以及你可以在代码中采用的某些模式,这些模式能够使系统更加稳定。在本章中,我们将更深入地探讨事件驱动架构。

随着你的系统不断增长,这些模式变得越来越重要;它们允许你松散地耦合你的微服务,因此你不必绑定到在单体应用中常见的相互交织的对象的相同依赖。我们将了解到,通过适当的前期设计和努力,使用事件来松散耦合系统并不一定是一个痛苦的过程。

在我们开始之前,请确保从github.com/building-microservices-with-go/chapter9获取源代码。

同步处理和异步处理之间的差异

如果在同步处理消息和异步处理消息之间有选择,那么我总是会选择同步,因为它总是使应用程序更简单,组件更少,代码更容易理解,测试更容易编写,系统更容易调试。

异步处理应该是一个由需求驱动的设计决策,无论是解耦、扩展、批量处理还是基于时间的处理。事件驱动系统能够以比单体系统更高的水平进行扩展,其原因是由于松散耦合,代码可以水平扩展,具有更大的粒度和效率。

异步处理的一个问题是它给你的操作增加了额外的负担。我们需要创建消息队列和消息传递的基础设施,这个基础设施需要被监控和管理,即使你正在使用云提供商的功能,如 SNS/SQS 或 PubSub。

甚至有一个问题,即你是否应该实现微服务或构建单体,然而,我认为较小的代码块总是更容易部署和测试,尽管这会增加设置持续集成和硬件供应的重复性,但这是一个一次性障碍,也是值得学习的事情。我们将在下一章中探讨这一点,当我们检查持续部署和不可变基础设施时,但现在,让我们继续关注事件。

在解决了警告之后,让我们重新审视两种消息处理方式的差异。

同步处理

使用同步处理,所有与下游应用的通信都在进程中发生。发送一个请求,并使用相同的网络连接等待回复,而不是使用任何回调。同步处理是通信的最简单方法;当你等待答案时,下游服务正在处理请求。你必须自己管理重试逻辑,并且通常仅在需要立即回复时使用最佳。让我们看看以下描述同步处理的图示:

异步处理

使用异步处理,所有与下游应用的通信都在进程外发生,利用队列或消息代理作为中介。而不是直接与下游服务通信,消息被调度到队列,例如 AWS SQS/SNSGoogle Cloud Pub/SubNATS.io。由于在这一层没有进行任何处理,唯一的延迟就是传递消息所需的时间,这非常快,也得益于这些系统的设计,消息的接受与否是你必须实现的唯一情况。重试和连接处理逻辑委托给消息代理或下游系统,因为它是消息的存储,用于归档或重放:

异步消息类型

异步处理通常有两种不同的形式,例如推送和拉取。你实施的策略取决于你的需求,通常一个系统会实现这两种模式。让我们来看看这两种不同的方法。

拉取/队列消息

拉取模式是一种优秀的设计,你可能有一个运行中的工作进程,例如调整图片大小。API 会接收请求,然后将其添加到队列以进行后台处理。工作进程或进程从队列中读取消息,逐个执行所需的工作,然后从队列中删除消息。通常还有一个队列,通常称为“死信队列”,如果工作进程因任何原因失败,则消息将被添加到死信队列。死信队列允许在增量失败或调试目的的情况下重新处理消息。让我们看看以下总结整个过程的图示:

在 Go 中实现基于队列的服务是一个相对直接的任务,让我们通过这本书附带源代码中的示例来了解一下。这个示例使用 Redis 来存储消息。Redis 是一个极快的数据库存储,虽然能够利用云服务提供商的队列而不是管理我们的基础设施是件好事,但这并不总是可能的。然而,即使我们使用云服务提供商的队列,我们即将看到的模式也容易用不同的数据存储客户端替换。如果我们考虑 queue/queue.go 中的示例代码的以下列表:

  7 // Message represents messages stored on the queue 
  8 type Message struct { 
  9   ID      string `json:"id"` 
 10   Name    string `json:"name"` 
 11   Payload string `json:"payload"` 
 12 } 
 13 
 14 // Queue defines the interface for a message queue 
 15 type Queue interface { 
 16   Add(messageName string, payload []byte) error 
 17   AddMessage(message Message) error 
 18   StartConsuming(size int, pollInterval time.Duration, callback func(Message) error) 
 19 } 

我们首先定义了一个 Message 对象,该对象由系统使用,并定义了三个简单的参数,这些参数可以序列化为 JSON。ID 永远不是由发布者直接填充的,而是每个消息都有的一个唯一计算出的 ID。如果消费者需要一个简单的机制来确定消息是否已经被接收和处理,那么可以使用 ID。Queue 接口定义了三个简单的方法,如下所示:

  • Add(messageName string, payload []byte) errorAdd 是一个方便的方法,用于发布一条新消息,发送者只需要提供消息的名称和一个字节数组。

  • AddMessage(message Message) errorAddMessage 执行与 Add 相同的功能,不同之处在于调用者需要构造一个 Message 类型并将其传递给该方法。AddMessage 的实现会自动生成 Message struct 中的 ID 字段,并覆盖任何初始的 ID 值。

  • StartConsuming(size int, pollInterval time.Duration, callback func(Message) error): StartConsuming 允许订阅者从队列中检索消息。第一个参数 size 与批次大小相关,它是在任何一次连接中返回的。pollInterval 参数决定了客户端检查队列中消息的频率。当消息从队列返回时,会执行 callback 函数。它有一个返回参数 error,当它不为 nil 时,通知客户端处理失败,并且消息不应该从队列中移除。需要注意的一点是,StartConsuming 不是一个阻塞方法,在它将回调函数注册到队列后,它立即返回。

queue/redis_queue.go 的实现中定义了 NewRedisQueue 函数,这是一个方便的函数,用于创建我们的队列。我们使用 github.com/adjust/rmq 库,该库在 Redis 队列之上有一个出色的实现。在第 27 行,我们打开了一个连接到我们的 Redis 数据存储:

 26 // NewRedisQueue creates a new RedisQueue 
 27 func NewRedisQueue(connectionString string, queueName string) (*RedisQueue, error) { 
 28   connection := rmq.OpenConnection("my service", "tcp", connectionString, 1) 
 29   taskQueue := connection.OpenQueue(queueName) 
 30 
 31   return &RedisQueue{Queue: taskQueue, name: queueName}, nil 
 32 }  

在第 29 行,我们需要打开一个连接到我们将要从中读取和写入的队列:

 42 // AddMessage to the queue, generating a unique ID for the message before dispatch 
 43 func (r *RedisQueue) AddMessage(message Message) error { 
 44   serialNumber, _ := rand.Int(rand.Reader, serialNumberLimit) 
 45   message.ID = strconv.Itoa(time.Now().Nanosecond()) + serialNumber.String() 
 46 
 47   payloadBytes, err := json.Marshal(message) 
 48   if err != nil { 
 49     // handle error 
 50     return err 
 51   } 
 52 
 53   fmt.Println("Add event to queue:", string(payloadBytes)) 
 54   if !r.Queue.PublishBytes(payloadBytes) { 
 55     return fmt.Errorf("unable to add message to the queue") 
 56   } 
 57 
 58   return nil 
 59 }  

Add 方法,这是我们的接口 Add 方法的实现,仅仅是一个便利方法,它从给定的参数创建一个消息,然后调用 AddMessage 函数。AddMessage 函数首先为消息生成一个 ID,在这个简单的实现中,我们只是生成一个随机数并将其附加到当前时间的纳秒数上,这应该足以提供足够的唯一性,而无需检查队列。然后我们需要将消息转换为它的 JSON 表示形式,作为一个字节数组,在我们最终在第 54 行将消息发布到队列之前。

我们实现中的最后一部分是消费队列中消息的方法:

 61 // StartConsuming consumes messages from the queue 
 62 func (r *RedisQueue) StartConsuming(size int, pollInterval time.Duration, callback func(Message) error) { 
 63   r.callback = callback 
 64   r.Queue.StartConsuming(size, pollInterval) 
 65   r.Queue.AddConsumer("RedisQueue_"+r.name, r) 
 66 } 
 67 
 68 // Consume is the internal callback for the message queue 
 69 func (r *RedisQueue) Consume(delivery rmq.Delivery) { 
 70   fmt.Println("Got event from queue:", delivery.Payload()) 
 71 
 72   message := Message{} 
 73 
 74   if err := json.Unmarshal([]byte(delivery.Payload()), &message); err != nil { 
 75     fmt.Println("Error consuming event, unable to deserialise event") 
 76     // handle error 
 77     delivery.Reject() 
 78     return 
 79   } 
 80 
 81   if err := r.callback(message); err != nil { 
 82     delivery.Reject() 
 83     return 
 84   } 
 85 
 86   delivery.Ack() 
 87 } 

StartConsuming 方法只负责将回调设置到队列实例上;然后我们调用 StartConsumingAddConsumer 方法,这两个方法属于 Redis 包。在第 65 行,我们将回调消费者设置为队列自身使用,而不是传递到方法中的回调。将委托模式分配给内部方法允许我们从实现代码库中抽象出底层队列的实现。当检测到队列上有新消息时,会调用 Consume 方法,传递 rmq.Delivery 的实例,这是一个在 rmq 包中定义的接口:

type Delivery interface { 
  Payload() string 
  Ack() bool 
  Reject() bool 
  Push() bool 
} 

我们需要做的第一件事是将作为字节数组传递的消息反序列化到我们的 Message 结构中。如果这失败,我们将在 Delivery 接口上调用 Reject 方法,将消息推回队列。一旦我们得到回调期望的消息格式,我们就可以执行传递给 StartConsuming 方法的 callback 函数。回调的类型如下:

func(Message) error 

实现此方法的代码的责任是在消息处理失败时返回一个错误。返回错误允许我们的消费代码调用 delivery.Reject(),这将使消息留在队列中以便稍后处理。当消息成功处理时,我们传递一个 nil 错误,并且消费者调用 delivery.Ack(),这表示消息已成功处理并从队列中移除。这些操作是进程安全的;它们不应可供其他消费者使用,因此在我们有多个工作者读取队列的情况下,我们可以确保他们都在不同的列表上工作。

让我们看看一个将消息写入队列的服务实现,如果我们查看 queue/writer/main.go 的示例代码文件,我们可以看到这是一个非常简单的实现。这对于生产系统来说过于简单,处理程序中没有消息验证或安全性。然而,这个例子被简化到最基本的形式,以突出显示消息是如何添加到队列中的:

   16 func main() { 
   17   q, err := queue.NewRedisQueue("redis:6379", "test_queue") 
   18   if err != nil { 
   19     log.Fatal(err) 
   20   } 
   21 
   22   http.HandleFunc("/", func(rw http.ResponseWriter, r 
        *http.Request) { 
   23     data, _ := ioutil.ReadAll(r.Body) 
   24     err := q.Add("new.product", data) 
   25     if err != nil { 
   26       log.Println(err) 
   27       rw.WriteHeader(http.StatusInternalServerError) 
   28       return                                                                                                                                      
   29     } 
   30   }) 
   31 
   32   http.ListenAndServe(":8080", http.DefaultServeMux) 
   33 } 

我们创建一个RedisQueue实例,并传递我们 Redis 服务器的位置和我们想要写入消息的队列名称。然后我们有一个非常简单的http.Handler实现;这个函数将请求体作为字节数组读取,并使用消息名称和有效载荷调用Add方法。然后我们在返回和关闭连接之前检查此操作的输出。

消费者实现甚至更简单,因为此代码实现了一个简单的工作者,并且没有实现任何基于 HTTP 的接口:

11 func main() { 
12   log.Println("Starting worker") 
13 
14   q, err := queue.NewRedisQueue("redis:6379", "test_queue") 
15   if err != nil { 
16     log.Fatal(err) 
17   } 
18 
19   q.StartConsuming(10, 100*time.Millisecond, func(message 
queue.Message) error { 
20     log.Printf("Received message: %v, %v, %v\n", message.ID, message.Name, message.Payload) 
21 
22     return nil // successfully processed message 
23   }) 
24 
25   runtime.Goexit() 
26 } 

与客户端类似,我们创建我们的队列实例,然后调用带有我们请求参数和callback函数的StartConsuming方法。callback方法为从队列中检索到的每条消息执行,由于我们可能每 100 毫秒返回一批 10 条消息,因此此方法可能会快速连续调用,并且每次执行都在自己的goroutine中运行,因此在编写实现时,我们需要考虑这个细节。例如,如果我们处理消息并将它们写入数据库,那么数据库的连接数不是无限的。为了确定合适的批量大小,我们需要进行初始测试,并随后进行持续监控,以便调整应用程序以获得最佳性能。这些设置应作为参数实现,以便在硬件扩展时易于更改。

推送消息

有时候,你希望服务能够立即对事件做出反应,而不是使用队列。你的服务订阅从像 NATS.io 或 SNS 这样的代理接收消息。当代理收到来自另一个服务的消息时,代理会通过调用已注册的端点并发送消息副本来通知所有已注册的服务。接收者通常在收到消息后断开连接,并假设消息已正确处理。这种模式允许消息代理具有极高的吞吐量,在 NATS.io 的情况下,单个服务器实例每秒可以处理数百万条消息。如果客户端无法处理消息,则必须处理管理这种失败的逻辑。这种逻辑可能是向代理发送通知,或者消息可以被添加到死信队列以供稍后重放。

图片

在此示例中,我们将利用 NATS.io 的力量作为我们系统的消息代理,NATS 是一个由 Go 编写的极其轻量级的应用程序,提供了惊人的性能和稳定性。查看push/writer/main.go,我们可以看到我们不需要编写很多代码来实现 NATS.io:

 24 func main() { 
 25   var err error 
 26   natsClient, err = nats.Connect("nats://" + *natsServer) 
 27   if err != nil { 
 28     log.Fatal(err) 
 29   } 
 30   defer natsClient.Close() 
 31 
 32   http.DefaultServeMux.HandleFunc("/product", productsHandler) 
 33 
 34   log.Println("Starting product write service on port 8080") 
 35   log.Fatal(http.ListenAndServe(":8080", http.DefaultServeMux)) 
 36 } 

当我们开始我们的应用程序时,首先需要做的事情是通过在nats包上调用Connect函数来连接 NATS 服务器:

func Connect(url string, options ...Option) (*Conn,error)

url 参数定义为字符串,需要稍作说明。虽然你可以传递单个 URL,如 nats://server:port,但你也可以传递以逗号分隔的服务器列表。这样做的原因是为了容错,NATS 实现了集群,在我们的简单示例中我们只有一个实例,然而,在生产环境中,你将会有多个实例以实现冗余。然后我们定义我们的 http.Handler 函数并暴露 /product 端点:

 37 func productsHandler(rw http.ResponseWriter, r *http.Request) { 
 38   if r.Method == "POST" { 
 39     insertProduct(rw, r) 
 40   } 
 41 } 
 42 
 43 func insertProduct(rw http.ResponseWriter, r *http.Request) { 
 44   log.Println("/insert handler called") 
 45 
 46   data, err := ioutil.ReadAll(r.Body) 
 47   if err != nil { 
 48     rw.WriteHeader(http.StatusBadRequest) 
 49     return 
 50   } 
 51   defer r.Body.Close() 
 52 
 53   natsClient.Publish("product.inserted", data) 
 54 } 

处理器的实现非常直接,我们将工作委托给 insertProduct 函数。再次强调,在实现方面这很简短,以突出发布消息的使用;在生产环境中会有更高级别的实现来管理安全和验证。

在第 53 行,我们在客户端调用 Publish 方法;该方法具有一个极其简单的签名,包含主题和有效载荷:

func (nc *Conn) Publish(subjstring, data []byte)error

关于主题,我们需要考虑这是订阅者将要使用的相同名称,并且它必须是唯一的,否则可能会出现意外收件人接收消息的情况,这是一个极其难以追踪的错误。NATS 的完全可配置选项在 GoDoc godoc.org/github.com/nats-io/go-nats 中,相当全面。

现在我们已经看到向 NATS 发布消息是多么容易,让我们看看消费它们有多简单。如果我们查看 push/reader/main.go 中的示例代码,我们可以看到订阅消息非常简单:

 25 func main() { 
 26   var err error 
 27   natsClient, err = nats.Connect("nats://" + *natsServer) 
 28   if err != nil { 
 29     log.Fatal(err) 
 30   } 
 31   defer natsClient.Close() 
 32 
 33   log.Println("Subscribing to events") 
 34   natsClient.Subscribe("product.inserted", handleMessage) 
 35 } 
 36 
 37 func handleMessage(m *nats.Msg) { 
 38   p := product{} 
 39   err := json.Unmarshal(m.Data, &p) 
 40   if err != nil { 
 41     log.Println("Unable to unmarshal event object") 
 42     return 
 43   } 
 44 
 45   log.Printf("Received message: %v, %#v", m.Subject, p) 
 46 } 

再次,我们连接到 NATS 服务器,但要开始接收事件,我们在客户端调用 Subscribe 方法:

func (nc *Conn) Subscribe(subjstring, cbMsgHandler) (*Subscription,error)

Subscribe 方法将表达对给定主题的兴趣。主题可以有通配符(部分:*,全部:>)。消息将被发送到相关的 MsgHandler

如果没有提供 MsgHandler,则订阅是同步订阅,可以通过 Subscription.NextMsg() 进行轮询。

与我们的队列示例不同,我们不是轮询 NATS 服务器,而是暴露一个端点并将其注册到 NATS。当 NATS 服务器接收到一条消息时,它会尝试将其转发给所有已注册的端点。使用前一个代码示例中的实现,我们为系统上运行的每个工作进程获取消息的副本,这并不是最佳方案。我们不必自己管理这一点,可以使用 API 上的另一种方法,即 QueueSubscribe

func (nc *Conn) QueueSubscribe(subj, queuestring, cbMsgHandler) (*Subscription,error)

QueueSubscribe 函数在给定的主题上创建一个异步队列订阅者。具有相同队列名称的所有订阅者形成一个队列组,并且该组中只有一位成员会异步接收任何给定的消息。

签名类似于 Subscribe 方法,只是我们传递了一个额外的参数,即队列的名称或希望注册对给定主题感兴趣的唯一订阅者集群的名称。

现在我们已经定义了两种主要类型的异步消息,并查看每种的简单实现。让我们看看两种利用这种技术的常见模式。

命令查询责任分离(CQRS)

CQRS 是命令查询责任分离的缩写,这个术语归功于格雷格·杨(Greg Young)。这个概念是,你使用不同的模型来更新信息,而不是用于读取信息的模型。实施 CQRS 的两个主要原因是当模型存储与模型展示有显著差异时,以及当这种方法的背后概念是尝试创建一个针对存储优化的模型和一个针对显示优化的模型可能都无法解决这两个问题时。因此,CQRS 将这些模型分为用于展示逻辑的 查询 模型和用于存储和验证的 命令 模型。另一个好处是当我们在高性能应用程序中想要在读取和写入之间分离负载时。CQRS 模式并不是非常常见,当然也不应该到处使用,因为它确实增加了复杂性;然而,它是一个非常有用的模式,应该纳入你的工具箱中。

让我们看看以下图表:

图片

在我们的示例代码中,我们再次利用 NATS.io 来代理消息。然而,这并不一定是必须的。拥有一个服务同时具有两个独立的读写模型是合法的设置。而不是在进程通信中使用消息代理的复杂性,也可以同样有效地使用。

查看位于 cCQRS/product_writer/main.go 的示例代码:

 26 func init() { 
 27   flag.Parse() 
 28 
 29   schema = &memdb.DBSchema{ 
 30     Tables: map[string]*memdb.TableSchema{ 
 31       "product": &memdb.TableSchema{ 
 32         Name: "product", 
 33         Indexes: map[string]*memdb.IndexSchema{ 
 34           "id": &memdb.IndexSchema{ 
 35             Name:    "id", 
 36             Unique:  true, 
 37             Indexer: &memdb.StringFieldIndex{Field: "SKU"}, 
 38           }, 
 39         }, 
 40       }, 
 41     }, 
 42   } 
... 
 66   natsClient, err = nats.Connect("nats://" + *natsServer) 
 67   if err != nil { 
 68     log.Fatal(err) 
 69   } 
 70 } 

为了简单起见,这个例子使用了一个内存数据库,由 HashiCorp 编写的 https://github.com/hashicorp/go-memdb,大部分设置是配置这个数据存储。我们将分离我们的读取和写入数据存储,读取服务不实现任何返回产品给调用者的方法。相反,这个责任委托给第二个服务,该服务运行一个独立的数据库,甚至不同的数据模型:

 84 func insertProduct(rw http.ResponseWriter, r *http.Request) { 
 85   log.Println("/insert handler called") 
 86 
 87   p := &product{} 
 88 
 89   data, err := ioutil.ReadAll(r.Body) 
 90   if err != nil { 
 91     rw.WriteHeader(http.StatusBadRequest) 
 92     return 
 93   } 
 94   defer r.Body.Close() 
 95 
 96   err = json.Unmarshal(data, p) 
 97   if err != nil { 
 98     log.Println(err) 
 99     rw.WriteHeader(http.StatusBadRequest) 
100     return 
101   } 
102 
103   txn := db.Txn(true) 
104   if err := txn.Insert("product", p); err != nil { 
105     log.Println(err) 
106     rw.WriteHeader(http.StatusInternalServerError) 
107     return 
108   } 
109   txn.Commit() 
110 
111   natsClient.Publish("product.inserted", data) 
112 } 

我们的处理程序首先将模型写入数据库,然后像我们的推送示例一样,向 NATS 发布包含消息负载的消息。

再次查看位于 CQRS/product-read/main.go 的读取服务器,我们正在设置我们的数据存储,然而,模型与读取模型不同:

  • 写入模型
        type product struct { 
           Name       string `json:"name"` 
           SKU        string `json:"sku"` 
           StockCount int    `json:"stock_count"`                                                                                                        
        } 

  • 读取模型
        type product struct { 
          Name        string `json:"name"` 
          Code        string `json:"code"` 
          LastUpdated string `json:"last_updated"` 
        } 

我们还定义了一个包含从 NATS 收到的事件详细信息的结构。在这个例子中,这个结构反映了写入模型;然而,这并不总是必须的:

type productInsertedEvent struct { 
  Name string `json:"name"` 
  SKU  string `json:"sku"` 
} 

在接收到消息后,我们首先将有效载荷解码为预期的类型productInsertedEvent,然后将其转换为存储在数据库中的产品模型。最后,我们将信息存储在数据库中,以我们的消费者希望接收的格式创建我们的副本:

112 func productMessage(m *nats.Msg) { 
113   pie := productInsertedEvent{} 
114   err := json.Unmarshal(m.Data, &pie) 
115   if err != nil { 
116     log.Println("Unable to unmarshal event object") 
117     return 
118   } 
119 
120   p := product{}.FromProductInsertedEvent(pie) 
121 
122   txn := db.Txn(true) 
123   if err := txn.Insert("product", p); err != nil { 
124     log.Println(err) 
125     return 
126   } 
127   txn.Commit() 
128 
129   log.Println("Saved product: ", p) 
130 } 

当用户调用/products端点时,他们得到的是本地缓存的副本数据,而不是存储在单独服务中的主数据。这个过程可能会引起一致性问题,因为两个数据副本最终是一致的,当我们实现 CQRS 模式时,我们需要考虑这一点。如果我们公开库存水平,那么可能不希望有最终一致性,然而,我们可以做出设计决策,当需要这个信息时,我们通过向库存端点发起同步调用牺牲性能来获取。

领域驱动设计

在实现事件驱动微服务时,你需要很好地掌握你的系统运作方式以及数据、交互如何从一个服务流向下一个服务。对任何复杂系统进行建模的一个有用技术是领域驱动设计。

当谈到领域驱动设计时,那么就有 Vaughn Vaughn,他的两本书《领域驱动设计精粹》和《实现领域驱动设计》扩展了 Eric Evans 的开创性工作,对于一些人来说,这些工作可能有些难以阅读。对于 DDD 的新手,我建议从《领域驱动设计精粹》开始阅读,然后转向阅读《实现领域驱动设计》。首先阅读《领域驱动设计精粹》为你提供了术语的基础,然后再深入研究这是一本相当详细的书。DDD 绝对是一个高级话题,不可能在这本书的一个章节中全面涵盖,我也不敢声称有足够的经验来写得更详细,因为 DDD 是一种通过实践学习的模式。DDD 也是一个用于更复杂的大型系统、有众多利益相关者和许多动态部件的工具。即使你不在这样的系统上工作,聚合和隔离的概念也是引人入胜的,并且适用于大多数系统。至少,继续阅读以使你在下一次架构会议中更擅长玩“流行词汇接龙”。

什么是 DDD?

引用 Vaughn Vernon 自己的话:

"DDD 是一套工具,帮助你设计和实现能够带来高价值的软件,无论是战略上还是战术上。你的组织不可能在所有事情上都做得最好,所以它必须谨慎选择必须精通的事情。DDD 战略开发工具帮助你和你团队做出最具竞争力的软件设计选择和业务集成决策。"

-- Vaughn Vernon

这确实是一个相当长的介绍;然而,我认为它突出了这样一个事实,即领域驱动设计(DDD)是设计软件的工具,而不是一个软件框架。在二十年前那些黑暗的日子里,软件架构师和项目经理会做出软件系统设计的决策,他们通常会提供非常详细的计划,由开发团队执行。根据我的经验,这很少是一种愉快的工作方式,而且也没有产生高质量的软件,也没有按时交付。敏捷革命提出了另一种工作方式,幸运的是,它改善了这种情况。我们现在也把自己视为软件工程师,而不是开发者,我不认为这种转变是时尚,而是由我们所看到的角色变化所驱动的。你现在扮演的角色是一个设计师、特性的谈判者、架构师、调解者,你还必须对可用的材料有全面的理解,包括对压力和应变的反应。你现在扮演的角色更像是一位传统工程师,而不是过去软件开发者所扮演的装配线工人角色。

希望这能回答你心中可能存在的疑问,即为什么在关于 Go 的书中需要学习 DDD。好吧,这本书从未被写成用来教你语言,它旨在展示你如何使用它来构建成功的微服务。

我听到很多关于 DDD 的噪音,说它是一种困难的技巧,诚实地讲,当我第一次阅读 DDD 时,我也感到同样的感觉,所有关于聚合、通用语言、领域和子领域的东西。然而,一旦我开始思考 DDD 以实现分离,并思考我过去在混乱的领域模型中遇到的问题,它就逐渐开始在我脑海中清晰起来。

技术债务

如果你曾经参与过单体应用的开发,你就会意识到对象之间发生的耦合和依赖,这主要发生在数据层,然而,你也经常发现代码没有正确实现,并且与另一个对象紧密绑定。当你想要改变这个系统时,问题就出现了;一个区域的变化会在另一个区域产生不期望的影响,而且只有当你幸运的时候才会如此。在做出改变之前,系统重构需要巨大的努力。通常发生的情况是,修改被强行塞入现有的代码库中,而没有进行重构,坦白说,对系统来说,把它拿到外面,绕到谷仓后面,然后在它的后脑勺卸下两颗霰弹会更仁慈一些。

不要自欺欺人,认为你会有机会做这件事;如果你曾经参与过任何真正年龄的系统,你的工作就像列宁的防腐师。当你本应该挖个坑把尸体埋掉的时候,你却花费了巨大的努力来保持尸体看起来体面。DDD 可以帮助你理解单体架构并逐步解耦它;它也是一个工具,可以防止无序的单体架构发生。让我们快速看一下技术解剖结构。

DDD 的解剖结构

在 DDD 中,战略设计的主要部分是应用一个称为边界上下文的概念。边界上下文是将你的领域分割成模型的方法。通过使用称为上下文映射的技术,你可以通过定义团队和技术关系来整合多个边界上下文。

战术设计是在这里细化你的领域模型细节的地方。在这个阶段,我们学习如何将实体和值对象聚合在一起。

战略设计

在处理 DDD 时,你经常会听到的一个短语是“边界上下文”。边界上下文的概念是一个语义上下文边界,其中每个边界内的组件具有特定的含义并执行特定的事情。其中最重要的边界上下文之一是核心域;这是使你的组织在竞争中区别于其他所有组织的领域。我们已经提到,你不能做所有的事情,通过专注于你的核心域,这应该是你花费大部分时间的地方。

战术设计

从战略设计的基座出发是战术设计,再次引用 Vaughn Vernon 的话:

“战术设计就像用细刷为你的领域模型描绘更细致的细节。”

--Vaughn Vernon

在这个设计阶段,我们需要开始考虑聚合领域事件。聚合由实体和值对象组成。值对象模拟一个不可变的整体,它没有唯一的标识符,等价性是通过比较值类型封装的属性来确定的。领域事件由聚合发布并由感兴趣的各方订阅。这种订阅可能来自同一个边界上下文,也可能来自不同的来源。

通用语言

在 DDD 中,通用语言这个术语指的是一个核心语言,团队中的每个人都理解正在开发的软件。完全有可能,在不同的上下文中并由不同的团队开发的组件,对于相同的术语有不同的含义。实际上,他们可能正在谈论与你的模型不同的组件。

你如何开发通用语言是一个团队将形成的活动。你不应该过分强调只使用名词来描述你的模型,你应该开始构建简单的场景。考虑我们来自测试章节的例子,我们在功能测试和集成测试中使用了 BDD。这些是你的场景;你写它们的语言是你的团队的通用语言。你应该编写这些场景,使它们对你团队有意义,而不是试图编写对整个部门都有意义的东西。

边界上下文

使用边界上下文的一个主要原因是,团队往往不知道何时停止将事物堆积到他们的软件模型中。随着团队添加更多功能,模型很快变得难以管理和理解。不仅如此,模型的语言开始变得模糊。当软件变得庞大且错综复杂,有许多无关的相互连接时,它开始成为所谓的大泥球。大泥球可能比你的传统单体更糟糕。单体本身并不是邪恶的,只是因为它们是单体的;单体之所以不好,是因为在它们内部,良好的编码标准早已被遗忘。边界上下文过大且由太多人拥有的另一个问题是,它开始变得难以使用通用语言来描述它。

上下文映射

当 DDD 中的两个边界上下文需要集成时,这种集成被称为上下文映射。定义这种上下文映射的重要性在于,一个定义良好的合同支持随着时间的推移进行受控的变更。在《领域驱动设计精粹》一书中,Vaughn Vernon 描述了以下不同类型的映射:

  • 伙伴关系:当两个团队各自负责一个边界上下文,并且有一个依赖的目标集时,存在伙伴关系映射。

  • 共享内核:共享内核由两个独立的边界上下文的交集定义,当两个团队共享一个小但共同的模式时存在。

  • 客户-供应商:客户-供应商描述了两个边界上下文及其各自团队之间的关系。供应商是上游上下文,下游是客户。供应商必须提供客户所需的东西,两个团队必须共同规划以满足他们的期望。只要供应商仍然考虑客户的需求,这将是团队之间非常典型且实用的关系。

  • 遵从者:当存在上游团队和下游团队,且上游团队没有动机支持下游团队的具体需求时,存在遵从者关系。而不是将上游的通用语言翻译成适合其自身需求,下游团队采用上游的语言。

  • 反腐败层:当你连接两个系统时,这是一个标准和推荐的模型,下游团队在其通用语言和上游语言之间构建一个翻译层,从而将其与上游隔离开来。

  • 开放主机服务:开放主机服务定义了一个协议或接口,它允许您将边界上下文作为一组服务访问。这些服务通过一个良好的文档化的 API 提供,并且易于消费。

  • 发布语言:发布语言是一个经过良好文档化的信息交换语言,它使消费和翻译变得容易。XML SchemaJSON Schema 和基于 RPC 的框架,如 Protobufs 常常被使用。

  • 分道扬镳:在这种情况下,通过消费各种通用语言并没有带来显著收益,团队决定在其边界上下文中生成他们的解决方案。

  • 一团糟:现在这应该已经很明确了,这不是团队应该追求的目标;事实上,这正是 DDD 努力避免的事情。

软件

当我们开始使用 DDD 和面向事件架构时,我们很快就会发现自己需要一些帮助来代理我们的消息,以确保应用程序所需的至少一次和最多一次投递。我们当然可以实施我们的策略。然而,互联网上有许多开源项目为我们处理这种能力,很快我们就发现自己正在寻求利用这些项目之一。

Kafka

Kafka 是一个分布式流平台,允许您发布和订阅记录流。它允许您以容错的方式存储文档流,并按发生顺序处理记录流。它被设计为一个快速且容错的系统,通常作为一个或多个服务器的集群运行,以实现冗余。

NATS.io

NATS.io 是一个用 Go 编写的开源消息系统,它具有执行最多一次和至少一次投递的能力,让我们看看这意味着什么:

  • 最多一次投递:在基本模式下,NATS 可以充当 Pub/Sub 路由器,其中监听客户端可以订阅消息主题,并将新消息推送到它们。如果一个消息没有订阅者,那么它将被发送到 /dev/null 并不会在系统内部存储。

  • 至少一次投递:当需要更高层次的服务和更严格的投递保证时,NATS 可以在至少一次投递模式下运行。在这种模式下,NATS 无法作为一个独立的实体运行,需要由存储设备支持,目前支持的存储设备包括文件和内存。现在,NATS 流式传输不支持扩展和复制,这正是 Kafka 发挥优势的地方。然而,我们并不是所有都在构建像 Netflix 那样大的系统,Kafka 的配置和管理本身就是一本专著,而 NATS 可以很快地理解。

AWS SNS/SQS

亚马逊的简单队列服务SQS)是一种队列服务,允许发布者向队列中添加消息,这些消息随后可以被客户端消费。消息被读取并从队列中移除,使其对其他读者不再可用。

SQS 有两种不同类型,例如标准模式,它以牺牲消息可能被多次传递为代价提供最大吞吐量,以及 SQS FIFO,它确保消息只被传递一次,并且按照接收的顺序传递。然而,FIFO 队列的吞吐量大幅降低,因此其使用必须仔细考虑。

亚马逊的简单通知服务SNS)是一种协调和管理消息队列传递的服务。SNS 代表简单通知服务;你配置一个可以发布消息的主题,然后订阅者可以注册接收通知。SNS 可以将消息传递到以下不同的协议:

  • HTTP(S)

  • Email

  • Email-JSON

  • SMS

  • AWS Lambda

  • SQS

你可能会想知道,为什么你想要将消息添加到队列中,而不是直接将消息推送给接收者?SNS 的一个问题是它只能通过 HTTP 向公开可访问的服务传递消息。如果你的内部工作者没有连接到公共互联网,并且从阅读第八章《安全》,我希望他们不是。因此,基于拉取的方法可能是你的唯一选择;从队列中读取也可能是管理大量消息流的一个更好的选择。你不需要担心 SQS(大多数时候)的可用性,也不需要为可以轮询队列的简单应用程序工作者实现 HTTP 接口。

Google Cloud Pub/Sub

Google Cloud Pub/Sub 与 AWS SNS 非常相似,因为它是一种消息中间件,允许创建带有发布者和订阅者的主题。在撰写本文时,谷歌云上没有像 SQS 这样的正式产品。然而,使用你拥有的许多数据存储选项之一来实现这一点是非常简单的。

摘要

在本章中,我们探讨了使用事件解耦微服务的一些主要模式,我们还介绍了构建分布式系统的现代设计方法,即领域驱动设计(DDD)。有了合适的工具和前期设计,构建高度可扩展和维护的系统不应过于具有挑战性,你现在拥有所有使用 Go 进行此操作所需的信息。在最后一章中,我们将探讨代码的自动化构建和部署,总结你需要成为成功的微服务实践者所需的信息。

第十章:持续交付

到目前为止,我们已经涵盖了大量的内容,包括如何构建弹性系统以及如何保持它们的安全,但现在我们需要看看如何替换我们流程中的所有手动步骤并引入持续交付。

在本章中,我们将讨论以下概念:

  • 持续交付

  • 容器编排

  • 不可变基础设施

  • Terraform

  • 示例应用

什么是持续交付?

持续交付是构建和部署代码的过程,持续进行。目标是尽可能高效和有效地将代码从开发转移到生产。

在传统的或瀑布式的工作流程中,发布围绕着主要功能或更新的完成。大型企业每季度发布一次并不罕见。当我们审视这种策略的原因时,风险和努力经常被提及。发布存在风险,因为软件的信心较弱;发布需要付出努力,因为需要涉及质量保证和软件发布的操作方面的主要是手动流程。其中一部分是我们已经在第五章“常见模式”中讨论过的内容,即对质量的关注,以及可能缺乏令人满意的测试套件或可能无法自动运行的能力。第二个元素涉及到物理部署和部署后的测试过程。到目前为止,我们在这本书中并没有过多地涉及这个方面;我们在第四章“测试”中提到了 Docker。

如果我们能减少部署代码的风险和努力,你会更频繁地做吗?比如每次完成一个小的功能,或者每次修复一个错误,甚至一天好几次?我会鼓励你这样做,在本章中,我们将探讨我们需要了解的所有事情,并基于我们之前学到的所有知识来实现持续交付。

手动部署

手动部署最多是问题重重;即使你有一个出色的团队,事情也可能出错。团队越大,知识分布越广,对全面文档的需求就越大。在小团队中,资源有限,部署所需的时间可能会分散构建优质代码的注意力。你还会遇到一个薄弱环节;所以,当负责执行流程的人生病或度假时,你会暂停部署吗?

手动部署的问题

  • 部署步骤的顺序和时间可能会出现问题

  • 文档需要全面且始终更新

  • 对手动测试有显著的依赖

  • 存在不同的状态的应用服务器

  • 由于前面的几点,手动部署经常出现持续的问题

随着系统的复杂性增加,涉及的组件更多,部署代码所需的步骤也随之增加。由于部署步骤需要按顺序执行,这个过程很快就会变成负担。考虑一下部署应用程序更新的情况,应用程序及其依赖项需要在所有应用程序服务器实例上安装。通常需要更新数据库模式,并且需要在旧应用程序和新应用程序之间进行干净的切换。即使你正在利用 Docker 的强大功能,这个过程也可能充满灾难。随着应用程序复杂性的增加,部署应用程序所需的文档也相应增加,这通常是一个薄弱环节。根据我的个人经验,在截止日期临近时,文档的更新和维护通常是首先受到影响的地方。一旦应用程序代码部署完成,我们需要测试应用程序的功能。假设应用程序是手动部署的,通常会假设应用程序也是手动测试的。测试人员需要运行测试计划(假设有测试计划)来检查系统是否处于正常工作状态。如果系统不工作,那么可能需要逆转过程回滚到之前的状态,或者需要决定热修复应用程序并再次运行标准的构建和部署周期。当这个过程纳入计划发布时,由于整个团队都在场,所以会有更多的安全性。然而,当这个过程在深夜由于事件发生时,会发生什么呢?最好的情况是,修复被部署,然而,没有更新任何文档或流程。最坏的情况是,应用程序最终处于比尝试热修复应用程序代码之前更糟糕的状态。在非工作时间,事件通常由一线响应人员执行,这通常是基础设施团队。我假设如果你没有运行持续交付,那么你也不会遵循开发者值班的做法。那么,部署所需的时间呢?整个团队抽出时间来监视部署的财务成本是多少?这个过程的动机和心理生产力成本又是多少?你有没有因为将应用程序代码部署到生产环境中的不确定性而感到压力?

持续交付消除了这些风险和问题。

持续交付的好处

持续交付的概念是,你计划这些问题,并投入前期工作来解决它们。自动化所有涉及步骤允许操作的连续性,并且是一个自我记录的过程。不再需要专门的人类知识,而且移除人类的额外好处是,由于过程的自动化,质量得到了提高。一旦我们有了自动化,提高了部署的质量和速度,我们就可以进一步提高水平,开始持续部署。持续交付的好处包括:

  • 发布更小且更简单

  • 主分支和功能分支之间的差异更小

  • 部署后需要监控的区域更少

  • 回滚可能更容易

  • 他们更早地交付业务价值

我们开始以更小的块部署我们的代码,不再等待主要功能的完成,而是在每次提交后可能就会进行部署。这样做的主要好处是主分支和功能分支之间的差异更小,合并代码所需的时间也更少。较小的更改也创造了更少的监控区域,因此,如果出现问题,更容易将更改回滚到已知的工作状态。最重要的是,它使你能够更快地交付业务价值;无论是以错误还是新功能的形式,这种能力都比瀑布模型中可用的要早得多。

持续交付的方面

持续交付有几个重要的方面,其中大多数对于流程的成功至关重要。在本节中,我们将在探讨如何实现它们以构建我们自己的管道之前,先看看这些方面是什么。

持续交付的重要方面:

  • 可重复性和易于设置

  • 文物存储

  • 测试自动化

  • 集成测试自动化

  • 基础设施即代码

  • 安全扫描

  • 静态代码分析

  • 烟雾测试

  • 端到端测试

  • 监控 - 通过指标跟踪部署

可重复性和一致性

我有一点疑问,在你们职业生涯的某个时刻,你们可能已经见过这个梗:

图片

如果你们还没有遇到过,不要担心,我确信你们会在某个时刻遇到。在我的机器上工作这个梗为什么这么受欢迎?会不会是因为其中包含了很多真实的元素?我确实知道我经历过,我相信你们很多人也经历过。如果我们要持续交付,也就是说尽可能频繁地交付,那么我们需要关注一致性和可重复性。

可重复性是指整个实验或研究的分析可以被复制,无论是同一研究者还是独立工作的其他人。在我的机器上运行正常是不被接受的。如果我们想要持续交付,那么我们需要将我们的构建过程编码化,并确保我们的软件和其他元素的依赖项要么最小化,要么得到管理。

另一件重要的事情是我们构建的一致性。我们不能花费时间去修复损坏的构建或手动部署软件,因此我们必须像对待我们的生产代码一样对待它们。如果构建失败,我们需要立即停止生产线并修复它,了解构建失败的原因,并在必要时引入新的安全措施或流程,以防止再次发生。

工件存储

当我们实施任何形式的持续集成时,由于构建过程,我们会产生各种工件。这些工件的范围可以从二进制文件到测试输出。我们需要考虑我们将如何存储这些数据;幸运的是,云计算为这个问题提供了许多解决方案。一种解决方案是云存储,如 AWS S3,它非常丰富,且成本较低。许多软件即服务 CI 提供商,如 Travis 和 CircleCI,也提供内置的这种功能;因此,为了利用它,我们几乎不需要做任何事情。如果我们使用 Jenkins,我们也可以利用相同的存储。云的存在意味着我们很少需要担心 CI 工件的管理。

测试的自动化

测试自动化是必不可少的,为了确保构建的应用程序的完整性,我们必须在持续集成平台上运行我们的单元测试。测试自动化迫使我们考虑简单且可重复的设置,需要最小化依赖项,我们只应检查代码的行为和完整性。在这一步中,我们避免集成测试,测试应在没有任何东西但go test命令的情况下运行。

集成测试的自动化

当然,我们确实需要验证我们的代码与任何其他依赖项(如数据库或下游服务)之间的集成。配置错误很容易发生,尤其是在涉及数据库语句时。集成测试的水平应该远低于单元测试的覆盖率,而且我们还需要能够在可重复的环境中运行这些测试。Docker 在这种情况下是一个出色的盟友;我们可以利用 Docker 在多个环境中运行的能力。这使得我们能够在构建服务器上执行之前,在我们的本地环境中配置和调试集成测试。同样,单元测试是成功构建的门槛,集成测试也是如此;这些测试的失败不应导致部署。

基础设施即代码

当我们自动化构建和部署过程时,这一步是必不可少的;理想情况下,我们不希望将代码部署到脏环境中,因为这会增加污染的风险,例如错误地 vendored 依赖项。然而,如果需要,我们也需要能够重新构建环境,而且这应该在没有实施我们之前引入的任何问题的前提下成为可能。

安全扫描

如果可能的话,安全扫描应该集成到管道中;我们需要尽早和经常地捕捉到 bug。无论你的服务是否面向外部,扫描它都可以确保攻击者有有限的攻击向量可以滥用。我们已经在之前的章节中讨论了模糊测试,执行这项任务所需的时间相当可观,可能不适合包含在管道中。然而,可以在不减缓部署的情况下,将安全扫描的各个方面集成到管道中。

静态代码分析

静态代码分析是应对应用程序中 bug 和漏洞的极其有效的工具,通常开发者会将govetgofmt等工具作为他们 IDE 的一部分来运行。当源代码保存时,代码检查器会运行并识别源代码中的问题。在管道内运行这些应用程序同样重要,因为我们不能总是保证更改来自已经以这种方式配置的 IDE。除了节省保存时间之外,我们还可以运行静态代码分析来检测 SQL 语句的问题和代码质量的问题。这些额外的工具通常不包括在 IDE 的保存工作流程中,因此,在 CI 上运行它们以检测任何可能遗漏的问题至关重要。

烟雾测试

烟雾测试是我们确定部署是否成功的一种方式。我们运行一个测试,这个测试可以从简单的 curl 到更复杂的编码测试,以检查运行中的应用程序中的各种集成点。

端到端测试

端到端测试是对运行系统的全面检查,通常遵循用户流程测试各个部分。通常这些测试是针对整个应用程序的,而不是针对服务的局部,并且使用基于 BDD 的工具(如 cucumber)自动化。你决定将端到端测试作为部署的门槛还是并行过程,这取决于你公司对风险的承受能力。如果你相信你的单元、集成和冒烟测试有足够的覆盖率来给你带来安心,或者所讨论的服务不是核心用户旅程的关键,那么你可以决定并行运行这些测试。然而,如果所讨论的功能是核心旅程的一部分,那么你可能选择将这些测试按顺序运行,作为部署到预发布环境的门槛。即使端到端测试作为门槛运行,如果进行了任何配置更改,例如将预发布提升到生产,在宣布部署成功之前再次运行端到端测试是明智的。

监控

部署后,我们不应依赖用户通知我们出现问题,这就是为什么我们需要将应用程序监控与自动通知系统(如PagerDuty)链接起来的原因。当错误阈值超过时,监控器会触发并提醒你问题;这给你提供了回滚上一个部署或修复问题的机会。

持续交付流程

到目前为止,我们已经讨论了问题,以及为什么这对我们很重要。我们还研究了成功持续交付系统的组成部分,但我们是怎样为我们的应用程序实施这样一个流程的,Go 语言作为帮助我们的语言,又带来了什么?现在,让我们看看这个过程:

  • 构建

  • 测试

  • 打包

  • 集成测试

  • 基准测试

  • 安全测试

  • 部署生产

  • 冒烟测试

  • 监控

概述

构建过程主要是开发者关注的问题,以便在他们本地机器上启动和运行,但我的建议是我们从一开始就需要考虑跨平台和跨系统的构建。我所说的跨系统构建是指,即使我们在 Macintosh 上开发,我们可能不会在 Mac 上构建发布产品。实际上,这种行为相当普遍。我们需要第三方构建我们的发布版本,并且优先在无尘室环境中构建,这样就不会受到其他构建的污染。

每个功能都应该有一个分支,每个分支都应该有一个构建。每次将应用程序代码推送到源代码库时,我们都应该触发构建,即使这些代码根本不会接近生产环境。永远不要让构建处于损坏状态是一种良好的实践,这包括分支构建。你应该在问题发生时立即处理它们;推迟这一行动可能会危及你的部署能力,而且虽然你可能不打算在冲刺结束时部署到生产环境,但你必须考虑可能发生的意外问题,例如需要更改配置或修复热补丁错误。如果构建过程处于损坏状态,那么你将无法处理即时问题,这可能导致计划中的部署延迟。

除了在每次推送到分支时自动触发构建之外,另一个重要的方面是运行夜间构建。在构建和测试之前,分支的夜间构建应该与主分支进行 rebase。这一步骤的原因是给你提供关于潜在合并冲突的早期警告。我们希望尽早捕捉到这些问题;失败的夜间构建应该是当天第一项任务。

我们在第四章“测试”中较早地讨论了 Docker,我们应该将 Docker 引入我们的构建过程。通过其容器的不变性,Docker 为我们提供了一个干净的房间环境,以确保可重复性。因为我们每次构建都是从零开始,所以我们不能依赖于预存在状态,这会导致开发环境和构建环境之间的差异。环境污染可能看似微不足道,但我职业生涯中因为一个应用程序使用了安装在机器上的依赖项,而另一个使用了不同版本而浪费在调试损坏构建上的时间是无法衡量的。

容器编排是什么?

简单来说,容器编排是指运行一个或多个应用程序实例的过程。想象一下我们对管弦乐队的普遍理解,一群音乐家共同合作创作音乐。你应用程序中的容器就像管弦乐队中的音乐家;你可能有一些专业容器,实例数量较少,比如打击乐手,或者你可能有很多实例,比如弦乐部分。在管弦乐队中,指挥保持一切同步,并确保相关音乐家在正确的时间演奏正确的音乐。在容器世界中,我们有一个调度器;调度器负责确保在任何时候运行的容器数量正确,并且这些容器在集群的节点上正确分布,以确保高可用性。调度器,就像指挥一样,也负责确保正确的乐器在正确的时间演奏。除了确保一组应用程序持续运行外,调度器还可以在特定时间或基于特定条件启动容器以运行临时作业。这种能力类似于在基于 Linux 的系统上由cron执行的操作。

容器编排的选项

幸运的是,今天有许多应用程序提供了编排功能,这些被分为两类:托管,如 AWS ECS 这样的 PaaS 解决方案,以及非托管,如 Kubenetes 这样的开源调度器,它们需要管理和调度器应用程序。不幸的是,没有一种适合所有情况的解决方案。你选择的选项取决于你需要的规模和应用程序的复杂程度。如果你是初创公司或者刚开始进入微服务领域,那么更托管的一端,如Elastic Beanstalk,将绰绰有余。如果你计划进行大规模迁移,那么你可能需要考虑一个完整的调度器。我确信的一点是,通过使用 Docker 容器化你的应用程序,你拥有这种灵活性,即使你计划进行大规模迁移,也可以从简单开始,逐步增加复杂性。我们将探讨编排和基础设施即代码的概念如何帮助我们完成这项工作。我们永远不应该忽视前期设计和长期思考,但我们也不应该让这阻止我们快速行动。就像代码基础设施可以被重构和升级一样,重要的概念是模式和强大的基础。

什么是不可变基础设施?

不可变性是指无法更改的状态。我们已经探讨了 Docker 以及 Docker 容器是如何成为图像的不可变实例的。然而,关于 Docker 服务器运行的硬件呢?不可变基础设施给我们带来了同样的好处——我们有一个已知的状态,并且该状态在我们整个环境中是一致的。传统上,软件会在应用服务器上升级,但这个过程往往存在问题。软件更新过程有时不会按计划进行,让操作员面临艰难的任务,试图回滚这个过程。我们也会遇到应用服务器处于不同状态的情况,需要不同的过程来升级每个服务器。如果只有两个应用服务器,更新过程可能没问题,但如果你有 200 个呢?认知负荷变得过高,以至于管理被分散到团队或多个团队,然后我们需要开始维护文档以升级每个应用程序。当我们处理裸机服务器时,通常没有其他方法来处理这种情况;配置一台机器所需的时间以天计算。随着虚拟化的发展,这种时间得到了改善,因为它使我们能够创建一个基础镜像,其中包含部分配置,然后我们可以在几分钟内配置新的实例。随着云的出现,抽象级别又提高了一层;我们甚至不再需要担心虚拟化层,因为我们有能力在几秒钟内启动计算资源。因此,云解决了硬件的过程,但关于应用程序配置的过程呢?我们是否还需要编写文档并保持其更新?实际上,我们不需要。已经创建了工具,使我们能够将基础设施和应用配置编码化。代码成为文档,因为它是代码,我们可以使用标准的版本控制系统(如 Git)对其进行版本控制。有众多工具可供选择,例如 Chef、Puppet、Ansible 和 Terraform;然而,在本章中,我们将探讨 Terraform,因为在我看来,除了是最现代的工具和最容易使用的工具之外,它还体现了不可变性的所有原则。

Terraform

Terraform (terraform.io) 是由 HashiCorp (hashicorp.com) 开发的一个应用程序,它能够为多个应用程序和云提供商提供基础设施配置。

它允许您使用 HCL 语言格式编写编码化的基础设施。它实现了我们讨论过的可重复性和一致性概念,这些对于持续部署至关重要。

作为应用程序的 Terraform 是一个强大的工具,它比本书应该涵盖的内容更为广泛;然而,我们将探讨其基本工作原理,以便理解我们的演示应用程序。

我们将把我们的基础设施分成多个部分,每个微服务拥有的基础设施代码位于源代码仓库中。

在本节中,我们将仔细研究共享的基础设施和服务,以更深入地理解 Terraform 的概念。让我们看一下以下 GitHub 仓库中的示例代码:

github.com/building-microservices-with-go/chapter11-services-main

共享基础设施包含以下组件:

  • VPC:这是虚拟云,它允许连接到它的所有应用程序无需经过公共互联网即可通信

  • S3 存储桶:这是配置和工件远程存储

  • Elastic Beanstalk:这是将运行 NATS.io 消息系统的 Elastic Beanstalk 应用程序,我们可以将其分布在两个可用区,这相当于数据中心,在多个区域托管应用程序为我们提供了冗余,以防区域出现故障

  • 内部 ALB:当我们向我们的 VPC 添加其他应用程序时,为了与我们的 NATS.io 服务器通信,我们需要使用内部应用程序负载均衡器。内部 ALB 具有与外部负载均衡器相同的功能,但它仅对连接到 VPC 的应用程序可访问,不允许来自公共互联网的连接

  • 互联网网关:如果我们需要我们的应用程序能够向其他互联网服务发起出站调用,那么我们需要附加一个互联网网关。出于安全考虑,VPC 默认没有出站连接

现在我们可以理解我们需要创建的组件,让我们看一下可以创建它们的 Terraform 配置。

提供者

Terraform 被分解为提供者。提供者负责理解 API 交互并暴露所选平台上的资源。在第一部分,我们将查看 AWS 的提供者配置。在以下代码中,provider块允许您使用您的凭据配置 Terraform 并设置 AWS 区域:

provider "aws" { 
    access_key = "XXXXXXXXXXX" 
    secret_key = "XXXXXXXXXXX" 
    region = "us-west-1" 
} 

Terraform 中的块通常遵循之前的模式。HCL 不是 JSON;然而,它与 JSON 是互操作的。HCL 的设计是为了在机器可读和人类可读格式之间找到平衡。在这个特定的提供者中,我们可以配置一些不同的参数;然而,作为一个基本要求,我们必须设置您的access_keysecret_keyregion。以下是对这些参数的解释:

  • access_key:这是 AWS 访问密钥。这是一个必需的参数;然而,它也可以通过设置AWS_ACCESS_KEY_ID环境变量来提供。

  • secret_key:这是 AWS 密钥。这是一个必需的参数;然而,它也可以通过设置AWS_SECRET_ACCESS_KEY环境变量来提供。

  • region:这是 AWS 区域。这是一个必需的参数;然而,它也可以通过设置 AWS_DEFAULT_REGION 环境变量来提供。

所有必需的变量都可以用环境变量替换;我们不希望将我们的 AWS 密钥提交到 GitHub,因为如果它们泄露,我们很可能会发现有人善意地启动了大量昂贵的资源来挖比特币(www.securityweek.com/how-hackers-target-cloud-services-bitcoin-profit)。

如果我们使用环境变量,我们就可以将这些变量安全地注入到我们的 CI 服务中,在那里它们可用于作业。查看我们的 provider.tf 提供者块,我们可以看到它不包含任何设置:

provider "aws" { } 

此外,在这个文件中,您会注意到有一个名为 terraform 的块。这个配置块允许我们将 Terraform 状态存储在 S3 存储桶中:

terraform { 
  backend "s3" { 
    bucket = "nicjackson-terraform-state" 
    key    = "chapter11-main.tfstate" 
    region = "eu-west-1" 
  } 
} 

状态是 terraform 块用来理解为模块创建的资源。每次您更改配置并运行 Terraform 的计划之一时,Terraform 都会检查状态文件以查找差异,以了解它需要删除、更新或创建的内容。关于远程状态的特殊说明是,它永远不应该被提交到 git。远程状态包含有关您的基础设施的信息,包括可能机密的详细信息,这是您绝对不希望泄露的。因此,我们可以使用远程状态,而不是在本地磁盘上保留状态;Terraform 将状态文件保存到远程后端,如 s3。我们甚至可以实施某些后端的锁定,以确保在任何时候只有一个配置运行。在我们的配置中,我们使用 AWS s3 后端,它具有以下属性:

  • bucket:这是存储状态的 S3 存储桶的名称。S3 存储桶是全局命名的,并且不与您的用户账户命名空间相关联。因此,此值不仅必须对您来说是唯一的,而且对 AWS 来说也必须是特定的。

  • key:这是存储状态的存储桶对象的密钥。这是存储桶特有的。只要这个密钥是唯一的,您就可以使用存储桶来存储多个 Terraform 配置。

  • region:这是 S3 存储桶所在的区域。

Terraform 配置入口点

我们应用程序的主要入口点是 terraform.tf 文件。对此文件名没有规定,Terraform 是基于图的。它会递归遍历目录中所有以 .tf 结尾的文件,并构建依赖图。这样做是为了了解创建资源的顺序。

如果我们查看这个文件,我们会看到它由模块组成。模块是 Terraform 创建可重用基础设施代码部分或仅为了可读性而逻辑上分离事物的一种方式。它们与 Go 中的包概念非常相似:

module "vpc" { 
  source = "./vpc" 

  namespace = "bog-chapter11" 
} 

module "s3" { 
  source = "./s3" 

  application_name = "chapter11" 
} 

module "nats" { 
  source = "./nats" 

  application_name        = "nats" 
  application_description = "Nats.io server" 
  application_environment = "dev" 

  deployment_bucket    = "${module.s3.deployment_bucket}" 
  deployment_bucket_id = "${module.s3.deployment_bucket_id}" 

  application_version = "1.1" 
  docker_image        = "nats" 
  docker_tag          = "latest" 

  elb_scheme   = "internal" 
  health_check = "/varz" 

  vpc_id  = "${module.vpc.id}" 
  subnets = ["${module.vpc.subnets}"] 
} 

让我们更深入地看看 VPC 模块。

VPC 模块

VPC 模块在 AWS 内部创建我们的私有网络;我们不想也不需要将 NATS 服务器暴露给外部世界,因此我们可以创建一个仅允许连接到该网络的资源访问它的私有网络,如下面的代码所示:

module "vpc" { 
  source = "./vpc" 

  namespace = "bog-chapter11" 
} 

source 属性是模块的位置;Terraform 支持以下来源:

  • 本地文件路径

  • GitHub

  • Bitbucket

  • 通用 Git、Mercurial 仓库

  • HTTP URL

  • S3 存储桶

source 属性之后,我们可以配置自定义属性,这些属性对应于模块中的变量。变量是模块的必需占位符;当它们不存在时,Terraform 在尝试运行时会报错。

vpc/variables.tf 文件包含以下内容:

variable "namespace" { 
  description = "The namespace for our module, will be prefixed to all resources."  
} 

variable "vpc_cidr_block" { 
  description = "The top-level CIDR block for the VPC." 
  default     = "10.1.0.0/16" 
} 

variable "cidr_blocks" { 
  description = "The CIDR blocks to create the workstations in." 
  default     = ["10.1.1.0/24", "10.1.2.0/24"] 
} 

变量的配置与提供者的配置非常相似,它遵循以下语法:

variable "[name]" { 
  [config] 
} 

变量有三个可能的配置选项:

  • type: 这是一个可选属性,用于设置变量的类型。有效的值有 stringlistmap。如果没有提供值,则默认类型为 string

  • default: 这是一个可选属性,用于设置变量的默认值。

  • description: 这是一个可选属性,用于为变量分配一个友好的描述。此属性的主要目的是为了记录你的 Terraform 配置文档。

变量可以在 terraform.tfvars 文件中显式声明,就像我们仓库根目录下的文件一样:

namespace = "chapter10-bog" 

我们也可以通过在变量名称前缀添加 TF_VAR_ 来设置环境变量:

export TF_VAR_namespace=chapter10-bog 

或者,我们可以在运行 terraform 命令时在命令中包含变量:

terraform plan -var namespace=chapter10-bog 

我们正在配置应用程序的命名空间和网络分配的 IP 地址块。如果我们查看包含 VPC 块的文件,我们可以看到它是如何使用的。

vpc/vpc.tf 文件包含以下内容:

# Create a VPC to launch our instances into 
resource "aws_vpc" "default" { 
  cidr_block           = "${var.vpc_cidr_block}" 
  enable_dns_hostnames = true 

  tags { 
    "Name" = "${var.namespace}" 
  } 
} 

resource 块是 Terraform 语法的一部分,用于在 AWS 中定义资源,其语法如下:

resource "[resource_type]" "[resource_id]" { 
    [config] 
} 

Terraform 中的资源映射到 AWS SDK 中 API 调用所需的对象。如果你查看 cidr_block 属性,你会看到我们正在使用 Terraform 插值语法引用变量:

cidr_block = "${var.vpc_cidr_block}" 

插值语法是 Terraform 内部的元编程语言。它允许你操作变量和资源的输出,并使用 ${[interpolation]} 语法定义。我们正在使用变量集合,它以前缀 var 开头,并引用 vpc_cidr_block 变量。当 Terraform 运行 ${var.vpc_cidr_block} 时,它将被替换为变量文件中的 10.1.0.0/16 值。

在 AWS 中创建具有外部互联网访问的 VPC 需要四个部分:

  • aws_vpc: 这是一个为我们实例提供的私有网络

  • aws_internet_gateway: 这是一个连接到我们的 VPC 以允许互联网访问的网关

  • aws_route: 这是映射到网关的路由表条目

  • aws_subnet:这是一个我们的实例启动到的子网--我们为每个可用区域创建一个子网

这种复杂性不是 Terraform,而是 AWS。其他云提供商也有非常类似的复杂性,遗憾的是,这是不可避免的。一开始可能会觉得令人畏惧,然而,外面有一些非常棒的资源。

VPC 设置的下一个部分是配置互联网网关:

# Create an internet gateway to give our subnet access to the outside world 
resource "aws_internet_gateway" "default" { 
  vpc_id = "${aws_vpc.default.id}" 

  tags { 
    "Name" = "${var.namespace}" 
  } 
} 

再次,我们有一个与 aws_vpc 块类似的格式;然而,在这个块中,我们需要设置 vpc_id 块,它需要引用我们在上一个块中创建的 VPC。我们还可以再次使用 Terraform 插值语法来找到这个引用,即使它尚未创建。aws_vpc.default.id 引用具有以下形式,这在 Terraform 的所有资源中都是通用的:

 [resource].[name].[attribute] 

当我们在 Terraform 中引用另一个块时,它也告诉依赖关系图,引用的块需要在当前块之前创建。这样,Terraform 能够组织哪些资源可以并行设置,哪些资源有确切的顺序。当创建图时,我们不需要自己声明顺序,它会自动为我们构建这个顺序。

下一个块设置 VPC 的路由表,启用对公共互联网的出站访问:

# Grant the VPC Internet access on its main route table 
resource "aws_route" "internet_access" { 
  route_table_id         = "${aws_vpc.default.main_route_table_id}" 
  destination_cidr_block = "0.0.0.0/0" 
  gateway_id             = "${aws_internet_gateway.default.id}" 
} 

让我们更详细地看看这个块中的属性:

  • route_table_id:这是我们要为要创建的新引用的路由表的引用。我们可以从 aws_vpc 的输出属性 main_route_table_id 获取它。

  • destination_cidr_block:这是将要连接到 VPC 的实例的 IP 范围,这些实例可以向网关发送流量。我们使用 0.0.0.0/0 块,允许所有连接的实例。如果需要,我们只能允许对某些 IP 范围的外部访问。

  • gateway_id:这是对我们之前创建的网关块的引用。

下一个块为我们引入了一个新的数据源概念。数据源允许从存储在 Terraform 外部或存储在单独的 Terraform 配置中的信息中检索或计算数据。例如,数据源可以在 AWS 中查找信息,你可以查询现有 EC2 实例的列表,这些实例可能存在于你的账户中。你也可以查询其他提供者,例如,你在 CloudFlare 中有一个 DNS 条目,你想要获取其详细信息或甚至是不同云提供商(如 Google 或 Azure)中负载均衡器的地址。

我们将使用它来检索 AWS 中的可用区域列表。当我们创建 VPC 时,我们需要在每个可用区域中创建一个子网,因为我们只配置了区域,我们没有为该区域设置可用区域。我们可以在变量部分显式配置这些;然而,这会使我们的配置更加脆弱。在可能的情况下,最好的方法是使用数据块:

# Grab the list of availability zones 
data "aws_availability_zones" "available" {} 

配置相当简单,再次遵循常见的语法:

data [resource] "[name]" 

我们将在 VPC 设置的最后一部分使用此信息,即配置子网;这也引入了另一个新的 Terraform 功能count

# Create a subnet to launch our instances into 
resource "aws_subnet" "default" { 
  count                   = "${length(var.cidr_blocks)}" 
  vpc_id                  = "${aws_vpc.default.id}" 
  availability_zone       = "${data.aws_availability_zones.available.names[count.index]}" 
  cidr_block              = "${var.cidr_blocks[count.index]}" 
  map_public_ip_on_launch = true 

  tags { 
    "Name" = "${var.namespace}" 
  } 
} 

让我们仔细看看count属性;一个count属性是一个特殊属性,当设置时,会创建n个资源实例。我们的属性值也扩展了我们在前面检查的插值语法,以引入length函数:

# cidr_blocks = ["10.1.1.0/24", 10.1.2.0/24"] 
${length(var.cidr_blocks)} 

cidr_blocks 是一个 Terraform 列表。在 Go 中,这将是一个切片,其长度将返回列表中元素的数量。为了比较,让我们看看我们如何在 Go 中编写这个:

cidrBlocks := []string {"10.1.1.0/24", "10.1.2.0/24"} 
elements := len(cidrBlocks) 

Terraform 中的插值语法是一个惊人的特性,允许您使用许多内置函数操作变量。插值语法的文档可以在以下位置找到:

www.terraform.io/docs/configuration/interpolation.html

我们还有使用条件语句的能力。count函数的一个最佳特性是,如果您将其设置为0,Terraform 将省略资源的创建;例如,它将允许我们编写如下内容:

resource "aws_instance" "web" { 
  count = "${var.env == "production" ? 1 : 0}" 
} 

条件语句的语法使用三元运算符,这在许多语言中都有:

CONDITION ? TRUEVAL : FALSEVAL 

当我们使用count Terraform 时,它还为我们提供了一个索引,我们可以使用它从列表中获取正确的元素。考虑我们如何在availability_zone属性中使用它:

availability_zone = "${data.aws_availability_zones.available.names[count.index]}" 

count.index将为我们提供一个基于 0 的索引,因为data.aws_availability_zones.available.names返回一个列表,我们可以像切片一样访问它。让我们看看aws_subnet上的剩余属性:

  • vpc_id: 这是 VPC 的 ID,我们在前面的块中创建的,我们希望将其附加到子网

  • availability_zone: 这是子网的可用区名称

  • cidr_block: 这是地址的 IP 范围,当我们在特定的 VPC 和可用区中启动实例时,将分配给实例

  • map_public_ip_on_launch: 当实例创建时,我们是否应该附加一个公共 IP 地址,这是一个可选参数,并确定您的实例是否也应该有一个公共 IP 地址,除了从cidr_block属性分配的私有 IP 地址之外

输出变量

当我们在 Terraform 中构建模块时,我们经常需要引用来自其他模块的属性。模块之间存在清晰的分离,这意味着它们不能直接访问另一个模块的资源。例如,在这个模块中,我们正在创建一个 VPC,稍后我们希望创建一个附加到该 VPC 的 EC2 实例。我们无法使用即将显示的语法。

module2/terraform.tf文件包含以下内容:

resource "aws_instance" "web" { 
# ... 
    vpc_id = "${aws_vpc.default.id}" 
} 

之前的例子会导致错误,因为我们试图引用在这个模块中不存在的变量,尽管它在你的全局 Terraform 配置中存在。将这些视为类似于 Go 包。如果我们有两个以下 Go 包,它们包含非导出变量:

a/main.go

package a 

var notExported = "Some Value" 

b/main.go

package b 

func doSomething() { 
    // invalid reference 
    if a.notExported == "Some Value { 
        //... 
    } 
} 

在 Go 中,我们当然可以通过将变量的名称 notExported 大写为 NotExported 来导出变量。要在 Terraform 中实现相同的效果,我们使用输出变量:

output "id" { 
  value = "${aws_vpc.default.id}" 
} 

output "subnets" { 
  value = ["${aws_subnet.default.*.id}"] 
} 

output "subnet_names" { 
  value = ["${aws_subnet.default.*.arn}"] 
} 

语法现在应该开始变得熟悉了:

output "[name]" { 
    value = "..." 
} 

然后,我们可以使用一个模块的输出作为另一个模块的输入--这是在 terraform.tf 文件中找到的一个例子:

module "nats" { 
  source = "./nats" 

  application_name        = "nats" 
  application_description = "Nats.io server" 
  application_environment = "dev" 

  deployment_bucket    = "${module.s3.deployment_bucket}" 
  deployment_bucket_id = "${module.s3.deployment_bucket_id}" 

  application_version = "1.1" 
  docker_image        = "nats" 
  docker_tag          = "latest" 

  elb_scheme   = "internal" 
  health_check = "/varz" 

  vpc_id  = "${module.vpc.id}" 
  subnets = ["${module.vpc.subnets}"] 
} 

vpc_id 属性引用了 vpc 模块的输出:

vpc_id  = "${module.vpc.id}" 

上述语句的语法如下:

module.[module name].[output variable] 

除了让我们保持代码的简洁和清晰外,输出变量和模块引用还允许 Terraform 构建其依赖图。在这个例子中,Terraform 知道由于 nats 模块中存在对 vpc 模块的引用,它需要在 nats 模块之前创建 vpc 模块资源。这可能会感觉信息量很大,确实如此。我并没有说基础设施即代码很容易,但当我们到达这个例子的结尾时,它将开始变得清晰。将这些概念应用到创建其他资源变得相当直接,唯一的复杂性在于资源的工作方式,而不是创建该资源所需的 Terraform 配置。

创建基础设施

要运行 Terraform 并创建我们的基础设施,我们首先必须设置一些环境变量:

$ export AWS_SECRET_ID=[your aws secret id] 
$ export AWS_SECRET_ACCESS_KEY=[your aws access key] 
$ export AWS_DEFAULT_REGION=[aws region to create resource] 

我们接下来需要初始化 Terraform 以引用模块和远程数据存储。我们通常只有在第一次克隆仓库或对模块进行更改时才需要执行此步骤:

$ terraform init  

下一步是运行计划;我们使用 Terraform 中的计划命令来了解 apply 命令将创建、更新或删除哪些资源。它还将对我们的配置进行语法检查,而不会创建任何资源:

$ terraform plan -out=main.terraform  

-out 参数将计划保存到 main.terraform 文件。这是一个可选步骤,但如果我们使用计划的输出运行 apply,我们可以确保从检查和批准 plan 命令的输出以来没有发生变化。然后,我们可以运行 apply 命令来创建基础设施:

$ terraform apply main.terraform  

apply 命令的第一个参数是我们在上一步中创建的计划输出。Terraform 现在将在 AWS 中创建你的资源,这取决于你创建的资源类型,可能需要几秒钟到 30 分钟。一旦创建完成,Terraform 将将我们在 output.tf 文件中定义的输出变量写入 stdout

我们在我们的主要基础设施项目中只覆盖了一个模块。我建议您阅读剩余的模块,并熟悉 Terraform 代码及其创建的 AWS 资源。Terraform 网站(terraform.io)和 AWS 网站上有优秀的文档。

示例应用

我们的示例应用是一个简单的分布式系统,由三个服务组成。这三个主要服务,产品、搜索和认证,依赖于一个数据库,它们使用该数据库来存储它们的状态。为了简单起见,我们使用 MySQL;然而,在实际的生产环境中,您可能希望为您的用例选择最合适的数据存储。这三个服务通过我们使用的 NATS.io 消息系统连接,该系统是一个供应商无关的系统,我们在第九章“事件驱动架构”中进行了探讨[2952a830-163e-4610-8554-67498ec77e1e.xhtml]。

图片

为了配置此系统,我们将基础设施和源代码分解为四个独立的存储库:

单个存储库使我们能够以这种方式将应用程序分离,我们只构建和部署更改的组件。共享基础设施存储库包含用于创建共享网络和创建 NATS.io 服务器的 Terraform 配置。认证服务创建了一个基于 JWT 的认证微服务,并包含用于将服务部署到 Elastic Beanstalk 的单独的 Terraform 配置。产品服务和搜索服务存储库也各自包含一个微服务和 Terraform 基础设施配置。所有服务都配置为使用 Circle CI 构建和部署。

持续交付工作流程

在本章的剩余部分,我们将专注于搜索服务,因为构建管道是最复杂的。在我们的示例应用中,我们有以下步骤来构建管道:

  • 编译应用程序

  • 单元测试

  • 基准测试

  • 静态代码分析

  • 集成测试

  • 构建 Docker 镜像

  • 部署应用程序

  • 烟雾测试

许多这些步骤是独立的,可以并行运行,因此当我们构建管道时,它看起来像以下图示:

图片

请查看github.com/building-microservices-with-go/chapter11-services-auth上的示例代码。我们使用 Circle CI 构建这个应用程序;然而,这些概念适用于你使用的任何平台。如果我们查看 circleci/config.yml 文件,我们会看到我们首先设置过程的配置,这包括选择构建执行的 Docker 容器的版本以及安装一些初始依赖项。然后我们组合作业,这些作业在工作流程中执行,并为每个作业定义各种步骤:

defaults: &defaults 
  docker: 
    # CircleCI Go images available at: https://hub.docker.com/r/circleci/golang/ 
    - image: circleci/golang:1.8 

  working_directory: /go/src/github.com/building-microservices-with-go/chapter11-services-search 

  environment: 
    TEST_RESULTS: /tmp/test-results 

version: 2 
jobs: 
  build: 
    <<: *defaults 

    steps: 
      - checkout 

      - run:  
          name: Install dependencies 
          command: | 
            go get github.com/Masterminds/glide 
            glide up 

      - run: 
          name: Build application for Linux  
          command: make build_linux 

      - persist_to_workspace: 
          root: /go/src/github.com/building-microservices-with-go/ 
          paths: 
            - chapter11-services-search 

# ... 

workflows: 
  version: 2 
  build_test_and_deploy: 
    jobs: 
      - build 
      - unit_test: 
          requires: 
            - build 
      - benchmark: 
          requires: 
            - build 
      - staticcheck: 
          requires: 
            - build 
      - integration_test: 
          requires: 
            - build 
            - unit_test 
            - benchmark 
            - staticcheck 
      - deploy: 
          requires: 
            - integration_test 

最后,我们将这些作业组合成一个工作流程或管道。这个工作流程定义了步骤之间的关系,因为存在明显的依赖关系。

为了在我们的配置中隔离依赖项,并确保构建和测试的命令在各种过程中保持一致,这些命令已经被放置在仓库根目录下的 Makefile 中。

start_stack: 
    docker-compose up -d 

circleintegration: 
    docker build -t circletemp -f ./IntegrationDockerfile .     
    docker-compose up -d 
    docker run -network chapter11servicessearch_default -w /go/src/github.com/building-microservices-with-go/chapter11-services-search/features -e "MYSQL_CONNECTION=root:password@tcp(mysql:3306)/kittens" circletemp godog ./ 
    docker-compose stop 
    docker-compose rm -f 

integration: start_stack 
    cd features && MYSQL_CONNECTION="root:password@tcp(${DOCKER_IP}:3306)/kittens" godog ./ 
    docker-compose stop 
    docker-compose rm -f 

unit: 
    go test -v -race $(shell go list ./... | grep -v /vendor/) 

staticcheck: 
    staticcheck $(shell go list ./... | grep -v /vendor/) 

safesql: 
    safesql github.com/building-microservices-with-go/chapter11-services-search 

benchmark: 
    go test -bench=. github.com/building-microservices-with-go/chapter11-services-search/handlers 

build_linux: 
    CGO_ENABLED=0 GOOS=linux go build -o ./search . 

build_docker: 
    docker build -t buildingmicroserviceswithgo/search . 

run: start_stack 
    go run main.go 
    docker-compose stop 

test: unit benchmark integration 

构建

让我们更详细地看看构建过程。在构建作业配置中,我们有三个步骤。第一步是检出仓库。作业本身被分解成步骤,其中第一个值得注意的步骤是安装依赖项。Glide 是我们仓库的包管理器,我们需要安装它以获取我们供应商包的更新。我们还需要一个 go-junit-report 工具包。这个应用程序允许我们将 Go 测试输出转换为 JUnit 格式,这是 Circle 所需的,以便展示某些仪表板信息。然后我们执行 glide up 来获取任何更新。在这个例子中,我已经将 vendor 文件夹检入到仓库中;然而,我没有将包锁定到特定版本。你应该设置一个最低包版本,而不是一个确切的包版本,频繁更新你的包可以让你利用开源社区中的常规发布。当然,你运行的风险是包中可能会有破坏性的更改,这种更改会破坏构建,但如前所述,最好是尽快捕捉到这个问题,而不是在你面临发布压力时处理问题。

因为我们是为生产构建,所以我们需要创建一个 Linux 二进制文件,这就是为什么我们在运行构建之前设置 GOOS=linux 环境变量的原因。当我们运行构建在 Circle CI 上时设置环境是多余的,因为我们已经在基于 Linux 的 Docker 容器中运行;然而,为了使我们的开发机器能够进行跨平台构建(如果它们不是基于 Linux 的),有一个共同的命令是有用的。

一旦我们构建了我们的应用程序,我们需要持久化工作区,以便其他作业可以使用它。在 Circle CI 中,我们使用特殊的步骤 persist_to_workspace;然而,这种能力在基于管道的工作流程中是通用的:

图片

测试

我们还提到,我们需要一致性,如果我们持续部署,我们需要有一个良好的稳定的测试套件,它几乎取代了我们的所有手动测试。我并不是说没有手动测试的地方,因为探索性测试总有用途,但当我们持续部署时,我们需要自动化所有这些。即使你在流程中添加手动测试,它也更有可能作为一个与构建管道互补的异步过程运行,而不是作为它的关卡。

配置中的测试部分运行我们的单元测试,正如我们在第四章“测试”中看到的。使用以下配置,我们首先需要附加我们在构建步骤中创建的工作区。这样做的原因是我们不需要再次检出仓库。

unit_test: 
    <<: *defaults 

    steps: 
      - attach_workspace: 
          at: /go/src/github.com/building-microservices-with-go 

      - run: mkdir -p $TEST_RESULTS 

      - run:  
          name: Install dependencies 
          command:  go get github.com/jstemmer/go-junit-report 

      - run:  
          name: Run unit tests 
          command: | 
            trap "go-junit-report <${TEST_RESULTS}/go-test.out > ${TEST_RESULTS}/go-test-report.xml" EXIT 
            make unit | tee ${TEST_RESULTS}/go-test.out 

      - store_test_results: 
          path: /tmp/test-results 

我们需要做的第二件事是安装依赖项,Circle CI 要求测试输出以 JUnit 格式呈现。为了启用这一点,我们可以获取go-junit-report包,它可以将我们的测试输出转换为 JUnit 格式。

要运行测试,我们必须做一些稍微不同的事情,如果我们只是运行了单元测试并将它们管道化到go-junit-report命令中,那么我们会丢失输出。按相反的顺序读取命令,我们运行单元测试和输出,make unit | tee ${TEST_RESULTS}/go-test.outtee命令将输入管道化到它并写入指定的输出文件以及stdout文件。然后我们可以使用 trap,它在另一个命令匹配退出代码时执行命令。在我们的例子中,如果单元测试以状态码 0(正常情况)退出,那么我们执行go-junit-report命令。最后,我们写入测试结果以便 Circle CI 能够使用store_test_results步骤来解释它们。

基准测试

基准测试是我们 CI 管道的一个重要特性;我们需要了解我们的应用程序性能何时会下降。为此,我们将运行基准测试并使用方便的工具benchcmp,它比较两次测试运行。benchcmp 的标准版本仅输出两次测试运行之间的差异。虽然这对于比较来说是不错的,但它并不提供在差异在一定阈值内时使我们的 CI 作业失败的能力。为了启用这种能力,我已经分叉了 benchcmp 工具并添加了flag-tollerance=[FLOAT]。如果任何基准测试变化±给定的容差,那么 benchcmp 将以状态码 1 退出,允许我们失败作业并调查这种变化发生的原因。为了使这可行,我们需要保留以前的基准数据以供比较,因此我们可以使用缓存功能来存储最后运行的测试数据。

静态代码分析

静态代码分析是一种快速高效的方法,可以自动检查源代码中可能存在的问题。在我们的例子中,我们将运行两个不同的静态代码分析工具,第一个是由 Dominik Honnef 开发的 megacheck,它检查代码中常见的错误,例如标准库的误用、并发问题以及许多其他问题。

第二个是来自 Stripe 团队的 SafeSQL。SafeSQL 遍历我们的代码,寻找 SQL 包的使用情况。然后,它检查那些看起来有漏洞的查询,例如不正确构造的查询,这些查询可能容易受到 SQL 注入的影响。

最后,我们将检查我们的代码,包括对未处理错误的测试,例如,你有一个以下函数:

func DoSomething() (*Object, error) 

当调用此类方法时,错误对象可以被丢弃而不被处理:

obj, _ := DoSomething() 

未处理的错误通常在测试中而不是代码的主体中找到;然而,即使在测试中,这也可能由于未处理的行为引入错误,errcheck 会遍历代码寻找此类实例,并在找到时报告错误并失败构建:

staticcheck: 
    <<: *defaults 

    steps: 
      - attach_workspace: 
          at: /go/src/github.com/building-microservices-with-go 

      - run: 
          name: Install dependencies 
          command: | 
            go get honnef.co/go/tools/cmd/staticcheck 
            go get github.com/stripe/safesql 

      - run: 
          name: Static language checks 
          command: make staticcheck 

      - run: 
          name: Safe SQL checks 
          command: make safesql 

        - run: 
          name: Check for unhandled errors 
          command: make errcheck 

静态检查调用 megacheck 检查器,该检查器运行 staticcheck,这是一个静态代码分析工具,有助于检测错误,Go simple 识别应该通过以更简单的方式重写来改进的源代码区域,以及 unused 识别未使用的常量、类型和函数。第一个检查器旨在发现错误;然而,其余三个关注你的应用程序生命周期管理。

清洁的代码对于无错误代码至关重要;你的代码越简单、越简单,逻辑错误的概率就越低。为什么?因为代码更容易理解,而且你花在阅读代码上的时间比写代码的时间多,所以优化可读性是有意义的。静态代码分析不应取代代码审查。然而,这些工具允许你专注于逻辑错误而不是语义。将它们集成到你的持续集成管道中,充当代码库健全性的守门人,检查运行得非常快,在我看来,这是一个必不可少的步骤。

github.com/dominikh/go-tools/tree/master/cmd/megacheck

来自 Stripe 团队的 SafeSQL 是一个静态代码分析工具,用于防止 SQL 注入。它试图找出对 database/sql 包使用不当的问题。

github.com/stripe/safesql

集成测试

然后,还有集成测试。在这个例子中,我们再次使用 GoDog BDD;然而,当我们运行在 Circle CI 上时,我们需要稍微修改我们的设置,因为 Circle 处理 Docker 安全的方式。第一步仍然是附加工作区,包括我们在前一步骤中构建的二进制文件;然后我们可以获取依赖项,这些依赖项仅是 GoDog 应用程序。setup_remote_docker命令从 Circle CI 请求一个 Docker 实例。当前的构建正在 Docker 容器中运行;然而,由于安全配置,我们无法访问当前构建正在运行的 Docker 主机。

circleintegration: 
    docker build -t circletemp -f ./IntegrationDockerfile .     
    docker-compose up -d 
    docker run -network chapter11servicessearch_default -w /go/src/github.com/building-microservices-with-go/chapter11-services-search/features -e "MYSQL_CONNECTION=root:password@tcp(mysql:3306)/kittens" circletemp godog ./ 
    docker-compose stop 
    docker-compose rm -f 

在 CI 上运行的 Makefile 部分比在我们本地机器上运行时要复杂得多。我们需要这个修改,因为我们需要将源代码和安装godog命令复制到容器中,该容器将在与 Docker compose 启动的堆栈相同的网络上运行。当我们本地运行时,这并不是必要的,因为我们有连接到网络的能力。在 Circle CI 以及大多数其他共享的持续集成环境中,这种访问是被禁止的。

FROM golang:1.8 

COPY . /go/src/github.com/building-microservices-with-go/chapter11-services-search 
RUN go get github.com/DATA-DOG/godog/cmd/godog 

我们构建我们的临时容器,它包含当前目录并添加了godog依赖。然后我们可以通过运行docker-compose upgodog命令来正常启动堆栈。

在持续交付上的集成测试在我们部署到生产环境之前是一个必不可少的关卡。我们还想能够测试我们的 Docker 镜像,以确保启动过程运行正确,并且我们已经测试了所有我们的资产。当我们查看第四章中的集成测试,“测试”时,我们只是运行了应用程序,这对于我们的开发过程来说是可以接受的——它给我们提供了质量和速度之间的快乐平衡。然而,当我们构建我们的生产镜像时,这种妥协是不可接受的,因此,我们需要对开发过程进行一些修改,以确保我们将生产镜像包含在我们的测试计划中。

部署

由于我们已经构建、测试并打包了所有应用程序代码,现在是时候考虑将其部署到生产环境中了。我们需要开始考虑我们的基础设施为不可变,也就是说,我们不会更改基础设施,而是替换它。这种发生的级别可以是多个。例如,我们有我们的容器调度器,它只运行容器。当我们更新我们的应用程序二进制文件时,我们是在调度器上替换容器而不是刷新其中的应用程序。容器给我们提供了一个不可变级别,另一个级别是调度器本身。为了成功进行持续交付,这个方面的设置也需要自动化,我们需要将我们的基础设施视为代码。

对于我们的应用程序,我们将基础设施拆分成单独的部分。我们有一个主要的基础设施仓库,它创建 VPC、部署使用的 S3 存储桶,并为我们的消息平台 NATS.io 创建一个 Elastic Beanstalk 实例。我们还有每个服务的 Terraform 配置。我们可以创建一个巨大的 Terraform 配置,因为 Terraform 会替换或销毁已更改的基础设施,然而,有几个原因我们不希望这样做。首先,我们希望能够将基础设施代码分解成小块,就像我们分解应用程序代码一样;第二个原因是由于 Terraform 的工作方式。为了确保状态的一致性,我们一次只能对基础设施代码运行一个操作。Terraform 在运行时获取锁,以确保您不能同时运行多次。如果我们考虑一个有多个微服务并且这些服务正在持续部署的情况,那么有一个单线程的单一部署就变得非常糟糕。当我们分解基础设施配置并将其与每个服务本地化时,这个问题就不再存在了。这个分布式配置的一个问题是,我们仍然需要一个方法来访问主仓库中的资源信息。在我们的案例中,我们在这个仓库中创建主要 VPC,我们需要详细信息来连接我们的微服务。幸运的是,Terraform 使用远程状态的概念管理得相当愉快。

terraform { 
  backend "s3" { 
    bucket = "nicjackson-terraform-state" 
    key    = "chapter11-main.tfstate" 
    region = "eu-west-1" 
  } 
} 

我们可以配置我们的主 Terraform 配置使用远程状态,然后我们可以使用远程状态数据元素从搜索 Terraform 配置中访问它:

data "terraform_remote_state" "main" { 
  backend = "s3" 

  config { 
    bucket = "nicjackson-terraform-state" 
    key    = "chapter11-main.tfstate" 
    region = "eu-west-1" 
  } 
} 

当构建过程中的所有前一步骤完成时,我们会自动将其部署到 AWS。这样,每次主分支构建新实例时,我们都会进行部署。

烟雾测试

在部署后对应用程序进行烟雾测试是持续交付中一个必不可少的步骤,我们需要确保应用程序运行正常,并且在构建和部署步骤中没有出错。在我们的例子中,我们只是检查我们能否到达健康端点。然而,烟雾测试可以像所需的那样简单或复杂。许多组织运行更详细的检查,这些检查确认了与已部署系统的核心集成是正确且正常工作的。烟雾测试是以编码测试的形式进行的,它重用了 GoDog 集成测试中的许多步骤,或者是一个专门的测试。在我们的例子中,我们只是检查搜索服务的健康端点。

- run: 
          name: Smoke test 
          command: | 
            cd terraform 
            curl $(terraform output search_alb)/health 

在我们的应用程序中,我们可以运行这个测试,因为端点是公开的。当一个端点不是公开的,测试就会变得更加复杂,我们需要通过公开端点调用以检查集成。

端到端测试的考虑因素之一是,你需要小心不要污染生产数据库中的数据。一种补充或替代的方法是确保你的系统有广泛的日志记录和监控。我们可以设置仪表板和警报,这些仪表板和警报会主动检查用户错误。当部署后发生问题时,我们可以调查问题,并在必要时回滚到具有已知良好状态的构建的先前版本。

监控/警报

当应用程序运行时,我们需要确保应用程序的健康状况和状态。监控是持续部署生命周期中一个极其重要的方面。如果我们正在自动部署,我们需要了解我们的应用程序表现如何,以及这与之前的版本有何不同。我们看到了如何使用 StatsD 将关于我们服务的数据发射到后端,如 Prometheus 或像 Datadog 这样的托管应用程序。如果我们的最近部署表现出异常行为,我们会收到警报,然后我们可以采取行动来帮助确定问题的根源,必要时间歇性地回滚,或者根据服务器可能正在做更多工作来修改我们的警报。

# Create a new Datadog timeboard 
resource "datadog_timeboard" "search" { 
  title       = "Search service Timeboard (created via Terraform)" 
  description = "created using the Datadog provider in Terraform" 
  read_only   = true 

  graph { 
    title = "Authentication" 
    viz   = "timeseries" 

    request { 
      q    = "sum:chapter11.auth.jwt.badrequest{*}" 
      type = "bars" 

      style { 
        palette = "warm" 
      } 
    } 

    request { 
      q    = "sum:chapter11.auth.jwt.success{*}" 
      type = "bars" 
    } 
  } 

  graph { 
    title = "Health Check" 
    viz   = "timeseries" 

    request { 
      q    = "sum:chapter11.auth.health.success{*}" 
      type = "bars" 
    } 
  } 
} 

再次强调,使用基础设施即代码的概念,我们可以在构建时使用 Terraform 来配置这些监控器。虽然错误对于监控很有用,但也不应忘记时间数据。错误告诉你某件事出了问题;然而,通过在服务中巧妙地使用时间信息,我们可以了解到某件事即将出错。

完整的工作流程

假设一切运行良好,我们应该有一个成功的构建,并且我们构建环境中的 UI 应该显示所有步骤都通过。记住我们本章开头的一个警告——当你的构建失败时,你需要将其作为首要目标来修复它;你永远不知道你什么时候会需要它。

图片

摘要

在本章中,我们了解到为您的应用程序设置持续集成和部署并不一定是一项艰巨的任务,实际上,这对应用程序的健康和成功至关重要。我们基于前几章中涵盖的所有概念进行了构建,虽然最终的例子相对简单,但它包含了您可以将之应用到您应用程序中的所有组成部分,以确保您的时间用于开发新功能,而不是修复生产问题或反复且风险地部署应用程序代码。就像我们开发的各个方面一样,我们应该练习和测试这个过程。在将持续交付集成到您的生产工作流程之前,您需要确保您能够处理诸如热修复和回滚发布等问题。这项活动应在团队间完成,并且根据您对加班支持的处理流程,也应涉及任何支持人员。一个熟练且有效的部署流程让您有信心,当出现问题时,您将能够舒适且自信地处理它。

我希望通过阅读这本书,您现在对使用 Go 成功构建微服务所需的大部分内容有了更深入的理解。我无法教授的唯一一件事是您需要通过走出去并执行来自己发现的经验。我祝愿您在这段旅程中一切顺利,并且我从我的职业生涯中学到的一点是,您永远不会后悔投入时间和精力去学习这些技术。我相信您将取得巨大的成功。

posted @ 2025-09-06 13:43  绝不原创的飞龙  阅读(7)  评论(0)    收藏  举报