Go-编程蓝图(全)
Go 编程蓝图(全)
原文:
zh.annas-archive.org/md5/AC9839247134C458206EE3BE6D404A66译者:飞龙
前言
我决定写《Go 编程蓝图》是因为我想驱散一个谣言,即相对年轻的 Go 语言和社区不适合快速编写和迭代软件。我有一个朋友,他可以在一个周末内用 Ruby on Rails 开发完整的应用程序,通过混合现有的宝石和库;Rails 作为一个平台已经以其快速开发而闻名。由于我在 Go 和不断增长的开源软件包中也做到了同样的事情,我想分享一些真实世界的例子,展示我们如何可以快速构建和发布表现出色的软件,从第一天起就准备好扩展,这是 Rails 无法与之竞争的。当然,大多数的可扩展性发生在语言之外,但是像 Go 内置的并发性这样的特性意味着即使在最基本的硬件上,你也可以获得一些非常令人印象深刻的结果,这让你在事情开始变得真实时就能提前开始。
这本书探讨了五个非常不同的项目,其中任何一个都可以成为一个真正的创业基础。无论是低延迟的聊天应用程序、域名建议工具、建立在 Twitter 上的社交投票和选举服务,还是由 Google Places 提供支持的随机夜生活生成器,每一章都涉及大多数使用 Go 编写的产品或服务需要解决的各种问题。我在书中提出的解决方案只是解决每个项目的许多方法之一,我鼓励你自己对我如何解决它们做出自己的判断。概念比代码本身更重要,但你希望能够从中学到一些技巧和窍门,可以加入到你的 Go 工具包中。
我写这本书的过程可能会很有趣,因为它代表了许多敏捷开发者采用的一些哲学。我开始给自己一个挑战,即在深入研究并编写第一个版本之前,先构建一个真正可部署的产品(尽管是一个简单的产品;如果你愿意,可以称之为最小可行产品)。一旦我让它运行起来,我会从头开始重写它。小说家和记者们多次说过写作的艺术就是重写;我发现这对软件也是真实的。第一次我们写代码时,我们真正做的只是了解问题以及可能解决问题的方式,并将一些想法从我们的脑海中记录到纸上(或文本编辑器中)。第二次写代码时,我们将应用我们的新知识来真正解决问题。如果你从未尝试过这样做,试一试吧——你可能会发现,就像我一样,你的代码质量会显著提高。这并不意味着第二次就是最后一次——软件是不断演进的,我们应该尽量保持它的成本低廉和可替换性,这样如果某些部分过时或开始妨碍我们,我们也不介意将其丢弃。
我所有的代码都遵循测试驱动开发(TDD)的实践,其中一些我们将在章节中一起完成,而一些你只会在最终代码中看到结果。即使在印刷版中没有包含,所有的测试代码都可以在本书的 GitHub 存储库中找到。
一旦我完成了我的测试驱动的第二个版本,我会开始撰写描述我做了什么以及为什么这样做的章节。在大多数情况下,我采取的迭代方法被省略在书中,因为这只会增加页面的调整和编辑,这可能会让读者感到沮丧。然而,在一些情况下,我们将一起进行迭代,以了解渐进改进和小迭代的过程(从简单开始,只在绝对必要时引入复杂性)如何应用于编写 Go 软件包和程序。
我在 2012 年从英国搬到美国,但这并不是为什么这些章节以美式英语撰写的原因;这是出版商的要求。我想这本书是针对美国读者的,或者可能是因为美式英语是计算机的标准语言(在英国的代码中,处理颜色的属性是不带 U 拼写的)。无论如何,我提前为任何跨大西洋的差错道歉;我知道程序员有多么苛刻。
任何问题、改进、建议或辩论(我喜欢 Go 社区以及核心团队和语言本身的主张)都是非常欢迎的。这些可能最好在专门设置的书籍 GitHub 问题中进行,网址为github.com/matryer/goblueprints,以便每个人都可以参与。
最后,如果有人基于这些项目创建了一家初创公司,或者在其他地方利用了它们,我会感到非常兴奋。我很想听听这方面的消息;你可以在 Twitter 上@matryer 给我发消息,让我知道情况。
本书内容包括
第一章 ,使用 Web 套接字的聊天应用程序,展示了如何构建一个完整的 Web 应用程序,允许多人在其 Web 浏览器中进行实时对话。我们看到 net/http 包如何让我们提供 HTML 页面,并与客户端的浏览器建立 Web 套接字连接。
第二章 ,添加身份验证,展示了如何向我们的聊天应用程序添加 OAuth,以便我们可以跟踪谁说了什么,但让他们可以使用 Google、Facebook 或 GitHub 登录。
第三章 ,实现个人资料图片的三种方式,解释了如何向聊天应用程序添加个人资料图片,可以从身份验证服务、Gravitar.com网站获取,或者允许用户从硬盘上传自己的图片。
第四章 ,用命令行工具查找域名,探讨了在 Go 中构建命令行工具的简易性,并将这些技能应用于解决为我们的聊天应用程序找到完美域名的问题。它还探讨了 Go 语言如何轻松利用标准输入和标准输出管道来生成一些非常强大的可组合工具。
第五章 ,构建分布式系统并处理灵活数据,解释了如何通过 NSQ 和 MongoDB 构建高度可扩展的 Twitter 投票和计票引擎,为民主的未来做准备。
第六章 ,通过 RESTful 数据 Web 服务 API 公开数据和功能,介绍了如何通过 JSON Web 服务公开我们在第五章 中构建的功能,具体来说,是如何通过包装 http.HandlerFunc 函数来实现强大的管道模式。
第七章 ,随机推荐 Web 服务,展示了如何使用 Google Places API 来生成基于位置的随机推荐 API,这是探索任何地区的一种有趣方式。它还探讨了保持内部数据结构私有的重要性,控制对相同数据的公共视图,以及如何在 Go 中实现枚举器。
第八章,文件系统备份,帮助我们构建一个简单但功能强大的文件系统备份工具,用于我们的代码项目,并探索使用 Go 标准库中的 os 包与文件系统进行交互。它还探讨了 Go 的接口如何允许简单的抽象产生强大的结果。
附录,稳定的 Go 环境的良好实践,教会我们如何从头开始在新机器上安装 Go,并讨论了我们可能拥有的一些环境选项以及它们将来可能产生的影响。我们还将考虑协作如何影响我们的一些决定,以及开源我们的包可能产生的影响。
本书所需内容
要编译和运行本书中的代码,您需要一台能够运行支持 Go 工具集的操作系统的计算机,可以在golang.org/doc/install#requirements找到支持的操作系统列表。
附录,稳定的 Go 环境的良好实践,提供了一些有用的提示,包括如何安装 Go 并设置开发环境,以及如何使用 GOPATH 环境变量。
本书适合对象
本书适用于所有 Go 程序员——从想通过构建真实项目来探索该语言的初学者到对如何以有趣的方式应用该语言感兴趣的专家 gophers。
读累了记得休息一会哦~
公众号:古德猫宁李
-
电子书搜索下载
-
书单分享
-
书友学习交流
网站:沉金书屋 https://www.chenjin5.com
-
电子书搜索下载
-
电子书打包资源分享
-
学习资源分享
约定
在本书中,您会发现一些文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名显示如下:"我们可以使用import关键字从其他包中使用功能,之前我们使用go get来下载它们。"
代码块设置如下:
package meander
type Cost int8
const (
_ Cost = iota
Cost1
Cost2
Cost3
Cost4
Cost5
)
当我们希望引起您对代码块的特定部分的注意时,相关行或项目会以粗体显示:
package meander
type Cost int8
const (
_ Cost = iota
Cost1
Cost2
Cost3
Cost4
Cost5
)
任何命令行输入或输出都会以以下方式书写:
go build -o project && ./project
新术语和重要单词以粗体显示。您在屏幕上看到的单词,比如菜单或对话框中的单词,会以这样的方式出现在文本中:"一旦安装了 Xcode,您就打开首选项,然后导航到下载部分。
注意
警告或重要提示会以这样的方式出现在一个框中。
提示
提示和技巧会以这种方式出现。
第一章:带有 Web 套接字的聊天应用程序
Go 非常适合编写高性能、并发的服务器应用程序和工具,而 Web 是传递它们的理想媒介。如今很难找到一个不支持 Web 的设备,并且允许我们构建一个针对几乎所有平台和设备的单一应用程序。
我们的第一个项目将是一个基于 Web 的聊天应用程序,允许多个用户在其 Web 浏览器中进行实时对话。成语化的 Go 应用程序通常由许多包组成,这些包通过在不同文件夹中放置代码来组织,Go 标准库也是如此。我们将首先使用net/http包构建一个简单的 Web 服务器,该服务器将提供 HTML 文件。然后,我们将继续添加对 Web 套接字的支持,通过它我们的消息将流动。
在诸如 C#,Java 或 Node.js 之类的语言中,需要使用复杂的线程代码和巧妙地使用锁来保持所有客户端同步。正如我们将看到的,Go 通过其内置的通道和并发范例极大地帮助了我们。
在本章中,您将学习如何:
-
使用
net/http包来提供 HTTP 请求 -
向用户的浏览器提供基于模板的内容
-
满足 Go 接口以构建我们自己的
http.Handler类型 -
使用 Go 的 goroutines 允许应用程序同时执行多个任务
-
使用通道在运行的 Go 例程之间共享信息
-
升级 HTTP 请求以使用诸如 Web 套接字之类的现代功能
-
为应用程序添加跟踪,以更好地了解其内部工作原理
-
使用测试驱动开发实践编写完整的 Go 包
-
通过导出的接口返回未导出的类型
注意
此项目的完整源代码可以在github.com/matryer/goblueprints/tree/master/chapter1/chat找到。源代码定期提交,因此 GitHub 中的历史实际上也遵循本章的流程。
一个简单的 Web 服务器
我们的聊天应用程序首先需要一个具有两个主要职责的 Web 服务器:它必须为在用户浏览器中运行的 HTML 和 JavaScript 聊天客户端提供服务,并接受 Web 套接字连接以允许客户端进行通信。
注意
GOPATH环境变量在附录中有详细介绍,稳定的 Go 环境的良好实践。如果您需要帮助设置,请务必先阅读。
在GOPATH中的新文件夹chat中创建一个main.go文件,并添加以下代码:
package main
import (
"log"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`
<html>
<head>
<title>Chat</title>
</head>
<body>
Let's chat!
</body>
</html>
`))
})
// start the web server
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatal("ListenAndServe:", err)
}
}
这是一个完整但简单的 Go 程序,将会:
-
使用
net/http包监听根路径 -
当请求被发出时,写出硬编码的 HTML
-
使用
ListenAndServe方法在端口:8080上启动 Web 服务器
http.HandleFunc函数将路径模式"/"映射到我们作为第二个参数传递的函数,因此当用户访问http://localhost:8080/时,该函数将被执行。func(w http.ResponseWriter, r *http.Request)的函数签名是处理整个 Go 标准库中的 HTTP 请求的常见方式。
提示
我们使用package main,因为我们希望从命令行构建和运行我们的程序。然而,如果我们正在构建一个可重用的聊天包,我们可能会选择使用不同的东西,比如package chat。
在终端中,通过导航到您刚创建的main.go文件并执行以下命令来运行程序:
go run main.go
打开浏览器到localhost:8080,看到让我们聊天!消息。
像这样将 HTML 代码嵌入到我们的 Go 代码中是有效的,但它非常丑陋,并且随着我们的项目增长,情况只会变得更糟。接下来,我们将看到模板如何帮助我们清理这些内容。
模板
模板允许我们将通用文本与特定文本混合在一起,例如,将用户的姓名注入欢迎消息中。例如,考虑以下模板:
Hello {name}, how are you?
我们能够用真实的人名替换前面模板中的{name}文本。所以如果 Laurie 登录,她可能会看到:
Hello Laurie, how are you?
Go 标准库有两个主要的模板包:一个叫做text/template用于文本,另一个叫做html/template用于 HTML。html/template包与文本版本相同,只是它了解数据将被注入模板的上下文。这很有用,因为它避免了脚本注入攻击,并解决了诸如必须对 URL 编码特殊字符之类的常见问题。
最初,我们只想将 HTML 代码从我们的 Go 代码中移动到自己的文件中,但暂时不混合任何文本。模板包使加载外部文件非常容易,所以这对我们来说是一个不错的选择。
在我们的chat文件夹下创建一个名为templates的新文件夹,并在其中创建一个名为chat.html的文件。我们将 HTML 从main.go移动到这个文件中,但我们将进行一些小的更改,以确保我们的更改已生效。
<html>
<head>
<title>Chat</title>
</head>
<body>
Let's chat
(from template)
</body>
</html>
现在,我们已经准备好使用外部 HTML 文件,但我们需要一种方法来编译模板并将其提供给用户的浏览器。
提示
编译模板是一个过程,通过这个过程,源模板被解释并准备好与各种数据混合,这必须在模板可以使用之前发生,但只需要发生一次。
我们将编写自己的struct类型,负责加载、编译和传递我们的模板。我们将定义一个新类型,它将接受一个filename字符串,一次编译模板(使用sync.Once类型),保持对编译模板的引用,然后响应 HTTP 请求。您需要导入text/template、path/filepath和sync包来构建您的代码。
在main.go中,在func main()行上面插入以下代码:
// templ represents a single template
type templateHandler struct {
once sync.Once
filename string
templ *template.Template
}
// ServeHTTP handles the HTTP request.
func (t *templateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
t.once.Do(func() {
t.templ = template.Must(template.ParseFiles(filepath.Join("templates", t.filename)))
})
t.templ.Execute(w, nil)
}
提示
您知道您可以自动添加和删除导入的包吗?请参阅附录,稳定 Go 环境的良好实践,了解如何做到这一点。
templateHandler类型有一个名为ServeHTTP的单一方法,其签名看起来很像我们之前传递给http.HandleFunc的方法。这个方法将加载源文件,编译模板并执行它,并将输出写入指定的http.ResponseWriter对象。因为ServeHTTP方法满足http.Handler接口,我们实际上可以直接将它传递给http.Handle。
提示
快速查看位于golang.org/pkg/net/http/#Handler的 Go 标准库源代码,将会发现http.Handler的接口定义规定了只有ServeHTTP方法需要存在,才能使类型用于通过net/http包来提供 HTTP 请求。
只做一次的事情
我们只需要一次编译模板,Go 中有几种不同的方法可以实现这一点。最明显的方法是有一个NewTemplateHandler函数来创建类型并调用一些初始化代码来编译模板。如果我们确信该函数只会被一个 goroutine 调用(可能是main函数中的主要函数),那么这将是一个完全可以接受的方法。另一种方法是在ServeHTTP方法内部编译模板一次,这是我们在前面的部分中采用的方法。sync.Once类型保证我们传递为参数的函数只会被执行一次,不管有多少 goroutine 在调用ServeHTTP。这很有帮助,因为 Go 中的 Web 服务器是自动并发的,一旦我们的聊天应用席卷世界,我们很可能会有许多并发调用ServeHTTP方法。
在ServeHTTP方法中编译模板还确保我们的代码在绝对需要之前不会浪费时间。这种懒惰的初始化方法在我们目前的情况下并没有节省太多时间,但在设置任务耗时和资源密集的情况下,并且功能使用频率较低的情况下,很容易看出这种方法会派上用场。
使用自己的处理程序
为了实现我们的templateHandler类型,我们需要更新main主体函数,使其看起来像这样:
func main() {
// root
http.Handle("/", &templateHandler{filename: "chat.html"})
// start the web server
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatal("ListenAndServe:", err)
}
}
templateHandler结构是有效的http.Handler类型,因此我们可以直接将其传递给http.Handle函数,并要求它处理与指定模式匹配的请求。在前面的代码中,我们创建了一个templateHandler类型的新对象,指定文件名为chat.html,然后取其地址(使用& 地址运算符)并将其传递给http.Handle函数。我们不存储对新创建的templateHandler类型的引用,但这没关系,因为我们不需要再次引用它。
在终端中,按下Ctrl + C退出程序,然后刷新您的浏览器,注意添加了(来自模板)文本。现在我们的代码比 HTML 代码简单得多,没有那些丑陋的块。
正确构建和执行 Go 程序
使用go run命令运行 Go 程序时,当我们的代码由单个main.go文件组成时非常方便。然而,通常我们可能需要快速添加其他文件。这要求我们在运行之前将整个包正确构建为可执行二进制文件。这很简单,从现在开始,这就是您将在终端中构建和运行程序的方式:
go build -o {name}
./{name}
go build命令使用指定文件夹中的所有.go文件创建输出二进制文件,-o标志指示生成的二进制文件的名称。然后,您只需通过名称调用程序直接运行程序。
例如,在我们的聊天应用程序中,我们可以运行:
go build -o chat
./chat
由于我们在首次提供页面时编译模板,因此每次发生更改时,我们都需要重新启动您的 Web 服务器程序,以查看更改生效。
在服务器上建模聊天室和客户端
我们聊天应用程序的所有用户(客户端)将自动放置在一个大的公共房间中,每个人都可以与其他人聊天。room类型将负责管理客户端连接并路由消息进出,而client类型表示与单个客户端的连接。
提示
Go 将类称为类型,将这些类的实例称为对象。
为了管理我们的网络套接字,我们将使用 Go 社区最强大的一个方面——开源第三方包。每天都会发布解决现实问题的新包,供您在自己的项目中使用,甚至允许您添加功能,报告和修复错误,并获得支持。
提示
重新发明轮子通常是不明智的,除非您有非常好的理由。因此,在着手构建新包之前,值得搜索可能已经解决了您的问题的任何现有项目。如果找到一个类似的项目,但不完全满足您的需求,请考虑为该项目添加功能。Go 拥有一个特别活跃的开源社区(请记住 Go 本身也是开源的),随时欢迎新面孔或头像。
我们将使用 Gorilla Project 的websocket包来处理我们的服务器端套接字,而不是编写我们自己的。如果您对它的工作原理感到好奇,请转到 GitHub 上的项目主页,github.com/gorilla/websocket,并浏览开源代码。
建模客户端
在chat文件夹中的main.go旁边创建一个名为client.go的新文件,并添加以下代码:
package main
import (
"github.com/gorilla/websocket"
)
// client represents a single chatting user.
type client struct {
// socket is the web socket for this client.
socket *websocket.Conn
// send is a channel on which messages are sent.
send chan []byte
// room is the room this client is chatting in.
room *room
}
在前面的代码中,套接字将保存一个与客户端通信的网络套接字的引用,send字段是一个缓冲通道,通过它接收到的消息排队准备转发到用户的浏览器(通过套接字)。room字段将保留客户端正在聊天的房间的引用——这是必需的,以便我们可以将消息转发给房间中的其他所有人。
如果您尝试构建此代码,您将注意到一些错误。您必须确保已调用go get来检索websocket包,这很容易,只需打开终端并输入以下内容:
go get github.com/gorilla/websocket
再次构建代码将产生另一个错误:
./client.go:17 undefined: room
问题在于我们引用了一个未定义的room类型。为了让编译器满意,创建一个名为room.go的文件,并插入以下占位符代码:
package main
type room struct {
// forward is a channel that holds incoming messages
// that should be forwarded to the other clients.
forward chan []byte
}
一旦我们了解了房间需要做什么,我们将稍后改进这个定义,但现在这将允许我们继续。稍后,forward通道将用于将传入的消息发送到所有其他客户端。
注意
您可以将通道视为内存中的线程安全消息队列,发送者通过非阻塞的线程安全方式传递数据,接收者读取数据。
为了让客户端执行任何工作,我们必须定义一些方法,这些方法将实际读取和写入到网络套接字。将以下代码添加到client.go之外(在client结构下方)将向client类型添加名为read和write的两个方法:
func (c *client) read() {
for {
if _, msg, err := c.socket.ReadMessage(); err == nil {
c.room.forward <- msg
} else {
break
}
}
c.socket.Close()
}
func (c *client) write() {
for msg := range c.send {
if err := c.socket.WriteMessage(websocket.TextMessage, msg); err != nil {
break
}
}
c.socket.Close()
}
read方法允许我们的客户端通过ReadMessage方法从套接字中读取,不断将接收到的任何消息发送到room类型的forward通道。如果遇到错误(例如“套接字已断开”),循环将中断并关闭套接字。类似地,write方法不断接受send通道的消息,通过WriteMessage方法将所有内容写入套接字。如果向套接字写入失败,for循环将中断并关闭套接字。再次构建包以确保一切都编译。
建模一个房间
我们需要一种方法让客户端加入和离开房间,以确保前面部分中的c.room.forward <- msg代码实际上将消息转发给所有客户端。为了确保我们不会同时尝试访问相同的数据,一个明智的方法是使用两个通道:一个用于向房间添加客户端,另一个用于将其删除。让我们更新我们的room.go代码如下:
package main
type room struct {
// forward is a channel that holds incoming messages
// that should be forwarded to the other clients.
forward chan []byte
// join is a channel for clients wishing to join the room.
join chan *client
// leave is a channel for clients wishing to leave the room.
leave chan *client
// clients holds all current clients in this room.
clients map[*client]bool
}
我们添加了三个字段:两个通道和一个映射。join和leave通道存在的简单目的是允许我们安全地向clients映射中添加和删除客户端。如果我们直接访问映射,可能会出现两个同时运行的 Go 例程同时尝试修改映射,导致内存损坏或不可预测的状态。
使用符合惯例的 Go 并发编程
现在我们可以使用 Go 并发提供的一个非常强大的功能——select语句。我们可以在需要同步或修改共享内存,或根据通道内的各种活动采取不同的操作时使用select语句。
在room结构下方,添加包含两个select子句的run方法:
func (r *room) run() {
for {
select {
case client := <-r.join:
// joining
r.clients[client] = true
case client := <-r.leave:
// leaving
delete(r.clients, client)
close(client.send)
case msg := <-r.forward:
// forward message to all clients
for client := range r.clients {
select {
case client.send <- msg:
// send the message
default:
// failed to send
delete(r.clients, client)
close(client.send)
}
}
}
}
}
尽管这可能看起来是很多代码要消化,但一旦我们稍微分解一下,我们就会发现它其实相当简单,尽管非常强大。顶部的for循环表示这个方法将一直运行,直到程序被终止。这可能看起来像是一个错误,但请记住,如果我们将这段代码作为 Go 例程运行,它将在后台运行,不会阻塞我们应用程序的其余部分。前面的代码将一直监视我们房间内的三个通道:join,leave和forward。如果在这些通道中收到消息,select语句将运行特定情况的代码块。重要的是要记住,它一次只会运行一个 case 代码块。这就是我们能够同步以确保我们的r.clients地图一次只能被一件事情修改的方式。
如果我们在join通道上收到消息,我们只需更新r.clients地图以保留已加入房间的客户端的引用。请注意,我们将值设置为true。我们使用地图更像是一个切片,但不必担心随着时间的推移客户端的增减而收缩切片 - 将值设置为true只是一种方便的、低内存的存储引用的方式。
如果我们在leave通道上收到消息,我们只需从地图中删除client类型,并关闭其send通道。关闭通道在 Go 语言中具有特殊的意义,当我们看到最终的select语句时,这一点就变得很清楚了。
如果我们在forward通道上收到消息,我们会遍历所有客户端,并将消息发送到每个客户端的发送通道。然后,我们的客户端类型的write方法将接收并将其发送到浏览器的套接字。如果send通道关闭,那么我们知道客户端不再接收任何消息,这就是我们的第二个select子句(特别是默认情况)采取的移除客户端并整理事情的操作。
将房间转换为 HTTP 处理程序
现在我们将把我们的room类型转换为http.Handler类型,就像我们之前对模板处理程序所做的那样。您会记得,为了做到这一点,我们只需添加一个名为ServeHTTP的方法,具有适当的签名。将以下代码添加到room.go文件的底部:
const (
socketBufferSize = 1024
messageBufferSize = 256
)
var upgrader = &websocket.Upgrader{ReadBufferSize: socketBufferSize, WriteBufferSize: socketBufferSize}
func (r *room) ServeHTTP(w http.ResponseWriter, req *http.Request) {
socket, err := upgrader.Upgrade(w, req, nil)
if err != nil {
log.Fatal("ServeHTTP:", err)
return
}
client := &client{
socket: socket,
send: make(chan []byte, messageBufferSize),
room: r,
}
r.join <- client
defer func() { r.leave <- client }()
go client.write()
client.read()
}
ServeHTTP方法意味着房间现在可以作为处理程序。我们很快将实现它,但首先让我们看看这段代码中发生了什么。
为了使用 Web 套接字,我们必须使用websocket.Upgrader类型升级 HTTP 连接,该类型是可重用的,因此我们只需要创建一个。然后,当请求通过ServeHTTP方法进入时,我们通过调用upgrader.Upgrade方法获取套接字。一切顺利的话,我们就创建客户端并将其传递到当前房间的join通道中。我们还推迟了客户端完成后的离开操作,这将确保用户离开后一切都整理得很好。
然后,客户端的write方法被调用为 Go 例程,如行首的三个字符所示go(单词go后跟一个空格字符)。这告诉 Go 在不同的线程或 goroutine 中运行该方法。
注意
比较在其他语言中实现多线程或并发所需的代码量与在 Go 中实现它的三个关键按键,您会发现为什么它已经成为系统开发人员中的最爱。
最后,我们在主线程中调用read方法,它将阻塞操作(保持连接活动),直到关闭连接的时候。在代码片段的顶部添加常量是一个很好的做法,用于声明在整个项目中原本将硬编码的值。随着这些值的增加,您可能会考虑将它们放在自己的文件中,或者至少放在各自文件的顶部,以便保持易读易修改。
使用辅助函数来减少复杂性
我们的房间几乎可以使用了,尽管为了让它有用,需要创建频道和地图。目前,可以通过要求开发者使用以下代码来实现这一点:
r := &room{
forward: make(chan []byte),
join: make(chan *client),
leave: make(chan *client),
clients: make(map[*client]bool),
}
另一个稍微更加优雅的解决方案是提供一个newRoom函数来代替。这样就不需要其他人知道确切需要做什么才能让我们的房间有用。在type room struct的定义下面,添加这个函数:
// newRoom makes a new room that is ready to go.
func newRoom() *room {
return &room{
forward: make(chan []byte),
join: make(chan *client),
leave: make(chan *client),
clients: make(map[*client]bool),
}
}
现在,我们的代码用户只需要调用newRoom函数,而不是更冗长的六行代码。
创建和使用房间
让我们更新main.go中的main函数,首先创建,然后运行一个房间,让每个人都可以连接到:
func main() {
r := newRoom()
http.Handle("/", &templateHandler{filename: "chat.html"})
http.Handle("/room", r)
// get the room going
go r.run()
// start the web server
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatal("ListenAndServe:", err)
}
}
我们在一个单独的 Go 例程中运行房间(再次注意go关键字),以便聊天操作在后台进行,使我们的主线程运行 Web 服务器。我们的服务器现在已经完成并成功构建,但没有客户端进行交互,它仍然是无用的。
构建 HTML 和 JavaScript 聊天客户端
为了让我们的聊天应用程序的用户与服务器以及其他用户进行交互,我们需要编写一些客户端代码,利用现代浏览器中的 Web 套接字。当用户访问我们应用程序的根目录时,我们已经通过模板传递 HTML 内容,所以我们可以增强它。
使用以下标记更新templates文件夹中的chat.html文件:
<html>
<head>
<title>Chat</title>
<style>
input { display: block; }
ul { list-style: none; }
</style>
</head>
<body>
<ul id="messages"></ul>
<form id="chatbox">
<textarea></textarea>
<input type="submit" value="Send" />
</form> </body>
</html>
上述的 HTML 将在页面上呈现一个简单的网络表单,其中包含一个文本区域和一个“发送”按钮——这是我们的用户将消息提交到服务器的方式。上述代码中的messages元素将包含聊天消息的文本,以便所有用户都能看到正在说什么。接下来,我们需要添加一些 JavaScript 来为我们的页面添加一些功能。在form标签下,在闭合的</body>标签上面,插入以下代码:
<script src="img/jquery.min.js"></script>
<script>
$(function(){
var socket = null;
var msgBox = $("#chatbox textarea");
var messages = $("#messages");
$("#chatbox").submit(function(){
if (!msgBox.val()) return false;
if (!socket) {
alert("Error: There is no socket connection.");
return false;
}
socket.send(msgBox.val());
msgBox.val("");
return false;
});
if (!window["WebSocket"]) {
alert("Error: Your browser does not support web sockets.")
} else {
socket = new WebSocket("ws://localhost:8080/room");
socket.onclose = function() {
alert("Connection has been closed.");
}
socket.onmessage = function(e) {
messages.append($("<li>").text(e.data));
}
}
});
</script>
socket = new WebSocket("ws://localhost:8080/room")这一行是我们打开套接字并为两个关键事件onclose和onmessage添加事件处理程序的地方。当套接字接收到消息时,我们使用 jQuery 将消息附加到列表元素,从而呈现给用户。
提交 HTML 表单触发对socket.send的调用,这是我们向服务器发送消息的方式。
再次构建和运行程序,以确保模板重新编译,以便这些更改得到体现。
在两个不同的浏览器(或同一个浏览器的两个标签)中导航到http://localhost:8080/并使用应用程序。您会注意到从一个客户端发送的消息立即出现在其他客户端中。

更多地利用模板
目前,我们正在使用模板传递静态 HTML,这很好,因为它为我们提供了一种清晰简单的方法来将客户端代码与服务器代码分离。然而,模板实际上更加强大,我们将调整我们的应用程序以更加现实地使用它们。
我们应用程序的主机地址(:8080)目前在两个地方都是硬编码的。第一个实例是在main.go中启动 Web 服务器的地方:
if err := http.ListenAndServe("
:8080
", nil); err != nil {
log.Fatal("ListenAndServe:", err)
}
第二次是在 JavaScript 中硬编码的,当我们打开套接字时:
socket = new WebSocket("ws://
localhost:8080
/room");
我们的聊天应用程序非常固执,坚持只在本地端口8080上运行,因此我们将使用命令行标志使其可配置,然后使用模板的注入功能确保我们的 JavaScript 知道正确的主机。
更新main.go中的main函数:
func main() {
var addr = flag.String("addr", ":8080", "The addr of the application.")
flag.Parse() // parse the flags
r := newRoom()
http.Handle("/", &templateHandler{filename: "chat.html"})
http.Handle("/room", r)
// get the room going
go r.run()
// start the web server
log.Println("Starting web server on", *addr)
if err := http.ListenAndServe(
*addr
, nil); err != nil {
log.Fatal("ListenAndServe:", err)
}
}
为了使此代码构建,您需要导入flag包。addr变量的定义将我们的标志设置为一个默认为:8080的字符串(并简要描述了该值的用途)。我们必须调用flag.Parse()来解析参数并提取适当的信息。然后,我们可以通过使用*addr引用主机标志的值。
注意
对flag.String的调用返回*string类型,也就是说它返回存储标志值的字符串变量的地址。要获取值本身(而不是值的地址),我们必须使用指针间接操作符*。
我们还添加了一个log.Println调用,以在终端中输出地址,以确保我们的更改已生效。
我们将修改我们编写的templateHandler类型,以便将请求的详细信息作为数据传递到模板的Execute方法中。在main.go中,更新ServeHTTP函数,将请求r作为data参数传递给Execute方法:
func (t *templateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
t.once.Do(func() {
t.templ = template.Must(template.ParseFiles(filepath.Join("templates", t.filename)))
})
t.templ.Execute(w, r)
}
这告诉模板使用可以从http.Request中提取的数据进行渲染,其中包括我们需要的主机地址。
要使用http.Request的Host值,我们可以利用特殊的模板语法来注入数据。更新我们在chat.html文件中创建套接字的行:
socket = new WebSocket("ws://{{.Host}}/room");
双花括号表示注释,告诉模板源注入数据的方式。{{.Host}}本质上等同于告诉它用request.Host的值替换注释(因为我们将请求r对象作为数据传递)。
提示
我们只是初步了解了 Go 标准库中内置模板的强大功能。text/template包的文档是了解更多内容的好地方。您可以在golang.org/pkg/text/template找到更多信息。
重新构建并再次运行聊天程序,但是这次请注意,无论我们指定哪个主机,聊天操作都不再产生错误:
go build -o chat
./chat -addr=":3000"
在浏览器中查看页面源代码,注意{{.Host}}已被实际应用的主机替换。有效的主机不仅仅是端口号;您还可以指定 IP 地址或其他主机名——只要它们在您的环境中被允许,例如-addr="192.168.0.1:3000"。
跟踪代码以深入了解内部情况
我们唯一知道我们的应用程序是否工作的方法是打开两个或更多浏览器,并使用我们的 UI 发送消息。换句话说,我们正在手动测试我们的代码。这对于实验性项目(如我们的聊天应用程序)或不希望增长的小项目来说是可以接受的,但是如果我们的代码要有更长的寿命或由多个人共同开发,这种手动测试就成了一种负担。我们不打算为我们的聊天程序解决测试驱动开发(TDD),但我们应该探索另一种有用的调试技术,称为跟踪。
跟踪是一种实践,通过它我们可以记录或打印程序流程中的关键步骤,以使程序内部发生的事情可见。在前一节中,我们添加了一个log.Println调用来输出聊天程序绑定到的地址。在本节中,我们将正式化这一过程,并编写我们自己完整的跟踪包。
我们将探索 TDD 实践,因为编写跟踪代码是一个完美的例子,我们很可能会重用、添加、共享,甚至开源。
使用 TDD 编写包
Go 中的包被组织到文件夹中,每个文件夹一个包。在同一个文件夹中有不同的包声明会导致构建错误,因为所有同级文件都被期望为单个包做出贡献。Go 没有子包的概念,这意味着嵌套包(在嵌套文件夹中)只存在于美学或信息上的原因,但不会继承任何功能或可见性。在我们的聊天应用中,所有文件都属于main包,因为我们想要构建一个可执行工具。我们的追踪包永远不会直接运行,因此可以并且应该使用不同的包名。我们还需要考虑我们包的应用程序编程接口(API),考虑如何建模一个包,使其对用户来说尽可能具有可扩展性和灵活性。这包括应该导出(对用户可见)的字段、函数、方法和类型,以及为简单起见应该保持隐藏的内容。
注意
Go 使用名称的大写来表示哪些项目是导出的,以便以大写字母开头的名称(例如Tracer)对包的用户可见,以小写字母开头的名称(例如templateHandler)是隐藏或私有的。
在chat文件夹旁边创建一个名为trace的新文件夹,这将是我们追踪包的名称。
在我们开始编码之前,让我们就我们包的一些设计目标达成一致,以便衡量成功:
-
包应该易于使用
-
单元测试应该覆盖功能
-
用户应该有灵活性来用自己的实现替换追踪器
接口
Go 语言中的接口是一种非常强大的语言特性,它允许我们定义一个 API 而不严格或具体地定义实现细节。在可能的情况下,使用接口描述包的基本构建块通常会在未来产生回报,这也是我们追踪包的起点。
在trace文件夹内创建一个名为tracer.go的新文件,并写入以下代码:
package trace
// Tracer is the interface that describes an object capable of
// tracing events throughout code.
type Tracer interface {
Trace(...interface{})
}
首先要注意的是,我们将包定义为trace。
注意
虽然将文件夹名称与包名称匹配是一个好习惯,但 Go 工具不强制执行这一点,这意味着如果有意义,你可以自由地给它们命名不同的名称。记住,当人们导入你的包时,他们会输入文件夹的名称,如果突然导入了一个不同名称的包,可能会让人困惑。
我们的Tracer类型(大写的T表示我们打算将其作为公开可见类型)是一个描述单个名为Trace的方法的接口。...interface{}参数类型表示我们的Trace方法将接受零个或多个任意类型的参数。你可能会认为这是多余的,因为该方法应该只接受一个字符串(我们只想追踪一些字符的字符串,不是吗?)。然而,考虑到fmt.Sprint和log.Fatal等函数,它们都遵循了 Go 标准库中的一种模式,提供了一个有用的快捷方式,用于一次性传递多个内容。在可能的情况下,我们应该遵循这样的模式和实践,因为我们希望我们自己的 API 对 Go 社区来说是熟悉和清晰的。
单元测试
我们答应自己要遵循测试驱动的实践,但接口只是定义,不提供任何实现,因此无法直接进行测试。但我们即将编写一个Tracer方法的真正实现,并且我们确实会先编写测试。
在trace文件夹中创建一个名为tracer_test.go的新文件,并插入以下框架代码:
package trace
import (
"testing"
)
func TestNew(t *testing.T) {
t.Error("We haven't written our test yet")
}
测试是从一开始就内置在 Go 工具链中的,使得编写可自动化测试成为一等公民。测试代码与生产代码一起存放在以_test.go结尾的文件中。Go 工具将把任何以Test开头的函数(接受一个*testing.T参数)视为单元测试,并在运行测试时执行它们。要为此包运行它们,请在终端中导航到trace文件夹并执行以下操作:
go test
您会看到我们的测试失败,因为我们在TestNew函数的主体中调用了t.Error:
--- FAIL: TestNew (0.00 seconds)
tracer_test.go:8: We haven't written our test yet
FAIL
exit status 1
FAIL trace 0.011s
提示
在每次测试运行之前清除终端是一个很好的方法,可以确保您不会将之前的运行与最近的运行混淆。在 Windows 上,您可以使用cls命令;在 Unix 机器上,clear命令可以做同样的事情。
显然,我们没有正确地编写我们的测试,我们也不希望它通过,所以让我们更新TestNew函数:
func TestNew(t *testing.T) {
var buf bytes.Buffer
tracer := New(&buf)
if tracer == nil {
t.Error("Return from New should not be nil")
} else {
tracer.Trace("Hello trace package.")
if buf.String() != "Hello trace package.\n" {
t.Errorf("Trace should not write '%s'.", buf.String())
}
}
}
本书中的大多数包都来自 Go 标准库,因此您可以添加适当的包的import语句以访问该包。其他包是外部的,这时您需要使用go get来下载它们,然后才能导入。对于这种情况,您需要在文件顶部添加import "bytes"。
我们已经开始通过成为第一个用户来设计我们的 API。我们希望能够在bytes.Buffer中捕获我们的跟踪器的输出,以便我们可以确保缓冲区中的字符串与预期值匹配。如果不匹配,对 t.Errorf的调用将使测试失败。在此之前,我们检查一下虚构的New函数的返回值是否不是nil;同样,如果是,测试将因为对 t.Error的调用而失败。
红绿测试
现在运行go test实际上会产生一个错误;它抱怨没有New函数。我们没有犯错;我们正在遵循一种被称为红绿测试的实践。红绿测试建议我们首先编写一个单元测试,看到它失败(或产生错误),然后编写尽可能少的代码使该测试通过,并重复这个过程。这里的关键点是我们要确保我们添加的代码实际上正在做一些事情,并确保我们编写的测试代码正在测试一些有意义的东西。
注意
考虑一分钟的无意义测试:
if true == true {
t.Error("True should be true")
}
逻辑上不可能让 true 不等于 true(如果 true 等于 false,那么是时候换台新电脑了),因此我们的测试是毫无意义的。如果测试或声明不能失败,那么它就毫无价值。
将true替换为一个您希望在特定条件下设置为true的变量,这意味着这样的测试确实可能失败(比如在被测试的代码行为不当时)——在这一点上,您有一个值得为代码库做出贡献的有意义的测试。
您可以将go test的输出视为待办事项列表,一次只解决一个问题。现在,我们只会解决有关缺少New函数的投诉。在trace.go文件中,让我们添加尽可能少的代码来继续进行;在接口类型定义下面添加以下代码片段:
func New() {}
现在运行go test会显示事情确实有所进展,尽管进展不是很大。我们现在有两个错误:
./tracer_test.go:11: too many arguments in call to New
./tracer_test.go:11: New(&buf) used as value
第一个错误告诉我们,我们正在向我们的New函数传递参数,但New函数不接受任何参数。第二个错误说我们正在使用New函数的返回值作为值,但New函数并不返回任何东西。您可能已经预料到了这一点,确实随着您在编写测试驱动的代码方面获得更多经验,您很可能会跳过这样的琐事。但是,为了正确地说明这种方法,我们将要有一段时间的迂腐。让我们通过更新我们的New函数来解决第一个错误:
func New(w io.Writer) {}
我们正在接收一个满足io.Writer接口的参数,这意味着指定的对象必须有一个合适的Write方法。
注意
使用现有接口,特别是在 Go 标准库中找到的接口,是确保您的代码尽可能灵活和优雅的一种极其强大且经常必要的方式。
接受io.Writer意味着用户可以决定跟踪输出将写入何处。这个输出可以是标准输出,文件,网络套接字,bytes.Buffer,甚至是一些自定义对象,只要它实现了io.Writer接口的Write方法。
再次运行go test显示我们已解决第一个错误,我们只需要添加一个返回类型以继续通过第二个错误:
func New(w io.Writer) Tracer {}
我们声明我们的New函数将返回一个Tracer,但我们没有返回任何东西,这让go test很高兴地抱怨:
./tracer.go:13: missing return at end of function
修复这很容易;我们可以从New函数中返回nil:
func New(w io.Writer) Tracer {
return nil
}
当然,我们的测试代码已经断言返回值不应该是nil,所以go test现在给我们一个失败消息:
tracer_test.go:14: Return from New should not be nil
注意
你可以看到严格遵循红绿原则可能有点乏味,但非常重要的是我们不要跳得太远。如果我们一次写很多实现代码,很可能会有代码没有被单元测试覆盖。
体贴的核心团队甚至通过提供代码覆盖率统计数据来解决了这个问题,我们可以通过运行以下命令生成:
go test -cover
只要所有测试通过,添加-cover标志将告诉我们在执行测试期间有多少代码被触及。显然,我们越接近 100%越好。
实现接口
为了满足这个测试,我们需要一个可以从New方法中正确返回的东西,因为Tracer只是一个接口,我们必须返回一些真实的东西。让我们在tracer.go文件中添加一个 tracer 的实现:
type tracer struct {
out io.Writer
}
func (t *tracer) Trace(a ...interface{}) {}
我们的实现非常简单;tracer类型有一个名为out的io.Writer字段,我们将把跟踪输出写入其中。Trace方法与Tracer接口所需的方法完全匹配,尽管它目前什么也不做。
现在我们终于可以修复New方法了:
func New(w io.Writer) Tracer {
return &tracer{out: w}
}
再次运行go test显示我们的期望没有达到,因为在调用Trace时没有写入任何内容:
tracer_test.go:18: Trace should not write ''.
让我们更新我们的Trace方法,将混合参数写入指定的io.Writer字段:
func (t *tracer) Trace(a ...interface{}) {
t.out.Write([]byte(fmt.Sprint(a...)))
t.out.Write([]byte("\n"))
}
当调用Trace方法时,我们在out字段中存储的io.Writer上调用Write,并使用fmt.Sprint格式化a参数。我们将fmt.Sprint的字符串返回类型转换为string,然后转换为[]byte,因为这是io.Writer接口所期望的。
我们最终满足了我们的测试吗?
go test -cover
PASS
coverage: 100.0% of statements
ok trace 0.011s
恭喜!我们成功通过了测试,测试覆盖率为100.0%。一旦我们喝完香槟,我们可以花一分钟时间考虑一下我们的实现非常有趣的地方。
将未导出的类型返回给用户
我们编写的tracer结构类型是未导出的,因为它以小写的t开头,那么我们如何能够从导出的New函数中返回它呢?毕竟,用户会接收到返回的对象吗?这是完全可以接受和有效的 Go 代码;用户只会看到一个满足Tracer接口的对象,甚至不会知道我们私有的tracer类型。由于他们只与接口交互,我们的tracer实现暴露其他方法或字段也无所谓;它们永远不会被看到。这使我们能够保持包的公共 API 清晰简单。
这种隐藏的实现技术在 Go 标准库中被广泛使用,例如,ioutil.NopCloser方法是一个将普通的io.Reader转换为io.ReadCloser的函数,而Close方法什么也不做(用于将不需要关闭的io.Reader对象传递给需要io.ReadCloser类型的函数)。该方法在用户看来返回io.ReadCloser,但在底层,有一个秘密的nopCloser类型隐藏了实现细节。
注意
要亲自看到这一点,请浏览 Go 标准库源代码golang.org/src/pkg/io/ioutil/ioutil.go,并搜索nopCloser结构。
使用我们的新的 trace 包
现在我们已经完成了trace包的第一个版本,我们可以在聊天应用程序中使用它,以更好地理解用户通过用户界面发送消息时发生了什么。
在room.go中,让我们导入我们的新包并对Trace方法进行一些调用。我们刚刚编写的trace包的路径将取决于您的GOPATH环境变量,因为导入路径是相对于$GOPATH/src文件夹的。因此,如果您在$GOPATH/src/mycode/trace中创建了trace包,则需要导入mycode/trace。
像这样更新room类型和run()方法:
type room struct {
// forward is a channel that holds incoming messages
// that should be forwarded to the other clients.
forward chan []byte
// join is a channel for clients wishing to join the room.
join chan *client
// leave is a channel for clients wishing to leave the room.
leave chan *client
// clients holds all current clients in this room.
clients map[*client]bool
// tracer will receive trace information of activity
// in the room.
tracer trace.Tracer
}
func (r *room) run() {
for {
select {
case client := <-r.join:
// joining
r.clients[client] = true
r.tracer.Trace("New client joined")
case client := <-r.leave:
// leaving
delete(r.clients, client)
close(client.send)
r.tracer.Trace("Client left")
case msg := <-r.forward:
r.tracer.Trace("Message received: ", string(msg))
// forward message to all clients
for client := range r.clients {
select {
case client.send <- msg:
// send the message
r.tracer.Trace(" -- sent to client")
default:
// failed to send
delete(r.clients, client)
close(client.send)
r.tracer.Trace(" -- failed to send, cleaned up client")
}
}
}
}
}
我们在room类型中添加了一个trace.Tracer字段,然后在整个代码中定期调用Trace方法。如果我们运行程序并尝试发送消息,您会注意到应用程序会因为tracer字段为nil而发生 panic。我们可以通过确保在创建room类型时创建并分配一个适当的对象来暂时解决这个问题。更新main.go文件以执行此操作:
r := newRoom()
r.tracer = trace.New(os.Stdout)
我们使用我们的New方法来创建一个对象,该对象将输出发送到os.Stdout标准输出管道(这是一种技术方式,表示我们希望它将输出打印到我们的终端)。
现在重新构建并运行程序,并使用两个浏览器玩耍应用程序,注意终端现在有一些有趣的跟踪信息供我们查看:
New client joined
New client joined
Message received: Hello Chat
-- sent to client
-- sent to client
Message received: Good morning :)
-- sent to client
-- sent to client
Client left
Client left
现在我们能够使用调试信息来洞察应用程序的运行情况,这将在开发和支持项目时对我们有所帮助。
使跟踪变为可选
一旦应用程序发布,我们生成的跟踪信息将变得非常无用,如果它只是打印到某个终端上,甚至更糟的是,如果它为我们的系统管理员创建了大量噪音。另外,请记住,当我们没有为room类型设置跟踪器时,我们的代码会发生 panic,这并不是一个非常用户友好的情况。为了解决这两个问题,我们将增强我们的trace包,添加一个trace.Off()方法,该方法将返回一个满足Tracer接口但在调用Trace方法时不执行任何操作的对象。
让我们添加一个测试,调用Off函数以获取一个静默的 tracer,然后调用Trace以确保代码不会发生 panic。由于跟踪不会发生,这就是我们在测试代码中能做的全部。将以下测试函数添加到tracer_test.go文件中:
func TestOff(t *testing.T) {
var silentTracer Tracer = Off()
silentTracer.Trace("something")
}
为了使其通过,将以下代码添加到tracer.go文件中:
type nilTracer struct{}
func (t *nilTracer) Trace(a ...interface{}) {}
// Off creates a Tracer that will ignore calls to Trace.
func Off() Tracer {
return &nilTracer{}
}
我们的nilTracer结构定义了一个什么也不做的Trace方法,调用Off()方法将创建一个新的nilTracer结构并返回它。请注意,我们的nilTracer结构与我们的tracer结构不同,它不需要io.Writer;因为它不会写任何东西。
现在让我们通过更新room.go文件中的newRoom方法来解决我们的第二个问题:
func newRoom() *room {
return &room{
forward: make(chan []byte),
join: make(chan *client),
leave: make(chan *client),
clients: make(map[*client]bool),
tracer: trace.Off(),
}
}
默认情况下,我们的room类型将使用nilTracer结构创建,并且对Trace的任何调用都将被忽略。您可以通过从main.go文件中删除r.tracer = trace.New(os.Stdout)行来尝试这一点:注意当您使用应用程序时没有任何内容被写入终端,并且没有发生恐慌。
清晰的包 API
快速浏览 API(在这种情况下,暴露的变量、方法和类型)我们的trace包突出显示了一个简单明显的设计已经出现:
-
New()方法 -
Off()方法 -
Tracer接口
我非常有信心将这个包交给一个没有任何文档或指南的 Go 程序员,我相信他们会知道如何处理它。
注意
在 Go 中,添加文档就像在每个项目的前一行添加注释一样简单。关于这个主题的博客文章是值得一读的(blog.golang.org/godoc-documenting-go-code),在那里你可以看到tracer.go的托管源代码的副本,这是一个你可能如何注释trace包的示例。有关更多信息,请参阅github.com/matryer/goblueprints/blob/master/chapter1/trace/tracer.go。
总结
在本章中,我们开发了一个完整的并发聊天应用程序,以及我们自己简单的包来跟踪程序的流程,以帮助我们更好地理解底层发生了什么。
我们使用net/http包快速构建了一个非常强大的并发 HTTP Web 服务器。在一个特定的情况下,我们升级了连接以在客户端和服务器之间打开一个 Web 套接字。这意味着我们可以轻松快速地向用户的 Web 浏览器发送消息,而不必编写混乱的轮询代码。我们探讨了模板如何有用地将代码与内容分离,以及允许我们将数据注入到我们的模板源中,这使我们可以使主机地址可配置。命令行标志帮助我们为托管我们的应用程序的人提供简单的配置控制,同时让我们指定合理的默认值。
我们的聊天应用程序利用了 Go 强大的并发能力,使我们能够用几行惯用的 Go 代码编写清晰的线程化代码。通过通过通道控制客户端的到来和离开,我们能够在代码中设置同步点,防止我们尝试同时修改相同对象而破坏内存。
我们学习了诸如http.Handler和我们自己的trace.Tracer这样的接口,使我们能够提供不同的实现,而无需触及使用它们的代码,并且在某些情况下,甚至无需向用户公开实现的名称。我们看到,通过向我们的room类型添加ServeHTTP方法,我们将我们的自定义房间概念转变为一个有效的 HTTP 处理程序对象,它管理我们的 Web 套接字连接。
实际上,我们离能够正确发布我们的应用程序并不远,除了一个重大的疏忽:你无法看到谁发送了每条消息。我们没有用户的概念,甚至没有用户名,对于一个真正的聊天应用来说,这是不可接受的。
在下一章中,我们将添加回复消息的人的名称,以使他们感觉自己正在与其他人进行真正的对话。
第二章:添加身份验证
我们在上一章构建的聊天应用程序侧重于从客户端到服务器再到客户端的消息高性能传输,但我们的用户无法知道他们在和谁交谈。解决这个问题的一个方法是构建某种注册和登录功能,让我们的用户在打开聊天页面之前创建帐户并进行身份验证。
每当我们要从头开始构建东西时,我们必须问自己在此之前其他人是如何解决这个问题的(真正原创的问题极为罕见),以及是否存在任何开放的解决方案或标准可以供我们使用。授权和身份验证并不是新问题,特别是在网络世界中,有许多不同的协议可供选择。那么我们如何决定追求最佳选择?和往常一样,我们必须从用户的角度来看待这个问题。
如今,许多网站允许您使用社交媒体或社区网站上现有的帐户进行登录。这样一来,用户就不必在尝试不同的产品和服务时一遍又一遍地输入所有帐户信息。这也对新站点的转化率产生了积极的影响。
在本章中,我们将增强我们的聊天代码库,添加身份验证,这将允许我们的用户使用 Google、Facebook 或 GitHub 进行登录,您还将看到添加其他登录门户也是多么容易。为了加入聊天,用户必须首先登录。之后,我们将使用授权数据来增强我们的用户体验,以便每个人都知道谁在房间里,以及谁说了什么。
在本章中,您将学习:
-
使用装饰器模式将
http.Handler类型包装起来,为处理程序添加额外功能 -
使用动态路径提供 HTTP 端点
-
使用 Gomniauth 开源项目访问身份验证服务
-
使用
http包获取和设置 cookie -
将对象编码为 Base64,然后再转换为正常状态
-
通过网络套接字发送和接收 JSON 数据
-
向模板提供不同类型的数据
-
使用自己类型的通道进行工作
一路处理程序
对于我们的聊天应用程序,我们实现了自己的http.Handler类型,以便轻松地编译、执行和向浏览器传递 HTML 内容。由于这是一个非常简单但功能强大的接口,我们将在添加功能到我们的 HTTP 处理时继续使用它。
为了确定用户是否经过身份验证,我们将创建一个身份验证包装处理程序来执行检查,并仅在用户经过身份验证时将执行传递给内部处理程序。
我们的包装处理程序将满足与其内部对象相同的http.Handler接口,允许我们包装任何有效的处理程序。实际上,即将编写的身份验证处理程序如果需要的话也可以稍后封装在类似的包装器中。

应用于 HTTP 处理程序的链接模式图
前面的图显示了这种模式如何应用于更复杂的 HTTP 处理程序场景。每个对象都实现了http.Handler接口,这意味着对象可以传递到http.Handle方法中直接处理请求,或者可以传递给另一个对象,该对象添加了某种额外的功能。Logging处理程序可能会在调用内部处理程序的ServeHTTP方法之前和之后写入日志文件。由于内部处理程序只是另一个http.Handler,任何其他处理程序都可以包装在(或使用)Logging处理程序中。
对象通常包含决定执行哪个内部处理程序的逻辑。例如,我们的身份验证处理程序将要么将执行传递给包装处理程序,要么通过向浏览器发出重定向来处理请求。
现在理论已经足够了;让我们写一些代码。在chat文件夹中创建一个名为auth.go的新文件:
package main
import (
"net/http"
)
type authHandler struct {
next http.Handler
}
func (h *authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if _, err := r.Cookie("auth"); err == http.ErrNoCookie {
// not authenticated
w.Header().Set("Location", "/login")
w.WriteHeader(http.StatusTemporaryRedirect)
} else if err != nil {
// some other error
panic(err.Error())
} else {
// success - call the next handler
h.next.ServeHTTP(w, r)
}
}
func MustAuth(handler http.Handler) http.Handler {
return &authHandler{next: handler}
}
authHandler类型不仅实现了ServeHTTP方法(满足http.Handler接口),还在next字段中存储(包装)http.Handler。我们的MustAuth辅助函数只是创建包装任何其他http.Handler的authHandler。让我们调整以下根映射行:
http.Handle("/", &templateHandler{filename: "chat.html"})
让我们更改第一个参数,以明确指定用于聊天的页面。接下来,让我们使用MustAuth函数包装templateHandler作为第二个参数:
http.Handle("/chat", MustAuth(&templateHandler{filename: "chat.html"}))
使用MustAuth函数包装templateHandler将导致执行首先通过我们的authHandler,仅在请求经过身份验证时才到达templateHandler。
我们的authHandler中的ServeHTTP方法将寻找一个名为auth的特殊 cookie,并使用http.ResponseWriter上的Header和WriteHeader方法来重定向用户到登录页面,如果缺少 cookie。
构建并运行聊天应用程序,并尝试访问http://localhost:8080/chat:
go build -o chat
./chat -host=":8080"
提示
您需要删除您的 cookie 以清除先前的 auth 令牌,或者从通过 localhost 提供的其他开发项目中留下的任何其他 cookie。
如果您查看浏览器的地址栏,您会注意到您立即被重定向到/login页面。由于我们目前无法处理该路径,您将收到一个404 页面未找到错误。
创建一个漂亮的社交登录页面
到目前为止,我们并没有太关注使我们的应用程序看起来漂亮,毕竟这本书是关于 Go 而不是用户界面开发。但是,构建丑陋的应用程序是没有借口的,因此我们将构建一个既漂亮又实用的社交登录页面。
Bootstrap 是用于在 Web 上开发响应式项目的前端框架。它提供了解决许多用户界面问题的 CSS 和 JavaScript 代码,以一致和美观的方式。虽然使用 Bootstrap 构建的网站往往看起来都一样(尽管 UI 可以定制的方式有很多),但它是早期应用程序的绝佳选择,或者对于没有设计师访问权限的开发人员。
提示
如果您使用 Bootstrap 制定的语义标准构建应用程序,那么为您的站点或应用程序制作 Bootstrap 主题将变得很容易,并且您知道它将完全适合您的代码。
我们将使用托管在 CDN 上的 Bootstrap 版本,因此我们不必担心通过我们的聊天应用程序下载和提供自己的版本。这意味着为了正确呈现我们的页面,我们需要保持活动的互联网连接,即使在开发过程中也是如此。
注意
如果您喜欢下载和托管自己的 Bootstrap 副本,可以这样做。将文件保存在assets文件夹中,并将以下调用添加到您的main函数中(它使用http.Handle通过您的应用程序提供资产):
http.Handle("/assets/", http.StripPrefix("/assets", http.FileServer(http.Dir("/path/to/assets/"))))
请注意,http.StripPrefix和http.FileServer函数返回满足http.Handler接口的对象,这是我们使用MustAuth辅助函数实现的装饰器模式。
在main.go中,让我们为登录页面添加一个端点:
http.Handle("/chat", MustAuth(&templateHandler{filename: "chat.html"}))
http.Handle("/login", &templateHandler{filename: "login.html"})
http.Handle("/room", r)
显然,我们不希望在我们的登录页面使用MustAuth方法,因为它会导致无限重定向循环。
在我们的templates文件夹中创建一个名为login.html的新文件,并插入以下 HTML 代码:
<html>
<head>
<title>Login</title>
<link rel="stylesheet"
href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css">
</head>
<body>
<div class="container">
<div class="page-header">
<h1>Sign in</h1>
</div>
<div class="panel panel-danger">
<div class="panel-heading">
<h3 class="panel-title">In order to chat, you must be signed in</h3>
</div>
<div class="panel-body">
<p>Select the service you would like to sign in with:</p>
<ul>
<li>
<a href="/auth/login/facebook">Facebook</a>
</li>
<li>
<a href="/auth/login/github">GitHub</a>
</li>
<li>
<a href="/auth/login/google">Google</a>
</li>
</ul>
</div>
</div>
</div>
</body>
</html>
重新启动 Web 服务器并导航到http://localhost:8080/login。您会注意到它现在显示我们的登录页面:

具有动态路径的端点
Go 标准库中的http包的模式匹配并不是最全面和功能齐全的实现。例如,Ruby on Rails 更容易在路径内部具有动态段。
"auth/:action/:provider_name"
然后,这将提供一个数据映射(或字典),其中包含框架自动从匹配的路径中提取的值。因此,如果您访问auth/login/google,那么params[:provider_name]将等于google,而params[:action]将等于login。
默认情况下,http包让我们指定的最多是路径前缀,我们可以通过在模式的末尾留下一个斜杠来实现:
"auth/"
然后我们必须手动解析剩余的段,以提取适当的数据。这对于相对简单的情况是可以接受的,因为目前我们只需要处理一些不同的路径,比如:
-
/auth/login/google -
/auth/login/facebook -
/auth/callback/google -
/auth/callback/facebook
提示
如果您需要处理更复杂的路由情况,您可能希望考虑使用专用包,如 Goweb、Pat、Routes 或 mux。对于像我们这样极其简单的情况,内置的功能就足够了。
我们将创建一个新的处理程序来支持我们的登录流程。在auth.go中,添加以下loginHandler代码:
// loginHandler handles the third-party login process.
// format: /auth/{action}/{provider}
func loginHandler(w http.ResponseWriter, r *http.Request) {
segs := strings.Split(r.URL.Path, "/")
action := segs[2]
provider := segs[3]
switch action {
case "login":
log.Println("TODO handle login for", provider)
default:
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, "Auth action %s not supported", action)
}
}
在上述代码中,我们使用strings.Split将路径分成段,然后提取action和provider的值。如果已知action的值,我们将运行特定的代码;否则,我们将写出错误消息并返回http.StatusNotFound状态码(在 HTTP 状态码的语言中,是404代码)。
注意
我们现在不会让我们的代码完全健壮,但值得注意的是,如果有人使用太少的段访问loginHandler,我们的代码将会 panic,因为它期望segs[2]和segs[3]存在。
额外加分,看看您是否可以防止这种情况,并在有人访问/auth/nonsense时返回一个友好的错误消息,而不是一个 panic。
我们的loginHandler只是一个函数,而不是实现http.Handler接口的对象。这是因为,与其他处理程序不同,我们不需要它来存储任何状态。Go 标准库支持这一点,因此我们可以使用http.HandleFunc函数将其映射到与我们之前使用http.Handle类似的方式。在main.go中更新处理程序:
http.Handle("/chat", MustAuth(&templateHandler{filename: "chat.html"}))
http.Handle("/login", &templateHandler{filename: "login.html"})
http.HandleFunc("/auth/", loginHandler)
http.Handle("/room", r)
重新构建并运行聊天应用程序:
go build –o chat
./chat –host=":8080"
访问以下 URL 并注意终端中记录的输出:
-
http://localhost:8080/auth/login/google输出TODO handle login for google -
http://localhost:8080/auth/login/facebook输出TODO handle login for facebook
我们已经成功实现了一个动态路径匹配机制,目前只是打印出待办事项消息;接下来,我们需要编写与认证服务集成的代码。
读累了记得休息一会哦~
公众号:古德猫宁李
-
电子书搜索下载
-
书单分享
-
书友学习交流
网站:沉金书屋 https://www.chenjin5.com
-
电子书搜索下载
-
电子书打包资源分享
-
学习资源分享
OAuth2
OAuth2 是一种开放的认证和授权标准,旨在允许资源所有者通过访问令牌交换握手向客户端提供委托访问私人数据(如墙上的帖子或推文)。即使您不希望访问私人数据,OAuth2 也是一个很好的选择,它允许人们使用其现有凭据登录,而不会将这些凭据暴露给第三方网站。在这种情况下,我们是第三方,我们希望允许我们的用户使用支持 OAuth2 的服务进行登录。
从用户的角度来看,OAuth2 流程是:
-
用户选择希望使用的提供者登录到客户端应用程序。
-
用户被重定向到提供者的网站(其中包括客户端应用程序 ID 的 URL),并被要求授予客户端应用程序权限。
-
用户从 OAuth2 服务提供商登录,并接受第三方应用程序请求的权限。
-
用户被重定向回客户端应用程序,并附带一个请求代码。
-
在后台,客户端应用程序将授予代码发送给提供者,提供者将返回一个授权令牌。
-
客户端应用程序使用访问令牌向提供者发出授权请求,例如获取用户信息或墙上的帖子。
为了避免重复造轮子,我们将看一些已经为我们解决了这个问题的开源项目。
开源 OAuth2 包
Andrew Gerrand 自 2010 年 2 月以来一直在核心 Go 团队工作,即在 Go 1.0 正式发布两年前。他的goauth2包(请参阅code.google.com/p/goauth2/)是 OAuth2 协议的优雅实现,完全使用 Go 编写。
Andrew 的项目启发了 Gomniauth(请参阅github.com/stretchr/gomniauth)。作为 Ruby 的omniauth项目的开源 Go 替代品,Gomniauth 提供了一个统一的解决方案来访问不同的 OAuth2 服务。在未来,当 OAuth3(或者下一代认证协议)推出时,理论上,Gomniauth 可以承担实现细节的痛苦,使用户代码不受影响。
对于我们的应用程序,我们将使用 Gomniauth 来访问 Google、Facebook 和 GitHub 提供的 OAuth 服务,因此请确保您已通过运行以下命令进行安装:
go get github.com/stretchr/gomniauth
提示
Gomniauth 的一些项目依赖项存储在 Bazaar 存储库中,因此您需要前往wiki.bazaar.canonical.com下载它们。
告诉身份验证提供程序有关您的应用
在我们要求身份验证提供程序帮助我们的用户登录之前,我们必须告诉他们有关我们的应用程序。大多数提供程序都有一种网络工具或控制台,您可以在其中创建应用程序以启动该过程。以下是 Google 的一个示例:

为了识别客户端应用程序,我们需要创建客户端 ID 和密钥。尽管 OAuth2 是一个开放标准,但每个提供程序都有自己的语言和机制来设置事物,因此您很可能需要在每种情况下通过用户界面或文档进行尝试来弄清楚。
在撰写本文时,在Google 开发者控制台中,您可以导航到API 和身份验证 | 凭据,然后单击创建新的客户端 ID按钮。
在大多数情况下,为了增加安全性,您必须明确指定请求将来自哪些主机 URL。目前,因为我们将在localhost:8080上本地托管我们的应用程序,所以您应该使用该 URL。您还将被要求提供一个重定向 URI,该 URI 是我们聊天应用程序中的端点,并且用户在成功登录后将被重定向到该端点。回调将是我们loginHandler上的另一个操作,因此 Google 客户端的重定向 URL 将是http://localhost:8080/auth/callback/google。
完成要支持的提供程序的身份验证过程后,您将为每个提供程序获得客户端 ID 和密钥。记下这些信息,因为在设置我们的聊天应用程序中的提供程序时,我们将需要它们。
注意
如果我们将我们的应用程序托管在真实域上,我们必须创建新的客户端 ID 和密钥,或者更新我们的身份验证提供程序的适当 URL 字段,以确保它们指向正确的位置。无论哪种方式,为了安全起见,为开发和生产的密钥设置不同的密钥并不是坏习惯。
实现外部登录
为了使用我们在身份验证提供程序网站上创建的项目、客户端或帐户,我们必须告诉 Gomniauth 我们想要使用哪些提供程序,以及我们将如何与它们交互。我们通过在主要的 Gomniauth 包上调用WithProviders函数来实现这一点。将以下代码片段添加到main.go(就在main函数顶部的flag.Parse()行下面):
// set up gomniauth
gomniauth.SetSecurityKey("some long key")
gomniauth.WithProviders(
facebook.New("key", "secret",
"http://localhost:8080/auth/callback/facebook"),
github.New("key", "secret",
"http://localhost:8080/auth/callback/github"),
google.New("key", "secret",
"http://localhost:8080/auth/callback/google"),
)
您应该用您之前记录的实际值替换key和secret占位符。第三个参数表示回调 URL,应与您在提供者网站上创建客户端时提供的 URL 匹配。注意第二个路径段是callback;虽然我们还没有实现这个,但这是我们处理认证过程的响应的地方。
像往常一样,您需要确保导入了所有适当的包:
import (
"github.com/stretchr/gomniauth/providers/facebook"
"github.com/stretchr/gomniauth/providers/github"
"github.com/stretchr/gomniauth/providers/google"
)
注意
Gomniauth 需要SetSecurityKey调用,因为它在客户端和服务器之间发送状态数据以及签名校验和,以确保状态值在传输过程中没有被篡改。安全密钥在创建哈希时使用,以一种几乎不可能在不知道确切安全密钥的情况下重新创建相同的哈希。您应该用您选择的安全哈希或短语替换some long key。
登录
现在我们已经配置了 Gomniauth,当用户登陆到我们的/auth/login/{provider}路径时,我们需要将用户重定向到提供者的认证页面。我们只需要更新我们在auth.go中的loginHandler函数:
func loginHandler(w http.ResponseWriter, r *http.Request) {
segs := strings.Split(r.URL.Path, "/")
action := segs[2]
provider := segs[3]
switch action {
case "login":
provider, err := gomniauth.Provider(provider)
if err != nil {
log.Fatalln("Error when trying to get provider", provider, "-", err)
}
loginUrl, err := provider.GetBeginAuthURL(nil, nil)
if err != nil {
log.Fatalln("Error when trying to GetBeginAuthURL for", provider, "-", err)
}
w.Header.Set("Location",loginUrl)
w.WriteHeader(http.StatusTemporaryRedirect)
default:
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, "Auth action %s not supported", action)
}
}
我们在这里做了两件主要的事情。首先,我们使用gomniauth.Provider函数来获取与 URL 中指定的对象(如google或github)匹配的提供者对象。然后我们使用GetBeginAuthURL方法获取我们必须发送用户的位置,以开始认证过程。
注意
GetBeginAuthURL(nil, nil)参数是用于状态和选项的,对于我们的聊天应用程序,我们不打算使用它们。
第一个参数是编码、签名并发送到认证提供者的数据状态映射。提供者不会对状态进行任何操作,只是将其发送回我们的回调端点。例如,如果我们想要将用户重定向回他们在认证过程中尝试访问的原始页面,这是很有用的。对于我们的目的,我们只有/chat端点,所以我们不需要担心发送任何状态。
第二个参数是一个附加选项的映射,将被发送到认证提供者,以某种方式修改认证过程的行为。例如,您可以指定自己的scope参数,这允许您请求许可以访问提供者的其他信息。有关可用选项的更多信息,请在互联网上搜索 OAuth2 或阅读每个提供者的文档,因为这些值因服务而异。
如果我们的代码从GetBeginAuthURL调用中没有错误,我们只需将用户的浏览器重定向到返回的 URL。
重新构建并运行聊天应用程序:
go build -o chat
./chat -host=":8080"
通过访问http://localhost:8080/chat来打开主要的聊天页面。由于我们还没有登录,我们被重定向到我们的登录页面。点击 Google 选项,使用您的 Google 账户登录,您会注意到您被呈现出一个特定于 Google 的登录页面(如果您还没有登录到 Google)。一旦您登录,您将被呈现一个页面,要求您在查看有关您的账户的基本信息之前,先允许我们的聊天应用程序:

这是我们的聊天应用程序用户在登录时会经历的相同流程。
点击接受,您会注意到您被重定向回我们的应用程序代码,但是出现了Auth action callback not supported错误。这是因为我们还没有在loginHandler中实现回调功能。
处理来自提供者的响应
一旦用户在提供者的网站上点击接受(或者点击相当于取消的选项),他们将被重定向回我们应用程序的回调端点。
快速浏览返回的完整 URL,我们可以看到提供者给我们的授权代码。
http://localhost:8080/auth/callback/google?
code=4/Q92xJ-BQfoX6PHhzkjhgtyfLc0Ylm.QqV4u9AbA9sYguyfbjFEsNoJKMOjQI
我们不必担心该代码该怎么处理,因为 Gomniauth 将为我们处理 OAuth URL 参数(通过将授权代码发送到 Google 服务器并根据 OAuth 规范将其交换为访问令牌),因此我们可以直接跳转到实现我们的回调处理程序。然而,值得知道的是,这段代码将被身份验证提供程序交换为一个允许我们访问私人用户数据的令牌。为了增加安全性,这个额外的步骤是在后台从服务器到服务器进行的,而不是在浏览器中进行的。
在auth.go中,我们准备向我们的动作路径段添加另一个 switch case。在默认情况之前插入以下代码:
case "callback":
provider, err := gomniauth.Provider(provider)
if err != nil {
log.Fatalln("Error when trying to get provider", provider, "-", err)
}
creds, err := provider.CompleteAuth(objx.MustFromURLQuery(r.URL.RawQuery))
if err != nil {
log.Fatalln("Error when trying to complete auth for", provider, "-", err)
}
user, err := provider.GetUser(creds)
if err != nil {
log.Fatalln("Error when trying to get user from", provider, "-", err)
}
authCookieValue := objx.New(map[string]interface{}{
"name": user.Name(),
}).MustBase64()
http.SetCookie(w, &http.Cookie{
Name: "auth",
Value: authCookieValue,
Path: "/"})
w.Header()["Location"] = []string{"/chat"}
w.WriteHeader(http.StatusTemporaryRedirect)
当身份验证提供程序在用户授予权限后将用户重定向回来时,URL 指定它是一个回调动作。我们像之前一样查找身份验证提供程序,并调用它的CompleteAuth方法。我们将http.Request(用户浏览器现在正在进行的GET请求)中的RawQuery解析为objx.Map(Gomniauth 使用的多用途映射类型),CompleteAuth方法使用 URL 查询参数值来完成与提供程序的身份验证握手。一切顺利的话,我们将获得一些授权凭据,用于访问用户的基本数据。然后我们使用提供程序的GetUser方法,Gomniauth 使用指定的凭据访问用户的一些基本信息。
一旦我们有了用户数据,我们将Name字段在 JSON 对象中进行 Base64 编码,并将其存储为我们的auth cookie 的值,以便以后使用。
提示
数据的 Base64 编码确保它不会包含任何特殊或不可预测的字符,就像在 URL 中传递数据或将其存储在 cookie 中一样。请记住,尽管 Base64 编码的数据看起来像是加密的,但实际上并不是——您可以很容易地将 Base64 编码的数据解码回原始文本,而不费吹灰之力。有在线工具可以为您完成这项工作。
设置完 cookie 后,我们将用户重定向到聊天页面,可以安全地假设这是最初的目的地。
再次构建和运行代码,然后访问/chat页面,您会注意到注册流程起作用了,我们最终被允许返回到聊天页面。大多数浏览器都有检查器或控制台——一种允许您查看服务器发送给您的 cookie 的工具——您可以使用它来查看auth cookie 是否已出现:
go build –o chat
./chat –host=":8080"
在我们的情况下,cookie 值是eyJuYW1lIjoiTWF0IFJ5ZXIifQ==,这是{"name":"Mat Ryer"}的 Base64 编码版本。请记住,我们在聊天应用中从未输入过名字;相反,当我们选择使用 Google 登录时,Gomniauth 会向 Google 请求一个名字。像这样存储非签名的 cookie 对于像用户姓名这样的偶发信息是可以的,但是,您应该避免使用非签名的 cookie 存储任何敏感信息,因为人们可以轻松访问和更改数据。
呈现用户数据
将用户数据放在 cookie 中是一个很好的开始,但非技术人员甚至不会知道它的存在,所以我们必须将数据提到前台。我们将通过增强我们的templateHandler方法来实现这一点,该方法首先将用户数据传递到模板的Execute方法中;这使我们能够在 HTML 中使用模板注释来向用户显示用户数据。
更新main.go中我们的templateHandler的ServeHTTP方法:
func (t *templateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
t.once.Do(func() {
t.templ = template.Must(template.ParseFiles(filepath.Join("templates", t.filename)))
})
data := map[string]interface{}{
"Host": r.Host,
}
if authCookie, err := r.Cookie("auth"); err == nil {
data["UserData"] = objx.MustFromBase64(authCookie.Value)
}
t.templ.Execute(w, data)
}
我们不仅仅将整个http.Request对象作为数据传递给我们的模板,而是为一个数据对象创建一个新的map[string]interface{}定义,该对象可能有两个字段:Host和UserData(后者只有在存在auth cookie 时才会出现)。通过指定映射类型后跟花括号,我们能够在同一时间添加Host条目和创建我们的映射。然后我们将这个新的data对象作为第二个参数传递给我们模板的Execute方法。
现在我们在模板源中添加一个 HTML 文件来显示名称。更新chat.html中的chatbox表单:
<form id="chatbox">
{{.UserData.name}}:<br/>
<textarea></textarea>
<input type="submit" value="Send" />
</form>
{{.UserData.name}}注释告诉模板引擎在textarea控件之前插入我们用户的名称。
提示
由于我们正在使用objx包,请不要忘记运行go get github.com/stretchr/objx,并导入它。
重新构建并再次运行聊天应用程序,您会注意到在聊天框之前添加了您的名称。
go build -o chat
./chat –host=":8080"
增加消息的附加数据
到目前为止,我们的聊天应用程序只传输消息作为字节片或[]byte类型在客户端和服务器之间;因此,我们的房间的forward通道具有chan []byte类型。为了发送数据(例如发送者和发送时间)以及消息本身,我们增强了我们的forward通道以及我们在两端与 web 套接字交互的方式。
通过在chat文件夹中创建一个名为message.go的新文件,定义一个将[]byte切片替换的新类型:
package main
import (
"time"
)
// message represents a single message
type message struct {
Name string
Message string
When time.Time
}
message类型将封装消息字符串本身,但我们还添加了分别保存用户名称和消息发送时间戳的Name和When字段。
由于client类型负责与浏览器通信,它需要传输和接收的不仅仅是单个消息字符串。由于我们正在与 JavaScript 应用程序(即在浏览器中运行的聊天客户端)进行交流,并且 Go 标准库具有出色的 JSON 实现,因此这似乎是在消息中编码附加信息的完美选择。我们将更改client.go中的read和write方法,以使用套接字上的ReadJSON和WriteJSON方法,并对我们的新message类型进行编码和解码:
func (c *client) read() {
for {
var msg *message
if err := c.socket.ReadJSON(&msg); err == nil {
msg.When = time.Now()
msg.Name = c.userData["name"].(string)
c.room.forward <- msg
} else {
break
}
}
c.socket.Close()
}
func (c *client) write() {
for msg := range c.send {
if err :=
c.socket.WriteJSON(msg)
; err != nil {
break
}
}
c.socket.Close()
}
当我们从浏览器接收到消息时,我们只期望填充Message字段,这就是为什么我们在前面的代码中设置了When和Name字段。
当您尝试构建前面的代码时,您会注意到它会抱怨一些问题。主要原因是我们试图通过forward和send chan []byte通道发送*message对象。在room.go中,将forward字段更改为chan *message类型,并在client.go中对send chan类型执行相同操作。
我们必须更新初始化通道的代码,因为类型现在已经改变。或者,您可以等待编译器提出这些问题,并在进行修复时解决它们。在room.go中,您需要进行以下更改:
-
将
forward: make(chan []byte)更改为forward: make(chan *message) -
将
r.tracer.Trace("Message received: ", string(msg))更改为r.tracer.Trace("Message received: ", msg.Message) -
将
send: make(chan []byte, messageBufferSize)更改为send: make(chan *message, messageBufferSize)
编译器还会抱怨客户端缺少用户数据,这是一个公平的观点,因为client类型对我们已添加到 cookie 中的新用户数据一无所知。更新client结构以包括一个名为userData的新map[string]interface{}:
// client represents a single chatting user.
type client struct {
// socket is the web socket for this client.
socket *websocket.Conn
// send is a channel on which messages are sent.
send chan *message
// room is the room this client is chatting in.
room *room
// userData holds information about the user
userData map[string]interface{}
}
用户数据来自客户端 cookie,我们通过http.Request对象的Cookie方法访问它。在room.go中,使用以下更改更新ServeHTTP:
func (r *room) ServeHTTP(w http.ResponseWriter, req *http.Request) {
socket, err := upgrader.Upgrade(w, req, nil)
if err != nil {
log.Fatal("ServeHTTP:", err)
return
}
authCookie, err := req.Cookie("auth")
if err != nil {
log.Fatal("Failed to get auth cookie:", err)
return
}
client := &client{
socket: socket,
send: make(chan *message, messageBufferSize),
room: r,
userData: objx.MustFromBase64(authCookie.Value),
}
r.join <- client
defer func() { r.leave <- client }()
go client.write()
client.read()
}
我们使用http.Request类型上的Cookie方法来获取用户数据,然后将其传递给客户端。我们使用objx.MustFromBase64方法将编码的 cookie 值转换回可用的 map 对象。
现在我们已经将从套接字发送和接收的类型从[]byte更改为*message,我们必须告诉我们的 JavaScript 客户端,我们正在发送 JSON 而不仅仅是普通字符串。还必须要求在用户提交消息时,它将 JSON 发送回服务器。在chat.html中,首先更新socket.send调用:
socket.send(JSON.stringify({"Message": msgBox.val()}));
我们使用JSON.stringify将指定的 JSON 对象(仅包含Message字段)序列化为字符串,然后发送到服务器。我们的 Go 代码将把 JSON 字符串解码(或取消编组)为message对象,将客户端 JSON 对象的字段名称与我们的message类型的字段名称匹配。
最后,更新socket.onmessage回调函数以期望 JSON,并在页面上添加发送者的名称:
socket.onmessage = function(e) {
var msg = eval("("+e.data+")");
messages.append(
$("<li>").append(
$("<strong>").text(msg.Name + ": "),
$("<span>").text(msg.Message)
)
);
}
在前面的代码片段中,我们使用了 JavaScript 的eval函数将 JSON 字符串转换为 JavaScript 对象,然后访问字段以构建显示它们所需的元素。
构建并运行应用程序,如果可以的话,在两个不同的浏览器中使用两个不同的帐户登录(或者邀请朋友帮助测试):
go build -o chat
./chat -host=":8080"
以下截图显示了聊天应用程序的浏览器聊天界面:

摘要
在本章中,我们通过要求用户使用 OAuth2 服务提供商进行身份验证,然后允许他们加入对话,为我们的聊天应用程序添加了一个有用且必要的功能。我们利用了几个开源包,如Objx和Gomniauth,大大减少了我们需要处理的多服务器复杂性。
当我们包装http.Handler类型时,我们实现了一种模式,以便轻松指定哪些路径需要用户进行身份验证,哪些即使没有auth cookie 也是可用的。我们的MustAuth辅助函数使我们能够以流畅简单的方式生成包装类型,而不会给我们的代码添加混乱和困惑。
我们看到如何使用 Cookie 和 Base64 编码来安全(虽然不安全)地在用户的浏览器中存储特定用户的状态,并利用该数据通过普通连接和网络套接字。我们更多地控制了模板中可用的数据,以便向 UI 提供用户的名称,并看到如何在特定条件下仅提供某些数据。
由于我们需要通过网络套接字发送和接收附加信息,我们学会了如何轻松地将本机类型的通道更改为适用于我们自己的类型(如我们的message类型)的通道。我们还学会了如何通过套接字传输 JSON 对象,而不仅仅是字节片。由于 Go 的类型安全性,以及能够为通道指定类型,编译器有助于确保我们不会通过chan *message发送除message对象以外的任何东西。尝试这样做将导致编译器错误,立即提醒我们这一事实。
在我们之前构建的应用程序中,看到聊天的人的名字是一个巨大的可用性进步,但它非常正式,可能不会吸引现代 Web 用户,他们习惯于更加视觉化的体验。我们缺少聊天的人的图片,在下一章中,我们将探讨不同的方式,让用户更好地在我们的应用程序中代表自己。
作为额外的任务,看看是否可以利用我们放入message类型中的time.Time字段,告诉用户消息何时发送。
第三章:实现个人资料图片的三种方法
到目前为止,我们的聊天应用程序已经使用了 OAuth2 协议,允许用户登录到我们的应用程序,以便我们知道谁在说什么。在本章中,我们将添加个人资料图片,使聊天体验更加引人入胜。
我们将研究以下几种方法来在我们的应用程序中的消息旁边添加图片或头像:
-
使用认证服务器提供的头像图片
-
使用Gravatar.com网络服务通过用户的电子邮件地址查找图片
-
允许用户上传自己的图片并自行托管
前两个选项允许我们将图片的托管委托给第三方——要么是认证服务,要么是Gravatar.com——这很棒,因为它减少了我们应用程序的托管成本(存储成本和带宽成本,因为用户的浏览器实际上会从认证服务的服务器上下载图片,而不是我们自己的服务器)。第三个选项要求我们在可以通过 web 访问的位置托管图片。
这些选项并不是互斥的;在真实的生产应用程序中,您很可能会使用它们的某种组合。在本章结束时,我们将看到灵活的设计使我们能够依次尝试每种实现,直到找到合适的头像。
在本章中,我们将灵活设计,尽量做到每个里程碑所需的最低工作量。这意味着在每个部分结束时,我们将拥有在浏览器中可演示的工作实现。这也意味着我们将根据需要重构代码,并讨论我们做出的决定背后的原因。
具体来说,在本章中,您将学到以下内容:
-
即使没有标准,也有哪些获取认证服务的额外信息的良好实践
-
何时适合将抽象构建到我们的代码中
-
Go 的零初始化模式如何节省时间和内存
-
如何重用接口使我们能够以与现有接口相同的方式处理集合和单个对象
-
如何使用Gravatar.com网络服务
-
如何在 Go 中进行 MD5 哈希
-
如何通过 HTTP 上传文件并将其存储在服务器上
-
如何通过 Go web 服务器提供静态文件
-
如何使用单元测试指导代码重构
-
何时以及如何将
struct类型的功能抽象为接口
认证服务器的头像
事实证明,大多数认证服务器已经为其用户准备了图片,并通过我们已经知道如何访问的受保护用户资源使其可用。要使用这个头像图片,我们需要从提供者那里获取 URL,将其存储在我们用户的 cookie 中,并通过 web 套接字发送,以便每个客户端可以在相应的消息旁边呈现图片。
获取头像 URL
用户或个人资料资源的架构不是 OAuth2 规范的一部分,这意味着每个提供者都负责决定如何表示这些数据。事实上,提供者的做法各不相同,例如,GitHub 用户资源中的头像 URL 存储在名为avatar_url的字段中,而在 Google 中,相同的字段称为picture。Facebook 甚至通过在名为picture的对象内嵌套头像 URL 值的url字段来进一步进行。幸运的是,Gomniauth 为我们抽象了这一点;它在提供者上的GetUser调用标准化了获取常见字段的接口。
为了使用头像 URL 字段,我们需要回去并将其信息存储在我们的 cookie 中。在auth.go中,查看callback操作开关情况,并更新创建authCookieValue对象的代码如下:
authCookieValue := objx.New(map[string]interface{}{
"name": user.Name(),
"avatar_url": user.AvatarURL(),
}).MustBase64()
在前面的代码中调用的AvatarURL方法将返回适当的 URL 值,然后我们将其存储在avatar_url字段中,该字段将存储在 cookie 中。
提示
Gomniauth 定义了一个接口类型的User,每个提供者都实现了自己的版本。从认证服务器返回的通用map[string]interface{}数据存储在每个对象内,方法调用使用适当的字段名访问相应的值。这种方法描述了访问信息的方式,而不严格关注实现细节,是 Go 中接口的一个很好的用法。
传输头像 URL
我们需要更新我们的message类型,以便它也可以携带头像 URL。在message.go中,添加AvatarURL字符串字段:
type message struct {
Name string
Message string
When time.Time
AvatarURL string
}
到目前为止,我们实际上还没有为AvatarURL分配一个值,就像我们为Name字段所做的那样,因此我们必须更新client.go中的read方法:
func (c *client) read() {
for {
var msg *message
if err := c.socket.ReadJSON(&msg); err == nil {
msg.When = time.Now()
msg.Name = c.userData["name"].(string)
if avatarUrl, ok := c.userData["avatar_url"]; ok {
msg.AvatarURL = avatarUrl.(string)
}
c.room.forward <- msg
} else {
break
}
}
c.socket.Close()
}
我们在这里所做的一切就是从userData字段中取出代表我们放入 cookie 的值,并将其分配给message中的适当字段,如果该值在映射中存在的话。我们现在将进一步检查该值是否存在,因为我们不能保证认证服务将为此字段提供一个值。并且由于它可能是nil,如果它实际上缺失,将其分配给string类型可能会导致恐慌。
将头像添加到用户界面
现在,我们的 JavaScript 客户端通过套接字获取了一个头像 URL 值,我们可以使用它来在消息旁边显示图像。我们通过更新chat.html中的socket.onmessage代码来实现这一点:
socket.onmessage = function(e) {
var msg = eval("("+e.data+")");
messages.append(
$("<li>").append(
$("<img>").css({
width:50,
verticalAlign:"middle"
}).attr("src", msg.AvatarURL),
$("<strong>").text(msg.Name + ": "),
$("<span>").text(msg.Message)
)
);
}
当我们收到一条消息时,我们将插入一个img标签,其中源设置为消息的AvatarURL字段。我们将使用 jQuery 的css方法强制宽度为50像素。这可以防止大图片破坏我们的界面,并允许我们将图像对齐到周围文本的中间。
如果我们使用先前版本登录后构建和运行我们的应用程序,你会发现不包含头像 URL 的auth cookie 仍然存在。我们不会被要求重新登录(因为我们已经登录了),添加avatar_url字段的代码也永远不会运行。我们可以删除 cookie 并刷新页面,但是在开发过程中每次进行更改时都需要这样做。让我们通过添加注销功能来正确解决这个问题。
注销
注销用户的最简单方法是删除auth cookie 并将用户重定向到聊天页面,这将导致重定向到登录页面,因为我们刚刚删除了 cookie。我们通过在main.go中添加一个新的HandleFunc调用来实现这一点:
http.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "auth",
Value: "",
Path: "/",
MaxAge: -1,
})
w.Header()["Location"] = []string{"/chat"}
w.WriteHeader(http.StatusTemporaryRedirect)
})
前面的处理函数使用http.SetCookie来更新MaxAge设置为-1的 cookie 设置,这表示它应该立即被浏览器删除。并非所有浏览器都被强制删除 cookie,这就是为什么我们还提供了一个新的Value设置为空字符串的设置,从而删除以前存储的用户数据。
提示
作为额外的任务,你可以通过更新auth.go中authHandler的ServeHTTP的第一行来使其适应空值情况以及缺少 cookie 的情况,从而使你的应用程序更加健壮:
if cookie, err := r.Cookie("auth"); err == http.ErrNoCookie || cookie.Value == ""
不要忽略r.Cookie的返回,我们保留返回的 cookie 的引用(如果实际上有的话),并添加额外的检查,看看 cookie 的Value字符串是否为空。
在继续之前,让我们添加一个“登出”链接,以便更轻松地删除 cookie,并允许我们的用户注销。在chat.html中,更新chatbox表单,插入一个简单的 HTML 链接到新的/logout处理程序:
<form id="chatbox">
{{.UserData.name}}:<br/>
<textarea></textarea>
<input type="submit" value="Send" />
or <a href="/logout">sign out</a>
</form>
现在构建并运行应用程序,并在浏览器中打开localhost:8080/chat:
go build –o chat
./chat –host=:8080
如果需要,注销并重新登录。当您点击发送时,您将看到您的头像图片出现在您的消息旁边。

使事情更美观
我们的应用程序开始看起来有点丑陋,是时候做点什么了。在上一章中,我们将 Bootstrap 库引入了我们的登录页面,现在我们将扩展其用途到我们的聊天页面。我们将在chat.html中进行三处更改:包括 Bootstrap 并调整页面的 CSS 样式,更改我们表单的标记,并调整我们在页面上呈现消息的方式。
首先,让我们更新页面顶部的style标签,并在其上插入一个link标签以包含 Bootstrap:
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css">
<style>
ul#messages { list-style: none; }
ul#messages li { margin-bottom: 2px; }
ul#messages li img { margin-right: 10px; }
</style>
接下来,让我们用以下代码替换body标签顶部的标记(在script标签之前):
<div class="container">
<div class="panel panel-default">
<div class="panel-body">
<ul id="messages"></ul>
</div>
</div>
<form id="chatbox" role="form">
<div class="form-group">
<label for="message">Send a message as {{.UserData.name}}</label> or <a href="/logout">Sign out</a>
<textarea id="message" class="form-control"></textarea>
</div>
<input type="submit" value="Send" class="btn btn-default" />
</form>
</div>
这个标记遵循 Bootstrap 标准,将适当的类应用于各种项目,例如,form-control类可以整洁地格式化form中的元素(您可以查看 Bootstrap 文档,了解这些类的更多信息)。
最后,让我们更新我们的socket.onmessage JavaScript 代码,将发送者的名称作为我们图像的title属性。这样,当您将鼠标悬停在图像上时,我们的应用程序将显示图像,而不是在每条消息旁边显示它:
socket.onmessage = function(e) {
var msg = eval("("+e.data+")");
messages.append(
$("<li>").append(
$("<img>").
attr("title", msg.Name)
.css({
width:50,
verticalAlign:"middle"
}).attr("src", msg.AvatarURL),
$("<span>").text(msg.Message)
)
);
}
构建并运行应用程序,刷新浏览器,看看是否出现新的设计:
go build –o chat
./chat –host=:8080
上述命令显示以下输出:

通过对代码进行相对较少的更改,我们大大改善了应用程序的外观和感觉。
实现 Gravatar
Gravatar 是一个网络服务,允许用户上传单个个人资料图片,并将其与其电子邮件地址关联,以便从任何网站获取。像我们这样的开发人员可以通过在特定 API 端点上执行GET操作来访问这些图像,用于我们的应用程序。在本节中,我们将看到如何实现 Gravatar,而不是使用认证服务提供的图片。
抽象化头像 URL 过程
由于我们的应用程序有三种不同的获取头像 URL 的方式,我们已经达到了一个合理的学习如何将功能抽象化以清晰地实现这些选项的点。抽象化是指我们将某物的概念与其具体实现分离的过程。http.Handler是一个很好的例子,它展示了如何使用处理程序以及其细节,而不具体说明每个处理程序采取的操作。
在 Go 中,我们开始通过定义一个接口来描述获取头像 URL 的想法。让我们创建一个名为avatar.go的新文件,并插入以下代码:
package main
import (
"errors"
)
// ErrNoAvatar is the error that is returned when the
// Avatar instance is unable to provide an avatar URL.
var ErrNoAvatarURL = errors.New("chat: Unable to get an avatar URL.")
// Avatar represents types capable of representing
// user profile pictures.
type Avatar interface {
// GetAvatarURL gets the avatar URL for the specified client,
// or returns an error if something goes wrong.
// ErrNoAvatarURL is returned if the object is unable to get
// a URL for the specified client.
GetAvatarURL(c *client) (string, error)
}
Avatar接口描述了一个类型必须满足的GetAvatarURL方法,以便能够获取头像 URL。我们将客户端作为参数,以便知道为哪个用户返回 URL。该方法返回两个参数:一个字符串(如果一切顺利,将是 URL),以及一个错误,以防出现问题。
可能出错的一件事是Avatar的特定实现之一无法获取 URL。在这种情况下,GetAvatarURL将作为第二个参数返回ErrNoAvatarURL错误。因此,ErrNoAvatarURL错误成为接口的一部分;它是该方法可能返回的一个可能值,也是我们代码的用户可能需要明确处理的内容。我们在方法的注释部分提到了这一点,这是在 Go 中传达这种设计决策的唯一方式。
提示
因为错误是立即使用errors.New初始化并存储在ErrNoAvatarURL变量中的,所以只会创建一个这样的对象;将错误的指针作为返回传递是非常廉价的。这与 Java 的检查异常不同,后者用作控制流的一部分时会创建并使用昂贵的异常对象。
认证服务和头像的实现
我们编写的第一个Avatar实现将替换现有的功能,其中我们硬编码了从认证服务获取的头像 URL。让我们使用测试驱动开发(TDD)的方法,这样我们就可以确保我们的代码可以正常工作,而不必手动测试。让我们在chat文件夹中创建一个名为avatar_test.go的新文件:
package main
import "testing"
func TestAuthAvatar(t *testing.T) {
var authAvatar AuthAvatar
client := new(client)
url, err := authAvatar.GetAvatarURL(client)
if err != ErrNoAvatarURL {
t.Error("AuthAvatar.GetAvatarURL should return ErrNoAvatarURL when no value present")
}
// set a value
testUrl := "http://url-to-gravatar/"
client.userData = map[string]interface{}{"avatar_url": testUrl}
url, err = authAvatar.GetAvatarURL(client)
if err != nil {
t.Error("AuthAvatar.GetAvatarURL should return no error when value present")
} else {
if url != testUrl {
t.Error("AuthAvatar.GetAvatarURL should return correct URL")
}
}
}
这个测试文件包含了我们尚不存在的AuthAvatar类型的GetAvatarURL方法的测试。首先,它使用一个没有用户数据的客户端,并确保返回ErrNoAvatarURL错误。在设置合适的值之后,我们的测试再次调用该方法,这次是为了断言它返回正确的值。然而,构建这段代码失败了,因为AuthAvatar类型不存在,所以我们将接下来声明authAvatar。
在编写我们的实现之前,值得注意的是,我们只声明了authAvatar变量作为AuthAvatar类型,但实际上从未给它赋值,所以它的值保持为nil。这不是一个错误;我们实际上正在利用 Go 的零初始化(或默认初始化)能力。由于我们的对象不需要状态(我们将client作为参数传递),因此没有必要在初始化一个实例上浪费时间和内存。在 Go 中,可以在nil对象上调用方法,前提是该方法不会尝试访问字段。当我们实际编写我们的实现时,我们将考虑一种方法,以确保这种情况成立。
让我们回到avatar.go,让我们的测试通过。在文件底部添加以下代码:
type AuthAvatar struct{}
var UseAuthAvatar AuthAvatar
func (_ AuthAvatar) GetAvatarURL(c *client) (string, error) {
if url, ok := c.userData["avatar_url"]; ok {
if urlStr, ok := url.(string); ok {
return urlStr, nil
}
}
return "", ErrNoAvatarURL
}
在这里,我们将我们的AuthAvatar类型定义为空结构,并定义GetAvatarURL方法的实现。我们还创建了一个方便的变量UseAuthAvatar,它具有AuthAvatar类型,但其值保持为nil。我们以后可以将UseAuthAvatar变量分配给任何寻找Avatar接口类型的字段。
通常,方法的接收器(在名称之前括号中定义的类型)将被分配给一个变量,以便在方法体中访问它。由于在我们的情况下,我们假设对象可以具有nil值,我们可以使用下划线告诉 Go 丢弃引用。这也作为一个额外的提醒,告诉我们自己应该避免使用它。
我们的实现主体在其他方面相对简单:我们安全地寻找avatar_url的值,并确保它是一个字符串,然后将其返回。如果沿途出现任何问题,我们将返回接口中定义的ErrNoAvatarURL错误。
让我们通过打开终端并导航到chat文件夹,然后输入以下内容来运行测试:
go test
如果一切顺利,我们的测试将通过,我们将成功创建我们的第一个Avatar实现。
使用一个实现
当我们使用一个实现时,我们可以直接引用辅助变量,或者在需要功能时创建自己的接口实例。然而,这样做会违背抽象的初衷。相反,我们使用Avatar接口类型来指示我们需要的功能的位置。
对于我们的聊天应用程序,我们将有一种方法来获取每个聊天室的头像 URL。所以让我们更新room类型,使其可以保存一个Avatar对象。在room.go中,向room struct类型的字段定义中添加以下内容:
// avatar is how avatar information will be obtained.
avatar Avatar
更新newRoom函数,以便我们可以传入一个Avatar实现来使用;当我们创建我们的room实例时,我们将简单地将这个实现分配给新字段:
// newRoom makes a new room that is ready to go.
func newRoom(avatar Avatar) *room {
return &room{
forward: make(chan *message),
join: make(chan *client),
leave: make(chan *client),
clients: make(map[*client]bool),
tracer: trace.Off(),
avatar: avatar,
}
}
现在构建项目将突出显示main.go中对newRoom的调用是错误的,因为我们没有提供Avatar参数;让我们通过传入我们方便的UseAuthAvatar变量来更新它:
r := newRoom(UseAuthAvatar)
我们不必创建AuthAvatar的实例,因此没有分配内存。在我们的情况下,这并不会带来很大的节省(因为我们的整个应用程序只有一个房间),但是想象一下,如果我们的应用程序有成千上万个房间,潜在的节省规模是多么庞大。我们命名UseAuthAvatar变量的方式意味着前面的代码非常容易阅读,也使我们的意图明显。
提示
在设计接口时考虑代码的可读性是很重要的。考虑一个接受布尔输入的方法——如果你不知道参数名称,只传递 true 或 false 会隐藏真正的含义。考虑定义一些辅助常量,如以下简短示例中所示:
func move(animated bool) { /* ... */ }
const Animate = true
const DontAnimate = false
考虑一下以下对move的调用哪一个更容易理解:
move(true)
move(false)
move(Animate)
move(DontAnimate)
现在剩下的就是将client更改为使用我们的新Avatar接口。在client.go中,更新read方法如下:
func (c *client) read() {
for {
var msg *message
if err := c.socket.ReadJSON(&msg); err == nil {
msg.When = time.Now()
msg.Name = c.userData["name"].(string)
msg.AvatarURL, _ = c.room.avatar.GetAvatarURL(c)
c.room.forward <- msg
} else {
break
}
}
c.socket.Close()
}
在这里,我们要求room上的avatar实例为我们获取头像 URL,而不是从userData中提取它。
当构建和运行应用程序时,您会注意到(尽管我们稍微重构了一些东西),行为和用户体验根本没有改变。这是因为我们告诉我们的房间使用AuthAvatar实现。
现在让我们向房间添加另一个实现。
Gravatar 实现
Avitar中的 Gravatar 实现将执行与AuthAvatar实现相同的工作,只是它将生成托管在Gravatar.com上的个人资料图片的 URL。让我们首先在avatar_test.go文件中添加一个测试:
func TestGravatarAvatar(t *testing.T) {
var gravatarAvitar GravatarAvatar
client := new(client)
client.userData = map[string]interface{}{"email": "MyEmailAddress@example.com"}
url, err := gravatarAvitar.GetAvatarURL(client)
if err != nil {
t.Error("GravatarAvitar.GetAvatarURL should not return an error")
}
if url != "//www.gravatar.com/avatar/0bc83cb571cd1c50ba6f3e8a78ef1346" {
t.Errorf("GravatarAvitar.GetAvatarURL wrongly returned %s", url)
}
}
Gravatar 使用电子邮件地址的哈希来生成每个个人资料图片的唯一 ID,因此我们设置一个客户端,并确保userData包含一个电子邮件地址。接下来,我们调用相同的GetAvatarURL方法,但这次是在具有GravatarAvatar类型的对象上。然后我们断言返回了正确的 URL。我们已经知道这是指定电子邮件地址的适当 URL,因为它在 Gravatar 文档中作为示例列出了,这是确保我们的代码正在执行应该执行的工作的一个很好的策略。
提示
请记住,本书的所有源代码都可以在 GitHub 上找到。您可以通过从github.com/matryer/goblueprints复制和粘贴片段来节省构建前述核心的时间。通常硬编码诸如基本 URL 之类的东西并不是一个好主意;我们在整本书中都进行了硬编码,以使代码片段更容易阅读和更明显,但是如果您愿意,您可以在进行过程中提取它们。
运行这些测试(使用go test)显然会导致错误,因为我们还没有定义我们的类型。让我们回到avatar.go,并在确保导入io包的情况下添加以下代码:
type GravatarAvatar struct{}
var UseGravatar GravatarAvatar
func (_ GravatarAvatar) GetAvatarURL(c *client) (string, error) {
if email, ok := c.userData["email"]; ok {
if emailStr, ok := email.(string); ok {
m := md5.New()
io.WriteString(m, strings.ToLower(emailStr))
return fmt.Sprintf("//www.gravatar.com/avatar/%x", m.Sum(nil)), nil
}
}
return "", ErrNoAvatarURL
}
我们使用了与AuthAvatar相同的模式:一个空的结构体,一个有用的UseGravatar变量,以及GetAvatarURL方法的实现本身。在这个方法中,我们遵循 Gravatar 的指南,从电子邮件地址生成 MD5 哈希(在确保它是小写之后),并将其附加到硬编码的基本 URL 上。
在 Go 中很容易实现哈希处理,这要归功于 Go 标准库的作者们的辛勤工作。crypto包具有令人印象深刻的密码学和哈希处理能力——所有这些都非常容易使用。在我们的情况下,我们创建一个新的md5哈希处理器;因为哈希处理器实现了io.Writer接口,我们可以使用io.WriteString向其中写入一串字节。调用Sum返回已写入字节的当前哈希值。
提示
您可能已经注意到,每次需要头像 URL 时,我们都会对电子邮件地址进行哈希处理。这在规模上是相当低效的,但我们应该优先考虑完成工作而不是优化。如果需要,我们随时可以回来改变这种工作方式。
现在运行测试会显示我们的代码正在工作,但我们还没有在auth cookie 中包含电子邮件地址。我们通过定位在auth.go中为authCookieValue对象分配值的代码,并更新它以从 Gomniauth 获取Email值来实现这一点:
authCookieValue := objx.New(map[string]interface{}{
"name": user.Name(),
"avatar_url": user.AvatarURL(),
"email": user.Email(),
}).MustBase64()
我们必须做的最后一件事是告诉我们的房间使用 Gravatar 实现而不是AuthAvatar实现。我们通过在main.go中调用newRoom并进行以下更改来实现这一点:
r := newRoom(UseGravatar)
再次构建和运行聊天程序,然后转到浏览器。请记住,由于我们已更改 cookie 中存储的信息,我们必须注销并重新登录,以便看到我们的更改生效。
假设您的 Gravatar 帐户有不同的图像,您会注意到系统现在从 Gravatar 而不是认证提供程序中获取图像。使用浏览器的检查器或调试工具将显示img标签的src属性确实已更改。

如果您没有 Gravatar 帐户,您可能会看到一个默认的占位图像代替您的个人资料图片。
上传头像图片
在上传图片的第三种方法中,我们将看看如何允许用户从本地硬盘上传图像,以便在聊天时用作他们的个人资料图片。我们需要一种方法来将文件与特定用户关联起来,以确保我们将正确的图片与相应的消息关联起来。
用户识别
为了唯一标识我们的用户,我们将复制 Gravatar 的方法,通过对他们的电子邮件地址进行哈希处理,并使用结果字符串作为标识符。我们将用户 ID 与其他用户特定数据一起存储在 cookie 中。这实际上还有一个额外的好处,就是从GravatarAuth中删除了与持续哈希处理相关的低效。
在auth.go中,用以下代码替换创建authCookieValue对象的代码:
m := md5.New()
io.WriteString(m, strings.ToLower(user.Name()))
userId := fmt.Sprintf("%x", m.Sum(nil))
// save some data
authCookieValue := objx.New(map[string]interface{}{
"userid": userId,
"name": user.Name(),
"avatar_url": user.AvatarURL(),
"email": user.Email(),
}).MustBase64()
在用户登录时,我们对电子邮件地址进行了哈希处理,并将结果值存储在userid字段中。从此时起,我们可以在我们的 Gravatar 代码中使用这个值,而不是为每条消息对电子邮件地址进行哈希处理。为了做到这一点,首先我们通过从avatar_test.go中删除以下行来更新测试:
client.userData = map[string]interface{}{"email": "MyEmailAddress@example.com"}
然后用这行替换前面的行:
client.userData = map[string]interface{}{"userid": "0bc83cb571cd1c50ba6f3e8a78ef1346"}
我们不再需要设置email字段,因为它没有被使用;相反,我们只需要为新的userid字段设置一个合适的值。但是,如果您在终端中运行go test,您会看到这个测试失败。
为了使测试通过,在avatar.go中,更新GravatarAuth类型的GetAvatarURL方法:
func (_ GravatarAvatar) GetAvatarURL(c *client) (string, error) {
if userid, ok := c.userData["userid"]; ok {
if useridStr, ok := userid.(string); ok {
return "//www.gravatar.com/avatar/" + useridStr, nil
}
}
return "", ErrNoAvatarURL
}
这不会改变行为,但它允许我们进行意外的优化,这是一个很好的例子,说明为什么不应该过早优化代码——你早期发现的低效可能不值得修复所需的努力。
一个上传表单
如果我们的用户要上传文件作为他们的头像,他们需要一种方法来浏览本地硬盘并将文件提交到服务器。我们通过添加一个新的基于模板的页面来实现这一点。在chat/templates文件夹中,创建一个名为upload.html的文件:
<html>
<head>
<title>Upload</title>
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css">
</head>
<body>
<div class="container">
<div class="page-header">
<h1>Upload picture</h1>
</div>
<form role="form" action="/uploader" enctype="multipart/form-data" method="post">
<input type="hidden" name="userid" value="{{.UserData.userid}}" />
<div class="form-group">
<label for="message">Select file</label>
<input type="file" name="avatarFile" />
</div>
<input type="submit" value="Upload" class="btn " />
</form>
</div>
</body>
</html>
我们再次使用 Bootstrap 使我们的页面看起来漂亮,并且使其与其他页面相匹配。但是,这里需要注意的关键点是 HTML 表单,它将为上传文件提供必要的用户界面。操作指向 /uploader,我们尚未实现的处理程序,enctype 属性必须是 multipart/form-data,以便浏览器可以通过 HTTP 传输二进制数据。然后,有一个 file 类型的 input 元素,它将包含我们要上传的文件的引用。还要注意,我们已将 UserData 映射中的 userid 值作为隐藏输入包含在内 —— 这将告诉我们哪个用户正在上传文件。重要的是 name 属性是正确的,因为这是我们在服务器上实现处理程序时将引用数据的方式。
现在让我们将新模板映射到 main.go 中的 /upload 路径:
http.Handle("/upload", &templateHandler{filename: "upload.html"})
处理上传
当用户在选择文件后点击 上传 时,浏览器将发送文件的数据以及用户 ID 到 /uploader,但是现在,这些数据实际上并没有去任何地方。我们将实现一个新的 HandlerFunc,能够接收文件,读取通过连接流传输的字节,并将其保存为服务器上的新文件。在 chat 文件夹中,让我们创建一个名为 avatars 的新文件夹 —— 这是我们将保存头像图像文件的地方。
接下来,创建一个名为 upload.go 的新文件,并插入以下代码 —— 确保添加适当的包名和导入(即 ioutils,net/http,io 和 path)。
func uploaderHandler(w http.ResponseWriter, req *http.Request) {
userId := req.FormValue("userid")
file, header, err := req.FormFile("avatarFile")
if err != nil {
io.WriteString(w, err.Error())
return
}
data, err := ioutil.ReadAll(file)
if err != nil {
io.WriteString(w, err.Error())
return
}
filename := path.Join("avatars", userId+path.Ext(header.Filename))
err = ioutil.WriteFile(filename, data, 0777)
if err != nil {
io.WriteString(w, err.Error())
return
}
io.WriteString(w, "Successful")
}
这里,首先 uploaderHandler 使用 http.Request 上的 FormValue 方法来获取我们在 HTML 表单中隐藏输入中放置的用户 ID。然后通过调用 req.FormFile 获取一个能够读取上传字节的 io.Reader 类型,它返回三个参数。第一个参数表示文件本身,具有 multipart.File 接口类型,也是一个 io.Reader。第二个是一个包含有关文件的元数据的 multipart.FileHeader 对象,例如文件名。最后,第三个参数是一个我们希望具有 nil 值的错误。
当我们说 multipart.File 接口类型也是 io.Reader 时,我们是什么意思呢?嗯,快速浏览一下 golang.org/pkg/mime/multipart/#File 上的文档,就会清楚地看到该类型实际上只是一些其他更一般接口的包装接口。这意味着 multipart.File 类型可以传递给需要 io.Reader 的方法,因为任何实现 multipart.File 的对象必须实现 io.Reader。
提示
嵌入标准库接口来描述新概念是确保代码在尽可能多的上下文中工作的好方法。同样,您应该尝试编写使用尽可能简单的接口类型的代码,理想情况下是来自标准库。例如,如果您编写了一个需要读取文件内容的方法,您可以要求用户提供 multipart.File 类型的参数。但是,如果您要求使用 io.Reader,您的代码将变得更加灵活,因为任何具有适当的 Read 方法的类型都可以传递进来,这也包括用户定义的类型。
ioutil.ReadAll 方法将继续从指定的 io.Reader 读取,直到接收到所有字节,因此这是我们实际从客户端接收字节流的地方。然后我们使用 path.Join 和 path.Ext 来使用 userid 构建一个新的文件名,并从 multipart.FileHeader 中获取原始文件名的扩展名。
然后,我们使用ioutil.WriteFile方法在avatars文件夹中创建一个新文件。我们在文件名中使用userid来将图像与正确的用户关联起来,就像 Gravatar 一样。0777值指定我们创建的新文件具有完整的文件权限,这是一个很好的默认设置,如果您不确定应设置什么其他权限。
如果在任何阶段发生错误,我们的代码将将其写入响应,这将帮助我们调试它,或者如果一切顺利,它将写入成功。
为了将这个新的处理程序函数映射到/uploader,我们需要回到main.go并在func main中添加以下行:
http.HandleFunc("/uploader", uploaderHandler)
现在构建并运行应用程序,并记得注销并重新登录,以便我们的代码有机会上传auth cookie。
go build -o chat
./chat -host=:8080
打开http://localhost:8080/upload,单击选择文件,然后从硬盘中选择一个文件,然后单击上传。转到您的chat/avatars文件夹,您会注意到文件确实已上传并重命名为您的userid字段的值。
提供图像
现在我们有了一个在服务器上保存用户头像图像的地方,我们需要一种方法使它们可以被浏览器访问。我们通过使用net/http包的内置文件服务器来实现这一点。在main.go中,添加以下代码:
http.Handle("/avatars/",
http.StripPrefix("/avatars/",
http.FileServer(http.Dir("./avatars"))))
实际上,这实际上是一行代码,已经被分解以提高可读性。http.Handle调用应该感觉很熟悉:我们正在指定我们要将/avatars/路径与指定的处理程序进行映射-这就是有趣的地方。http.StripPrefix和http.FileServer都返回Handler,它们使用我们在上一章中学到的装饰器模式。StripPrefix函数接受Handler,通过删除指定的前缀修改路径,并将功能传递给内部处理程序。在我们的情况下,内部处理程序是一个http.FileServer处理程序,它将简单地提供静态文件,提供索引列表,并在找不到文件时生成404 Not Found错误。http.Dir函数允许我们指定要公开的文件夹。
如果我们没有使用http.StripPrefix从请求中去掉/avatars/前缀,文件服务器将在实际avatars文件夹内寻找另一个名为avatars的文件夹,即/avatars/avatars/filename而不是/avatars/filename。
在打开浏览器之前,让我们构建程序并运行它http://localhost:8080/avatars/。您会注意到文件服务器已经生成了avatars文件夹内文件的列表。单击文件将要么下载文件,要么在图像的情况下简单地显示它。如果您还没有这样做,请转到http://localhost:8080/upload并上传一张图片,然后返回到列表页面并单击它以在浏览器中查看它。
本地文件的 Avatar 实现
使文件系统头像工作的最后一步是编写我们的Avatar接口的实现,生成指向我们在上一节中创建的文件系统端点的 URL。
让我们在我们的avatar_test.go文件中添加一个测试函数:
func TestFileSystemAvatar(t *testing.T) {
// make a test avatar file
filename := path.Join("avatars", "abc.jpg")
ioutil.WriteFile(filename, []byte{}, 0777)
defer func() { os.Remove(filename) }()
var fileSystemAvatar FileSystemAvatar
client := new(client)
client.userData = map[string]interface{}{"userid": "abc"}
url, err := fileSystemAvatar.GetAvatarURL(client)
if err != nil {
t.Error("FileSystemAvatar.GetAvatarURL should not return an error")
}
if url != "/avatars/abc.jpg" {
t.Errorf("FileSystemAvatar.GetAvatarURL wrongly returned %s", url)
}
}
这个测试与GravatarAvatar测试类似,但稍微复杂一些,因为我们还在avatars文件夹中创建一个测试文件,并在之后将其删除。
提示
defer关键字是确保代码运行的一个很好的方法,无论在函数的其余部分发生了什么。即使我们的测试代码发生恐慌,延迟函数仍将被调用。
测试的其余部分很简单:我们在client.userData中设置了一个userid字段,并调用GetAvatarURL以确保我们得到正确的值。当然,运行此测试将失败,所以让我们去添加以下代码以使其在avatar.go中通过:
type FileSystemAvatar struct{}
var UseFileSystemAvatar FileSystemAvatar
func (_ FileSystemAvatar) GetAvatarURL(c *client) (string, error) {
if userid, ok := c.userData["userid"]; ok {
if useridStr, ok := userid.(string); ok {
return "/avatars/" + useridStr + ".jpg", nil
}
}
return "", ErrNoAvatarURL
}
正如我们在这里看到的,为了生成正确的 URL,我们只需获取userid的值,并通过将适当的段连接在一起来构建最终的字符串。您可能已经注意到,我们已经将文件扩展名硬编码为.jpg,这意味着我们的聊天应用的初始版本只支持 JPEG 格式的图片。
提示
只支持 JPEG 可能看起来像是一个半成品的解决方案,但遵循敏捷方法论,这是完全可以的;毕竟,自定义 JPEG 个人资料图片总比没有个人资料图片要好。
通过更新main.go来查看我们的新代码的运行情况,以使用我们的新的Avatar实现:
r := newRoom(UseFileSystemAvatar)
现在像往常一样构建和运行应用程序,然后转到http://localhost:8080/upload,使用 Web 表单上传一个 JPEG 图像作为您的个人资料图片。为了确保它正常工作,请选择一个不是您 Gravatar 图片或认证服务图片的独特图片。在点击上传后看到成功消息后,转到http://localhost:8080/chat并发布一条消息。您会注意到应用程序确实使用了您上传的个人资料图片。
要更改您的个人资料图片,请返回到/upload页面并上传不同的图片,然后跳转回/chat页面并发布更多消息。
支持不同的文件类型
为了支持不同的文件类型,我们必须让我们的FileSystemAvatar类型的GetAvatarURL方法变得更加智能。
我们将使用非常有用的ioutil.ReadDir方法来获取文件列表,而不是盲目地构建字符串。列表还包括目录,因此我们将使用IsDir方法来确定我们是否应该跳过它。
然后,我们将检查每个文件是否以userid字段开头(记住我们以这种方式命名我们的文件),通过调用path.Match来进行检查。如果文件名与userid字段匹配,那么我们已经找到了该用户的文件,并返回路径。如果出现任何问题或者我们找不到文件,我们像往常一样返回ErrNoAvatarURL错误。
使用以下代码更新avatar.go中的适当方法:
func (_ FileSystemAvatar) GetAvatarURL(c *client) (string, error) {
if userid, ok := c.userData["userid"]; ok {
if useridStr, ok := userid.(string); ok {
if files, err := ioutil.ReadDir("avatars"); err == nil {
for _, file := range files {
if file.IsDir() {
continue
}
if match, _ := path.Match(useridStr+"*", file.Name()); match {
return "/avatars/" + file.Name(), nil
}
}
}
}
}
return "", ErrNoAvatarURL
}
删除avatar文件夹中的所有文件以防混淆,并重新构建程序。这次上传一个不同类型的图像,并注意到我们的应用程序没有任何困难地处理它。
重构和优化我们的代码
当我们回顾我们的Avatar类型的使用方式时,您会注意到每当有人发送消息时,应用程序都会调用GetAvatarURL。在我们最新的实现中,每次调用该方法时,我们都会遍历avatars文件夹中的所有文件。对于一个特别健谈的用户,这可能意味着我们会在一分钟内多次重复迭代。这显然是一种资源浪费,并且很快就会成为一个扩展问题。
我们将只在用户首次登录时获取头像 URL 并将其缓存在auth cookie 中,而不是为每条消息获取。不幸的是,我们的Avatar接口类型要求我们在GetAvatarURL方法中传入一个client对象,而在我们对用户进行身份验证时并没有这样的对象。
提示
那么,当我们设计Avatar接口时,我们是否犯了一个错误?虽然这是一个自然的结论,但事实上我们做得很对。我们根据当时可用的最佳信息设计了解决方案,因此比起尝试为每种可能的情况设计,我们更早地拥有了一个可用的聊天应用。软件会在开发过程中演变并几乎总是会发生变化,并且在代码的整个生命周期中肯定会发生变化。
用接口替换具体类型
我们得出结论,我们的GetAvatarURL方法依赖于我们在需要它的时候无法获得的类型,那么有什么好的替代方案呢?我们可以将每个所需的字段作为单独的参数传递,但这将使我们的接口变得脆弱,因为一旦Avatar实现需要新的信息,我们就必须更改方法签名。相反,我们将创建一个新类型,封装我们的Avatar实现需要的信息,同时在概念上保持与我们的特定情况解耦。
在auth.go中,在页面顶部添加以下代码(当然是在package关键字下面):
import gomniauthcommon "github.com/stretchr/gomniauth/common"
type ChatUser interface {
UniqueID() string
AvatarURL() string
}
type chatUser struct {
gomniauthcommon.User
uniqueID string
}
func (u chatUser) UniqueID() string {
return u.uniqueID
}
在这里,import语句从 Gomniauth 导入了common包,并同时为其指定了一个特定的名称,通过该名称可以访问它:gomniauthcommon。这并不是完全必要的,因为我们没有包名冲突。但是,这样做可以使代码更容易理解。
在前面的代码片段中,我们还定义了一个名为ChatUser的新接口类型,它公开了我们的Avatar实现生成正确 URL 所需的信息。然后,我们定义了一个名为chatUser的实际实现(注意小写字母开头),它实现了该接口。它还利用了 Go 中一个非常有趣的特性:类型嵌入。我们实际上嵌入了接口类型gomniauth/common.User,这意味着我们的struct自动实现了该接口。
您可能已经注意到,我们实际上只实现了满足ChatUser接口所需的两个方法中的一个。我们之所以能够这样做,是因为 Gomniauth 的User接口碰巧定义了相同的AvatarURL方法。实际上,当我们实例化我们的chatUser结构时——只要我们为暗示的 GomniauthUser字段设置适当的值——我们的对象同时实现了 Gomniauth 的User接口和我们自己的ChatUser接口。
以测试驱动的方式更改接口
在我们可以使用新类型之前,我们必须更新Avatar接口和适当的实现以利用它。由于我们将遵循 TDD 实践,我们将在测试文件中进行这些更改,看到我们尝试构建代码时的编译器错误,并在最终使测试通过之前看到失败的测试。
打开avatar_test.go,并用以下代码替换TestAuthAvatar:
func TestAuthAvatar(t *testing.T) {
var authAvatar AuthAvatar
testUser := &gomniauthtest.TestUser{}
testUser.On("AvatarURL").Return("", ErrNoAvatarURL)
testChatUser := &chatUser{User: testUser}
url, err := authAvatar.GetAvatarURL(testChatUser)
if err != ErrNoAvatarURL {
t.Error("AuthAvatar.GetAvatarURL should return ErrNoAvatarURL when no value present")
}
testUrl := "http://url-to-gravatar/"
testUser = &gomniauthtest.TestUser{}
testChatUser.User = testUser
testUser.On("AvatarURL").Return(testUrl, nil)
url, err = authAvatar.GetAvatarURL(testChatUser)
if err != nil {
t.Error("AuthAvatar.GetAvatarURL should return no error when value present")
} else {
if url != testUrl {
t.Error("AuthAvatar.GetAvatarURL should return correct URL")
}
}
}
提示
您还需要像在上一节中那样将gomniauth/test包导入为gomniauthtest。
在我们定义接口之前就使用我们的新接口是检查我们思路的合理性的好方法,这是练习 TDD 的另一个优势。在这个新测试中,我们创建了 Gomniauth 提供的TestUser,并将其嵌入到chatUser类型中。然后我们将新的chatUser类型传递给我们的GetAvatarURL调用,并对输出进行了与以往相同的断言。
提示
Gomniauth 的TestUser类型很有趣,因为它利用了Testify包的模拟能力。有关更多信息,请参阅github.com/stretchr/testify。
On和Return方法允许我们告诉TestUser在调用特定方法时该做什么。在第一种情况下,我们告诉AvatarURL方法返回错误,而在第二种情况下,我们要求它返回testUrl值,这模拟了我们在这个测试中涵盖的两种可能结果。
更新TestGravatarAvatar和TestFileSystemAvatar测试要简单得多,因为它们仅依赖于UniqueID方法,其值我们可以直接控制。
用以下代码替换avatar_test.go中的另外两个测试:
func TestGravatarAvatar(t *testing.T) {
var gravatarAvitar GravatarAvatar
user := &chatUser{uniqueID: "abc"}
url, err := gravatarAvitar.GetAvatarURL(user)
if err != nil {
t.Error("GravatarAvitar.GetAvatarURL should not return an error")
}
if url != "//www.gravatar.com/avatar/abc" {
t.Errorf("GravatarAvitar.GetAvatarURL wrongly returned %s", url)
}
}
func TestFileSystemAvatar(t *testing.T) {
// make a test avatar file
filename := path.Join("avatars", "abc.jpg")
ioutil.WriteFile(filename, []byte{}, 0777)
defer func() { os.Remove(filename) }()
var fileSystemAvatar FileSystemAvatar
user := &chatUser{uniqueID: "abc"}
url, err := fileSystemAvatar.GetAvatarURL(user)
if err != nil {
t.Error("FileSystemAvatar.GetAvatarURL should not return an error")
}
if url != "/avatars/abc.jpg" {
t.Errorf("FileSystemAvatar.GetAvatarURL wrongly returned %s", url)
}
}
当然,这个测试代码甚至不会编译,因为我们还没有更新我们的Avatar接口。在avatar.go中,更新Avatar接口类型中的GetAvatarURL签名,以接受ChatUser类型而不是client类型:
GetAvatarURL(ChatUser) (string, error)
提示
请注意,我们使用的是ChatUser接口(大写字母开头),而不是我们内部的chatUser实现结构——毕竟,我们希望对我们的GetAvatarURL方法接受的类型保持灵活。
尝试构建将会发现我们现在有破损的实现,因为所有的GetAvatarURL方法仍在要求一个client对象。
修复现有的实现
更改像我们这样的接口是自动查找受影响代码部分的好方法,因为它们会导致编译器错误。当然,如果我们正在编写其他人将使用的包,我们必须对更改接口更加严格。
现在,我们将更新三个实现签名以满足新的接口,并更改方法体以使用新类型。用以下内容替换FileSystemAvatar的实现:
func (_ FileSystemAvatar) GetAvatarURL(u ChatUser) (string, error) {
if files, err := ioutil.ReadDir("avatars"); err == nil {
for _, file := range files {
if file.IsDir() {
continue
}
if match, _ := path.Match(u.UniqueID()+"*", file.Name()); match {
return "/avatars/" + file.Name(), nil
}
}
}
return "", ErrNoAvatarURL
}
这里的关键变化是我们不再访问客户端上的userData字段,而是直接在ChatUser接口上调用UniqueID。
接下来,使用以下代码更新AuthAvatar的实现:
func (_ AuthAvatar) GetAvatarURL(u ChatUser) (string, error) {
url := u.AvatarURL()
if len(url) > 0 {
return url, nil
}
return "", ErrNoAvatarURL
}
我们的新设计正在证明更简单;如果我们能减少所需的代码量,这总是一件好事。上面的代码调用了AvatarURL值,并且只要它不为空(或len(url) > 0),我们就返回它;否则,我们返回ErrNoAvatarURL错误。
最后,更新GravatarAvatar的实现:
func (_ GravatarAvatar) GetAvatarURL(u ChatUser) (string, error) {
return "//www.gravatar.com/avatar/" + u.UniqueID(), nil
}
全局变量与字段
到目前为止,我们已经将Avatar实现分配给了room类型,这使我们可以为不同的房间使用不同的头像。然而,这暴露了一个问题:当用户登录时,我们不知道他们要去哪个房间,所以我们无法知道要使用哪种Avatar实现。因为我们的应用程序只支持一个房间,我们将考虑另一种选择实现的方法:使用全局变量。
全局变量就是在任何类型定义之外定义的变量,并且可以从包的任何部分访问(如果它被导出,则还可以从包外部访问)。对于简单的配置,比如使用哪种Avatar实现,它们是一个简单易行的解决方案。在main.go的import语句下面,添加以下行:
// set the active Avatar implementation
var avatars Avatar = UseFileSystemAvatar
这定义了avatars作为一个全局变量,当我们需要获取特定用户的头像 URL 时可以使用它。
实现我们的新设计
我们需要更改调用GetAvatarURL的代码,以便只访问我们放入userData缓存中的值(通过auth cookie)。更改分配msg.AvatarURL的行,如下所示:
if avatarUrl, ok := c.userData["avatar_url"]; ok {
msg.AvatarURL = avatarUrl.(string)
}
在auth.go的loginHandler中找到我们调用provider.GetUser的代码,并将其替换为设置authCookieValue对象的代码:
user, err := provider.GetUser(creds)
if err != nil {
log.Fatalln("Error when trying to get user from", provider, "-", err)
}
chatUser := &chatUser{User: user}
m := md5.New()
io.WriteString(m, strings.ToLower(user.Name()))
chatUser.uniqueID = fmt.Sprintf("%x", m.Sum(nil))
avatarURL, err := avatars.GetAvatarURL(chatUser)
if err != nil {
log.Fatalln("Error when trying to GetAvatarURL", "-", err)
}
在这里,我们在设置User字段(表示嵌入接口)为从 Gomniauth 返回的User值时创建了一个新的chatUser变量。然后我们将userid的 MD5 哈希保存到uniqueID字段中。
调用avatars.GetAvatarURL是我们辛苦工作的成果,因为现在我们在流程中更早地获取了用户的头像 URL。在auth.go中更新authCookieValue行,将头像 URL 缓存在 cookie 中,并删除电子邮件地址,因为它不再需要:
authCookieValue := objx.New(map[string]interface{}{
"userid": chatUser.uniqueID,
"name": user.Name(),
"avatar_url": avatarURL,
}).MustBase64()
无论Avatar实现需要做什么工作,比如在文件系统上迭代文件,都会因为实现只在用户首次登录时执行,而不是每次发送消息时执行而得到缓解。
整理和测试
最后,我们终于可以剪掉在重构过程中积累的一些废料。
由于我们不再将Avatar实现存储在room中,让我们从类型中删除该字段以及所有对它的引用。在room.go中,从room结构中删除avatar Avatar的定义,并更新newRoom方法:
func newRoom() *room {
return &room{
forward: make(chan *message),
join: make(chan *client),
leave: make(chan *client),
clients: make(map[*client]bool),
tracer: trace.Off(),
}
}
提示
记住尽可能使用编译器作为待办事项列表,并跟随错误找出你影响其他代码的地方。
在main.go中,删除传递给newRoom函数调用的参数,因为我们使用全局变量而不是这个。
在这个练习之后,最终用户体验保持不变。通常,在重构代码时,修改的是内部结构,而公共接口保持稳定和不变。
提示
通常,运行诸如golint和go vet之类的工具对你的代码进行检查是一个好主意,以确保它遵循良好的实践,并且不包含任何 Go 的错误,比如缺少注释或命名不当的函数。
合并所有三种实现
为了以一个轰轰烈烈的方式结束这一章,我们将实现一个机制,其中每个Avatar实现轮流尝试获取值。如果第一个实现返回ErrNoAvatarURL错误,我们将尝试下一个,依此类推,直到找到可用的值。
在avatar.go中,在Avatar类型下面,添加以下类型定义:
type TryAvatars []Avatar
TryAvatars类型只是Avatar对象的一个切片;因此,我们将添加以下GetAvatarURL方法:
func (a TryAvatars) GetAvatarURL(u ChatUser) (string, error) {
for _, avatar := range a {
if url, err := avatar.GetAvatarURL(u); err == nil {
return url, nil
}
}
return "", ErrNoAvatarURL
}
这意味着TryAvatars现在是一个有效的Avatar实现,并且可以用来替代任何特定的实现。在前面的方法中,我们按顺序迭代Avatar对象的切片,为每个对象调用GetAvatarURL。如果没有返回错误,我们返回 URL;否则,我们继续寻找。最后,如果我们无法找到一个值,我们只需根据接口设计返回ErrNoAvatarURL。
在main.go中更新avatars全局变量以使用我们的新实现:
var avatars Avatar = TryAvatars{
UseFileSystemAvatar,
UseAuthAvatar,
UseGravatar}
在这里,我们创建了一个新的TryAvatars切片类型的实例,同时将其他Avatar实现放在其中。顺序很重要,因为它按照它们在切片中出现的顺序对对象进行迭代。因此,首先我们的代码将检查用户是否上传了图片;如果没有,代码将检查认证服务是否有图片供我们使用。如果这两种方法都失败,将生成一个 Gravatar URL,在最坏的情况下(例如,如果用户没有添加 Gravatar 图片),将呈现一个默认的占位图像。
要查看我们的新功能的运行情况,请执行以下步骤:
- 构建并重新运行应用程序:
go build –o chat
./chat –host=:8080
-
通过访问
http://localhost:8080/logout注销。 -
从
avatars文件夹中删除所有图片。 -
通过导航到
http://localhost:8080/chat重新登录。 -
发送一些消息并注意你的个人资料图片。
-
访问
http://localhost:8080/upload并上传新的个人资料图片。 -
再次注销,然后像以前一样登录。
-
发送一些消息并注意你的个人资料图片已更新。
摘要
在本章中,我们为我们的聊天应用程序添加了三种不同的个人资料图片实现。首先,我们要求认证服务为我们提供一个 URL 来使用。我们通过使用 Gomniauth 对用户资源数据的抽象来实现这一点,然后将其作为用户界面的一部分包含在每次用户发送消息时。使用 Go 的零(或默认)初始化模式,我们能够引用Avatar接口的不同实现而不实际创建任何实例。
我们在用户登录时将数据存储在 cookie 中。因此,还有一个事实是,由于 cookie 在我们的代码构建之间保持持久性,我们添加了一个方便的注销功能来帮助我们验证我们的更改,我们还向用户公开了这个功能,以便他们也可以注销。对代码进行其他小的更改,并在我们的聊天页面上包含 Bootstrap,大大改善了我们应用程序的外观和感觉。
我们在 Go 中使用 MD5 哈希来实现Gravatar.com API,通过对认证服务提供的电子邮件地址进行哈希处理。如果 Gravatar 不知道电子邮件地址,他们会为我们提供一个漂亮的默认占位图像,这意味着我们的用户界面永远不会因缺少图像而出现问题。
然后,我们构建并完成了一个上传表单,并关联了保存上传图片的服务器功能到avatars文件夹。我们看到如何通过标准库的http.FileServer处理程序向用户公开保存的上传图片。由于这在我们的设计中引入了效率低下的问题,导致了过多的文件系统访问,我们通过单元测试的帮助重构了我们的解决方案。通过将GetAvatarURL调用移动到用户登录时而不是每次发送消息时,我们使我们的代码显著提高了可扩展性。
我们特殊的ErrNoAvatarURL错误类型被用作接口设计的一部分,以便在无法获取适当的 URL 时通知调用代码——当我们创建Avatars切片类型时,这变得特别有用。通过在一系列Avatar类型上实现Avatar接口,我们能够创建一个新的实现,轮流尝试从每个可用选项中获取有效的 URL,首先是文件系统,然后是认证服务,最后是 Gravatar。我们实现了这一点,而用户与接口交互的方式完全没有受到影响。如果一个实现返回ErrNoAvatarURL,我们就尝试下一个。
我们的聊天应用已经准备好上线,这样我们就可以邀请朋友进行真正的对话。但首先,我们需要选择一个域名来托管它,这是我们将在下一章中讨论的事情。
第四章:用于查找域名的命令行工具
我们在前几章中构建的聊天应用程序已经准备好在互联网上大放异彩,但在邀请朋友加入对话之前,我们需要为其在互联网上找一个家。在邀请朋友加入对话之前,我们需要选择一个有效、引人注目且可用的域名,以便将其指向运行我们 Go 代码的服务器。我们将开发一些命令行工具,而不是在我们喜爱的域名提供商前面花费数小时尝试不同的名称,这些工具将帮助我们找到合适的域名。在这个过程中,我们将看到 Go 标准库如何允许我们与终端和其他正在执行的应用程序进行交互,以及探索一些构建命令行程序的模式和实践。
在本章中,您将学到:
-
如何使用尽可能少的代码文件构建完整的命令行应用程序
-
如何确保我们构建的工具可以使用标准流与其他工具组合
-
如何与简单的第三方 JSON RESTful API 进行交互
-
如何在 Go 代码中利用标准输入和输出管道
-
如何从流式源中逐行读取
-
如何构建 WHOIS 客户端来查找域信息
-
如何存储和使用敏感或部署特定信息的环境变量
命令行工具的管道设计
我们将构建一系列命令行工具,这些工具使用标准流(stdin和stdout)与用户和其他工具进行通信。每个工具将通过标准输入管道逐行接收输入,以某种方式处理它,然后通过标准输出管道逐行打印输出,以供下一个工具或用户使用。
默认情况下,标准输入连接到用户的键盘,标准输出打印到运行命令的终端;但是,可以使用重定向元字符进行重定向。可以通过将输出重定向到 Windows 上的NUL或 Unix 机器上的/dev/null来丢弃输出,也可以将其重定向到文件,这将导致输出保存到磁盘。或者,您可以使用|管道字符将一个程序的输出管道到另一个程序的输入;我们将利用这个特性来连接我们的各种工具。例如,您可以通过以下代码将一个程序的输出管道到终端中的另一个程序的输入:
one | two
我们的工具将使用字符串行的形式进行操作,其中每行(由换行符分隔)代表一个字符串。当没有任何管道重定向时,我们将能够直接与程序进行交互,使用默认的输入和输出,这在测试和调试代码时将非常有用。
五个简单的程序
在本章中,我们将构建五个小程序,最后将它们组合在一起。程序的主要特点如下:
-
Sprinkle:该程序将添加一些适合网络的词语,以增加找到可用域名的机会
-
Domainify:该程序将确保单词适合作为域名,方法是删除不可接受的字符,用连字符替换空格,并在末尾添加适当的顶级域(如
.com和.net) -
Coolify:该程序将通过调整元音字母将无聊的普通单词变成 Web 2.0
-
Synonyms:该程序将使用第三方 API 查找同义词
-
可用:该程序将使用适当的 WHOIS 服务器检查域名是否可用
五个程序在一个章节中可能看起来很多,但不要忘记在 Go 中整个程序可以有多小。
Sprinkle
我们的第一个程序通过添加一些糖词来增加找到可用名称的几率。许多公司使用这种方法来保持核心消息一致,同时又能够负担得起.com域名。例如,如果我们传入单词chat,它可能输出chatapp;或者,如果我们传入talk,我们可能得到talk time。
Go 的math/rand包允许我们摆脱计算机的可预测性,为我们的程序过程提供机会或机会,并使我们的解决方案感觉比实际更智能一些。
为了使我们的 Sprinkle 程序工作,我们将:
-
使用特殊常量定义转换数组,以指示原始单词将出现在哪里
-
使用
bufio包从stdin扫描输入,并使用fmt.Println将输出写入stdout -
使用
math/rand包来随机选择要应用于单词的转换,比如在单词后添加"app"或在术语前添加"get"
提示
我们所有的程序都将驻留在$GOPATH/src目录中。例如,如果您的GOPATH是~/Work/projects/go,您将在~/Work/projects/go/src文件夹中创建您的程序文件夹。
在$GOPATH/src目录中,创建一个名为sprinkle的新文件夹,并添加一个包含以下代码的main.go文件:
package main
import (
"bufio"
"fmt"
"math/rand"
"os"
"strings"
"time"
)
const otherWord = "*"
var transforms = []string{
otherWord,
otherWord,
otherWord,
otherWord,
otherWord + "app",
otherWord + "site",
otherWord + "time",
"get" + otherWord,
"go" + otherWord,
"lets " + otherWord,
}
func main() {
rand.Seed(time.Now().UTC().UnixNano())
s := bufio.NewScanner(os.Stdin)
for s.Scan() {
t := transforms[rand.Intn(len(transforms))]
fmt.Println(strings.Replace(t, otherWord, s.Text(), -1))
}
}
从现在开始,假定您将自行解决适当的import语句。如果需要帮助,请参考附录中提供的提示,稳定的 Go 环境的良好实践。
前面的代码代表了我们完整的 Sprinkle 程序。它定义了三件事:一个常量,一个变量,以及作为 Sprinkle 入口点的必需的main函数。otherWord常量字符串是一个有用的标记,允许我们指定原始单词应出现在我们可能的每个转换中的位置。它让我们编写诸如otherWord+"extra"的代码,这清楚地表明,在这种特殊情况下,我们想在原始单词的末尾添加单词 extra。
可能的转换存储在我们声明为字符串切片的transforms变量中。在前面的代码中,我们定义了一些不同的转换,比如在单词末尾添加app或在单词前添加lets。随意添加一些更多的转换;越有创意,越好。
在main函数中,我们首先使用当前时间作为随机种子。计算机实际上无法生成随机数,但更改随机算法的种子数字会产生它可以的幻觉。我们使用纳秒级的当前时间,因为每次运行程序时它都是不同的(前提是系统时钟在每次运行之前没有被重置)。
然后,我们创建一个bufio.Scanner对象(称为bufio.NewScanner),并告诉它从os.Stdin读取输入,表示标准输入流。由于我们总是要从标准输入读取并写入标准输出,这将是我们五个程序中的常见模式。
提示
bufio.Scanner对象实际上将io.Reader作为其输入源,因此我们可以在这里使用各种类型。如果您为此代码编写单元测试,可以为扫描器指定自己的io.Reader,从中读取,而无需担心模拟标准输入流的需要。
作为默认情况,扫描器允许我们逐个读取由定义的分隔符分隔的字节块,例如回车和换行符。我们可以为扫描器指定自己的分割函数,或者使用标准库中内置的选项之一。例如,有bufio.ScanWords可以通过在空格上断开而不是换行符上断开来扫描单个单词。由于我们的设计规定每行必须包含一个单词(或短语),默认的逐行设置是理想的。
对Scan方法的调用告诉扫描器读取输入的下一块字节(下一行),并返回一个bool值,指示它是否找到了任何内容。这就是我们能够将其用作for循环的条件的方式。只要有内容可以处理,Scan就会返回true,并执行for循环的主体,当Scan到达输入的末尾时,它返回false,循环就会被打破。已选择的字节存储在扫描器的Bytes方法中,我们使用的方便的Text方法将[]byte切片转换为字符串。
在for循环内(对于每行输入),我们使用rand.Intn从transforms切片中选择一个随机项,并使用strings.Replace将原始单词插入到otherWord字符串出现的位置。最后,我们使用fmt.Println将输出打印到默认标准输出流。
让我们构建我们的程序并玩耍一下:
go build –o sprinkle
./sprinkle
一旦程序运行,由于我们没有输入任何内容,或者指定了一个来源来读取内容,我们将使用默认行为,从终端读取用户输入。输入chat并按回车。我们代码中的扫描器注意到单词末尾的换行符,并运行转换代码,输出结果。例如,如果您多次输入chat,您可能会看到类似的输出:
chat
go chat
chat
lets chat
chat
chat app
Sprinkle 永远不会退出(意味着Scan方法永远不会返回false来中断循环),因为终端仍在运行;在正常执行中,输入管道将被生成输入的任何程序关闭。要停止程序,请按Ctrl + C。
在我们继续之前,让我们尝试运行 Sprinkle,指定一个不同的输入源,我们将使用echo命令生成一些内容,并使用管道字符将其输入到我们的 Sprinkle 程序中:
echo "chat" | ./sprinkle
程序将随机转换单词,打印出来,然后退出,因为echo命令在终止和关闭管道之前只生成一行输入。
我们已经成功完成了我们的第一个程序,它有一个非常简单但有用的功能,我们将会看到。
练习-可配置的转换
作为额外的任务,不要像我们所做的那样将transformations数组硬编码,看看是否可以将其外部化到文本文件或数据库中。
Domainify
从 Sprinkle 输出的一些单词包含空格和其他在域名中不允许的字符,因此我们将编写一个名为 Domainify 的程序,将一行文本转换为可接受的域段,并在末尾添加适当的顶级域(TLD)。在sprinkle文件夹旁边,创建一个名为domainify的新文件夹,并添加一个带有以下代码的main.go文件:
package main
var tlds = []string{"com", "net"}
const allowedChars = "abcdefghijklmnopqrstuvwxyz0123456789_-"
func main() {
rand.Seed(time.Now().UTC().UnixNano())
s := bufio.NewScanner(os.Stdin)
for s.Scan() {
text := strings.ToLower(s.Text())
var newText []rune
for _, r := range text {
if unicode.IsSpace(r) {
r = '-'
}
if !strings.ContainsRune(allowedChars, r) {
continue
}
newText = append(newText, r)
}
fmt.Println(string(newText) + "." +
tlds[rand.Intn(len(tlds))])
}
}
您会注意到 Domainify 和 Sprinkle 程序之间的一些相似之处:我们使用rand.Seed设置随机种子,使用NewScanner方法包装os.Stdin读取器,并扫描每一行,直到没有更多的输入。
然后我们将文本转换为小写,并构建一个名为newText的rune类型的新切片。rune类型仅包含出现在allowedChars字符串中的字符,strings.ContainsRune让我们知道。如果rune是一个空格,我们通过调用unicode.IsSpace来确定,我们将其替换为连字符,这在域名中是可以接受的做法。
注意
在字符串上进行范围循环会返回每个字符的索引和rune类型,这是一个表示字符本身的数值(具体是int32)。有关符文、字符和字符串的更多信息,请参阅blog.golang.org/strings。
最后,我们将newText从[]rune切片转换为字符串,并在打印之前在末尾添加.com或.net。
构建并运行 Domainify:
go build –o domainify
./domainify
输入一些选项,看看domainify的反应如何:
-
Monkey -
Hello Domainify -
"What's up?" -
One (two) three!
例如,One (two) three!可能产生one-two-three.com。
现在我们将组合 Sprinkle 和 Domainify 以使它们一起工作。在您的终端中,导航到sprinkle和domainify的父文件夹(可能是$GOPATH/src),并运行以下命令:
./sprinkle/sprinkle | ./domainify/domainify
在这里,我们运行了 Sprinkle 程序并将输出导入 Domainify 程序。默认情况下,sprinkle使用终端作为输入,domanify输出到终端。再次尝试多次输入chat,注意输出与之前 Sprinkle 输出的类似,只是现在这些单词适合作为域名。正是这种程序之间的管道传输使我们能够组合命令行工具。
练习-使顶级域名可配置
仅支持.com和.net顶级域名相当受限。作为额外的任务,看看是否可以通过命令行标志接受 TLD 列表。
Coolify
通常,像chat这样的常见单词的域名已经被占用,一个常见的解决方案是对单词中的元音进行处理。例如,我们可能删除a得到cht(实际上更不太可能可用),或者添加一个a得到chaat。虽然这显然对酷度没有实际影响,但它已经成为一种流行的,尽管略显过时的方式来获得仍然听起来像原始单词的域名。
我们的第三个程序 Coolify 将允许我们处理通过输入的单词的元音,并将修改后的版本写入输出。
在sprinkle和domainify旁边创建一个名为coolify的新文件夹,并创建带有以下代码的main.go代码文件:
package main
const (
duplicateVowel bool = true
removeVowel bool = false
)
func randBool() bool {
return rand.Intn(2) == 0
}
func main() {
rand.Seed(time.Now().UTC().UnixNano())
s := bufio.NewScanner(os.Stdin)
for s.Scan() {
word := []byte(s.Text())
if randBool() {
var vI int = -1
for i, char := range word {
switch char {
case 'a', 'e', 'i', 'o', 'u', 'A', 'E', 'I', 'O', 'U':
if randBool() {
vI = i
}
}
}
if vI >= 0 {
switch randBool() {
case duplicateVowel:
word = append(word[:vI+1], word[vI:]...)
case removeVowel:
word = append(word[:vI], word[vI+1:]...)
}
}
}
fmt.Println(string(word))
}
}
虽然前面的 Coolify 代码看起来与 Sprinkle 和 Domainify 的代码非常相似,但它稍微复杂一些。在代码的顶部,我们声明了两个常量,duplicateVowel和removeVowel,这有助于使 Coolify 代码更易读。switch语句决定我们是复制还是删除元音。此外,使用这些常量,我们能够非常清楚地表达我们的意图,而不仅仅使用true或false。
然后我们定义randBool辅助函数,它只是通过要求rand包生成一个随机数,然后检查该数字是否为零来随机返回true或false。它将是0或1,因此它有 50/50 的机会成为true。
Coolify 的main函数的开始方式与 Sprinkle 和 Domainify 的main函数相同——通过设置rand.Seed方法并在执行循环体之前创建标准输入流的扫描器来执行每行输入的循环体。我们首先调用randBool来决定是否要改变一个单词,因此 Coolify 只会影响通过其中的一半单词。
然后我们遍历字符串中的每个符文,并寻找元音。如果我们的randBool方法返回true,我们将元音字符的索引保留在vI变量中。如果不是,我们将继续在字符串中寻找另一个元音,这样我们就可以随机选择单词中的元音,而不总是修改相同的元音。
一旦我们选择了一个元音,我们再次使用randBool来随机决定要采取什么行动。
注意
这就是有用的常量发挥作用的地方;考虑以下备用的 switch 语句:
switch randBool() {
case true:
word = append(word[:vI+1], word[vI:]...)
case false:
word = append(word[:vI], word[vI+1:]...)
}
在上述代码片段中,很难判断发生了什么,因为true和false没有表达任何上下文。另一方面,使用duplicateVowel和removeVowel告诉任何阅读代码的人我们通过randBool的结果的意图。
切片后面的三个点使每个项目作为单独的参数传递给append函数。这是一种将一个切片附加到另一个切片的成语方式。在switch情况下,我们对切片进行一些操作,以便复制元音或完全删除它。我们重新切片我们的[]byte切片,并使用append函数构建一个由原始单词的部分组成的新单词。以下图表显示了我们在代码中访问字符串的哪些部分:

如果我们以blueprints作为示例单词的值,并假设我们的代码选择第一个e字符作为元音(所以vI是3),我们可以看到单词的每个新切片在这个表中代表什么:
| 代码 | 值 | 描述 |
|---|---|---|
word[:vI+1] |
blue |
描述了从单词切片的开头到所选元音的切片。+1是必需的,因为冒号后面的值不包括指定的索引;它切片直到该值。 |
word[vI:] |
eprints |
描述了从所选元音开始并包括切片到切片的末尾。 |
word[:vI] |
blu |
描述了从单词切片的开头到所选元音之前的切片。 |
word[vI+1:] |
prints |
描述了从所选元音后的项目到切片的末尾。 |
修改单词后,我们使用fmt.Println将其打印出来。
让我们构建 Coolify 并玩一下,看看它能做什么:
go build –o coolify
./coolify
当 Coolify 运行时,尝试输入blueprints,看看它会做出什么样的修改:
blueprnts
bleprints
bluepriints
blueprnts
blueprints
bluprints
让我们看看 Coolify 如何与 Sprinkle 和 Domainify 一起玩,通过将它们的名称添加到我们的管道链中。在终端中,使用cd命令返回到父文件夹,并运行以下命令:
./coolify/coolify | ./sprinkle/sprinkle | ./domainify/domainify
首先,我们将用额外的部分来调整一个单词,通过调整元音字母使其更酷,最后将其转换为有效的域名。尝试输入一些单词,看看我们的代码会做出什么建议。
同义词
到目前为止,我们的程序只修改了单词,但要真正使我们的解决方案生动起来,我们需要能够集成一个提供单词同义词的第三方 API。这使我们能够在保留原始含义的同时建议不同的域名。与 Sprinkle 和 Domainify 不同,同义词将为每个给定的单词写出多个响应。我们将这三个程序连接在一起的架构意味着这不是问题;事实上,我们甚至不必担心,因为这三个程序都能够从输入源中读取多行。
bighughlabs.com的 Big Hugh Thesaurus 有一个非常干净简单的 API,允许我们进行一次 HTTP GET请求来查找同义词。
提示
如果将来我们使用的 API 发生变化或消失(毕竟,这是互联网!),您可以在github.com/matryer/goblueprints找到一些选项。
在使用 Big Hugh Thesaurus 之前,您需要一个 API 密钥,您可以通过在words.bighugelabs.com/注册该服务来获取。
使用环境变量进行配置
您的 API 密钥是一项敏感的配置信息,您不希望与他人分享。我们可以将其存储为代码中的const,但这不仅意味着我们不能在不分享密钥的情况下分享我们的代码(尤其是如果您喜欢开源项目),而且,也许更重要的是,如果密钥过期或者您想使用其他密钥,您将不得不重新编译您的项目。
更好的解决方案是使用环境变量来存储密钥,因为这样可以让您在需要时轻松更改它。您还可以为不同的部署设置不同的密钥;也许您在开发或测试中有一个密钥,而在生产中有另一个密钥。这样,您可以为代码的特定执行设置一个特定的密钥,这样您可以轻松地在不必更改系统级设置的情况下切换密钥。无论如何,不同的操作系统以类似的方式处理环境变量,因此如果您正在编写跨平台代码,它们是一个完美的选择。
创建一个名为BHT_APIKEY的新环境变量,并将您的 API 密钥设置为其值。
注意
对于运行 bash shell 的计算机,您可以修改您的~/.bashrc文件或类似文件,包括export命令,例如:
export BHT_APIKEY=abc123def456ghi789jkl
在 Windows 计算机上,您可以转到计算机的属性并在高级部分中查找环境变量。
消费 web API
在 Web 浏览器中请求words.bighugelabs.com/apisample.php?v=2&format=json会显示我们在查找单词 love 的同义词时 JSON 响应数据的结构。
{
"noun":{
"syn":[
"passion",
"beloved",
"dear"
]
},
"verb":{
"syn":[
"love",
"roll in the hay",
"make out"
],
"ant":[
"hate"
]
}
}
真正的 API 返回的实际单词比这里打印的要多得多,但结构才是重要的。它表示一个对象,其中键描述了单词类型(动词、名词等),值是包含在syn或ant(分别表示同义词和反义词)上的字符串数组的对象;这就是我们感兴趣的同义词。
要将这个 JSON 字符串数据转换成我们在代码中可以使用的东西,我们必须使用encoding/json包中的功能将其解码为我们自己的结构。因为我们正在编写的东西可能在我们项目的范围之外有用,所以我们将在一个可重用的包中消费 API,而不是直接在我们的程序代码中。在$GOPATH/src中的其他程序文件夹旁边创建一个名为thesaurus的新文件夹,并将以下代码插入到一个新的bighugh.go文件中:
package thesaurus
import (
"encoding/json"
"errors"
"net/http"
)
type BigHugh struct {
APIKey string
}
type synonyms struct {
Noun *words `json:"noun"`
Verb *words `json:"verb"`
}
type words struct {
Syn []string `json:"syn"`
}
func (b *BigHugh) Synonyms(term string) ([]string, error) {
var syns []string
response, err := http.Get("http://words.bighugelabs.com/api/2/" + b.APIKey + "/" + term + "/json")
if err != nil {
return syns, errors.New("bighugh: Failed when looking for synonyms for \"" + term + "\"" + err.Error())
}
var data synonyms
defer response.Body.Close()
if err := json.NewDecoder(response.Body).Decode(&data); err != nil {
return syns, err
}
syns = append(syns, data.Noun.Syn...)
syns = append(syns, data.Verb.Syn...)
return syns, nil
}
在上述代码中,我们定义的BigHugh类型包含必要的 API 密钥,并提供了Synonyms方法,该方法将负责访问端点、解析响应并返回结果。这段代码最有趣的部分是synonyms和words结构。它们用 Go 术语描述了 JSON 响应格式,即包含名词和动词对象的对象,这些对象又包含一个名为Syn的字符串切片。标签(在每个字段定义后面的反引号中的字符串)告诉encoding/json包将哪些字段映射到哪些变量;这是必需的,因为我们给它们赋予了不同的名称。
提示
通常,JSON 键具有小写名称,但我们必须在我们的结构中使用大写名称,以便encoding/json包知道这些字段存在。如果我们不这样做,包将简单地忽略这些字段。但是,类型本身(synonyms和words)不需要被导出。
Synonyms方法接受一个term参数,并使用http.Get向 API 端点发出 web 请求,其中 URL 不仅包含 API 密钥值,还包含term值本身。如果由于某种原因 web 请求失败,我们将调用log.Fatalln,它会将错误写入标准错误流并以非零退出代码(实际上是1的退出代码)退出程序,表示发生了错误。
如果 web 请求成功,我们将响应主体(另一个io.Reader)传递给json.NewDecoder方法,并要求它将字节解码为我们的synonyms类型的data变量。我们推迟关闭响应主体,以便在使用 Go 的内置append函数将noun和verb的同义词连接到我们然后返回的syns切片之前保持内存清洁。
虽然我们已经实现了BigHugh词库,但这并不是唯一的选择,我们可以通过为我们的包添加Thesaurus接口来表达这一点。在thesaurus文件夹中,创建一个名为thesaurus.go的新文件,并将以下接口定义添加到文件中:
package thesaurus
type Thesaurus interface {
Synonyms(term string) ([]string, error)
}
这个简单的接口只是描述了一个接受term字符串并返回包含同义词的字符串切片或错误(如果出现问题)的方法。我们的BigHugh结构已经实现了这个接口,但现在其他用户可以为其他服务添加可互换的实现,比如Dictionary.com或 Merriam-Webster 在线服务。
接下来我们将在一个程序中使用这个新的包。通过在终端中返回到$GOPATH/src,创建一个名为synonyms的新文件夹,并将以下代码插入到一个新的main.go文件中,然后将该文件放入该文件夹中:
func main() {
apiKey := os.Getenv("BHT_APIKEY")
thesaurus := &thesaurus.BigHugh{APIKey: apiKey}
s := bufio.NewScanner(os.Stdin)
for s.Scan() {
word := s.Text()
syns, err := thesaurus.Synonyms(word)
if err != nil {
log.Fatalln("Failed when looking for synonyms for \""+word+"\"", err)
}
if len(syns) == 0 {
log.Fatalln("Couldn't find any synonyms for \"" + word + "\"")
}
for _, syn := range syns {
fmt.Println(syn)
}
}
}
当你再次管理你的导入时,你将编写一个完整的程序,能够通过集成 Big Huge Thesaurus API 来查找单词的同义词。
在前面的代码中,我们的main函数首先要做的事情是通过os.Getenv调用获取BHT_APIKEY环境变量的值。为了使你的代码更加健壮,你可能需要再次检查以确保这个值被正确设置,并在没有设置时报告错误。现在,我们将假设一切都配置正确。
接下来,前面的代码开始看起来有点熟悉,因为它再次从os.Stdin扫描每一行输入,并调用Synonyms方法来获取替换词列表。
让我们构建一个程序,看看当我们输入单词chat时,API 返回了什么样的同义词:
go build –o synonyms
./synonyms
chat
confab
confabulation
schmooze
New World chat
Old World chat
conversation
thrush
wood warbler
chew the fat
shoot the breeze
chitchat
chatter
你得到的结果很可能与我们在这里列出的结果不同,因为我们正在使用实时 API,但这里重要的一点是,当我们将一个词或术语作为程序的输入时,它会返回一个同义词列表作为输出,每行一个。
提示
尝试以不同的顺序将你的程序链接在一起,看看你得到什么结果。无论如何,我们将在本章后面一起做这件事。
获取域名建议
通过组合我们在本章中迄今为止构建的四个程序,我们已经有了一个有用的工具来建议域名。现在我们所要做的就是运行这些程序,同时以适当的方式将输出导入输入。在终端中,导航到父文件夹并运行以下单行命令:
./synonyms/synonyms | ./sprinkle/sprinkle | ./coolify/coolify | ./domainify/domainify
因为synonyms程序在我们的列表中排在第一位,它将接收来自终端的输入(无论用户决定输入什么)。同样,因为domainify是链中的最后一个,它将把输出打印到终端供用户查看。在每一步,单词行将通过其他程序进行传输,使它们有机会发挥魔力。
输入一些单词来看一些域名建议,例如,如果你输入chat并回车,你可能会看到:
getcnfab.com
confabulationtim.com
getschmoozee.net
schmosee.com
neew-world-chatsite.net
oold-world-chatsite.com
conversatin.net
new-world-warblersit.com
gothrush.net
lets-wood-wrbler.com
chw-the-fat.com
你得到的建议数量实际上取决于同义词的数量,因为它是唯一一个生成比我们给它的输出更多行的程序。
我们仍然没有解决我们最大的问题——我们不知道建议的域名是否真的可用,所以我们仍然需要坐下来,把它们每一个输入到一个网站中。在下一节中,我们将解决这个问题。
可用
我们的最终程序 Available 将连接到 WHOIS 服务器,询问传入的域名的详细信息——当然,如果没有返回任何详细信息,我们可以安全地假设该域名可以购买。不幸的是,WHOIS 规范(参见tools.ietf.org/html/rfc3912)非常简单,没有提供关于当你询问域名的详细信息时,WHOIS 服务器应该如何回复的信息。这意味着以编程方式解析响应变得非常混乱。为了暂时解决这个问题,我们将只集成一个我们可以确定在响应中有“无匹配”(No match)的单个 WHOIS 服务器,当它没有该域名的记录时。
注意
一个更健壮的解决方案可能是使用具有明确定义结构的 WHOIS 接口来获取详细信息,也许在域名不存在的情况下提供错误消息,针对不同的 WHOIS 服务器有不同的实现。正如你所能想象的,这是一个相当大的项目;非常适合开源项目。
在$GOPATH/src目录旁边创建一个名为available的新文件夹,并在其中添加一个名为main.go的文件,其中包含以下函数代码:
func exists(domain string) (bool, error) {
const whoisServer string = "com.whois-servers.net"
conn, err := net.Dial("tcp", whoisServer+":43")
if err != nil {
return false, err
}
defer conn.Close()
conn.Write([]byte(domain + "\r\n"))
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
if strings.Contains(strings.ToLower(scanner.Text()), "no match") {
return false, nil
}
}
return true, nil
}
exists函数通过打开到指定whoisServer实例的端口43的连接来实现 WHOIS 规范中的一点内容,使用net.Dial进行调用。然后我们推迟关闭连接,这意味着无论函数如何退出(成功或出现错误,甚至是恐慌),都将在连接conn上调用Close()。连接打开后,我们只需写入域名,然后跟着\r\n(回车和换行字符)。这就是规范告诉我们的全部内容,所以从现在开始我们就要自己动手了。
基本上,我们正在寻找响应中是否提到了“无匹配”的内容,这就是我们决定域名是否存在的方式(在这种情况下,exists实际上只是询问 WHOIS 服务器是否有我们指定的域名的记录)。我们使用我们喜欢的bufio.Scanner方法来帮助我们迭代响应中的行。将连接传递给NewScanner是可行的,因为net.Conn实际上也是一个io.Reader。我们使用strings.ToLower,这样我们就不必担心大小写敏感性,使用strings.Contains来查看任何行是否包含“无匹配”文本。如果是,我们返回false(因为域名不存在),否则我们返回true。
com.whois-servers.net WHOIS 服务支持.com和.net的域名,这就是为什么 Domainify 程序只添加这些类型的域名。如果你使用的服务器对更广泛的域名提供了 WHOIS 信息,你可以添加对其他顶级域的支持。
让我们添加一个main函数,使用我们的exists函数来检查传入的域名是否可用。以下代码中的勾号和叉号符号是可选的——如果你的终端不支持它们,你可以自由地用简单的Yes和No字符串替换它们。
将以下代码添加到main.go中:
var marks = map[bool]string{true: "✔", false: "×"}
func main() {
s := bufio.NewScanner(os.Stdin)
for s.Scan() {
domain := s.Text()
fmt.Print(domain, " ")
exist, err := exists(domain)
if err != nil {
log.Fatalln(err)
}
fmt.Println(marks[!exist])
time.Sleep(1 * time.Second)
}
}
在main函数的前面代码中,我们只是迭代通过os.Stdin传入的每一行,用fmt.Print打印出域名(但不是fmt.Println,因为我们不想要换行),调用我们的exists函数来查看域名是否存在,然后用fmt.Println打印出结果(因为我们确实希望在最后有一个换行)。
最后,我们使用time.Sleep告诉进程在 1 秒内什么都不做,以确保我们对 WHOIS 服务器轻松一些。
提示
大多数 WHOIS 服务器都会以各种方式限制,以防止你占用过多资源。因此,减慢速度是确保我们不会惹恼远程服务器的明智方式。
考虑一下这对单元测试意味着什么。如果一个单元测试实际上是在向远程 WHOIS 服务器发出真实请求,每次测试运行时,您都会在您的 IP 地址上累积统计数据。一个更好的方法是对 WHOIS 服务器进行存根,以模拟真实的响应。
在前面代码的顶部的marks映射是将exists的布尔响应映射到人类可读的文本的一种好方法,这样我们只需使用fmt.Println(marks[!exist])在一行中打印响应。我们说不存在是因为我们的程序正在检查域名是否可用(逻辑上与是否存在于 WHOIS 服务器中相反)。
注意
我们可以在我们的代码中愉快地使用检查和叉字符,因为所有的 Go 代码文件都符合 UTF-8 标准——实际上获得这些字符的最好方法是在网上搜索它们,然后使用复制和粘贴将它们带入代码;否则,还有一些依赖于平台的方法来获得这样的特殊字符。
修复main.go文件的import语句后,我们可以尝试运行 Available,看看域名是否可用:
go build –o available
./available
一旦 Available 正在运行,输入一些域名:
packtpub.com
packtpub.com
×
google.com
google.com
×
madeupdomain1897238746234.net
madeupdomain1897238746234.net
✔
正如你所看到的,对于显然不可用的域名,我们得到了一个小叉号,但是当我们使用随机数字编造一个域名时,我们发现它确实是可用的。
读累了记得休息一会哦~
公众号:古德猫宁李
-
电子书搜索下载
-
书单分享
-
书友学习交流
网站:沉金书屋 https://www.chenjin5.com
-
电子书搜索下载
-
电子书打包资源分享
-
学习资源分享
组合所有五个程序
现在我们已经完成了我们的所有五个程序,是时候把它们全部放在一起,这样我们就可以使用我们的工具为我们的聊天应用程序找到一个可用的域名。这样做的最简单方法是使用我们在本章中一直在使用的技术:在终端中使用管道连接输出和输入。
在终端中,导航到这五个程序的父文件夹,并运行以下单行代码:
./synonyms/synonyms | ./sprinkle/sprinkle | ./coolify/coolify | ./domainify/domainify | ./available/available
程序运行后,输入一个起始词,看它如何生成建议,然后再检查它们的可用性。
例如,输入chat可能会导致程序执行以下操作:
- 单词
chat进入synonyms,然后出来一系列的同义词:
-
confab -
confabulation -
schmooze
- 同义词流入
sprinkle,在那里它们会被增加上网友好的前缀和后缀,比如:
-
confabapp -
goconfabulation -
schmooze time
- 这些新词汇流入
coolify,其中元音可能会被调整:
-
confabaapp -
goconfabulatioon -
schmoooze time
- 修改后的词汇流入
domainify,在那里它们被转换成有效的域名:
-
confabaapp.com -
goconfabulatioon.net -
schmooze-time.com
- 最后,域名流入
available,在那里它们被检查是否已经被某人注册了:
-
confabaapp.com× -
goconfabulatioon.net✔ -
schmooze-time.com✔
一款程序统治所有
通过将程序连接在一起来运行我们的解决方案是一种优雅的架构,但它并没有一个非常优雅的界面。具体来说,每当我们想要运行我们的解决方案时,我们都必须输入一个长长的混乱的行,其中每个程序都被列在一起,用管道字符分隔。在本节中,我们将编写一个 Go 程序,使用os/exec包来运行每个子程序,同时按照我们的设计将一个程序的输出传递到下一个程序的输入。
在其他五个程序旁边创建一个名为domainfinder的新文件夹,并在其中创建另一个名为lib的新文件夹。lib文件夹是我们将保存子程序构建的地方,但我们不想每次进行更改时都复制和粘贴它们。相反,我们将编写一个脚本,用于构建子程序并将二进制文件复制到lib文件夹中。
在 Unix 机器上创建一个名为build.sh的新文件,或者在 Windows 上创建一个名为build.bat的文件,并插入以下代码:
#!/bin/bash
echo Building domainfinder...
go build -o domainfinder
echo Building synonyms...
cd ../synonyms
go build -o ../domainfinder/lib/synonyms
echo Building available...
cd ../available
go build -o ../domainfinder/lib/available
cd ../build
echo Building sprinkle...
cd ../sprinkle
go build -o ../domainfinder/lib/sprinkle
cd ../build
echo Building coolify...
cd ../coolify
go build -o ../domainfinder/lib/coolify
cd ../build
echo Building domainify...
cd ../domainify
go build -o ../domainfinder/lib/domainify
cd ../build
echo Done.
前面的脚本只是构建了我们所有的子程序(包括我们尚未编写的domainfinder),告诉go build将它们放在我们的lib文件夹中。确保通过执行chmod +x build.sh或类似的操作赋予新脚本执行权限。从终端运行此脚本,并查看lib文件夹,确保它确实将我们的子程序的二进制文件放在那里。
提示
现在不要担心no buildable Go source files错误,这只是 Go 告诉我们domainfinder程序没有任何.go文件可供构建。
在domainfinder内创建一个名为main.go的新文件,并在文件中插入以下代码:
package main
var cmdChain = []*exec.Cmd{
exec.Command("lib/synonyms"),
exec.Command("lib/sprinkle"),
exec.Command("lib/coolify"),
exec.Command("lib/domainify"),
exec.Command("lib/available"),
}
func main() {
cmdChain[0].Stdin = os.Stdin
cmdChain[len(cmdChain)-1].Stdout = os.Stdout
for i := 0; i < len(cmdChain)-1; i++ {
thisCmd := cmdChain[i]
nextCmd := cmdChain[i+1]
stdout, err := thisCmd.StdoutPipe()
if err != nil {
log.Fatalln(err)
}
nextCmd.Stdin = stdout
}
for _, cmd := range cmdChain {
if err := cmd.Start(); err != nil {
log.Fatalln(err)
} else {
defer cmd.Process.Kill()
}
}
for _, cmd := range cmdChain {
if err := cmd.Wait(); err != nil {
log.Fatalln(err)
}
}
}
os/exec包为我们提供了一切我们需要从 Go 程序内部运行外部程序或命令的东西。首先,我们的cmdChain切片按照我们想要将它们连接在一起的顺序包含了*exec.Cmd命令。
在main函数的顶部,我们将第一个程序的Stdin(标准输入流)绑定到此程序的os.Stdin流,将最后一个程序的Stdout(标准输出流)绑定到此程序的os.Stdout流。这意味着,就像以前一样,我们将通过标准输入流接收输入,并将输出写入标准输出流。
我们的下一个代码块是通过迭代每个项目并将其Stdin设置为其前一个程序的Stdout来将子程序连接在一起的地方。
以下表格显示了每个程序,以及它从哪里获取输入,以及它的输出去哪里:
| 程序 | 输入(Stdin) | 输出(Stdout) |
|---|---|---|
synonyms |
与domainfinder相同的Stdin |
sprinkle |
sprinkle |
synonyms |
coolify |
coolify |
sprinkle |
domainify |
domainify |
coolify |
available |
available |
domainify |
与domainfinder相同的Stdout |
然后我们迭代每个命令调用Start方法,该方法在后台运行程序(与Run方法相反,后者将阻塞我们的代码,直到子程序退出——这当然是不好的,因为我们必须同时运行五个程序)。如果出现任何问题,我们将使用log.Fatalln退出,但如果程序成功启动,我们将推迟调用杀死进程。这有助于确保子程序在我们的main函数退出时退出,这将是domainfinder程序结束时。
一旦所有程序都在运行,我们就会再次迭代每个命令,并等待其完成。这是为了确保domainfinder不会提前退出并过早终止所有子程序。
再次运行build.sh或build.bat脚本,并注意domainfinder程序具有与我们之前看到的相同行为,但界面更加优雅。
总结
在这一章中,我们学习了五个小的命令行程序如何在组合在一起时产生强大的结果,同时保持模块化。我们避免了紧密耦合我们的程序,因此它们仍然可以单独使用。例如,我们可以使用我们的可用程序来检查手动输入的域名是否可用,或者我们可以将我们的synonyms程序仅用作命令行同义词词典。
我们学习了如何使用标准流来构建这些类型的程序的不同流,以及如何重定向标准输入和标准输出让我们非常容易地玩弄不同的流。
我们学习了在 Go 中消耗 JSON RESTful API web 服务是多么简单,当我们需要从 Big Hugh Thesaurus 获取同义词时。一开始我们保持简单,通过内联编码来编写代码,后来重构代码将Thesaurus类型抽象成自己的包,可以共享。当我们打开到 WHOIS 服务器的连接并通过原始 TCP 写入数据时,我们还使用了非 HTTP API。
我们看到了math/rand包如何通过允许我们在代码中使用伪随机数和决策,为我们带来了一些变化和不可预测性,这意味着每次运行程序时,我们都会得到不同的结果。
最后,我们构建了我们的domainfinder超级程序,将所有子程序组合在一起,为我们的解决方案提供了简单、干净和优雅的界面。
第五章:构建分布式系统并使用灵活数据
在本章中,我们将探讨可转移的技能,使我们能够使用无模式数据和分布式技术来解决大数据问题。本章中我们将构建的系统将为我们准备一个未来,在那里民主选举都将在线进行——当然是在 Twitter 上。我们的解决方案将通过查询 Twitter 的流 API 来收集和计算投票特定标签的提及,并且每个组件都能够水平扩展以满足需求。我们的用例是有趣而有趣的,但我们将学习的核心概念和我们将做出的具体技术选择是本章的真正重点。这里讨论的思想直接适用于任何需要真正规模能力的系统。
注意
水平扩展是指向系统添加节点,如物理机器,以改善其可用性、性能和/或容量。谷歌等大数据公司可以通过添加廉价且易获得的硬件(通常称为商品硬件)来扩展,因为他们编写软件和设计解决方案的方式。垂直扩展意味着增加单个节点的可用资源,例如向盒子添加额外的 RAM,或者具有更多内核的处理器。
在本章中,您将:
-
了解分布式 NoSQL 数据存储;特别是如何与 MongoDB 交互
-
了解分布式消息队列;特别是 Bit.ly 的 NSQ 以及如何使用
go-nsq包轻松发布和订阅事件 -
通过 Twitter 的流 API 流式传输实时推文数据并管理长时间运行的网络连接
-
学习如何正确停止具有许多内部 goroutine 的程序
-
学习如何使用低内存通道进行信令
系统设计
有一个基本的设计草图通常是有用的,特别是在分布式系统中,许多组件将以不同的方式相互通信。我们不希望在这个阶段花费太长时间,因为我们的设计可能会随着我们深入细节而发展,但我们将看一下高层次的概述,以便我们可以讨论组成部分以及它们如何相互配合。

前面的图片显示了我们将要构建的系统的基本概述:
-
Twitter 是我们都熟悉和喜爱的社交媒体网络。
-
Twitter 的流 API 允许长时间运行的连接,其中推文数据尽可能快地流式传输。
-
twittervotes是我们将编写的一个程序,它读取推文并将投票推送到消息队列中。twittervotes获取相关的推文数据,找出正在投票的内容(或者说,提到了哪些选项),并将投票推送到 NSQ 中。 -
NSQ 是一个开源的、实时的分布式消息平台,旨在大规模运行,由 Bit.ly 构建和维护。NSQ 在其实例之间传递消息,使其对任何对选举数据表示兴趣的人都可用。
-
counter是我们将编写的一个程序,它监听消息队列上的投票,并定期将结果保存在 MongoDB 数据库中。counter从 NSQ 接收投票消息,并在内存中定期计算结果,定期推送更新以持久化数据。 -
MongoDB 是一个设计用于大规模运行的开源文档数据库。
-
web是一个 Web 服务器程序,将在下一章中公开我们将编写的实时结果。
可以说,可以编写一个单个的 Go 程序来读取推文,计算投票并将它们推送到用户界面,但是这样的解决方案,虽然是一个很好的概念验证,但在规模上非常有限。在我们的设计中,任何一个组件都可以在特定能力的需求增加时进行水平扩展。如果我们有相对较少的投票,但有很多人查看数据,我们可以保持twittervotes和counter实例不变,并添加更多的web和 MongoDB 节点,或者反之亦然。
我们设计的另一个关键优势是冗余;因为我们可以同时拥有许多组件的实例在工作,如果其中一个箱子消失了(例如由于系统崩溃或断电),其他箱子可以接管工作。现代架构通常会将这样的系统分布在地理范围内,以防止本地自然灾害。如果我们以这种方式构建我们的解决方案,所有这些选项都是可用的。
我们选择本章中的特定技术,是因为它们与 Go 的关联(例如,NSQ 完全使用 Go 编写),以及有经过充分测试的驱动程序和软件包可用。然而,从概念上讲,您可以根据需要选择各种替代方案。
数据库设计
我们将称我们的 MongoDB 数据库为ballots。它将包含一个名为polls的单个集合,这是我们将存储投票详细信息的地方,例如标题、选项和结果(在一个 JSON 文档中)。投票的代码将如下所示:
{
"_id": "???",
"title": "Poll title",
"options": ["one", "two", "three"],
"results": {
"one": 100,
"two": 200,
"three": 300
}
}
_id字段是由 MongoDB 自动生成的,将是我们标识每个投票的方式。options字段包含一个字符串选项数组;这些是我们将在 Twitter 上寻找的标签。results字段是一个映射,其中键表示选项,值表示每个项目的总投票数。
安装环境
我们在本章中编写的代码具有真正的外部依赖关系,我们需要在开始构建系统之前设置这些依赖关系。
提示
如果您在安装任何依赖项时遇到困难,请务必查看github.com/matryer/goblueprints上的章节注释。
在大多数情况下,诸如mongod和nsqd之类的服务在我们运行程序之前必须启动。由于我们正在编写分布式系统的组件,我们将不得不同时运行每个程序,这就像打开许多终端窗口一样简单。
NSQ
NSQ 是一个消息队列,允许一个程序向另一个程序发送消息或事件,或者向通过网络连接的不同节点上运行的许多其他程序发送消息。NSQ 保证消息的传递,这意味着它会将未传递的消息缓存,直到所有感兴趣的方收到它们。这意味着,即使我们停止counter程序,我们也不会错过任何投票。您可以将此功能与“发送并忘记”消息队列进行对比,其中信息被视为过时,因此如果在规定时间内未传递,则被遗忘,并且发送消息的人不关心消费者是否收到它们。
消息队列抽象允许您在不同的位置运行系统的不同组件,只要它们与队列有网络连接。您的程序与其他程序解耦;相反,您的设计开始关心专门的微服务的细节,而不是数据通过单片程序的流动。
NSQ 传输原始字节,这意味着我们可以自行决定如何将数据编码为这些字节。例如,根据我们的需求,我们可以将数据编码为 JSON 或二进制格式。在我们的情况下,我们将投票选项作为字符串发送,而不需要任何额外的编码,因为我们只共享一个数据字段。
在浏览器中打开nsq.io/deployment/installing.html(或搜索install nsq)并按照您的环境的说明进行操作。您可以下载预编译的二进制文件,也可以从源代码构建自己的。如果您已经安装了 homebrew,安装 NSQ 就像输入以下命令一样简单:
brew install nsq
安装 NSQ 后,您需要将bin文件夹添加到您的PATH环境变量中,以便在终端中使用这些工具。
为了验证 NSQ 是否正确安装,打开一个终端并运行nsqlookupd;如果程序成功启动,您应该会看到类似以下的一些输出:
nsqlookupd v0.2.27 (built w/go1.3)
TCP: listening on [::]:4160
HTTP: listening on [::]:4161
我们将使用默认端口与 NSQ 进行交互,所以请注意输出中列出的 TCP 和 HTTP 端口,因为我们将在我们的代码中引用它们。
按下Ctrl + C暂停进程;稍后我们会正确启动它们。
我们将使用 NSQ 安装中的关键工具nsqlookupd和nsqd。nsqlookupd程序是一个管理分布式 NSQ 环境的拓扑信息的守护进程;它跟踪特定主题的所有nsqd生产者,并为客户端提供查询此类信息的接口。nsqd程序是一个守护进程,负责 NSQ 的重要工作,如接收、排队和传递来自和到感兴趣的各方的消息。有关 NSQ 的更多信息和背景,请访问nsq.io/。
Go 的 NSQ 驱动程序
NSQ 工具本身是用 Go 编写的,因此 Bit.ly 团队已经有一个使与 NSQ 交互非常容易的 Go 包。我们需要使用它,所以在终端中使用go get获取它:
go get github.com/bitly/go-nsq
MongoDB
MongoDB 是一个文档数据库,基本上允许您存储和查询 JSON 文档及其中的数据。每个文档都进入一个集合,可以用来将文档组合在一起,而不对其中的数据强制执行任何模式。与传统的 Oracle、Microsoft SQL Server 或 MySQL 中的行不同,文档可以具有不同的结构是完全可以接受的。例如,一个people集合可以同时包含以下三个 JSON 文档:
{"name":"Mat","lang":"en","points":57}
{"name":"Laurie","position":"Scrum Master"}
{"position":"Traditional Manager","exists":false}
这种灵活性使得具有不同结构的数据可以共存,而不会影响性能或浪费空间。如果您期望软件随着时间的推移而发展,这也非常有用,因为我们确实应该这样做。
MongoDB 被设计为可以扩展,同时在单机安装上也非常易于操作,比如我们的开发机。当我们将应用程序托管到生产环境时,我们可能会安装一个更复杂的多分片、复制系统,分布在许多节点和位置,但现在,只需运行mongod即可。
前往www.mongodb.org/downloads下载最新版本的 MongoDB 并安装它,确保像往常一样将bin文件夹注册到您的PATH环境变量中。
为了验证 MongoDB 是否成功安装,运行mongod命令,然后按下Ctrl + C暂停它。
Go 的 MongoDB 驱动程序
Gustavo Niemeyer 通过他在labix.org/mgo托管的mgo(发音为"mango")包,大大简化了与 MongoDB 的交互,这个包是可以通过以下命令go gettable的:
go get gopkg.in/mgo.v2
启动环境
现在我们已经安装了所有需要的部件,我们需要启动我们的环境。在本节中,我们将:
-
启动
nsqlookupd以便我们的nsqd实例可以被发现 -
启动
nsqd并告诉它要使用哪个nsqlookupd -
启动
mongod进行数据服务
这些守护进程中的每一个都应该在自己的终端窗口中运行,这样我们就可以通过按下Ctrl + C来轻松停止它们。
提示
记住这一节的页码,因为在您阅读本章时,您可能会多次回顾它。
在一个终端窗口中运行:
nsqlookupd
注意 TCP 端口,默认为4160,然后在另一个终端窗口中运行:
nsqd --lookupd-tcp-address=localhost:4160
确保--lookupd-tcp-address标志中的端口号与nsqlookupd实例的 TCP 端口匹配。一旦启动nsqd,您将注意到一些输出会从nsqlookupd和nsqd的终端打印出来;这表明这两个进程正在互相通信。
在另一个窗口或选项卡中,通过运行启动 MongoDB:
mongod --dbpath ./db
dbpath标志告诉 MongoDB 在哪里存储我们数据库的数据文件。您可以选择任何位置,但在mongod运行之前,您必须确保文件夹存在。
提示
通过随时删除dbpath文件夹,您可以有效地擦除所有数据并重新开始。这在开发过程中特别有用。
现在我们的环境已经运行,我们准备开始构建我们的组件。
来自 Twitter 的投票
在$GOPATH/src文件夹中,与其他项目一起,为本章创建一个名为socialpoll的新文件夹。该文件夹本身不是 Go 包或程序,但将包含我们的三个组件程序。在socialpoll中,创建一个名为twittervotes的新文件夹,并添加必需的main.go模板(这很重要,因为没有main函数的main包将无法编译):
package main
func main(){}
我们的twittervotes程序将:
-
使用
mgo从 MongoDB 数据库加载所有投票,并从每个文档的options数组中收集所有选项。 -
打开并保持与 Twitter 的流 API 的连接,寻找任何提及选项的内容。
-
对于与筛选器匹配的每条推文,找出提到的选项,并将该选项推送到 NSQ。
-
如果与 Twitter 的连接中断(这在长时间运行的连接中很常见,因为它实际上是 Twitter 的流 API 规范的一部分),则在短暂延迟后(以便我们不会用连接请求轰炸 Twitter),重新连接并继续。
-
定期重新查询 MongoDB 以获取最新的投票,并刷新与 Twitter 的连接,以确保我们始终关注正确的选项。
-
当用户通过按Ctrl + C终止程序时,它将自动停止。
与 Twitter 进行授权。
为了使用流 API,我们将需要从 Twitter 的应用程序管理控制台获取身份验证凭据,就像我们在第三章中为我们的 Gomniauth 服务提供者所做的那样,实现个人资料图片的三种方法。转到apps.twitter.com并创建一个名为SocialPoll的新应用程序(名称必须是唯一的,因此您可以在这里玩得很开心;名称的选择不会影响代码)。创建应用程序后,访问API 密钥选项卡,并找到您的访问令牌部分,在那里您需要创建一个新的访问令牌。短暂延迟后,刷新页面并注意到您实际上有两组密钥和秘钥;一个 API 密钥和秘钥,以及一个访问令牌和相应的秘密。遵循良好的编码实践,我们将这些值设置为环境变量,以便我们的程序可以访问它们,而无需在源文件中硬编码它们。
本章中我们将使用的密钥是:
-
SP_TWITTER_KEY -
SP_TWITTER_SECRET -
SP_TWITTER_ACCESSTOKEN -
SP_TWITTER_ACCESSSECRET
您可以根据需要设置环境变量,但由于应用程序依赖于它们才能工作,因此创建一个名为setup.sh(对于 bash shell)或setup.bat(在 Windows 上)的新文件是一个好主意,因为您可以将这些文件检入到源代码存储库中。通过从 Twitter 应用程序页面复制相应的值将以下代码插入setup.sh或setup.bat中:
#!/bin/bash
export SP_TWITTER_KEY=yCwwKKnuBnUBrelyTN...
export SP_TWITTER_SECRET=6on0YRYniT1sI3f...
export SP_TWITTER_ACCESSTOKEN=2427-13677...
export SP_TWITTER_ACCESSSECRET=SpnZf336u...
运行文件并使用源或调用命令来适当设置值,或将它们添加到您的.bashrc或C:\cmdauto.cmd文件中,以节省每次打开新终端窗口时运行它们的时间。
提取连接
Twitter 流 API 支持保持长时间打开的 HTTP 连接,并且考虑到我们解决方案的设计,我们需要从请求发生的 goroutine 之外访问net.Conn对象以关闭它。我们可以通过为我们将创建的http.Transport对象提供自己的dial方法来实现这一点。
在twittervotes(所有与 Twitter 相关的内容都将驻留在此处)中创建一个名为twitter.go的新文件,并插入以下代码:
var conn net.Conn
func dial(netw, addr string) (net.Conn, error) {
if conn != nil {
conn.Close()
conn = nil
}
netc, err := net.DialTimeout(netw, addr, 5*time.Second)
if err != nil {
return nil, err
}
conn = netc
return netc, nil
}
我们定制的dial函数首先确保关闭conn,然后打开一个新连接,保持conn变量更新为当前连接。如果连接中断(Twitter 的 API 偶尔会这样做)或被我们关闭,我们可以重新拨号,而不必担心僵尸连接。
我们将定期关闭连接并启动新连接,因为我们希望定期从数据库重新加载选项。为此,我们需要一个关闭连接的函数,并且还需要关闭我们将用于读取响应主体的io.ReadCloser。将以下代码添加到twitter.go中:
var reader io.ReadCloser
func closeConn() {
if conn != nil {
conn.Close()
}
if reader != nil {
reader.Close()
}
}
现在我们可以随时调用closeConn来中断与 Twitter 的持续连接并整理事情。在大多数情况下,我们的代码将再次从数据库加载选项并立即打开新连接,但如果我们正在关闭程序(响应Ctrl + C按键),那么我们可以在退出之前调用closeConn。
读取环境变量
接下来,我们将编写一个函数,该函数将读取环境变量并设置我们需要验证请求的OAuth对象。在twitter.go文件中添加以下代码:
var (
authClient *oauth.Client
creds *oauth.Credentials
)
func setupTwitterAuth() {
var ts struct {
ConsumerKey string `env:"SP_TWITTER_KEY,required"`
ConsumerSecret string `env:"SP_TWITTER_SECRET,required"`
AccessToken string `env:"SP_TWITTER_ACCESSTOKEN,required"`
AccessSecret string `env:"SP_TWITTER_ACCESSSECRET,required"`
}
if err := envdecode.Decode(&ts); err != nil {
log.Fatalln(err)
}
creds = &oauth.Credentials{
Token: ts.AccessToken,
Secret: ts.AccessSecret,
}
authClient = &oauth.Client{
Credentials: oauth.Credentials{
Token: ts.ConsumerKey,
Secret: ts.ConsumerSecret,
},
}
}
在这里,我们定义了一个struct类型来存储我们需要用来验证 Twitter 的环境变量。由于我们不需要在其他地方使用这种类型,我们内联定义它,并创建一个名为ts的变量,它是这种匿名类型(这就是为什么我们有了有些不寻常的var ts struct…代码)。然后我们使用 Joe Shaw 优雅的envdecode包来为我们拉取这些环境变量。您需要运行go get github.com/joeshaw/envdecode,并且还要导入log包。我们的程序将尝试为所有标记为required的字段加载适当的值,并在失败时返回错误,提醒人们如果没有 Twitter 凭据,程序将无法工作。
在struct中每个字段旁边的反引号内的字符串称为标签,并且可以通过反射接口获得,这就是envdecode知道要查找哪些变量的方式。Tyler Bunnell 和我为这个包添加了 required 参数,这表明如果缺少(或为空)任何环境变量都是错误的。
一旦我们获得了密钥,我们将使用它们来创建oauth.Credentials和oauth.Client对象,这些对象来自 Gary Burd 的go-oauth包,它将允许我们使用 Twitter 进行授权请求。
现在我们有了控制底层连接和授权请求的能力,我们准备编写实际构建授权请求并返回响应的代码。在twitter.go中,添加以下代码:
var (
authSetupOnce sync.Once
httpClient *http.Client
)
func makeRequest(req *http.Request, params url.Values) (*http.Response, error) {
authSetupOnce.Do(func() {
setupTwitterAuth()
httpClient = &http.Client{
Transport: &http.Transport{
Dial: dial,
},
}
})
formEnc := params.Encode()
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Content-Length", strconv.Itoa(len(formEnc)))
req.Header.Set("Authorization", authClient.AuthorizationHeader(creds, "POST", req.URL, params))
return httpClient.Do(req)
}
我们使用sync.Once来确保我们的初始化代码只运行一次,尽管我们调用makeRequest的次数有多少。在调用setupTwitterAuth方法之后,我们使用http.Transport创建一个新的http.Client,该http.Transport使用我们自定义的dial方法。然后,我们通过对包含我们要查询的选项的指定params对象进行编码,设置与 Twitter 授权所需的适当标头。
从 MongoDB 读取
为了加载投票,并因此搜索 Twitter 的选项,我们需要连接并查询 MongoDB。在main.go中,添加两个函数dialdb和closedb:
var db *mgo.Session
func dialdb() error {
var err error
log.Println("dialing mongodb: localhost")
db, err = mgo.Dial("localhost")
return err
}
func closedb() {
db.Close()
log.Println("closed database connection")
}
这两个函数将使用mgo包连接到本地运行的 MongoDB 实例,并将mgo.Session(数据库连接对象)存储在名为db的全局变量中,并从中断开连接。
提示
作为额外的任务,看看是否可以找到一种优雅的方式来使 MongoDB 实例的位置可配置,以便您不需要在本地运行它。
假设 MongoDB 正在运行并且我们的代码能够连接,我们需要加载投票对象并从文档中提取所有选项,然后我们将使用这些选项来搜索 Twitter。将以下Options函数添加到main.go中:
type poll struct {
Options []string
}
func loadOptions() ([]string, error) {
var options []string
iter := db.DB("ballots").C("polls").Find(nil).Iter()
var p poll
for iter.Next(&p) {
options = append(options, p.Options...)
}
iter.Close()
return options, iter.Err()
}
我们的投票文档包含的不仅仅是Options,但我们的程序不关心其他任何内容,因此我们不需要膨胀我们的poll结构。我们使用db变量访问ballots数据库中的polls集合,并调用mgo包的流畅Find方法,传递nil(表示没有过滤)。
注意
流畅接口(由 Eric Evans 和 Martin Fowler 首次创造)是指旨在通过允许您链接方法调用来使代码更可读的 API 设计。这是通过每个方法返回上下文对象本身来实现的,以便可以直接调用另一个方法。例如,mgo允许您编写诸如此类的查询:
query := col.Find(q).Sort("field").Limit(10).Skip(10)
然后我们通过调用Iter方法获得迭代器,这允许我们逐个访问每个投票。这是一种非常节省内存的读取投票数据的方式,因为它只使用一个poll对象。如果我们使用All方法,我们将使用的内存量取决于我们在数据库中拥有的投票数量,这将超出我们的控制。
当我们有一个投票时,我们使用append方法来构建选项切片。当然,随着数据库中有数百万个投票,这个切片也会变得庞大而难以控制。对于这种规模,我们可能会运行多个twittervotes程序,每个程序专门用于一部分投票数据。一个简单的方法是根据标题的首字母将投票分成组,例如 A-N 组和 O-Z 组。一个更复杂的方法是向poll文档添加一个字段,以更受控制的方式对其进行分组,也许是基于其他组的统计数据,以便我们能够在许多twittervotes实例之间平衡负载。
提示
append内置函数实际上是一个variadic函数,这意味着您可以为其附加多个元素。如果您有正确类型的切片,可以在末尾添加...,这模拟了将切片的每个项目作为不同参数传递。
最后,我们关闭迭代器并清理任何使用的内存,然后返回选项和在迭代过程中发生的任何错误(通过在mgo.Iter对象上调用Err方法)。
从 Twitter 阅读
现在我们能够加载选项并向 Twitter API 发出授权请求。因此,我们准备编写启动连接的代码,并持续从流中读取,直到我们调用我们的closeConn方法,或者 Twitter 因某种原因关闭连接。流中包含的结构是一个复杂的结构,包含有关推文的各种信息-谁发表了它以及何时,甚至在正文中出现了哪些链接或用户提及(有关更多详细信息,请参阅 Twitter 的 API 文档)。但是,我们只对推文文本本身感兴趣,因此您无需担心所有其他噪音;将以下结构添加到twitter.go中:
type tweet struct {
Text string
}
提示
这可能感觉不完整,但请考虑它如何清晰地表达了我们对其他程序员可能看到我们的代码的意图:推文有一些文本,这就是我们关心的全部。
使用这种新结构,在twitter.go中添加以下readFromTwitter函数,该函数接收一个名为votes的只发送通道;这是该函数通知程序的其余部分它已经在 Twitter 上注意到了一次投票的方式:
func readFromTwitter(votes chan<- string) {
options, err := loadOptions()
if err != nil {
log.Println("failed to load options:", err)
return
}
u, err := url.Parse("https://stream.twitter.com/1.1/statuses/filter.json")
if err != nil {
log.Println("creating filter request failed:", err)
return
}
query := make(url.Values)
query.Set("track", strings.Join(options, ","))
req, err := http.NewRequest("POST", u.String(), strings.NewReader(query.Encode()))
if err != nil {
log.Println("creating filter request failed:", err)
return
}
resp, err := makeRequest(req, query)
if err != nil {
log.Println("making request failed:", err)
return
}
reader := resp.Body
decoder := json.NewDecoder(reader)
for {
var tweet tweet
if err := decoder.Decode(&tweet); err != nil {
break
}
for _, option := range options {
if strings.Contains(
strings.ToLower(tweet.Text),
strings.ToLower(option),
) {
log.Println("vote:", option)
votes <- option
}
}
}
}
在上述代码中,加载所有投票数据的选项(通过调用loadOptions函数)后,我们使用url.Parse创建一个描述 Twitter 上适当端点的url.URL对象。我们构建一个名为query的url.Values对象,并将选项设置为逗号分隔的列表。根据 API,我们使用编码后的url.Values对象作为主体发出新的POST请求,并将其与查询对象一起传递给makeRequest。如果一切顺利,我们将从请求的主体中创建一个新的json.Decoder,并通过调用Decode方法在无限的for循环中不断读取。如果出现错误(可能是由于连接关闭),我们简单地中断循环并退出函数。如果有要读取的推文,它将被解码为tweet变量,这将使我们可以访问Text属性(推文本身的 140 个字符)。然后,我们遍历所有可能的选项,如果推文提到了它,我们就在votes通道上发送它。这种技术还允许一个推文同时包含许多投票,这取决于选举规则,您可能会决定是否更改。
注意
votes通道是只发送的(这意味着我们不能在其上接收),因为它的类型是chan<- string。想象一下小箭头告诉我们消息流向的方式:要么进入通道,要么离开通道。这是一种表达意图的好方法——很明显,我们从不打算使用readFromTwitter函数来读取投票;相反,我们只会在该通道上发送它们。
每当Decode返回错误时终止程序并不提供一个非常健壮的解决方案。这是因为 Twitter API 文档规定连接会不时中断,客户端在消费服务时应考虑到这一点。而且请记住,我们也会定期终止连接,所以我们需要考虑一种在连接中断后重新连接的方法。
信号通道
在 Go 中使用通道的一个很好的用途是在不同 goroutine 中运行的代码之间发出信号事件。当我们编写下一个函数时,我们将看到一个真实世界的例子。
该函数的目的是启动一个 goroutine,不断调用readFromTwitter函数(使用指定的votes通道接收投票),直到我们发出停止信号。一旦它停止,我们希望通过另一个信号通道得到通知。函数的返回值将是一个struct{}类型的通道;一个信号通道。
信号通道具有一些有趣的特性值得仔细研究。首先,通过通道发送的类型是一个空的struct{},实际上不占用任何字节,因为它没有字段。因此,struct{}{}是一个用于信号事件的内存高效选项。有些人使用bool类型,这也可以,尽管true和false都占用一个字节的内存。
注意
前往play.golang.org并自己尝试一下。
布尔类型的大小为 1:
fmt.Println(reflect.TypeOf(true).Size())
= 1
结构体struct{}{}的大小为0:
fmt.Println(reflect.TypeOf(struct{}{}).Size())
= 0
信号通道还具有缓冲区大小为 1,这意味着执行不会阻塞,直到有东西从通道中读取信号。
我们将在我们的代码中使用两个信号通道,一个是我们传递给函数的,告诉我们的 goroutine 它应该停止,另一个是函数提供的,一旦停止完成就发出信号。
在twitter.go中添加以下函数:
func startTwitterStream(stopchan <-chan struct{}, votes chan<- string) <-chan struct{} {
stoppedchan := make(chan struct{}, 1)
go func() {
defer func() {
stoppedchan <- struct{}{}
}()
for {
select {
case <-stopchan:
log.Println("stopping Twitter...")
return
default:
log.Println("Querying Twitter...")
readFromTwitter(votes)
log.Println(" (waiting)")
time.Sleep(10 * time.Second) // wait before reconnecting
}
}
}()
return stoppedchan
}
在上述代码中,第一个参数stopchan是一个类型为<-chan struct{}的通道,一个只接收的信号通道。在代码外部,将在此通道上发出信号,这将告诉我们的 goroutine 停止。请记住,在此函数内部它是只接收的,实际通道本身将能够发送。第二个参数是votes通道,用于发送投票。我们函数的返回类型也是一个类型为<-chan struct{}的信号通道;一个只接收的通道,我们将用它来指示我们已经停止。
这些通道是必要的,因为我们的函数会触发自己的 goroutine,并立即返回,所以没有这些,调用代码将不知道生成的代码是否仍在运行。
在startTwitterStream函数中,我们首先创建了stoppedchan,并延迟发送struct{}{}以指示我们的函数退出时已经完成。请注意,stoppedchan是一个普通通道,因此即使它作为只接收返回,我们也可以在此函数内部发送它。
然后我们开始一个无限的for循环,在其中我们从两个通道中选择一个。第一个是stopchan(第一个参数),这将表明是时候停止并返回(从而触发stoppedchan上的延迟信号)。如果还没有发生这种情况,我们将调用readFromTwitter(传入votes通道),它将从数据库中加载选项并打开到 Twitter 的连接。
当 Twitter 连接断开时,我们的代码将返回到这里,在这里我们使用time.Sleep函数睡眠十秒。这是为了让 Twitter API 休息一下,以防它由于过度使用而关闭连接。一旦休息过后,我们重新进入循环,并再次检查stopchan通道,看看调用代码是否希望我们停止。
为了使这个流程清晰,我们记录了一些关键语句,这些语句不仅有助于我们调试代码,还让我们窥视这个有些复杂的机制的内部工作。
发布到 NSQ
一旦我们的代码成功地注意到 Twitter 上的投票并将它们发送到votes通道中,我们需要一种方法将它们发布到 NSQ 主题;毕竟,这是twittervotes程序的目的。
我们将编写一个名为publishVotes的函数,它将接收类型为<-chan string(只接收通道)的votes通道,并发布从中接收到的每个字符串。
注意
在我们之前的函数中,votes通道的类型是chan<- string,但这次它的类型是<-chan string。您可能会认为这是一个错误,甚至认为这意味着我们不能同时使用同一个通道,但您是错误的。我们稍后创建的通道将使用make(chan string),既不是接收也不是发送,可以在两种情况下都起作用。在参数中使用<-运算符的原因是为了明确通道的使用意图;或者在它是返回类型的情况下,防止用户意外地在预期用于接收或发送的通道上发送。如果用户错误地使用这样的通道,编译器实际上会产生错误。
一旦votes通道关闭(这是外部代码告诉我们的函数停止工作的方式),我们将停止发布并向返回的停止信号通道发送信号。
将publishVotes函数添加到main.go:
func publishVotes(votes <-chan string) <-chan struct{} {
stopchan := make(chan struct{}, 1)
pub, _ := nsq.NewProducer("localhost:4150", nsq.NewConfig())
go func() {
for vote := range votes {
pub.Publish("votes", []byte(vote)) // publish vote
}
log.Println("Publisher: Stopping")
pub.Stop()
log.Println("Publisher: Stopped")
stopchan <- struct{}{}
}()
return stopchan
}
我们做的第一件事是创建stopchan,然后将其返回,这次不是延迟发送信号,而是通过向stopchan发送struct{}{}来内联执行。
注意
不同之处在于显示备选选项:在一个代码库中,您应该选择自己喜欢的风格并坚持下去,直到社区内出现一个标准;在这种情况下,我们都应该遵循这个标准。
然后我们通过调用NewProducer创建一个 NSQ 生产者,并连接到localhost上的默认 NSQ 端口,使用默认配置。我们启动一个 goroutine,它使用 Go 语言的另一个很棒的内置功能,让我们可以通过在通道上执行正常的for…range操作来不断地从通道中拉取值(在我们的情况下是votes通道)。每当通道没有值时,执行将被阻塞,直到有值传送过来。如果votes通道被关闭,for循环将退出。
提示
要了解 Go 语言中通道的强大之处,强烈建议您查找 John Graham-Cumming 的博客文章和视频,特别是他在 2014 年 Gophercon 上介绍的通道概览,其中包含了通道的简要历史,包括它们的起源。(有趣的是,John 还成功地请求英国政府正式为对待 Alan Turing 的方式道歉。)
当循环退出(在votes通道关闭后),发布者将停止,随后发送stopchan信号。
优雅地启动和停止
当我们的程序被终止时,我们希望在实际退出之前做一些事情;即关闭与 Twitter 的连接并停止 NSQ 发布者(实际上是取消其对队列的兴趣)。为了实现这一点,我们必须覆盖默认的Ctrl + C行为。
提示
即将到来的代码块都在main函数内部;它们被分开,以便我们在继续之前讨论每个部分。
在main函数内添加以下代码:
var stoplock sync.Mutex
stop := false
stopChan := make(chan struct{}, 1)
signalChan := make(chan os.Signal, 1)
go func() {
<-signalChan
stoplock.Lock()
stop = true
stoplock.Unlock()
log.Println("Stopping...")
stopChan <- struct{}{}
closeConn()
}()
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
在这里,我们创建了一个带有关联sync.Mutex的停止bool,以便我们可以同时从许多 goroutine 中访问它。然后我们创建了另外两个信号通道,stopChan和signalChan,并使用signal.Notify要求 Go 在有人尝试终止程序时将信号发送到signalChan(无论是使用SIGINT中断还是SIGTERM终止 POSIX 信号)。stopChan是我们指示要终止进程的方式,我们将其作为参数传递给startTwitterStream。
然后我们运行一个 goroutine,通过尝试从signalChan读取来阻塞等待信号;这就是这种情况下<-操作符的作用(它正在尝试从通道中读取)。由于我们不关心信号的类型,因此我们不需要捕获通道上返回的对象。一旦收到信号,我们将stop设置为true,并关闭连接。只有在发送了指定的信号之一后,才会运行剩余的 goroutine 代码,这就是我们能够在退出程序之前执行拆卸代码的方式。
在main函数内添加以下代码片段,以打开并延迟关闭数据库连接:
if err := dialdb(); err != nil {
log.Fatalln("failed to dial MongoDB:", err)
}
defer closedb()
由于readFromTwitter方法每次都会从数据库重新加载选项,并且我们希望在无需重新启动程序的情况下保持程序更新,因此我们将引入最后一个 goroutine。这个 goroutine 将每分钟调用closeConn,导致连接断开,并导致readFromTwitter再次被调用。在main函数的底部插入以下代码,以启动所有这些进程,然后等待它们优雅地停止:
// start things
votes := make(chan string) // chan for votes
publisherStoppedChan := publishVotes(votes)
twitterStoppedChan := startTwitterStream(stopChan, votes)
go func() {
for {
time.Sleep(1 * time.Minute)
closeConn()
stoplock.Lock()
if stop {
stoplock.Unlock()
break
}
stoplock.Unlock()
}
}()
<-twitterStoppedChan
close(votes)
<-publisherStoppedChan
首先,我们创建了我们在本节中一直在谈论的votes通道,它是一个简单的字符串通道。请注意,它既不是发送(chan<-)也不是接收(<-chan)通道;实际上,创建这样的通道没有多大意义。然后我们调用publishVotes,将votes通道传递给它进行接收,并将返回的停止信号通道捕获为publisherStoppedChan。类似地,我们调用startTwitterStream,传入我们在main函数开头的stopChan,以及votes通道进行发送,并捕获生成的停止信号通道为twitterStoppedChan。
然后我们启动刷新 goroutine,它立即进入无限的for循环,然后睡眠一分钟并通过调用closeConn关闭连接。如果停止bool已经设置为 true(在之前的 goroutine 中),我们将break循环并退出,否则我们将继续循环并等待另一分钟再次关闭连接。使用stoplock是重要的,因为我们有两个 goroutine 可能同时尝试访问停止变量,但我们希望避免冲突。
一旦 goroutine 启动,我们就会在twitterStoppedChan上阻塞,尝试从中读取。当成功时(这意味着在stopChan上发送了信号),我们关闭votes通道,这将导致发布者的for…range循环退出,并且发布者本身停止,之后会在publisherStoppedChan上发送信号,我们等待后退出。
测试
为了确保我们的程序正常工作,我们需要做两件事:首先,我们需要在数据库中创建一个投票,其次,我们需要查看消息队列,看看消息是否确实由twittervotes生成。
在终端中,运行mongo命令打开一个数据库 shell,允许我们与 MongoDB 交互。然后输入以下命令添加一个测试投票:
> use ballots
switched to db ballots
> db.polls.insert({"title":"Test poll","options":["happy","sad","fail","win"]})
前面的命令向ballots数据库的polls集合中添加了一个新项目。我们使用一些常见的选项词,这些选项可能会被 Twitter 上的人提到,以便我们可以观察到真实的推文被翻译成消息。您可能会注意到我们的投票对象缺少results字段;这没关系,因为我们处理的是非结构化数据,文档不必遵循严格的模式。我们将在下一节中编写的counter程序稍后为我们添加和维护results数据。
按下Ctrl + C退出 MongoDB shell,并输入以下命令:
nsq_tail --topic="votes" --lookupd-http-address=localhost:4161
nsq_tail工具连接到指定的消息队列主题,并输出它注意到的任何消息。这是我们验证我们的twittervotes程序是否正在发送消息的地方。
在一个单独的终端窗口中,让我们构建并运行twittervotes程序:
go build –o twittervotes
./twittervotes
现在切换回运行nsq_tail的窗口,并注意确实会生成消息以响应实时 Twitter 活动。
提示
如果您没有看到太多活动,请尝试在 Twitter 上查找热门标签,并添加另一个包含这些选项的投票。
计票
我们将要实现的第二个程序是counter工具,它将负责监视 NSQ 中的投票,对其进行计数,并将 MongoDB 与最新数字保持同步。
在twittervotes旁边创建一个名为counter的新文件夹,并将以下代码添加到一个新的main.go文件中:
package main
import (
"flag"
"fmt"
"os"
)
var fatalErr error
func fatal(e error) {
fmt.Println(e)
flag.PrintDefaults()
fatalErr = e
}
func main() {
defer func() {
if fatalErr != nil {
os.Exit(1)
}
}()
}
通常,当我们在代码中遇到错误时,我们使用log.Fatal或os.Exit这样的调用,它会立即终止程序。以非零退出代码退出程序很重要,因为这是我们告诉操作系统出现问题,我们没有成功完成任务的方式。常规方法的问题在于我们安排的任何延迟函数(因此我们需要运行的任何拆卸代码)都不会有机会执行。
在前面的代码片段中使用的模式允许我们调用fatal函数来记录发生错误。请注意,只有当我们的主函数退出时,推迟的函数才会运行,然后调用os.Exit(1)以退出带有退出代码1的程序。因为推迟的语句按 LIFO(后进先出)顺序运行,我们推迟的第一个函数将是最后执行的函数,这就是为什么我们在main函数中首先推迟退出代码。这使我们确信我们推迟的其他函数将在程序退出之前被调用。我们将使用此功能来确保无论发生任何错误,我们的数据库连接都会关闭。
连接到数据库
在成功获取资源后,立即考虑清理资源(例如数据库连接)是最佳时机;Go 的defer关键字使这变得容易。在主函数的底部,添加以下代码:
log.Println("Connecting to database...")
db, err := mgo.Dial("localhost")
if err != nil {
fatal(err)
return
}
defer func() {
log.Println("Closing database connection...")
db.Close()
}()
pollData := db.DB("ballots").C("polls")
此代码使用熟悉的mgo.Dial方法打开到本地运行的 MongoDB 实例的会话,并立即推迟一个关闭会话的函数。我们可以确信这段代码将在先前推迟的包含退出代码的语句之前运行(因为推迟的函数按照它们被调用的相反顺序运行)。因此,无论我们的程序发生什么,我们都知道数据库会话一定会正确关闭。
提示
日志语句是可选的,但将帮助我们在运行和退出程序时查看发生了什么。
在片段的末尾,我们使用mgo流畅的 API 将ballots.polls数据集的引用保存在pollData变量中,稍后我们将使用它来进行查询。
在 NSQ 中消费消息
为了计算选票,我们需要消耗 NSQ 中votes主题上的消息,并且我们需要一个地方来存储它们。将以下变量添加到main函数中:
var counts map[string]int
var countsLock sync.Mutex
在 Go 中,地图和锁(sync.Mutex)是常见的组合,因为我们将有多个 goroutine 尝试访问相同的地图,并且我们需要避免在同时尝试修改或读取它时破坏它。
将以下代码添加到main函数中:
log.Println("Connecting to nsq...")
q, err := nsq.NewConsumer("votes", "counter", nsq.NewConfig())
if err != nil {
fatal(err)
return
}
NewConsumer函数允许我们设置一个对象,该对象将侦听votes NSQ 主题,因此当twittervotes在该主题上发布选票时,我们可以在此程序中处理它。如果NewConsumer返回错误,我们将使用我们的fatal函数来记录并返回。
接下来,我们将添加处理来自 NSQ 的消息(选票)的代码:
q.AddHandler(nsq.HandlerFunc(func(m *nsq.Message) error {
countsLock.Lock()
defer countsLock.Unlock()
if counts == nil {
counts = make(map[string]int)
}
vote := string(m.Body)
counts[vote]++
return nil
}))
我们在nsq.Consumer上调用AddHandler方法,并将一个函数传递给它,该函数将在接收到votes主题上的每条消息时调用。
当选票到来时,我们首先锁定countsLock互斥体。接下来,我们推迟了互斥体的解锁,以便在函数退出时解锁。这使我们确信,在NewConsumer运行时,我们是唯一被允许修改地图的人;其他人必须等到我们的函数退出后才能使用它。对Lock方法的调用在放置锁时阻止执行,只有在通过调用Unlock释放锁时才继续执行。这就是为什么每个Lock调用都必须有一个Unlock对应项的原因,否则我们将使程序死锁。
每次收到一张选票时,我们都会检查counts是否为nil,如果是,则创建一个新地图,因为一旦数据库已更新为最新结果,我们希望重置一切并从零开始。最后,我们增加给定键的int值一次,并返回nil表示没有错误。
尽管我们已经创建了 NSQ 消费者,并添加了处理程序函数,但我们仍然需要连接到 NSQ 服务,我们将通过添加以下代码来实现:
if err := q.ConnectToNSQLookupd("localhost:4161"); err != nil {
fatal(err)
return
}
重要的是要注意,我们实际上是连接到nsqlookupd实例的 HTTP 端口,而不是 NSQ 实例;这种抽象意味着我们的程序不需要知道消息来自何处才能消费它们。如果我们无法连接到服务器(例如,如果我们忘记启动它),我们将收到错误,我们会在立即返回之前将其报告给我们的致命函数。
保持数据库更新
我们的代码将监听投票,并在内存中保留结果的映射,但是这些信息目前被困在我们的程序中。接下来,我们需要添加定期将结果推送到数据库的代码:
log.Println("Waiting for votes on nsq...")
var updater *time.Timer
updater = time.AfterFunc(updateDuration, func() {
countsLock.Lock()
defer countsLock.Unlock()
if len(counts) == 0 {
log.Println("No new votes, skipping database update")
} else {
log.Println("Updating database...")
log.Println(counts)
ok := true
for option, count := range counts {
sel := bson.M{"options": bson.M{"$in": []string{option}}}
up := bson.M{"$inc": bson.M{"results." + option: count}}
if _, err := pollData.UpdateAll(sel, up); err != nil {
log.Println("failed to update:", err)
ok = false
}
}
if ok {
log.Println("Finished updating database...")
counts = nil // reset counts
}
}
updater.Reset(updateDuration)
})
time.AfterFunc函数在指定的持续时间后调用函数的 goroutine。最后我们调用Reset,重新开始这个过程;这允许我们定期安排我们的更新代码定期运行。
当我们的更新函数运行时,我们首先锁定countsLock,并推迟其解锁。然后我们检查计数映射中是否有任何值。如果没有,我们只是记录我们正在跳过更新,并等待下一次。
如果有一些投票,我们会遍历counts映射,提取选项和投票数(自上次更新以来),并使用一些 MongoDB 魔法来更新结果。
注意
MongoDB 在内部存储 BSON(二进制 JSON)文档,这比普通 JSON 文档更容易遍历,这就是为什么mgo包带有mgo/bson编码包。在使用mgo时,我们经常使用bson类型,例如bson.M映射来描述 MongoDB 的概念。
我们首先使用bson.M快捷类型创建我们的更新操作的选择器,这类似于创建map[string]interface{}类型。我们创建的选择器将大致如下:
{
"options": {
"$in": ["happy"]
}
}
在 MongoDB 中,前面的 BSON 指定我们要选择options数组中包含"happy"的选项的投票。
接下来,我们使用相同的技术生成更新操作,大致如下:
{
"$inc": {
"results.happy": 3
}
}
在 MongoDB 中,前面的 BSON 指定我们要将results.happy字段增加 3。如果投票中没有results映射,将创建一个,如果results中没有happy键,则假定为0。
然后我们调用pollsData查询上的UpdateAll方法来向数据库发出命令,这将依次更新与选择器匹配的每个投票(与Update方法相反,它只会更新一个)。如果出现问题,我们会报告并将ok布尔值设置为 false。如果一切顺利,我们将counts映射设置为 nil,因为我们想重置计数器。
我们将在文件顶部将updateDuration指定为常量,这将使我们在测试程序时更容易进行更改。在main函数上面添加以下代码:
const updateDuration = 1 * time.Second
响应 Ctrl + C
在我们的程序准备就绪之前要做的最后一件事是确保我们的main函数在退出之前等待操作完成,就像我们在twittervotes程序中所做的那样。在main函数的末尾添加以下代码:
termChan := make(chan os.Signal, 1)
signal.Notify(termChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
for {
select {
case <-termChan:
updater.Stop()
q.Stop()
case <-q.StopChan:
// finished
return
}
}
这里我们采用了与以前略有不同的策略。我们捕获终止事件,这将导致在我们按下Ctrl + C时通过termChan发送信号。接下来,我们启动一个无限循环,在循环内部,我们使用 Go 的select结构,使我们能够在termChan或消费者的StopChan上接收到东西时运行代码。
实际上,我们只会在按下Ctrl+C时首先收到termChan信号,此时我们会停止updater计时器,并要求消费者停止监听投票。然后执行重新进入循环,并阻塞直到消费者通过在其StopChan上发出信号来报告已经停止。当这发生时,我们完成并退出,此时我们的延迟语句运行,如果您记得的话,它会整理数据库会话。
运行我们的解决方案
是时候看到我们的代码在运行了。确保在单独的终端窗口中运行nsqlookupd,nsqd和mongod:
nsqlookupd
nsqd --lookupd-tcp-address=127.0.0.1:4160
mongod --dbpath ./db
如果你还没有这样做,请确保twittervotes程序也在运行。然后在counter文件夹中,构建并运行我们的计数程序:
go build -o counter
./counter
你应该会看到定期的输出,描述counter正在做的工作,比如:
No new votes, skipping database update
Updating database...
map[win:2 happy:2 fail:1]
Finished updating database...
No new votes, skipping database update
Updating database...
map[win:3]
Finished updating database...
提示
输出当然会有所不同,因为我们实际上是在回应 Twitter 上的实时活动。
我们可以看到我们的程序正在从 NSQ 接收投票数据,并报告正在更新数据库的结果。我们可以通过打开 MongoDB shell 并查询投票数据来确认这一点,看看results map 是否正在更新。在另一个终端窗口中,打开 MongoDB shell:
mongo
要求它使用选票数据库:
> use ballots
switched to db ballots
使用无参数的 find 方法获取所有投票(在末尾添加pretty方法以获得格式良好的 JSON):
> db.polls.find().pretty()
{
"_id" : ObjectId("53e2a3afffbff195c2e09a02"),
"options" : [
"happy","sad","fail","win"
],
"results" : {
"fail" : 159, "win" : 711,
"happy" : 233, "sad" : 166,
},
"title" : "Test poll"
}
results map 确实在更新,并且随时包含每个选项的总票数。
摘要
在本章中,我们涵盖了很多内容。我们学习了使用信号通道优雅地关闭程序的不同技术,这在我们的代码在退出之前需要做一些工作时尤其重要。我们看到,在程序开始时推迟报告致命错误可以让我们的其他推迟函数有机会在进程结束之前执行。
我们还发现使用mgo包与 MongoDB 进行交互是多么容易,并且在描述数据库概念时如何使用 BSON 类型。bson.M替代了map[string]interface{},帮助我们保持代码更简洁,同时仍然提供了我们在处理非结构化或无模式数据时所需的所有灵活性。
我们了解了消息队列以及它们如何允许我们将系统的组件分解为独立和专业化的微服务。我们首先运行查找守护程序nsqlookupd,然后运行单个nsqd实例,并通过 TCP 接口将它们连接在一起。然后我们能够在twittervotes中将投票发布到队列中,并连接到查找守护程序,在我们的counter程序中为每个发送的投票运行处理函数。
虽然我们的解决方案实际上执行的是一个非常简单的任务,但是我们在本章中构建的架构能够做一些非常了不起的事情。
-
我们消除了
twittervotes和counter程序需要在同一台机器上运行的需求——只要它们都能连接到适当的 NSQ,无论它们在哪里运行,它们都会按预期运行。 -
我们可以将我们的 MongoDB 和 NSQ 节点分布在许多物理机器上,这意味着我们的系统能够实现巨大的规模——每当资源开始不足时,我们可以添加新的盒子来满足需求。
-
当我们添加其他需要查询和读取投票结果的应用程序时,我们可以确保我们的数据库服务是高度可用的,并且能够提供服务。
-
我们可以将我们的数据库分布在地理范围内,复制数据以备份,这样当灾难发生时我们不会丢失任何东西。
-
我们可以构建一个多节点、容错的 NSQ 环境,这意味着当我们的
twittervotes程序了解到有趣的推文时,总会有地方发送数据。 -
我们可以编写更多的程序,从不同的来源生成投票;唯一的要求是它们知道如何将消息放入 NSQ。
-
在下一章中,我们将构建自己的 RESTful 数据服务,通过它我们将公开我们社交投票应用程序的功能。我们还将构建一个 Web 界面,让用户创建自己的投票,并可视化结果。
第六章:通过 RESTful 数据 Web 服务 API 公开数据和功能
在上一章中,我们构建了一个从 Twitter 读取推文,计算标签投票并将结果存储在 MongoDB 数据库中的服务。我们还使用了 MongoDB shell 来添加投票并查看投票结果。如果我们是唯一使用我们的解决方案的人,那么这种方法是可以的,但是如果我们发布我们的项目并期望用户直接连接到我们的 MongoDB 实例以使用我们构建的服务,那将是疯狂的。
因此,在本章中,我们将构建一个 RESTful 数据服务,通过该服务将数据和功能公开。我们还将组建一个简单的网站来消费新的 API。用户可以使用我们的网站创建和监视投票,或者在我们发布的 Web 服务之上构建自己的应用程序。
提示
本章中的代码依赖于第五章中的代码,构建分布式系统并使用灵活数据,因此建议您首先完成该章节,特别是因为它涵盖了设置本章代码运行的环境。
具体来说,您将学到:
-
如何包装
http.HandlerFunc类型可以为我们的 HTTP 请求提供一个简单但强大的执行管道 -
如何在 HTTP 处理程序之间安全共享数据
-
编写负责公开数据的处理程序的最佳实践
-
小的抽象可以让我们现在编写尽可能简单的实现,但留下改进它们的空间,而不改变接口
-
如何向我们的项目添加简单的辅助函数和类型将防止我们(或至少推迟)对外部包添加依赖
RESTful API 设计
要使 API 被视为 RESTful,它必须遵循一些原则,这些原则忠实于 Web 背后的原始概念,并且大多数开发人员已经了解。这种方法可以确保我们没有在 API 中构建任何奇怪或不寻常的东西,同时也让我们的用户提前消费它,因为他们已经熟悉其概念。
一些最重要的 RESTful 设计概念是:
-
HTTP 方法描述要采取的操作类型,例如,
GET方法只会读取数据,而POST请求将创建某些东西 -
数据表示为资源集合
-
操作被表达为对数据的更改
-
URL 用于引用特定数据
-
HTTP 头用于描述进入和离开服务器的表示形式
注意
要深入了解 RESTful 设计的这些和其他细节,请参阅维基百科文章en.wikipedia.org/wiki/Representational_state_transfer。
以下表格显示了我们的 API 中支持的 HTTP 方法和 URL,以及我们打算如何使用调用的简要描述和示例用例:
| 请求 | 描述 | 用例 |
|---|---|---|
GET /polls/ |
读取所有投票 | 向用户显示投票列表 |
GET /polls/{id} |
读取投票 | 显示特定投票的详细信息或结果 |
POST /polls/ |
创建投票 | 创建新的投票 |
DELETE /polls/{id} |
删除投票 | 删除特定投票 |
{id}占位符表示路径中唯一的投票 ID 的位置。
在处理程序之间共享数据
如果我们希望保持处理程序与 Go 标准库中的http.Handler接口一样纯净,同时将常见功能提取到我们自己的方法中,我们需要一种在处理程序之间共享数据的方法。以下的HandlerFunc签名告诉我们,我们只允许传入一个http.ResponseWriter对象和一个http.Request对象,什么都不能传入:
type HandlerFunc func(http.ResponseWriter, *http.Request)
这意味着我们不能在一个地方创建和管理数据库会话对象,然后将它们传递给我们的处理程序,这理想情况下是我们想要做的。
相反,我们将实现一个按请求数据的内存映射,并为处理程序提供一种轻松访问它的方式。在twittervotes和counter文件夹旁边,创建一个名为api的新文件夹,并在其中创建一个名为vars.go的新文件。将以下代码添加到文件中:
package main
import (
"net/http"
"sync"
)
var vars map[*http.Request]map[string]interface{}
var varsLock sync.RWMutex
在这里,我们声明了一个vars映射,它的键是指向http.Request类型的指针,值是另一个映射。我们将存储与请求实例相关联的变量映射。varsLock互斥锁很重要,因为我们的处理程序将同时尝试访问和更改vars映射,同时处理许多并发的 HTTP 请求,我们需要确保它们可以安全地执行这些操作。
接下来,我们将添加OpenVars函数,允许我们准备vars映射以保存特定请求的变量:
func OpenVars(r *http.Request) {
varsLock.Lock()
if vars == nil {
vars = map[*http.Request]map[string]interface{}{}
}
vars[r] = map[string]interface{}{}
varsLock.Unlock()
}
这个函数首先锁定互斥锁,以便我们可以安全地修改映射,然后确保vars包含一个非 nil 映射,否则当我们尝试访问其数据时会导致恐慌。最后,它使用指定的http.Request指针作为键,分配一个新的空map值,然后解锁互斥锁,从而释放其他处理程序与之交互。
一旦我们完成了处理请求,我们需要一种方法来清理我们在这里使用的内存;否则,我们的代码的内存占用将不断增加(也称为内存泄漏)。我们通过添加CloseVars函数来实现这一点:
func CloseVars(r *http.Request) {
varsLock.Lock()
delete(vars, r)
varsLock.Unlock()
}
这个函数安全地删除了请求的vars映射中的条目。只要我们在尝试与变量交互之前调用OpenVars,并在完成后调用CloseVars,我们就可以自由地安全地存储和检索每个请求的数据。但是,我们不希望我们的处理程序代码在需要获取或设置一些数据时担心锁定和解锁映射,因此让我们添加两个辅助函数,GetVar和SetVar:
func GetVar(r *http.Request, key string) interface{} {
varsLock.RLock()
value := vars[r][key]
varsLock.RUnlock()
return value
}
func SetVar(r *http.Request, key string, value interface{}) {
varsLock.Lock()
vars[r][key] = value
varsLock.Unlock()
}
GetVar函数将使我们能够轻松地从映射中获取指定请求的变量,SetVar允许我们设置一个。请注意,GetVar函数调用RLock和RUnlock而不是Lock和Unlock;这是因为我们使用了sync.RWMutex,这意味着可以安全地同时进行许多读取,只要没有写入发生。这对于可以同时读取的项目的性能是有利的。对于普通的互斥锁,Lock会阻塞执行,等待锁定它的东西解锁它,而RLock则不会。
包装处理程序函数
在构建 Go 中的 Web 服务和网站时,学习的最有价值的模式之一是我们在第二章中已经使用过的添加身份验证,在那里我们通过用其他http.Handler类型包装它们来装饰http.Handler类型。对于我们的 RESTful API,我们将应用相同的技术到http.HandlerFunc函数上,以提供一种非常强大的模块化代码的方式,而不会破坏标准的func(w http.ResponseWriter, r *http.Request)接口。
API 密钥
大多数 Web API 要求客户端为其应用程序注册一个 API 密钥,并要求他们在每个请求中发送该密钥。这些密钥有许多用途,从简单地识别请求来自哪个应用程序到解决授权问题,例如一些应用程序只能根据用户允许的内容做有限的事情。虽然我们实际上不需要为我们的应用程序实现 API 密钥,但我们将要求客户端提供一个,这将允许我们在保持接口不变的同时稍后添加实现。
在您的api文件夹中添加必要的main.go文件:
package main
func main(){}
接下来,我们将在main.go的底部添加我们的第一个HandlerFunc包装器函数,名为withAPIKey:
func withAPIKey(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !isValidAPIKey(r.URL.Query().Get("key")) {
respondErr(w, r, http.StatusUnauthorized, "invalid API key")
return
}
fn(w, r)
}
}
正如你所看到的,我们的withAPIKey函数既接受一个http.HandlerFunc类型作为参数,又返回一个;这就是我们在这个上下文中所说的包装。withAPIKey函数依赖于许多其他我们尚未编写的函数,但你可以清楚地看到发生了什么。我们的函数立即返回一个新的http.HandlerFunc类型,通过调用isValidAPIKey来检查查询参数key。如果密钥被认为是无效的(通过返回false),我们将回应一个无效的 API 密钥错误。要使用这个包装器,我们只需将一个http.HandlerFunc类型传递给这个函数,以启用key参数检查。由于它也返回一个http.HandlerFunc类型,因此结果可以被传递到其他包装器中,或者直接传递给http.HandleFunc函数,以实际将其注册为特定路径模式的处理程序。
让我们接下来添加我们的isValidAPIKey函数:
func isValidAPIKey(key string) bool {
return key == "abc123"
}
目前,我们只是将 API 密钥硬编码为abc123;其他任何内容都将返回false,因此被视为无效。稍后,我们可以修改这个函数,以查阅配置文件或数据库来检查密钥的真实性,而不影响我们如何使用isValidAPIKey方法,或者withAPIKey包装器。
数据库会话
现在我们可以确保请求有一个有效的 API 密钥,我们必须考虑处理程序将如何连接到数据库。一种选择是让每个处理程序拨号自己的连接,但这并不是很DRY(不要重复自己),并且留下了潜在错误的空间,比如忘记在完成后关闭数据库会话的代码。相反,我们将创建另一个管理数据库会话的HandlerFunc包装器。在main.go中,添加以下函数:
func withData(d *mgo.Session, f http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
thisDb := d.Copy()
defer thisDb.Close()
SetVar(r, "db", thisDb.DB("ballots"))
f(w, r)
}
}
withData函数使用mgo包来接受一个 MongoDB 会话表示,以及另一个处理程序,符合该模式。返回的http.HandlerFunc类型将复制数据库会话,延迟关闭该副本,并使用我们的SetVar助手将ballots数据库的引用设置为db变量,最后调用下一个HandlerFunc。这意味着在此之后执行的任何处理程序都将通过GetVar函数访问受管数据库会话。一旦处理程序执行完毕,延迟关闭会话将发生,这将清理请求使用的任何内存,而无需个别处理程序担心它。
每个请求的变量
我们的模式允许我们非常轻松地代表我们的实际处理程序执行常见任务。请注意,其中一个处理程序正在调用OpenVars和CloseVars,以便GetVar和SetVar可以在不必关心设置和拆卸的情况下使用。该函数将返回一个首先调用OpenVars进行请求的http.HandlerFunc,延迟调用CloseVars,并调用指定的处理程序函数。任何使用withVars包装的处理程序都可以使用GetVar和SetVar。
将以下代码添加到main.go:
func withVars(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
OpenVars(r)
defer CloseVars(r)
fn(w, r)
}
}
使用这种模式可以解决许多其他问题;每当你发现自己在处理程序内部重复常见任务时,都值得考虑是否处理程序包装函数可以帮助简化代码。
跨浏览器资源共享
同源安全策略要求 Web 浏览器中的 AJAX 请求只允许服务于同一域上托管的服务,这将使我们的 API 相当受限,因为我们不一定会托管使用我们 Web 服务的所有网站。CORS 技术绕过了同源策略,允许我们构建一个能够为其他域上托管的网站提供服务的服务。为此,我们只需在响应中设置Access-Control-Allow-Origin头为*。顺便说一句,因为我们在创建投票调用中使用了Location头,我们也将允许客户端访问该头,这可以通过在Access-Control-Expose-Headers头中列出来实现。在main.go中添加以下代码:
func withCORS(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Expose-Headers", "Location")
fn(w, r)
}
}
这是最简单的包装函数,它只是在ResponseWriter类型上设置适当的头,并调用指定的http.HandlerFunc类型。
提示
在这一章中,我们明确处理 CORS,以便我们可以准确了解发生了什么;对于真正的生产代码,您应该考虑使用开源解决方案,比如github.com/fasterness/cors。
读累了记得休息一会哦~
公众号:古德猫宁李
-
电子书搜索下载
-
书单分享
-
书友学习交流
网站:沉金书屋 https://www.chenjin5.com
-
电子书搜索下载
-
电子书打包资源分享
-
学习资源分享
响应
任何 API 的重要部分是以状态码、数据、错误和有时头部的组合来响应请求,net/http包使得所有这些都非常容易实现。我们有一个选项,对于小型项目或者大项目的早期阶段来说,仍然是最好的选项,那就是直接在处理程序中构建响应代码。然而,随着处理程序数量的增加,我们将不得不重复大量的代码,并在整个项目中散布表示决策。一个更可扩展的方法是将响应代码抽象成助手函数。
对于我们 API 的第一个版本,我们将只使用 JSON,但如果需要,我们希望灵活地添加其他表示。
创建一个名为respond.go的新文件,并添加以下代码:
func decodeBody(r *http.Request, v interface{}) error {
defer r.Body.Close()
return json.NewDecoder(r.Body).Decode(v)
}
func encodeBody(w http.ResponseWriter, r *http.Request, v interface{}) error {
return json.NewEncoder(w).Encode(v)
}
这两个函数分别抽象了从Request和ResponseWriter对象解码和编码数据。解码器还关闭了请求体,这是推荐的。虽然我们在这里没有添加太多功能,但这意味着我们不需要在代码的其他地方提到 JSON,如果我们决定添加对其他表示的支持或者切换到二进制协议,我们只需要修改这两个函数。
接下来,我们将添加一些更多的助手,使得响应变得更加容易。在respond.go中,添加以下代码:
func respond(w http.ResponseWriter, r *http.Request,
status int, data interface{},
) {
w.WriteHeader(status)
if data != nil {
encodeBody(w, r, data)
}
}
这个函数使得使用我们的encodeBody助手,可以轻松地将状态码和一些数据写入ResponseWriter对象。
处理错误是另一个值得抽象的重要方面。添加以下respondErr助手:
func respondErr(w http.ResponseWriter, r *http.Request,
status int, args ...interface{},
) {
respond(w, r, status, map[string]interface{}{
"error": map[string]interface{}{
"message": fmt.Sprint(args...),
},
})
}
这个方法给我们提供了一个类似于respond函数的接口,但写入的数据将被包装在一个error对象中,以明确表示出现了问题。最后,我们可以添加一个特定于 HTTP 错误的助手,通过使用 Go 标准库中的http.StatusText函数为我们生成正确的消息:
func respondHTTPErr(w http.ResponseWriter, r *http.Request,
status int,
) {
respondErr(w, r, status, http.StatusText(status))
}
请注意,这些函数都是 dogfooding,这意味着它们彼此使用(就像吃自己的狗粮一样),这很重要,因为我们希望实际的响应只发生在一个地方,以便在需要进行更改时(或更可能的是,何时需要进行更改)。
理解请求
http.Request对象为我们提供了关于底层 HTTP 请求的所有信息,因此值得浏览net/http文档,真正感受其强大之处。例如,但不限于:
-
URL、路径和查询字符串
-
HTTP 方法
-
Cookies
-
文件
-
表单值
-
请求者的引荐者和用户代理
-
基本身份验证详细信息
-
请求体
-
头信息
有一些问题它没有解决,我们需要自己解决或寻求外部包的帮助。URL 路径解析就是一个例子——虽然我们可以通过http.Request类型的URL.Path字段访问路径(例如/people/1/books/2),但没有简单的方法来提取路径中编码的数据,比如1的 people ID 或2的 books ID。
注意
一些项目很好地解决了这个问题,比如 Goweb 或 Gorillz 的mux包。它们允许您映射包含占位符值的路径模式,然后从原始字符串中提取这些值并使其可用于您的代码。例如,您可以映射/users/{userID}/comments/{commentID}的模式,这将映射路径,如/users/1/comments/2。在处理程序代码中,您可以通过放在花括号内的名称获取值,而不必自己解析路径。
由于我们的需求很简单,我们将编写一个简单的路径解析工具;如果必要,我们随时可以使用不同的包,但这意味着向我们的项目添加依赖。
创建一个名为path.go的新文件,并插入以下代码:
package main
import (
"strings"
)
const PathSeparator = "/"
type Path struct {
Path string
ID string
}
func NewPath(p string) *Path {
var id string
p = strings.Trim(p, PathSeparator)
s := strings.Split(p, PathSeparator)
if len(s) > 1 {
id = s[len(s)-1]
p = strings.Join(s[:len(s)-1], PathSeparator)
}
return &Path{Path: p, ID: id}
}
func (p *Path) HasID() bool {
return len(p.ID) > 0
}
这个简单的解析器提供了一个NewPath函数,它解析指定的路径字符串并返回Path类型的新实例。前导和尾随斜杠被修剪(使用strings.Trim),剩下的路径被PathSeparator常量(即斜杠)分割(使用strings.Split)。如果有多个段(len(s) > 1),最后一个被认为是 ID。我们重新切片字符串切片以使用len(s)-1选择最后一个项目作为 ID,并使用s[:len(s)-1]选择路径的其余部分。在同样的行上,我们还使用PathSeparator常量重新连接路径段,以形成一个包含路径但不包含 ID 的单个字符串。
这支持任何collection/id对,这正是我们 API 所需要的。以下表格显示了给定原始路径字符串的Path类型的状态:
| 原始路径字符串 | 路径 | ID | 是否有 ID |
|---|---|---|---|
/ |
/ |
nil |
false |
/people/ |
people |
nil |
false |
/people/1/ |
people |
1 |
true |
用于提供我们的 API 的简单 main 函数
Web 服务只不过是绑定到特定 HTTP 地址和端口并提供请求的简单 Go 程序,因此我们可以使用所有我们的命令行工具编写知识和技术。
提示
我们还希望确保我们的main函数尽可能简单和适度,这始终是编码的目标,特别是在 Go 中。
在编写我们的main函数之前,让我们看一下我们的 API 程序的一些设计目标:
-
我们应该能够指定 API 监听的 HTTP 地址和端口以及 MongoDB 实例的地址,而无需重新编译程序(通过命令行标志)
-
我们希望程序在我们终止它时能够优雅地关闭,允许正在处理的请求(在发送终止信号给我们的程序时仍在处理的请求)完成。
-
我们希望程序能够记录状态更新并正确报告错误
在main.go文件的顶部,用以下代码替换main函数占位符:
func main() {
var (
addr = flag.String("addr", ":8080", "endpoint address")
mongo = flag.String("mongo", "localhost", "mongodb address")
)
flag.Parse()
log.Println("Dialing mongo", *mongo)
db, err := mgo.Dial(*mongo)
if err != nil {
log.Fatalln("failed to connect to mongo:", err)
}
defer db.Close()
mux := http.NewServeMux()
mux.HandleFunc("/polls/", withCORS(withVars(withData(db, withAPIKey(handlePolls)))))
log.Println("Starting web server on", *addr)
graceful.Run(*addr, 1*time.Second, mux)
log.Println("Stopping...")
}
这个函数就是我们的 API main函数的全部内容,即使我们的 API 增长,我们只需要添加一点点冗余。
我们要做的第一件事是指定两个命令行标志addr和mongo,并使用一些合理的默认值,并要求flag包解析它们。然后我们尝试拨号指定地址的 MongoDB 数据库。如果我们失败了,我们会通过调用log.Fatalln中止。假设数据库正在运行并且我们能够连接,我们会在延迟关闭连接之前将引用存储在db变量中。这确保我们的程序在结束时正确断开连接并整理自己。
然后,我们创建一个新的http.ServeMux对象,这是 Go 标准库提供的请求多路复用器,并为所有以路径/polls/开头的请求注册一个处理程序。
最后,我们使用 Tyler Bunnell 的优秀的Graceful包,可以在github.com/stretchr/graceful找到,来启动服务器。该包允许我们在运行任何http.Handler(例如我们的ServeMux处理程序)时指定time.Duration,这将允许任何正在进行的请求在函数退出之前有一些时间完成。Run函数将阻塞,直到程序终止(例如,当有人按下Ctrl + C)。
使用处理程序函数包装器
在ServeMux处理程序上调用HandleFunc时,我们使用了我们的处理程序函数包装器,代码如下:
withCORS(withVars(withData(db, withAPIKey(handlePolls)))))
由于每个函数都将http.HandlerFunc类型作为参数,并返回一个,我们可以通过嵌套函数调用来链接执行,就像我们之前做的那样。因此,当请求带有路径前缀/polls/时,程序将采取以下执行路径:
-
调用
withCORS,设置适当的标头。 -
调用
withVars,调用OpenVars并为请求延迟CloseVars。 -
然后调用
withData,它会复制提供的数据库会话作为第一个参数,并延迟关闭该会话。 -
接下来调用
withAPIKey,检查请求是否有 API 密钥,如果无效则中止,否则调用下一个处理程序函数。 -
然后调用
handlePolls,它可以访问变量和数据库会话,并且可以使用respond.go中的辅助函数向客户端编写响应。 -
执行返回到
withAPIKey,然后退出。 -
执行返回到
withData,然后退出,因此调用延迟的会话Close函数并清理数据库会话。 -
执行返回到
withVars,然后退出,因此调用CloseVars并清理。 -
最后,执行返回到
withCORS,然后退出。
注意
我们嵌套包装函数的顺序很重要,因为withData使用SetVar将每个请求的数据库会话放入该请求的变量映射中。因此,withVars必须在withData之外。如果不遵守这一点,代码很可能会出现 panic,并且您可能希望添加一个检查,以便 panic 对其他开发人员更有意义。
处理端点
拼图的最后一块是handlePolls函数,它将使用辅助函数来理解传入的请求并访问数据库,并生成一个有意义的响应,将发送回客户端。我们还需要对上一章中使用的投票数据进行建模。
创建一个名为polls.go的新文件,并添加以下代码:
package main
import "gopkg.in/mgo.v2/bson"
type poll struct {
ID bson.ObjectId `bson:"_id" json:"id"`
Title string `json":"title""`
Options []string `json:"options"`
Results map[string]int `json:"results,omitempty"`
}
在这里,我们定义了一个名为poll的结构,它有三个字段,依次描述了我们在上一章中编写的代码创建和维护的投票。每个字段还有一个标签(在ID情况下有两个),这使我们能够提供一些额外的元数据。
使用标签向结构体添加元数据
标签是跟随struct类型中字段定义的字符串,位于同一行代码中。我们使用反引号字符来表示字面字符串,这意味着我们可以在标签字符串本身中使用双引号。reflect包允许我们提取与任何键关联的值;在我们的情况下,bson和json都是键的示例,它们都是由空格字符分隔的键/值对。encoding/json和gopkg.in/mgo.v2/bson包允许您使用标签来指定将用于编码和解码的字段名称(以及一些其他属性),而不是从字段名称本身推断值。我们使用 BSON 与 MongoDB 数据库通信,使用 JSON 与客户端通信,因此我们实际上可以指定相同struct类型的不同视图。例如,考虑 ID 字段:
ID bson.ObjectId `bson:"_id" json:"id"`
在 Go 中的字段名是ID,JSON 字段是id,BSON 字段是_id,这是 MongoDB 中使用的特殊标识符字段。
单个处理程序的多个操作
因为我们简单的路径解析解决方案只关心路径,所以当查看客户端正在进行的 RESTful 操作类型时,我们需要做一些额外的工作。具体来说,我们需要考虑 HTTP 方法,以便知道如何处理请求。例如,对我们的/polls/路径进行GET调用应该读取投票,而POST调用将创建一个新的投票。一些框架为您解决了这个问题,允许您基于更多内容而不仅仅是路径来映射处理程序,比如 HTTP 方法或请求中特定标头的存在。由于我们的情况非常简单,我们将使用一个简单的switch情况。在polls.go中,添加handlePolls函数:
func handlePolls(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
handlePollsGet(w, r)
return
case "POST":
handlePollsPost(w, r)
return
case "DELETE":
handlePollsDelete(w, r)
return
}
// not found
respondHTTPErr(w, r, http.StatusNotFound)
}
我们根据 HTTP 方法进行分支,并根据是GET、POST还是DELETE来分支我们的代码。如果 HTTP 方法是其他的,我们只是用404 http.StatusNotFound错误进行响应。为了使这段代码编译,您可以在handlePolls处理程序下面添加以下函数存根:
func handlePollsGet(w http.ResponseWriter, r *http.Request) {
respondErr(w, r, http.StatusInternalServerError, errors.New("not implemented"))
}
func handlePollsPost(w http.ResponseWriter, r *http.Request) {
respondErr(w, r, http.StatusInternalServerError, errors.New("not implemented"))
}
func handlePollsDelete(w http.ResponseWriter, r *http.Request) {
respondErr(w, r, http.StatusInternalServerError, errors.New("not implemented"))
}
提示
在这一部分,我们学习了如何手动解析请求的元素(HTTP 方法)并在代码中做出决策。这对于简单的情况来说很好,但值得看看像 Goweb 或 Gorilla 的mux包这样的包,以便以更强大的方式解决这些问题。然而,将外部依赖保持在最低限度是编写良好且包含的 Go 代码的核心理念。
阅读投票
现在是时候实现我们的 Web 服务的功能了。在GET情况下,添加以下代码:
func handlePollsGet(w http.ResponseWriter, r *http.Request) {
db := GetVar(r, "db").(*mgo.Database)
c := db.C("polls")
var q *mgo.Query
p := NewPath(r.URL.Path)
if p.HasID() {
// get specific poll
q = c.FindId(bson.ObjectIdHex(p.ID))
} else {
// get all polls
q = c.Find(nil)
}
var result []*poll
if err := q.All(&result); err != nil {
respondErr(w, r, http.StatusInternalServerError, err)
return
}
respond(w, r, http.StatusOK, &result)
}
我们在每个子处理程序函数中的第一件事是使用GetVar获取mgo.Database对象,这将允许我们与 MongoDB 进行交互。由于此处理程序嵌套在withVars和withData中,我们知道数据库将在执行到达我们的处理程序时可用。然后,我们使用mgo创建一个对象,引用数据库中的polls集合——如果您记得,这就是我们的投票所在的地方。
然后,我们通过解析路径构建一个mgo.Query对象。如果存在 ID,我们使用polls集合上的FindId方法,否则我们将nil传递给Find方法,这表示我们要选择所有的投票。我们使用ObjectIdHex方法将 ID 从字符串转换为bson.ObjectId类型,以便我们可以使用它们的数字(十六进制)标识符引用投票。
由于All方法期望生成一组投票对象,我们将结果定义为[]*poll,或者指向投票类型的指针切片。在查询上调用All方法将导致mgo使用其与 MongoDB 的连接来读取所有投票并填充result对象。
注意
对于小规模项目,比如少量投票,这种方法是可以的,但随着投票数量的增加,我们需要考虑对结果进行分页或者使用查询中的Iter方法进行迭代,以便不要将太多数据加载到内存中。
现在我们已经添加了一些功能,让我们第一次尝试我们的 API。如果您使用的是我们在上一章中设置的相同的 MongoDB 实例,那么您应该已经在polls集合中有一些数据;为了确保我们的 API 正常工作,您应该确保数据库中至少有两个投票。
提示
如果您需要向数据库添加其他投票,在终端中运行mongo命令以打开一个允许您与 MongoDB 交互的数据库 shell。然后输入以下命令以添加一些测试投票:
> use ballots
switched to db ballots
> db.polls.insert({"title":"Test poll","options":["one","two","three"]})
> db.polls.insert({"title":"Test poll two","options":["four","five","six"]})
在终端中,导航到您的api文件夹,并构建和运行项目:
go build –o api
./api
现在,通过在浏览器中导航到http://localhost:8080/polls/?key=abc123,向/polls/端点发出GET请求;记得包括尾随斜杠。结果将以 JSON 格式返回一组投票。
复制并粘贴投票列表中的一个 ID,并将其插入到浏览器中?字符之前,以访问特定投票的数据;例如,http://localhost:8080/polls/5415b060a02cd4adb487c3ae?key=abc123。请注意,它只返回一个投票,而不是所有投票。
提示
通过删除或更改密钥参数来测试 API 密钥功能,看看错误是什么样子。
您可能还注意到,尽管我们只返回了一个投票,但这个投票值仍然嵌套在一个数组中。这是一个有意为之的设计决定,有两个原因:第一个和最重要的原因是,嵌套使得 API 的用户更容易编写代码来消费数据。如果用户总是期望一个 JSON 数组,他们可以编写描述这种期望的强类型,而不是为单个投票和投票集合编写另一种类型。作为 API 设计者,这是您的决定。我们将对象嵌套在数组中的第二个原因是,它使 API 代码更简单,允许我们只改变mgo.Query对象并保持其余代码不变。
创建投票
客户端应该能够向/polls/发出POST请求来创建一个投票。让我们在POST情况下添加以下代码:
func handlePollsPost(w http.ResponseWriter, r *http.Request) {
db := GetVar(r, "db").(*mgo.Database)
c := db.C("polls")
var p poll
if err := decodeBody(r, &p); err != nil {
respondErr(w, r, http.StatusBadRequest, "failed to read poll from request", err)
return
}
p.ID = bson.NewObjectId()
if err := c.Insert(p); err != nil {
respondErr(w, r, http.StatusInternalServerError, "failed to insert poll", err)
return
}
w.Header().Set("Location", "polls/"+p.ID.Hex())
respond(w, r, http.StatusCreated, nil)
}
在这里,我们首先尝试解码请求的主体,根据 RESTful 原则,请求的主体应包含客户端想要创建的投票对象的表示。如果发生错误,我们使用respondErr助手将错误写入用户,并立即返回该函数。然后,我们为投票生成一个新的唯一 ID,并使用mgo包的Insert方法将其发送到数据库。根据 HTTP 标准,我们设置响应的Location标头,并以201 http.StatusCreated消息做出响应,指向新创建的投票的 URL。
删除投票
我们要在 API 中包含的最后一个功能是能够删除投票。通过使用DELETE HTTP 方法向投票的 URL(例如/polls/5415b060a02cd4adb487c3ae)发出请求,我们希望能够从数据库中删除投票并返回200 Success响应:
func handlePollsDelete(w http.ResponseWriter, r *http.Request) {
db := GetVar(r, "db").(*mgo.Database)
c := db.C("polls")
p := NewPath(r.URL.Path)
if !p.HasID() {
respondErr(w, r, http.StatusMethodNotAllowed, "Cannot delete all polls.")
return
}
if err := c.RemoveId(bson.ObjectIdHex(p.ID)); err != nil {
respondErr(w, r, http.StatusInternalServerError, "failed to delete poll", err)
return
}
respond(w, r, http.StatusOK, nil) // ok
}
与GET情况类似,我们解析路径,但这次如果路径不包含 ID,我们会响应错误。目前,我们不希望人们能够通过一个请求删除所有投票,因此使用适当的StatusMethodNotAllowed代码。然后,使用我们在之前情况下使用的相同集合,我们调用RemoveId,传入路径中的 ID 并将其转换为bson.ObjectId类型。假设一切顺利,我们会以http.StatusOK消息做出响应,没有正文。
CORS 支持
为了使我们的DELETE功能在 CORS 上工作,我们必须做一些额外的工作,以支持 CORS 浏览器处理一些 HTTP 方法(如DELETE)的方式。CORS 浏览器实际上会发送一个预检请求(HTTP 方法为OPTIONS),请求权限进行DELETE请求(列在Access-Control-Request-Method请求标头中),API 必须做出适当的响应才能使请求工作。在switch语句中添加另一个OPTIONS的情况:
case "OPTIONS":
w.Header().Add("Access-Control-Allow-Methods", "DELETE")
respond(w, r, http.StatusOK, nil)
return
如果浏览器要求发送DELETE请求的权限,API 将通过将Access-Control-Allow-Methods标头设置为DELETE来响应,从而覆盖我们在withCORS包装处理程序中设置的默认*值。在现实世界中,Access-Control-Allow-Methods标头的值将根据所做的请求而改变,但由于我们只支持DELETE,因此现在可以硬编码它。
注意
CORS 的细节不在本书的范围之内,但建议您在打算构建真正可访问的 Web 服务和 API 时,如果打算构建真正可访问的 Web 服务和 API,建议您在网上研究相关内容。请访问enable-cors.org/开始。
使用 curl 测试我们的 API
curl是一个命令行工具,允许我们向我们的服务发出 HTTP 请求,以便我们可以像真正的应用程序或客户端一样访问它。
注意
Windows 用户默认没有curl,需要寻找替代方法。请查看curl.haxx.se/dlwiz/?type=bin或在网络上搜索“Windowscurl替代方法”。
在终端中,让我们通过我们的 API 读取数据库中的所有投票。转到您的api文件夹,构建和运行项目,并确保 MongoDB 正在运行:
go build –o api
./api
然后我们执行以下步骤:
- 输入以下
curl命令,使用-X标志表示我们要对指定的 URL 进行GET请求:
curl -X GET http://localhost:8080/polls/?key=abc123
- 在按下Enter键后,输出将被打印:
[{"id":"541727b08ea48e5e5d5bb189","title":"Best Beatle?","options":["john","paul","george","ringo"]},{"id":"541728728ea48e5e5d5bb18a","title":"Favorite language?","options":["go","java","javascript","ruby"]}]
- 虽然不够美观,但您可以看到 API 从数据库返回了投票。发出以下命令来创建一个新的投票:
curl --data '{"title":"test","options":["one","two","three"]}' -X POST http://localhost:8080/polls/?key=abc123
- 再次获取列表,以查看新的投票包括在内:
curl -X GET http://localhost:8080/polls/?key=abc123
- 复制并粘贴其中一个 ID,并调整 URL 以特指该投票:
curl -X GET http://localhost:8080/polls/541727b08ea48e5e5d5bb189?key=abc123
[{"id":"541727b08ea48e5e5d5bb189",","title":"Best Beatle?","options":["john","paul","george","ringo"]}]
- 现在我们只看到了选定的投票
Best Beatle。让我们发出DELETE请求来删除该投票:
curl -X DELETE http://localhost:8080/polls/541727b08ea48e5e5d5bb189?key=abc123
- 现在当我们再次获取所有投票时,我们会看到
Best Beatle投票已经消失了:
curl -X GET http://localhost:8080/polls/?key=abc123
[{"id":"541728728ea48e5e5d5bb18a","title":"Favorite language?","options":["go","java","javascript","ruby"]}]
现在我们知道我们的 API 正在按预期工作,是时候构建一个正确消耗 API 的东西了。
消耗 API 的 Web 客户端
我们将组建一个超级简单的 Web 客户端,通过我们的 API 公开的功能和数据,允许用户与我们在上一章和本章早些时候构建的投票系统进行交互。我们的客户端将由三个网页组成:
-
显示所有投票的
index.html页面 -
显示特定投票结果的
view.html页面 -
一个
new.html页面,允许用户创建新的投票
在api文件夹旁边创建一个名为web的新文件夹,并将以下内容添加到main.go文件中:
package main
import (
"flag"
"log"
"net/http"
)
func main() {
var addr = flag.String("addr", ":8081", "website address")
flag.Parse()
mux := http.NewServeMux()
mux.Handle("/", http.StripPrefix("/",
http.FileServer(http.Dir("public"))))
log.Println("Serving website at:", *addr)
http.ListenAndServe(*addr, mux)
}
这几行 Go 代码真正突出了这种语言和 Go 标准库的美。它们代表了一个完整的、高度可扩展的、静态网站托管程序。该程序接受一个addr标志,并使用熟悉的http.ServeMux类型从名为public的文件夹中提供静态文件。
提示
在构建下面的几个页面时,我们将编写大量的 HTML 和 JavaScript 代码。由于这不是 Go 代码,如果您不想全部输入,可以随时转到本书的 GitHub 存储库,从github.com/matryer/goblueprints复制并粘贴。
显示投票列表的索引页面
在web文件夹内创建public文件夹,并在其中添加index.html文件,然后写入以下 HTML 代码:
<!DOCTYPE html>
<html>
<head>
<title>Polls</title>
<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css">
</head>
<body>
</body>
</html>
我们将再次使用 Bootstrap 来使我们的简单 UI 看起来漂亮,但是我们需要在 HTML 页面的body标签中添加两个额外的部分。首先,添加将显示投票列表的 DOM 元素:
<div class="container">
<div class="col-md-4"></div>
<div class="col-md-4">
<h1>Polls</h1>
<ul id="polls"></ul>
<a href="new.html" class="btn btn-primary">Create new poll</a>
</div>
<div class="col-md-4"></div>
</div>
在这里,我们使用 Bootstrap 的网格系统来居中对齐我们的内容,内容由一系列投票列表和一个指向new.html的链接组成,用户可以在那里创建新的投票。
接下来,在上述代码下面添加以下script标签和 JavaScript:
<script src="img/jquery.min.js"></script>
<script src="img/bootstrap.min.js"></script>
<script>
$(function(){
var update = function(){
$.get("http://localhost:8080/polls/?key=abc123", null, null, "json")
.done(function(polls){
$("#polls").empty();
for (var p in polls) {
var poll = polls[p];
$("#polls").append(
$("<li>").append(
$("<a>")
.attr("href", "view.html?poll=polls/" + poll.id)
.text(poll.title)
)
)
}
}
);
window.setTimeout(update, 10000);
}
update();
});
</script>
我们使用 jQuery 的$.get函数向我们的 Web 服务发出 AJAX 请求。我们还将 API URL 硬编码。在实践中,您可能会决定反对这样做,但至少应该使用域名来进行抽象。一旦投票加载完成,我们使用 jQuery 构建一个包含指向view.html页面的超链接的列表,并将投票的 ID 作为查询参数传递。
创建新投票的页面
为了允许用户创建新的投票,创建一个名为new.html的文件放在public文件夹中,并将以下 HTML 代码添加到文件中:
<!DOCTYPE html>
<html>
<head>
<title>Create Poll</title>
<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css">
</head>
<body>
<script src="img/jquery.min.js"></script>
<script src="img/bootstrap.min.js"></script>
</body>
</html>
我们将为 HTML 表单添加元素,以捕获创建新投票时所需的信息,即投票的标题和选项。在body标签内添加以下代码:
<div class="container">
<div class="col-md-4"></div>
<form id="poll" role="form" class="col-md-4">
<h2>Create Poll</h2>
<div class="form-group">
<label for="title">Title</label>
<input type="text" class="form-control" id="title" placeholder="Title">
</div>
<div class="form-group">
<label for="options">Options</label>
<input type="text" class="form-control" id="options" placeholder="Options">
<p class="help-block">Comma separated</p>
</div>
<button type="submit" class="btn btn-primary">Create Poll</button> or <a href="/">cancel</a>
</form>
<div class="col-md-4"></div>
</div>
由于我们的 API 使用 JSON,我们需要做一些工作,将 HTML 表单转换为 JSON 编码的字符串,并将逗号分隔的选项字符串拆分为选项数组。添加以下script标签:
<script>
$(function(){
var form = $("form#poll");
form.submit(function(e){
e.preventDefault();
var title = form.find("input[id='title']").val();
var options = form.find("input[id='options']").val();
options = options.split(",");
for (var opt in options) {
options[opt] = options[opt].trim();
}
$.post("http://localhost:8080/polls/?key=abc123",
JSON.stringify({
title: title, options: options
})
).done(function(d, s, r){
location.href = "view.html?poll=" + r.getResponseHeader("Location");
});
});
});
</script>
在这里,我们添加一个监听器来监听表单的submit事件,并使用 jQuery 的val方法来收集输入值。我们用逗号分隔选项,并在使用$.post方法发出POST请求到适当的 API 端点之前去除空格。JSON.stringify允许我们将数据对象转换为 JSON 字符串,并将该字符串用作请求的主体,正如 API 所期望的那样。成功后,我们提取Location头并将用户重定向到view.html页面,将新创建的投票作为参数传递。
显示投票详细信息的页面
我们需要完成应用程序的最终页面是view.html页面,用户可以在该页面上查看投票的详细信息和实时结果。在public文件夹中创建一个名为view.html的新文件,并将以下 HTML 代码添加到其中:
<!DOCTYPE html>
<html>
<head>
<title>View Poll</title>
<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css">
</head>
<body>
<div class="container">
<div class="col-md-4"></div>
<div class="col-md-4">
<h1 data-field="title">...</h1>
<ul id="options"></ul>
<div id="chart"></div>
<div>
<button class="btn btn-sm" id="delete">Delete this poll</button>
</div>
</div>
<div class="col-md-4"></div>
</div>
</body>
</html>
这个页面与其他页面大部分相似;它包含用于呈现投票标题、选项和饼图的元素。我们将使用谷歌的可视化 API 与我们的 API 结合,呈现结果。在view.html的最后一个div标签下(并在闭合的body标签上方),添加以下script标签:
<script src="img/"></script>
<script src="img/jquery.min.js"></script>
<script src="img/bootstrap.min.js"></script>
<script>
google.load('visualization', '1.0', {'packages':['corechart']});
google.setOnLoadCallback(function(){
$(function(){
var chart;
var poll = location.href.split("poll=")[1];
var update = function(){
$.get("http://localhost:8080/"+poll+"?key=abc123", null, null, "json")
.done(function(polls){
var poll = polls[0];
$('[data-field="title"]').text(poll.title);
$("#options").empty();
for (var o in poll.results) {
$("#options").append(
$("<li>").append(
$("<small>").addClass("label label-default").text(poll.results[o]),
" ", o
)
)
}
if (poll.results) {
var data = new google.visualization.DataTable();
data.addColumn("string","Option");
data.addColumn("number","Votes");
for (var o in poll.results) {
data.addRow([o, poll.results[o]])
}
if (!chart) {
chart = new google.visualization.PieChart(document.getElementById('chart'));
}
chart.draw(data, {is3D: true});
}
}
);
window.setTimeout(update, 1000);
};
update();
$("#delete").click(function(){
if (confirm("Sure?")) {
$.ajax({
url:"http://localhost:8080/"+poll+"?key=abc123",
type:"DELETE"
})
.done(function(){
location.href = "/";
})
}
});
});
});
</script>
我们包括我们将需要为页面提供动力的依赖项,jQuery 和 Bootstrap,以及 Google JavaScript API。该代码从谷歌加载适当的可视化库,并在提取 URL 上的投票 ID 时等待 DOM 元素加载,通过在poll=上拆分它。然后,我们创建一个名为update的变量,表示负责生成页面视图的函数。采用这种方法是为了使我们能够使用window.setTimeout轻松地发出对视图的定期调用。在update函数内部,我们使用$.get向我们的/polls/{id}端点发出GET请求,将{id}替换为我们之前从 URL 中提取的实际 ID。一旦投票加载完成,我们更新页面上的标题,并遍历选项以将它们添加到列表中。如果有结果(请记住在上一章中,results映射仅在开始计票时才添加到数据中),我们创建一个新的google.visualization.PieChart对象,并构建一个包含结果的google.visualization.DataTable对象。调用图表上的draw会导致它呈现数据,从而使用最新的数字更新图表。然后,我们使用setTimeout告诉我们的代码在另一个秒内再次调用update。
最后,我们绑定到我们页面上添加的delete按钮的click事件,并在询问用户是否确定后,向投票 URL 发出DELETE请求,然后将其重定向回主页。这个请求实际上会导致首先进行OPTIONS请求,请求权限,这就是为什么我们在之前的handlePolls函数中添加了显式支持的原因。
运行解决方案
在过去的两章中,我们构建了许多组件,现在是时候看到它们一起工作了。本节包含了您需要使所有项目运行的所有内容,假设您已经按照上一章开头描述的那样正确设置了环境。本节假设您有一个包含四个子文件夹的单个文件夹:api,counter,twittervotes和web。
假设没有任何运行中的内容,按照以下步骤进行(每个步骤在自己的终端窗口中):
- 在顶层文件夹中,启动
nsqlookupd守护进程:
nsqlookupd
- 在相同的目录中,启动
nsqd守护进程:
nsqd --lookupd-tcp-address=localhost:4160
- 启动 MongoDB 守护进程:
mongod
- 导航到
counter文件夹并构建并运行它:
cd counter
go build –o counter
./counter
- 导航到
twittervotes文件夹并构建并运行它。确保你设置了适当的环境变量,否则当你运行程序时会看到错误:
cd ../twittervotes
go build –o twittervotes
./twittervotes
- 导航到
api文件夹并构建并运行它:
cd ../api
go build –o api
./api
- 导航到
web文件夹并构建并运行它:
cd ../web
go build –o web
./web
现在一切都在运行,打开浏览器,转到http://localhost:8081/。使用用户界面,创建一个名为Moods的投票,并输入选项happy,sad,fail,and success。这些是足够常见的词,我们很可能会在 Twitter 上看到一些相关的活动。
创建了投票后,您将被带到查看页面,在那里您将开始看到结果的出现。等待几秒钟,享受您的辛勤工作的成果,因为 UI 会实时更新,显示实时结果。

总结
在本章中,我们通过一个高度可扩展的 RESTful API 公开了我们社交投票解决方案的数据,并构建了一个简单的网站,该网站使用 API 来提供用户与之交互的直观方式。该网站仅包含静态内容,没有服务器端处理(因为 API 为我们处理了繁重的工作)。这使我们能够在静态托管网站(如bitballoon.com)上以非常低的成本托管网站,或者将文件分发到内容交付网络。
在我们的 API 服务中,我们学会了如何在不破坏或混淆标准库中的处理程序模式的情况下在处理程序之间共享数据。我们还看到编写包装处理程序函数如何使我们能够以一种非常简单和直观的方式构建功能管道。
我们编写了一些基本的编码和解码函数,目前只是简单地包装了encoding/json包中的对应函数,以后可以改进以支持一系列不同的数据表示,而不改变我们代码的内部接口。我们编写了一些简单的辅助函数,使得响应数据请求变得容易,同时提供了相同类型的抽象,使我们能够以后发展我们的 API。
我们看到,对于简单的情况,切换到 HTTP 方法是支持单个端点的许多功能的一种优雅方式。我们还看到,通过添加几行额外的代码,我们能够构建支持 CORS 的功能,允许在不同域上运行的应用程序与我们的服务交互,而无需像 JSONP 那样的黑客。
本章的代码与我们在上一章中所做的工作结合起来,提供了一个实际的、可投入生产的解决方案,实现了以下流程:
-
用户在网站上点击创建投票按钮,并输入投票的标题和选项。
-
在浏览器中运行的 JavaScript 将数据编码为 JSON 字符串,并将其发送到我们的 API 的
POST请求的主体中。 -
API 收到请求后,验证 API 密钥,设置数据库会话,并将其存储在我们的变量映射中,调用
handlePolls函数处理请求,并将新的投票存储在 MongoDB 数据库中。 -
API 将用户重定向到新创建的投票的
view.html页面。 -
与此同时,
twittervotes程序从数据库中加载所有投票,包括新的投票,并打开到 Twitter 的连接,过滤代表投票选项的标签。 -
当选票进来时,
twittervotes将它们推送到 NSQ。 -
counter程序正在监听适当的频道,并注意到投票的到来,计算每一个,并定期更新数据库。 -
用户在
view.html页面上看到结果显示(并刷新),因为网站不断地向所选投票的 API 端点发出GET请求。
在下一章中,我们将发展我们的 API 和 web 技能,构建一个全新的创业应用程序 Meander。我们将看到如何在几行 Go 代码中编写一个完整的静态 web 服务器,并探索一种在官方不支持的语言中表示枚举器的有趣方式!
第七章:随机推荐网络服务
这个项目的概念是简单的:我们希望用户能够根据我们将通过 API 公开的预定义旅行类型,在特定地理位置生成随机推荐的活动。我们将给我们的项目起名为 Meander。
在现实世界的项目中,你通常需要负责整个技术栈;有人建立网站,另一个人可能编写 iOS 应用,也许外包公司建立桌面版本。在更成功的 API 项目中,你甚至可能不知道你的 API 的消费者是谁,特别是如果它是一个公共 API。
在本章中,我们将通过与虚构合作伙伴事先设计和达成最小 API 设计来模拟这一现实,然后再实施 API。一旦我们完成了项目的一部分,我们将下载由我们的队友构建的用户界面,看看它们如何一起工作,产生最终的应用程序。
在本章中,你将:
-
学会使用简短而简单的敏捷用户故事来表达项目的一般目标
-
发现你可以通过达成 API 设计来约定项目的会议点,这样可以让许多人并行工作。
-
看看早期版本的代码实际上可以在代码中编写数据固定装置并编译到程序中,这样我们可以稍后更改实现而不触及接口
-
学习一种策略,允许结构体(和其他类型)代表它们的公共版本,以便在我们想要隐藏或转换内部表示时使用
-
学会使用嵌入结构体来表示嵌套数据,同时保持我们类型的接口简单
-
学会使用
http.Get来进行外部 API 请求,特别是 Google Places API,而不会有代码膨胀 -
学会在 Go 中有效地实现枚举器,尽管它们实际上不是一种语言特性
-
体验 TDD 的真实例子
-
看看
math/rand包如何轻松地从切片中随机选择一个项目 -
学会从
http.Request类型的 URL 参数中轻松获取数据
项目概述
遵循敏捷方法,让我们写两个用户故事来描述我们项目的功能。用户故事不应该是描述应用程序整套功能的全面文档;小卡片不仅适合描述用户试图做什么,还适合描述为什么。此外,我们应该在不试图事先设计整个系统或深入实现细节的情况下完成这一点。
首先,我们需要一个关于看到我们的用户可以选择的不同旅行类型的故事:
| 作为 | 旅行者 |
|---|---|
| 我想 | 看到我可以获得推荐的不同旅行类型 |
| 以便 | 我可以决定带我的伴侣去哪种类型的晚上 |
其次,我们需要一个关于为选定的旅行类型提供随机推荐的故事:
| 作为 | 旅行者 |
|---|---|
| 我想 | 看到我选择的旅行类型的随机推荐 |
| 以便 | 我知道去哪里,晚上会是什么样子 |
这两个故事代表了我们的 API 需要提供的两个核心功能,并最终代表了两个端点。
为了发现指定位置周围的地方,我们将使用 Google Places API,它允许我们搜索具有给定类型的企业列表,比如酒吧,咖啡馆或电影院。然后我们将使用 Go 的math/rand包随机选择这些地方,为我们的用户建立完整的旅程。
提示
Google Places API 支持许多业务类型;请参阅developers.google.com/places/documentation/supported_types获取完整列表。
项目设计细节
为了将我们的故事转化为一个交互式应用程序,我们将提供两个 JSON 端点;一个用于提供用户可以在应用程序中选择的旅程类型,另一个用于实际生成所选旅程类型的随机推荐。
GET /journeys
上述调用应返回以下列表:
[
{
name: "Romantic",
journey: "park|bar|movie_theater|restaurant|florist"
},
{
name: "Shopping",
journey: "department_store|clothing_store|jewelry_store"
}
]
name字段是应用程序生成的推荐类型的可读标签,journey字段是支持的旅程类型的管道分隔列表。我们将传递旅程值作为 URL 参数到我们的另一个端点,该端点生成实际的推荐:
GET /recommendations?
lat=1&lng=2&journey=bar|cafe&radius=10&cost=$...$$$$$
这个端点负责查询 Google Places API 并在返回地点对象数组之前生成推荐。我们将使用 URL 中的参数来控制查询的类型,根据 HTTP 规范。lat和lng参数分别表示纬度和经度,告诉我们的 API 我们想要从世界的哪个地方获得推荐,radius参数表示我们感兴趣的点周围的米数距离。cost值是表示 API 返回的地点价格范围的一种可读方式。它由两个值组成:用三个点分隔的较低和较高范围。美元符号的数量表示价格水平,$是最实惠的,$$$$$是最昂贵的。使用这种模式,$...$$的值将表示非常低成本的推荐,而$$$$...$$$$$将表示相当昂贵的体验。
提示
一些程序员可能会坚持用数值表示成本范围,但由于我们的 API 将被人们使用,为什么不让事情变得更有趣呢?
对于这个调用的示例负载可能看起来像这样:
[
{
icon: "http://maps.gstatic.com/mapfiles/place_api/icons/cafe-71.png",
lat: 51.519583, lng: -0.146251,
vicinity: "63 New Cavendish St, London",
name: "Asia House",
photos: [{
url: "https://maps.googleapis.com/maps/api/place/photo?maxwidth=400&photoreference=CnRnAAAAyLRN"
}]
}, ...
]
返回的数组包含代表旅程中每个段的随机推荐的地点对象,按适当的顺序。上面的示例是伦敦的一家咖啡馆。数据字段相当不言自明;lat和lng字段表示地点的位置(它们是纬度和经度的缩写),name和vicinity字段告诉我们业务是什么和在哪里,photos数组给出了来自 Google 服务器的相关照片列表。vicinity和icon字段将帮助我们为用户提供更丰富的体验。
在代码中表示数据
我们首先要公开用户可以选择的旅程,因此在GOPATH中创建一个名为meander的新文件夹,并添加以下journeys.go代码:
package meander
type j struct {
Name string
PlaceTypes []string
}
var Journeys = []interface{}{
&j{Name: "Romantic", PlaceTypes: []string{"park", "bar", "movie_theater", "restaurant", "florist", "taxi_stand"}},
&j{Name: "Shopping", PlaceTypes: []string{"department_store", "cafe", "clothing_store", "jewelry_store", "shoe_store"}},
&j{Name: "Night Out", PlaceTypes: []string{"bar", "casino", "food", "bar", "night_club", "bar", "bar", "hospital"}},
&j{Name: "Culture", PlaceTypes: []string{"museum", "cafe", "cemetery", "library", "art_gallery"}},
&j{Name: "Pamper", PlaceTypes: []string{"hair_care", "beauty_salon", "cafe", "spa"}},
}
在这里,我们在meander包内定义了一个名为j的内部类型,然后我们使用它来通过在Journeys切片内创建它们的实例来描述旅程。这种方法是在代码中以一种超简单的方式表示数据,而不会构建对外部数据存储的依赖。
提示
作为额外的任务,为什么不看看您是否可以在整个过程中让golint保持愉快?每次添加一些代码时,运行golint来检查包并满足任何建议。它非常关心没有文档的导出项,因此以正确格式添加简单注释将使其保持愉快。要了解有关golint的更多信息,请参阅github.com/golang/lint。
当然,这可能会在以后演变成这样,甚至可能让用户创建和分享自己的旅程。由于我们通过 API 公开我们的数据,我们可以自由更改内部实现而不影响接口,因此这种方法非常适合 1.0 版本。
提示
我们使用[]interface{}类型的切片,因为我们将稍后实现一种通用的方式来公开公共数据,而不考虑实际类型。
一次浪漫的旅程包括首先访问公园,然后是酒吧,电影院,然后是餐厅,然后是花店,最后是乘坐出租车回家;你可以得到一个大致的想法。随意发挥创意,并通过查阅 Google Places API 中支持的类型来添加其他类型。
您可能已经注意到,由于我们将代码包含在名为meander(而不是main)的包中,我们的代码永远无法像我们迄今为止编写的其他 API 一样作为工具运行。在meander内创建一个名为cmd的新文件夹;这将容纳通过 HTTP 端点公开meander包功能的实际命令行工具。
在cmd文件夹中,将以下代码添加到main.go文件中:
package main
func main() {
runtime.GOMAXPROCS(runtime.NumCPU())
//meander.APIKey = "TODO"
http.HandleFunc("/journeys", func(w http.ResponseWriter, r *http.Request) {
respond(w, r, meander.Journeys)
})
http.ListenAndServe(":8080", http.DefaultServeMux)
}
func respond(w http.ResponseWriter, r *http.Request, data []interface{}) error {
return json.NewEncoder(w).Encode(data)
}
您会认出这是一个简单的 API 端点程序,映射到/journeys端点。
提示
您将不得不导入encoding/json,net/http和runtime包,以及您之前创建的meander包。
runtime.GOMAXPROCS调用设置了我们的程序可以使用的 CPU 的最大数量,并告诉它使用所有 CPU。然后我们在meander包中设置了APIKey的值(目前已注释掉,因为我们还没有实现它),然后在net/http包上调用熟悉的HandleFunc函数来绑定我们的端点,然后只是响应meander.Journeys变量。我们从上一章借用了抽象响应的概念,提供了一个respond函数,将指定的数据编码到http.ResponseWriter类型中。
让我们通过在终端中导航到cmd文件夹并使用go run来运行我们的 API 程序。在这个阶段,我们不需要将其构建成可执行文件,因为它只是一个单独的文件:
go run main.go
访问http://localhost:8080/journeys端点,注意我们提供的Journeys数据负载,它看起来像这样:
[{
Name: "Romantic",
PlaceTypes: [
"park",
"bar",
"movie_theater",
"restaurant",
"florist",
"taxi_stand"
]
}]
这是完全可以接受的,但有一个主要缺陷:它暴露了我们实现的内部信息。如果我们将PlaceTypes字段名称更改为Types,我们的 API 将发生变化,我们应该避免这种情况。
项目随着时间的推移会不断发展和变化,尤其是成功的项目,作为开发人员,我们应该尽力保护我们的客户免受演变的影响。抽象接口是实现这一点的好方法,以及拥有数据对象的公共视图的所有权。
Go 结构体的公共视图
为了控制 Go 中结构体的公共视图,我们需要发明一种方法,允许单独的journey类型告诉我们它们希望如何暴露。在meander文件夹中,创建一个名为public.go的新文件,并添加以下代码:
package meander
type Facade interface {
Public() interface{}
}
func Public(o interface{}) interface{} {
if p, ok := o.(Facade); ok {
return p.Public()
}
return o
}
Facade接口公开了一个Public方法,该方法将返回结构体的公共视图。Public函数接受任何对象并检查它是否实现了Facade接口(它是否有一个Public() interface{}方法?);如果实现了,就调用该方法并返回结果,否则就原样返回对象。这允许我们在将结果写入ResponseWriter对象之前通过Public函数传递任何内容,从而允许单独的结构体控制它们的公共外观。
让我们通过在journeys.go中添加以下代码来为我们的j类型实现一个Public方法:
func (j *j) Public() interface{} {
return map[string]interface{}{
"name": j.Name,
"journey": strings.Join(j.PlaceTypes, "|"),
}
}
我们的j类型的公共视图将PlaceTypes字段连接成一个由管道字符分隔的字符串,按照我们的 API 设计。
回到cmd/main.go,用使用我们的新Public函数替换respond方法:
func respond(w http.ResponseWriter, r *http.Request, data []interface{}) error {
publicData := make([]interface{}, len(data))
for i, d := range data {
publicData[i] = meander.Public(d)
}
return json.NewEncoder(w).Encode(publicData)
}
在这里,我们遍历数据切片,为每个项目调用meander.Public函数,将结果构建到一个相同大小的新切片中。对于我们的j类型,它的Public方法将被调用以提供数据的公共视图,而不是默认视图。在终端中,再次导航到cmd文件夹,并在运行http://localhost:8080/journeys之前再次运行go run main.go。注意,相同的数据现在已更改为新结构:
[{
journey: "park|bar|movie_theater|restaurant|florist|taxi_stand",
name: "Romantic"
}, ...]
生成随机推荐
为了获取我们的代码将随机构建推荐的地点,我们需要查询 Google Places API。在meander文件夹中,添加以下query.go文件:
package meander
type Place struct {
*googleGeometry `json:"geometry"`
Name string `json:"name"`
Icon string `json:"icon"`
Photos []*googlePhoto `json:"photos"`
Vicinity string `json:"vicinity"`
}
type googleResponse struct {
Results []*Place `json:"results"`
}
type googleGeometry struct {
*googleLocation `json:"location"`
}
type googleLocation struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
}
type googlePhoto struct {
PhotoRef string `json:"photo_reference"`
URL string `json:"url"`
}
这段代码定义了我们需要解析来自 Google Places API 的 JSON 响应的结构,以便将其转换为可用的对象。
提示
转到 Google Places API 文档,查看我们期望的响应示例。请参阅developers.google.com/places/documentation/search。
大部分前面的代码都是显而易见的,但值得注意的是Place类型嵌入了googleGeometry类型,这允许我们根据 API 表示嵌套数据,同时在我们的代码中实质上将其展平。我们在googleGeometry内部也是这样做的,这意味着我们将能够直接在Place对象上访问Lat和Lng值,即使它们在技术上是嵌套在其他结构中的。
因为我们想要控制Place对象如何公开显示,让我们给这个类型添加以下Public方法:
func (p *Place) Public() interface{} {
return map[string]interface{}{
"name": p.Name,
"icon": p.Icon,
"photos": p.Photos,
"vicinity": p.Vicinity,
"lat": p.Lat,
"lng": p.Lng,
}
}
提示
记得在这段代码上运行golint,看看哪些注释需要添加到导出的项目中。
Google Places API 密钥
与大多数 API 一样,我们需要一个 API 密钥才能访问远程服务。转到 Google API 控制台,使用 Google 账户登录,并为 Google Places API 创建一个密钥。有关更详细的说明,请参阅 Google 开发者网站上的文档。
一旦您获得了密钥,让我们在meander包中创建一个可以保存它的变量。在query.go的顶部,添加以下定义:
var APIKey string
现在返回到main.go,从APIKey行中删除双斜杠//,并用 Google API 控制台提供的实际密钥替换TODO值。
Go 中的枚举器
为了处理我们 API 的各种成本范围,使用枚举器(或enum)来表示各种值并处理到和从字符串表示的转换是有意义的。Go 并没有明确提供枚举器,但有一种巧妙的实现方法,我们将在本节中探讨。
Go 中编写枚举器的一个简单灵活的检查表是:
-
定义一个基于原始整数类型的新类型
-
在需要用户指定适当值之一时使用该类型
-
使用
iota关键字在const块中设置值,忽略第一个零值 -
实现一个合理的字符串表示到枚举器值的映射
-
在类型上实现一个
String方法,从映射中返回适当的字符串表示 -
实现一个
ParseType函数,使用映射从字符串转换为您的类型
现在我们将编写一个枚举器来表示我们 API 中的成本级别。在meander文件夹中创建一个名为cost_level.go的新文件,并添加以下代码:
package meander
type Cost int8
const (
_ Cost = iota
Cost1
Cost2
Cost3
Cost4
Cost5
)
在这里,我们定义了我们的枚举器的类型,我们称之为Cost,由于我们只需要表示一些值,所以我们基于int8范围进行了定义。对于我们需要更大值的枚举器,您可以自由地使用任何与iota一起使用的整数类型。Cost类型现在是一个真正的类型,我们可以在需要表示支持的值之一的地方使用它,例如,我们可以在函数的参数中指定Cost类型,或者将其用作结构中字段的类型。
然后,我们定义了该类型的常量列表,并使用iota关键字指示我们希望为常量获得递增的值。通过忽略第一个iota值(始终为零),我们指示必须显式使用指定的常量之一,而不是零值。
为了提供我们的枚举器的字符串表示,我们只需要为Cost类型添加一个String方法。即使您不需要在代码中使用字符串,这也是一个有用的练习,因为每当您使用 Go 标准库的打印调用(如fmt.Println)时,默认情况下将使用数字值。这些值通常是没有意义的,并且需要您查找它们,甚至计算每个项目的数值。
注意
有关 Go 中String()方法的更多信息,请参阅fmt包中的Stringer和GoStringer接口,网址为golang.org/pkg/fmt/#Stringer。
测试驱动的枚举器
为了确保我们的枚举器代码正常工作,我们将编写单元测试,对预期行为进行一些断言。
在cost_level.go旁边,添加一个名为cost_level_test.go的新文件,并添加以下单元测试:
package meander_test
import (
"testing"
"github.com/cheekybits/is"
"path/to/meander"
)
func TestCostValues(t *testing.T) {
is := is.New(t)
is.Equal(int(meander.Cost1), 1)
is.Equal(int(meander.Cost2), 2)
is.Equal(int(meander.Cost3), 3)
is.Equal(int(meander.Cost4), 4)
is.Equal(int(meander.Cost5), 5)
}
您需要运行go get来获取 CheekyBits 的is包(从github.com/cheekybits/is)。
提示
is包是一个替代测试助手包,但这个包非常简单,故意是最基本的。在编写自己的项目时,您可以选择自己喜欢的包。
通常,我们不会担心枚举中常量的实际整数值,但由于 Google Places API 使用数字值来表示相同的事物,我们需要关心这些值。
注意
您可能已经注意到这个测试文件与传统不同之处。虽然它在meander文件夹中,但它不是meander包的一部分;而是在meander_test中。
在 Go 中,这在除了测试之外的每种情况下都是错误的。因为我们将测试代码放入自己的包中,这意味着我们不再可以访问meander包的内部-请注意我们必须使用包前缀。这可能看起来像一个缺点,但实际上它允许我们确保我们测试包时就像我们是真正的用户一样。我们只能调用导出的方法,并且只能看到导出的类型;就像我们的用户一样。
通过在终端中运行go test来运行测试,并注意它是否通过。
让我们添加另一个测试,对每个Cost常量的字符串表示进行断言。在cost_level_test.go中,添加以下单元测试:
func TestCostString(t *testing.T) {
is := is.New(t)
is.Equal(meander.Cost1.String(), "$")
is.Equal(meander.Cost2.String(), "$$")
is.Equal(meander.Cost3.String(), "$$$")
is.Equal(meander.Cost4.String(), "$$$$")
is.Equal(meander.Cost5.String(), "$$$$$")
}
这个测试断言调用每个常量的String方法会产生预期的值。当然,运行这些测试会失败,因为我们还没有实现String方法。
在Cost常量下面,添加以下映射和String方法:
var costStrings = map[string]Cost{
"$": Cost1,
"$$": Cost2,
"$$$": Cost3,
"$$$$": Cost4,
"$$$$$": Cost5,
}
func (l Cost) String() string {
for s, v := range costStrings {
if l == v {
return s
}
}
return "invalid"
}
map[string]Cost变量将成本值映射到字符串表示形式,String方法遍历映射以返回适当的值。
提示
在我们的情况下,一个简单的返回strings.Repeat("$", int(l))也可以很好地工作(并且因为它是更简单的代码而胜出),但通常不会,因此本节探讨了一般方法。
现在,如果我们要打印Cost3的值,我们实际上会看到$$$,这比数字值更有用。然而,由于我们确实想在 API 中使用这些字符串,我们还将添加一个ParseCost方法。
在cost_value_test.go中,添加以下单元测试:
func TestParseCost(t *testing.T) {
is := is.New(t)
is.Equal(meander.Cost1, meander.ParseCost("$"))
is.Equal(meander.Cost2, meander.ParseCost("$$"))
is.Equal(meander.Cost3, meander.ParseCost("$$$"))
is.Equal(meander.Cost4, meander.ParseCost("$$$$"))
is.Equal(meander.Cost5, meander.ParseCost("$$$$$"))
}
在这里,我们断言调用ParseCost实际上会根据输入字符串产生适当的值。
在cost_value.go中,添加以下实现代码:
func ParseCost(s string) Cost {
return costStrings[s]
}
解析Cost字符串非常简单,因为这就是我们的映射布局。
由于我们需要表示一系列成本值,让我们想象一个CostRange类型,并为我们打算如何使用它编写测试。将以下测试添加到cost_value_test.go中:
func TestParseCostRange(t *testing.T) {
is := is.New(t)
var l *meander.CostRange
l = meander.ParseCostRange("$$...$$$")
is.Equal(l.From, meander.Cost2)
is.Equal(l.To, meander.Cost3)
l = meander.ParseCostRange("$...$$$$$")
is.Equal(l.From, meander.Cost1)
is.Equal(l.To, meander.Cost5)
}
func TestCostRangeString(t *testing.T) {
is := is.New(t)
is.Equal("$$...$$$$", (&meander.CostRange{
From: meander.Cost2,
To: meander.Cost4,
}).String())
}
我们指定传入一个以两个美元符号开头的字符串,然后是三个点,然后是三个美元符号,应该创建一个新的meander.CostRange类型,其中From设置为meander.Cost2,To设置为meander.Cost3。第二个测试通过测试CostRange.String方法返回适当的值来执行相反的操作。
为了使我们的测试通过,添加以下CostRange类型和相关的String和ParseString函数:
type CostRange struct {
From Cost
To Cost
}
func (r CostRange) String() string {
return r.From.String() + "..." + r.To.String()
}
func ParseCostRange(s string) *CostRange {
segs := strings.Split(s, "...")
return &CostRange{
From: ParseCost(segs[0]),
To: ParseCost(segs[1]),
}
}
这使我们能够将诸如$...$$$$$之类的字符串转换为包含两个Cost值的结构;From和To设置,反之亦然。
查询 Google Places API
现在我们能够表示 API 的结果,我们需要一种方法来表示和初始化实际查询。将以下结构添加到query.go中:
type Query struct {
Lat float64
Lng float64
Journey []string
Radius int
CostRangeStr string
}
这个结构包含了我们构建查询所需的所有信息,所有这些信息实际上都来自客户端请求的 URL 参数。接下来,添加以下find方法,它将负责向 Google 的服务器发出实际请求:
func (q *Query) find(types string) (*googleResponse, error) {
u := "https://maps.googleapis.com/maps/api/place/nearbysearch/json"
vals := make(url.Values)
vals.Set("location", fmt.Sprintf("%g,%g", q.Lat, q.Lng))
vals.Set("radius", fmt.Sprintf("%d", q.Radius))
vals.Set("types", types)
vals.Set("key", APIKey)
if len(q.CostRangeStr) > 0 {
r := ParseCostRange(q.CostRangeStr)
vals.Set("minprice", fmt.Sprintf("%d", int(r.From)-1))
vals.Set("maxprice", fmt.Sprintf("%d", int(r.To)-1))
}
res, err := http.Get(u + "?" + vals.Encode())
if err != nil {
return nil, err
}
defer res.Body.Close()
var response googleResponse
if err := json.NewDecoder(res.Body).Decode(&response); err != nil {
return nil, err
}
return &response, nil
}
首先,我们按照 Google Places API 规范构建请求 URL,通过附加url.Values编码的lat、lng、radius和APIKey值的数据字符串。
注意
url.Values类型实际上是map[string][]string类型,这就是为什么我们使用make而不是new。
我们指定的types值作为参数表示要查找的业务类型。如果有CostRangeStr,我们解析它并设置minprice和maxprice值,最后调用http.Get来实际发出请求。如果请求成功,我们推迟关闭响应主体,并使用json.Decoder方法将从 API 返回的 JSON 解码为我们的googleResponse类型。
建立推荐
接下来,我们需要编写一个方法,允许我们对不同旅程步骤进行多次调用。在find方法下面,添加以下Run方法到Query结构:
// Run runs the query concurrently, and returns the results.
func (q *Query) Run() []interface{} {
rand.Seed(time.Now().UnixNano())
var w sync.WaitGroup
var l sync.Mutex
places := make([]interface{}, len(q.Journey))
for i, r := range q.Journey {
w.Add(1)
go func(types string, i int) {
defer w.Done()
response, err := q.find(types)
if err != nil {
log.Println("Failed to find places:", err)
return
}
if len(response.Results) == 0 {
log.Println("No places found for", types)
return
}
for _, result := range response.Results {
for _, photo := range result.Photos {
photo.URL = "https://maps.googleapis.com/maps/api/place/photo?" +
"maxwidth=1000&photoreference=" + photo.PhotoRef + "&key=" + APIKey
}
}
randI := rand.Intn(len(response.Results))
l.Lock()
places[i] = response.Results[randI]
l.Unlock()
}(r, i)
}
w.Wait() // wait for everything to finish
return places
}
我们首先将随机种子设置为自 1970 年 1 月 1 日 UTC 以来的纳秒时间。这确保每次我们调用Run方法并使用rand包时,结果都会不同。如果我们不这样做,我们的代码将每次都建议相同的推荐,这就失去了意义。
由于我们需要向 Google 发出许多请求,并且希望尽快完成,我们将通过并发调用我们的Query.find方法同时运行所有查询。因此,我们接下来创建一个sync.WaitGroup方法,并创建一个地图来保存选定的地点以及一个sync.Mutex方法,以允许许多 go 例程同时访问地图。
然后,我们迭代Journey切片中的每个项目,可能是bar、cafe、movie_theater。对于每个项目,我们向WaitGroup对象添加1,并调用一个 goroutine。在例程内部,我们首先推迟w.Done调用,通知WaitGroup对象该请求已完成,然后调用我们的find方法进行实际请求。假设没有发生错误,并且确实能够找到一些地方,我们会遍历结果并构建出可用于任何可能存在的照片的 URL。根据 Google Places API,我们会得到一个photoreference键,我们可以在另一个 API 调用中使用它来获取实际的图像。为了使我们的客户不必完全了解 Google Places API,我们为他们构建完整的 URL。
然后我们锁定地图锁,并通过调用rand.Intn随机选择其中一个选项,并将其插入到places切片的正确位置,然后解锁sync.Mutex方法。
最后,我们等待所有 goroutine 完成,通过调用w.Wait,然后返回地点。
使用查询参数的处理程序
现在我们需要连接我们的/recommendations调用,因此返回cmd文件夹中的main.go,并在main函数内添加以下代码:
http.HandleFunc("/recommendations", func(w http.ResponseWriter, r *http.Request) {
q := &meander.Query{
Journey: strings.Split(r.URL.Query().Get("journey"), "|"),
}
q.Lat, _ = strconv.ParseFloat(r.URL.Query().Get("lat"), 64)
q.Lng, _ = strconv.ParseFloat(r.URL.Query().Get("lng"), 64)
q.Radius, _ = strconv.Atoi(r.URL.Query().Get("radius"))
q.CostRangeStr = r.URL.Query().Get("cost")
places := q.Run()
respond(w, r, places)
})
这个处理程序负责准备meander.Query对象并调用其Run方法,然后用结果进行响应。http.Request类型的 URL 值公开了提供Get方法的Query数据,该方法反过来查找给定键的值。
旅程字符串是从bar|cafe|movie_theater格式转换为字符串切片,通过在管道字符上进行分割。然后,对strconv包中的函数进行几次调用,将字符串纬度、经度和半径值转换为数值类型。
CORS
我们的 API 第一个版本的最后一部分将是实现 CORS,就像我们在上一章中所做的那样。在阅读下一节中的解决方案之前,看看你能否自己解决这个问题。
提示
如果您要自己解决这个问题,请记住,您的目标是将Access-Control-Allow-Origin响应标头设置为*。还考虑我们在上一章中所做的http.HandlerFunc包装。这段代码的最佳位置可能是在cmd程序中,因为它通过 HTTP 端点公开了功能。
在main.go中,添加以下cors函数:
func cors(f http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
f(w, r)
}
}
这种熟悉的模式接受一个http.HandlerFunc类型,并返回一个在调用传入的函数之前设置适当标头的新函数。现在我们可以修改我们的代码,以确保cors函数被调用我们的两个端点。更新main函数中的适当行:
func main() {
runtime.GOMAXPROCS(runtime.NumCPU())
meander.APIKey = "YOUR_API_KEY"
http.HandleFunc("/journeys", cors(func(w http.ResponseWriter, r *http.Request) {
respond(w, r, meander.Journeys)
}))
http.HandleFunc("/recommendations", cors(func(w http.ResponseWriter, r *http.Request) {
q := &meander.Query{
Journey: strings.Split(r.URL.Query().Get("journey"), "|"),
}
q.Lat, _ = strconv.ParseFloat(r.URL.Query().Get("lat"), 64)
q.Lng, _ = strconv.ParseFloat(r.URL.Query().Get("lng"), 64)
q.Radius, _ = strconv.Atoi(r.URL.Query().Get("radius"))
q.CostRangeStr = r.URL.Query().Get("cost")
places := q.Run()
respond(w, r, places)
}))
http.ListenAndServe(":8080", http.DefaultServeMux)
}
现在对我们的 API 的调用将允许来自任何域的调用,而不会发生跨域错误。
测试我们的 API
现在我们准备测试我们的 API,前往控制台并导航到cmd文件夹。因为我们的程序导入了meander包,构建程序将自动构建我们的meander包。
构建并运行程序:
go build –o meanderapi
./meanderapi
为了从我们的 API 中看到有意义的结果,让我们花一分钟找到您实际的纬度和经度。转到mygeoposition.com/并使用 Web 工具获取您熟悉的位置的x,y值。
或者从这些热门城市中选择:
-
英格兰伦敦:
51.520707 x 0.153809 -
美国纽约:
40.7127840 x -74.0059410 -
日本东京:
35.6894870 x 139.6917060 -
美国旧金山:
37.7749290 x -122.4194160
现在打开一个 Web 浏览器,并使用一些适当的值访问/recommendations端点:
http://localhost:8080/recommendations?
lat=51.520707&lng=-0.153809&radius=5000&
journey=cafe|bar|casino|restaurant&
cost=$...$$$
以下屏幕截图显示了伦敦周围的一个示例推荐的样子:

随意在 URL 中玩弄值,尝试不同的旅程字符串,调整位置,并尝试不同的成本范围值字符串,以查看简单 API 的强大之处。
Web 应用程序
我们将下载一个完整的 Web 应用程序,该应用程序构建到相同的 API 规范,并将其指向我们的实现,以便在我们眼前看到它变得生动。转到github.com/matryer/goblueprints/tree/master/chapter7/meanderweb并将meanderweb项目下载到您的GOPATH中。
在终端中,导航到meanderweb文件夹,并构建和运行它:
go build –o meanderweb
./meanderweb
这将启动一个在localhost:8081上运行的网站,它被硬编码为查找在localhost:8080上运行的 API。因为我们添加了 CORS 支持,尽管它们在不同的域上运行,这不会成为问题。
打开浏览器,访问http://localhost:8081/并与应用程序交互,虽然其他人构建了 UI,但没有我们构建的 API 支持它将会非常无用。
摘要
在本章中,我们构建了一个 API,它消耗和抽象了 Google Places API,以提供一个有趣而有趣的方式让用户规划他们的白天和夜晚。
我们开始写一些简单而简短的用户故事,以高层次描述我们想要实现的目标,而不是试图提前设计实现。为了并行化项目,我们同意将项目的会议点作为 API 设计,并朝着这个目标构建(就像我们的合作伙伴一样)。
我们直接在代码中嵌入数据,避免在项目的早期阶段进行数据存储的调查、设计和实施。我们关心的是数据如何被访问(通过 API 端点),这样我们就可以完全改变数据存储的方式和位置,而不会影响到已经编写为我们的 API 的应用程序。
我们实现了Facade接口,允许我们的结构体和其他类型提供它们的公共表示,而不会透露关于我们实现的混乱或敏感细节。
我们对枚举器的探索为我们提供了一个有用的起点,用于构建枚举类型,尽管语言中没有官方支持。我们使用的iota关键字让我们能够指定我们自己的数值类型的常量,并递增值。我们实现的常见String方法向我们展示了如何确保我们的枚举类型不会成为日志中的晦涩数字。与此同时,我们还看到了 TDD 的一个现实例子,以及红/绿编程,我们首先编写会失败的单元测试,然后通过编写实现代码使其通过。
读累了记得休息一会哦~
公众号:古德猫宁李
-
电子书搜索下载
-
书单分享
-
书友学习交流
网站:沉金书屋 https://www.chenjin5.com
-
电子书搜索下载
-
电子书打包资源分享
-
学习资源分享
第八章:文件系统备份
有许多解决方案提供文件系统备份功能。这些包括从应用程序(如 Dropbox、Box、Carbonite)到硬件解决方案(如苹果的 Time Machine、希捷或网络附加存储产品)等各种解决方案。大多数消费者工具提供一些关键的自动功能,以及一个应用程序或网站供您管理您的策略和内容。通常,特别是对于开发人员来说,这些工具并不能完全满足我们的需求。然而,由于 Go 的标准库(其中包括ioutil和os等包),我们有了构建备份解决方案所需的一切。
对于我们的最终项目,我们将为我们的源代码项目构建一个简单的文件系统备份,该备份将存档指定的文件夹并在每次更改时保存它们的快照。更改可能是当我们调整文件并保存它时,或者如果我们添加新文件和文件夹,甚至如果我们删除文件。我们希望能够回到任何时间点以检索旧文件。
具体来说,在本章中,您将学到:
-
如何构建由包和命令行工具组成的项目
-
在工具执行之间持久化简单数据的务实方法
-
os包如何允许您与文件系统交互 -
如何在无限定时循环中运行代码,同时尊重Ctrl + C
-
如何使用
filepath.Walk来迭代文件和文件夹 -
如何快速确定目录的内容是否已更改
-
如何使用
archive/zip包来压缩文件 -
如何构建关心命令行标志和普通参数组合的工具
解决方案设计
我们将首先列出一些高层次的解决方案验收标准以及我们想要采取的方法:
-
解决方案应该在我们对源代码项目进行更改时定期创建我们文件的快照
-
我们希望控制检查目录更改的间隔
-
代码项目主要是基于文本的,因此将目录压缩以生成存档将节省大量空间
-
我们将快速构建这个项目,同时密切关注我们可能希望以后进行改进的地方
-
如果我们决定将来更改我们的实现,我们所做的任何实现决策都应该很容易修改
-
我们将构建两个命令行工具,后台守护进程执行工作,用户交互工具让我们列出、添加和删除备份服务中的路径
项目结构
在 Go 解决方案中,通常在单个项目中,既有一个允许其他 Go 程序员使用您的功能的包,也有一个允许最终用户使用您的代码的命令行工具。
一种约定正在兴起,即通过在主项目文件夹中放置包,并在名为cmd或cmds的子文件夹中放置命令行工具。由于在 Go 中所有包(无论目录树如何)都是平等的,您可以从子包中导入主包,知道您永远不需要从主包中导入命令。这可能看起来像是一个不必要的抽象,但实际上是一个非常常见的模式,并且可以在标准的 Go 工具链中看到,例如gofmt和goimports。
例如,对于我们的项目,我们将编写一个名为backup的包,以及两个命令行工具:守护进程和用户交互工具。我们将按以下方式构建我们的项目:
/backup - package
/backup/cmds/backup – user interaction tool
/backup/cmds/backupd – worker daemon
备份包
我们首先将编写backup包,我们将成为编写相关工具时的第一个客户。该包将负责决定目录是否已更改并需要备份,以及实际执行备份过程。
明显的接口?
在着手编写新的 Go 程序时,首先要考虑的是是否有任何接口吸引了你的注意。我们不希望在一开始就过度抽象或浪费太多时间设计我们知道在编码开始时会发生变化的东西,但这并不意味着我们不应该寻找值得提取的明显概念。由于我们的代码将对文件进行归档,Archiver接口显然是一个候选者。
在GOPATH中创建一个名为backup的新文件夹,并添加以下archiver.go代码:
package backup
type Archiver interface {
Archive(src, dest string) error
}
Archiver接口将指定一个名为Archive的方法,该方法接受源和目标路径,并返回一个错误。该接口的实现将负责对源文件夹进行归档,并将其存储在目标路径中。
注意
提前定义一个接口是将一些概念从我们的头脑中转移到代码中的好方法;这并不意味着随着我们解决方案的演变,这个接口就不能改变,只要我们记住简单接口的力量。还要记住,io包中的大多数 I/O 接口只公开一个方法。
从一开始,我们就已经说明了,虽然我们将实现 ZIP 文件作为我们的存档格式,但以后我们可以很容易地用其他类型的Archiver格式来替换它。
实现 ZIP
现在我们有了Archiver类型的接口,我们将实现一个使用 ZIP 文件格式的接口。
将以下struct定义添加到archiver.go:
type zipper struct{}
我们不打算导出这种类型,这可能会让你得出结论,包外的用户将无法使用它。实际上,我们将为他们提供该类型的一个实例供他们使用,以免他们担心创建和管理自己的类型。
添加以下导出的实现:
// Zip is an Archiver that zips and unzips files.
var ZIP Archiver = (*zipper)(nil)
这段有趣的 Go 代码实际上是一种非常有趣的方式,可以向编译器暴露意图,而不使用任何内存(确切地说是 0 字节)。我们定义了一个名为ZIP的变量,类型为Archiver,因此从包外部很清楚,我们可以在需要Archiver的任何地方使用该变量——如果你想要压缩文件。然后我们将其赋值为nil,转换为*zipper类型。我们知道nil不占用内存,但由于它被转换为zipper指针,并且考虑到我们的zipper结构没有字段,这是解决问题的一种合适方式,它隐藏了代码的复杂性(实际实现)对外部用户。包外部没有任何理由需要知道我们的zipper类型,这使我们可以随时更改内部而不触及外部;这就是接口的真正力量。
这个技巧的另一个方便之处是,编译器现在将检查我们的zipper类型是否正确实现了Archiver接口,如果你尝试构建这段代码,你将会得到一个编译器错误:
./archiver.go:10: cannot use (*zipper)(nil) (type *zipper) as type Archiver in assignment:
*zipper does not implement Archiver (missing Archive method)
我们看到我们的zipper类型没有实现接口中规定的Archive方法。
注意
你也可以在测试代码中使用Archive方法来确保你的类型实现了它们应该实现的接口。如果你不需要使用这个变量,你可以使用下划线将其丢弃,你仍然会得到编译器的帮助:
var _ Interface = (*Implementation)(nil)
为了让编译器满意,我们将为我们的zipper类型添加Archive方法的实现。
将以下代码添加到archiver.go:
func (z *zipper) Archive(src, dest string) error {
if err := os.MkdirAll(filepath.Dir(dest), 0777); err != nil {
return err
}
out, err := os.Create(dest)
if err != nil {
return err
}
defer out.Close()
w := zip.NewWriter(out)
defer w.Close()
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if info.IsDir() {
return nil // skip
}
if err != nil {
return err
}
in, err := os.Open(path)
if err != nil {
return err
}
defer in.Close()
f, err := w.Create(path)
if err != nil {
return err
}
io.Copy(f, in)
return nil
})
}
你还需要从 Go 标准库中导入archive/zip包。在我们的Archive方法中,我们采取以下步骤来准备写入 ZIP 文件:
-
使用
os.MkdirAll确保目标目录存在。0777代码表示用于创建任何缺失目录的文件权限。 -
使用
os.Create根据dest路径创建一个新文件。 -
如果文件创建没有错误,使用
defer out.Close()延迟关闭文件。 -
使用
zip.NewWriter创建一个新的zip.Writer类型,它将写入我们刚刚创建的文件,并延迟关闭写入器。
一旦我们准备好一个zip.Writer类型,我们使用filepath.Walk函数来迭代源目录src。
filepath.Walk函数接受两个参数:根路径和回调函数func,用于在遍历文件系统时遇到的每个项目(文件和文件夹)进行调用。filepath.Walk函数是递归的,因此它也会深入到子文件夹中。回调函数本身接受三个参数:文件的完整路径,描述文件或文件夹本身的os.FileInfo对象,以及错误(如果发生错误,它也会返回错误)。如果对回调函数的任何调用导致返回错误,则操作将被中止,并且filepath.Walk将返回该错误。我们只需将其传递给Archive的调用者,并让他们担心,因为我们无法做更多事情。
对于树中的每个项目,我们的代码采取以下步骤:
-
如果
info.IsDir方法告诉我们该项目是一个文件夹,我们只需返回nil,有效地跳过它。没有理由将文件夹添加到 ZIP 存档中,因为文件的路径将为我们编码该信息。 -
如果传入错误(通过第三个参数),这意味着在尝试访问有关文件的信息时出现了问题。这是不常见的,所以我们只需返回错误,最终将其传递给
Archive的调用者。 -
使用
os.Open打开源文件进行读取,如果成功则延迟关闭。 -
在
ZipWriter对象上调用Create,表示我们要创建一个新的压缩文件,并给出文件的完整路径,其中包括它所嵌套的目录。 -
使用
io.Copy从源文件读取所有字节,并通过ZipWriter对象将它们写入我们之前打开的 ZIP 文件。 -
返回
nil表示没有错误。
本章不涉及单元测试或测试驱动开发(TDD)实践,但请随意编写一个测试来确保我们的实现达到预期的效果。
提示
由于我们正在编写一个包,花一些时间注释到目前为止导出的部分。您可以使用golint来帮助您找到可能遗漏的任何导出部分。
文件系统是否发生了更改?
我们的备份系统面临的最大问题之一是如何以跨平台、可预测和可靠的方式确定文件夹是否发生了更改。当我们考虑这个问题时,有几件事情值得一提:我们应该只检查顶层文件夹的上次修改日期吗?我们应该使用系统通知来通知我们关心的文件何时发生更改吗?这两种方法都存在问题,事实证明这并不是一个微不足道的问题。
相反,我们将生成一个由我们关心的所有信息组成的 MD5 哈希,以确定某些内容是否发生了更改。
查看os.FileInfo类型,我们可以看到关于文件的许多信息:
type FileInfo interface {
Name() string // base name of the file
Size() int64 // length in bytes for regular files;
system-dependent for others
Mode() FileMode // file mode bits
ModTime() time.Time // modification time
IsDir() bool // abbreviation for Mode().IsDir()
Sys() interface{} // underlying data source (can return nil)
}
为了确保我们能够意识到文件夹中任何文件的各种更改,哈希将由文件名和路径(因此如果它们重命名文件,哈希将不同)、大小(如果文件大小发生变化,显然是不同的)、上次修改日期、项目是文件还是文件夹以及文件模式位组成。尽管我们不会存档文件夹,但我们仍然关心它们的名称和文件夹的树结构。
创建一个名为dirhash.go的新文件,并添加以下函数:
package backup
import (
"crypto/md5"
"fmt"
"io"
"os"
"path/filepath"
)
func DirHash(path string) (string, error) {
hash := md5.New()
err := filepath.Walk(path, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
io.WriteString(hash, path)
fmt.Fprintf(hash, "%v", info.IsDir())
fmt.Fprintf(hash, "%v", info.ModTime())
fmt.Fprintf(hash, "%v", info.Mode())
fmt.Fprintf(hash, "%v", info.Name())
fmt.Fprintf(hash, "%v", info.Size())
return nil
})
if err != nil {
return "", err
}
return fmt.Sprintf("%x", hash.Sum(nil)), nil
}
我们首先创建一个知道如何计算 MD5 的新hash.Hash,然后使用filepath.Walk来遍历指定路径目录中的所有文件和文件夹。对于每个项目,假设没有错误,我们使用io.WriteString将差异信息写入哈希生成器,这让我们可以将字符串写入io.Writer,以及fmt.Fprintf,它同时暴露了格式化功能,允许我们使用%v格式动词生成每个项目的默认值格式。
一旦每个文件都被处理,假设没有发生错误,我们就使用fmt.Sprintf生成结果字符串。hash.Hash上的Sum方法计算具有附加指定值的最终哈希值。在我们的情况下,我们不想附加任何东西,因为我们已经添加了所有我们关心的信息,所以我们只传递nil。%x格式动词表示我们希望该值以十六进制(基数 16)的小写字母表示。这是表示 MD5 哈希的通常方式。
检查更改并启动备份
现在我们有了哈希文件夹的能力,并且可以执行备份,我们将把这两者放在一个名为Monitor的新类型中。Monitor类型将具有一个路径映射及其关联的哈希值,任何Archiver类型的引用(当然,我们现在将使用backup.ZIP),以及一个表示存档位置的目标字符串。
创建一个名为monitor.go的新文件,并添加以下定义:
type Monitor struct {
Paths map[string]string
Archiver Archiver
Destination string
}
为了触发更改检查,我们将添加以下Now方法:
func (m *Monitor) Now() (int, error) {
var counter int
for path, lastHash := range m.Paths {
newHash, err := DirHash(path)
if err != nil {
return 0, err
}
if newHash != lastHash {
err := m.act(path)
if err != nil {
return counter, err
}
m.Paths[path] = newHash // update the hash
counter++
}
}
return counter, nil
}
Now方法遍历映射中的每个路径,并生成该文件夹的最新哈希值。如果哈希值与映射中的哈希值不匹配(上次检查时生成的哈希值),则认为它已更改,并需要再次备份。在调用尚未编写的act方法之前,我们会这样做,然后使用这个新的哈希值更新映射中的哈希值。
为了给我们的用户一个高层次的指示,当他们调用Now时发生了什么,我们还维护一个计数器,每次备份一个文件夹时我们会增加这个计数器。我们稍后将使用这个计数器来让我们的最终用户了解系统正在做什么,而不是用信息轰炸他们。
m.act undefined (type *Monitor has no field or method act)
编译器再次帮助我们,并提醒我们还没有添加act方法:
func (m *Monitor) act(path string) error {
dirname := filepath.Base(path)
filename := fmt.Sprintf("%d.zip", time.Now().UnixNano())
return m.Archiver.Archive(path, filepath.Join(m.Destination, dirname, filename))
}
因为我们在我们的 ZIP Archiver类型中已经做了大部分工作,所以我们在这里所要做的就是生成一个文件名,决定存档的位置,并调用Archive方法。
提示
如果Archive方法返回一个错误,act方法和Now方法将分别返回它。在 Go 中,这种将错误传递到链条上的机制非常常见,它允许你处理你可以做一些有用的恢复的情况,或者将问题推迟给其他人。
上述代码中的act方法使用time.Now().UnixNano()生成时间戳文件名,并硬编码.zip扩展名。
硬编码在短时间内是可以的
像我们这样硬编码文件扩展名在开始时是可以的,但是如果你仔细想想,我们在这里混合了一些关注点。如果我们改变Archiver的实现以使用 RAR 或我们自己制作的压缩格式,.zip扩展名将不再合适。
提示
在继续阅读之前,想想你可能会采取哪些步骤来避免硬编码。文件扩展名决策在哪里?为了正确避免硬编码,你需要做哪些改变?
文件扩展名决定的正确位置可能在Archiver接口中,因为它知道将要进行的归档类型。所以我们可以添加一个Ext()字符串方法,并从我们的act方法中访问它。但是我们可以通过允许Archiver作者指定整个文件名格式,而不仅仅是扩展名,来增加一点额外的功能而不需要太多额外的工作。
回到archiver.go,更新Archiver接口定义:
type Archiver interface {
DestFmt() string
Archive(src, dest string) error
}
我们的zipper类型现在需要实现这个:
func (z *zipper) DestFmt() string {
return "%d.zip"
}
现在我们可以要求我们的act方法从Archiver接口获取整个格式字符串,更新act方法:
func (m *Monitor) act(path string) error {
dirname := filepath.Base(path)
filename := fmt.Sprintf(m.Archiver.DestFmt(), time.Now().UnixNano())
return m.Archiver.Archive(path, filepath.Join(m.Destination, dirname, filename))
}
用户命令行工具
我们将构建的两个工具中的第一个允许用户为备份守护程序工具(稍后我们将编写)添加、列出和删除路径。你可以暴露一个 web 界面,或者甚至使用桌面用户界面集成的绑定包,但我们将保持简单,构建一个命令行工具。
在backup文件夹内创建一个名为cmds的新文件夹,并在其中创建另一个backup文件夹。
提示
将命令的文件夹和命令二进制本身命名为相同的名称是一个好的做法。
在我们的新backup文件夹中,将以下代码添加到main.go:
func main() {
var fatalErr error
defer func() {
if fatalErr != nil {
flag.PrintDefaults()
log.Fatalln(fatalErr)
}
}()
var (
dbpath = flag.String("db", "./backupdata", "path to database directory")
)
flag.Parse()
args := flag.Args()
if len(args) < 1 {
fatalErr = errors.New("invalid usage; must specify command")
return
}
}
我们首先定义我们的fatalErr变量,并推迟检查该值是否为nil的函数。如果不是,它将打印错误以及标志默认值,并以非零状态代码退出。然后我们定义一个名为db的标志,它期望filedb数据库目录的路径,然后解析标志并获取剩余的参数,并确保至少有一个。
持久化小数据
为了跟踪路径和我们生成的哈希,我们需要一种数据存储机制,最好是在我们停止和启动程序时仍然有效。我们在这里有很多选择:从文本文件到完全水平可扩展的数据库解决方案。Go 的简单原则告诉我们,将数据库依赖性构建到我们的小型备份程序中并不是一个好主意;相反,我们应该问问我们如何能以最简单的方式解决这个问题?
github.com/matryer/filedb包是这种问题的实验性解决方案。它允许您与文件系统交互,就好像它是一个非常简单的无模式数据库。它从mgo等包中获取设计灵感,并且可以在数据查询需求非常简单的情况下使用。在filedb中,数据库是一个文件夹,集合是一个文件,其中每一行代表不同的记录。当然,随着filedb项目的发展,这一切都可能会发生变化,但接口希望不会变。
将以下代码添加到main函数的末尾:
db, err := filedb.Dial(*dbpath)
if err != nil {
fatalErr = err
return
}
defer db.Close()
col, err := db.C("paths")
if err != nil {
fatalErr = err
return
}
在这里,我们使用filedb.Dial函数连接到filedb数据库。实际上,在这里并没有发生太多事情,除了指定数据库的位置,因为没有真正的数据库服务器可以连接(尽管这可能会在未来发生变化,这就是接口中存在这些规定的原因)。如果成功,我们推迟关闭数据库。关闭数据库确实会做一些事情,因为可能需要清理的文件可能是打开的。
按照mgo模式,接下来我们使用C方法指定一个集合,并将其引用保存在col变量中。如果在任何时候发生错误,我们将把它赋给fatalErr变量并返回。
为了存储数据,我们将定义一个名为path的类型,它将存储完整路径和最后一个哈希值,并使用 JSON 编码将其存储在我们的filedb数据库中。在main函数之前添加以下struct定义:
type path struct {
Path string
Hash string
}
解析参数
当我们调用flag.Args(而不是os.Args)时,我们会收到一个不包括标志的参数切片。这允许我们在同一个工具中混合标志参数和非标志参数。
我们希望我们的工具能够以以下方式使用:
- 添加路径:
backup -db=/path/to/db add {path} [paths...]
- 删除路径:
backup -db=/path/to/db remove {path} [paths...]
- 列出所有路径:
backup -db=/path/to/db list
为了实现这一点,因为我们已经处理了标志,我们必须检查第一个(非标志)参数。
将以下代码添加到main函数:
switch strings.ToLower(args[0]) {
case "list":
case "add":
case "remove":
}
在这里,我们只需切换到第一个参数,然后将其设置为小写(如果用户输入backup LIST,我们仍希望它能正常工作)。
列出路径
要列出数据库中的路径,我们将在路径的col变量上使用ForEach方法。在列表情况下添加以下代码:
var path path
col.ForEach(func(i int, data []byte) bool {
err := json.Unmarshal(data, &path)
if err != nil {
fatalErr = err
return false
}
fmt.Printf("= %s\n", path)
return false
})
我们向ForEach传递一个回调函数,该函数将为该集合中的每个项目调用。然后我们将其从 JSON 解封到我们的path类型,并使用fmt.Printf将其打印出来。我们根据filedb接口返回false,这告诉我们返回true将停止迭代,我们要确保列出它们所有。
自定义类型的字符串表示
如果以这种方式在 Go 中打印结构体,使用%s格式动词,你可能会得到一些混乱的结果,这些结果对用户来说很难阅读。但是,如果该类型实现了String()字符串方法,那么将使用该方法,我们可以使用它来控制打印的内容。在路径结构体下面,添加以下方法:
func (p path) String() string {
return fmt.Sprintf("%s [%s]", p.Path, p.Hash)
}
这告诉path类型应该如何表示自己。
添加路径
要添加一个或多个路径,我们将遍历剩余的参数并为每个参数调用InsertJSON方法。在add情况下添加以下代码:
if len(args[1:]) == 0 {
fatalErr = errors.New("must specify path to add")
return
}
for _, p := range args[1:] {
path := &path{Path: p, Hash: "Not yet archived"}
if err := col.InsertJSON(path); err != nil {
fatalErr = err
return
}
fmt.Printf("+ %s\n", path)
}
如果用户没有指定任何其他参数,比如他们只是调用backup add而没有输入任何路径,我们将返回一个致命错误。否则,我们将完成工作并打印出路径字符串(前缀为+符号)以指示成功添加。默认情况下,我们将哈希设置为Not yet archived字符串字面量-这是一个无效的哈希,但它具有双重目的,既让用户知道它尚未被归档,又向我们的代码指示这一点(因为文件夹的哈希永远不会等于该字符串)。
删除路径
要删除一个或多个路径,我们使用路径的集合的RemoveEach方法。在remove情况下添加以下代码:
var path path
col.RemoveEach(func(i int, data []byte) (bool, bool) {
err := json.Unmarshal(data, &path)
if err != nil {
fatalErr = err
return false, true
}
for _, p := range args[1:] {
if path.Path == p {
fmt.Printf("- %s\n", path)
return true, false
}
}
return false, false
})
我们提供给RemoveEach的回调函数期望我们返回两个布尔类型:第一个指示是否应删除该项,第二个指示我们是否应停止迭代。
使用我们的新工具
我们已经完成了我们简单的backup命令行工具。让我们看看它的运行情况。在backup/cmds/backup内创建一个名为backupdata的文件夹;这将成为filedb数据库。
通过导航到main.go文件并运行终端中的以下命令来构建工具:
go build -o backup
如果一切顺利,我们现在可以添加一个路径:
./backup -db=./backupdata add ./test ./test2
你应该看到预期的输出:
+ ./test [Not yet archived]
+ ./test2 [Not yet archived]
现在让我们添加另一个路径:
./backup -db=./backupdata add ./test3
现在你应该看到完整的列表:
./backup -db=./backupdata list
我们的程序应该产生:
= ./test [Not yet archived]
= ./test2 [Not yet archived]
= ./test3 [Not yet archived]
让我们删除test3以确保删除功能正常:
./backup -db=./backupdata remove ./test3
./backup -db=./backupdata list
这将把我们带回到:
+ ./test [Not yet archived]
+ ./test2 [Not yet archived]
我们现在能够以符合我们用例的方式与filedb数据库进行交互。接下来,我们构建将实际使用我们的backup包执行工作的守护程序。
守护进程备份工具
backup工具,我们将其称为backupd,将负责定期检查filedb数据库中列出的路径,对文件夹进行哈希处理以查看是否有任何更改,并使用backup包来执行需要的文件夹的归档。
在backup/cmds/backup文件夹旁边创建一个名为backupd的新文件夹,让我们立即处理致命错误和标志:
func main() {
var fatalErr error
defer func() {
if fatalErr != nil {
log.Fatalln(fatalErr)
}
}()
var (
interval = flag.Int("interval", 10, "interval between checks (seconds)")
archive = flag.String("archive", "archive", "path to archive location")
dbpath = flag.String("db", "./db", "path to filedb database")
)
flag.Parse()
}
你现在一定很习惯看到这种代码了。在指定三个标志之前,我们推迟处理致命错误:interval,archive和db。interval标志表示检查文件夹是否更改之间的秒数,archive标志是 ZIP 文件将存储的存档位置的路径,db标志是与backup命令交互的相同filedb数据库的路径。通常调用flag.Parse设置变量并验证我们是否准备好继续。
为了检查文件夹的哈希值,我们将需要我们之前编写的Monitor的一个实例。将以下代码附加到main函数:
m := &backup.Monitor{
Destination: *archive,
Archiver: backup.ZIP,
Paths: make(map[string]string),
}
在这里,我们使用archive值作为Destination类型创建了一个backup.Monitor方法。我们将使用backup.ZIP归档程序,并创建一个准备好在其中存储路径和哈希的映射。在守护程序开始时,我们希望从数据库加载路径,以便在停止和启动时不会不必要地进行归档。
将以下代码添加到main函数中:
db, err := filedb.Dial(*dbpath)
if err != nil {
fatalErr = err
return
}
defer db.Close()
col, err := db.C("paths")
if err != nil {
fatalErr = err
return
}
你以前也见过这段代码;它拨号数据库并创建一个允许我们与paths集合交互的对象。如果出现任何问题,我们设置fatalErr并返回。
重复的结构
由于我们将使用与用户命令行工具程序中相同的路径结构,因此我们也需要为该程序包含一个定义。在main函数之前插入以下结构:
type path struct {
Path string
Hash string
}
面向对象的程序员们毫无疑问现在正在对页面尖叫,要求这个共享的片段只存在于一个地方,而不是在两个程序中重复。我敦促你抵制这种早期抽象的冲动。这四行代码几乎不能证明我们的代码需要一个新的包和依赖,因此它们可以在两个程序中很容易地存在,而几乎没有额外开销。还要考虑到我们可能想要在我们的backupd程序中添加一个LastChecked字段,这样我们就可以添加规则,每个文件夹最多每小时归档一次。我们的backup程序不关心这一点,它将继续快乐地查看哪些字段构成了一个路径。
缓存数据
我们现在可以查询所有现有的路径并更新Paths映射,这是一种增加程序速度的有用技术,特别是在数据存储缓慢或断开连接的情况下。通过将数据加载到缓存中(在我们的情况下是Paths映射),我们可以以闪电般的速度访问它,而无需每次需要信息时都要查阅文件。
将以下代码添加到main函数的主体中:
var path path
col.ForEach(func(_ int, data []byte) bool {
if err := json.Unmarshal(data, &path); err != nil {
fatalErr = err
return true
}
m.Paths[path.Path] = path.Hash
return false // carry on
})
if fatalErr != nil {
return
}
if len(m.Paths) < 1 {
fatalErr = errors.New("no paths - use backup tool to add at least one")
return
}
再次使用ForEach方法使我们能够遍历数据库中的所有路径。我们将 JSON 字节解组成与我们在其他程序中使用的相同路径结构,并在Paths映射中设置值。假设没有出现问题,我们最后检查以确保至少有一个路径,如果没有,则返回错误。
注意
我们程序的一个限制是一旦启动,它将无法动态添加路径。守护程序需要重新启动。如果这让你烦恼,你可以随时构建一个定期更新Paths映射的机制。
无限循环
接下来,我们需要立即对哈希进行检查,看看是否需要进行归档,然后进入一个无限定时循环,在其中以指定的间隔定期进行检查。
无限循环听起来像一个坏主意;实际上,对于一些人来说,它听起来像一个 bug。然而,由于我们正在谈论这个程序内部的一个无限循环,并且由于无限循环可以很容易地通过简单的break命令打破,它们并不像听起来那么戏剧性。
在 Go 中,编写无限循环就像这样简单:
for {}
大括号内的指令会一遍又一遍地执行,尽可能快地运行代码的机器。再次听起来像一个坏计划,除非你仔细考虑你要求它做什么。在我们的情况下,我们立即启动了一个select case,它会安全地阻塞,直到其中一个通道有有趣的事情要说。
添加以下代码:
check(m, col)
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
for {
select {
case <-time.After(time.Duration(*interval) * time.Second):
check(m, col)
case <-signalChan:
// stop
fmt.Println()
log.Printf("Stopping...")
goto stop
}
}
stop:
当然,作为负责任的程序员,我们关心用户终止我们的程序时会发生什么。因此,在调用尚不存在的check方法之后,我们创建一个信号通道,并使用signal.Notify要求将终止信号发送到通道,而不是自动处理。在我们无限的for循环中,我们选择两种可能性:要么timer通道发送消息,要么终止信号通道发送消息。如果是timer通道消息,我们再次调用check,否则我们终止程序。
time.After函数返回一个通道,在指定的时间过去后发送一个信号(实际上是当前时间)。有些令人困惑的time.Duration(*interval) * time.Second代码只是指示在发送信号之前要等待的时间量;第一个*字符是解引用运算符,因为flag.Int方法表示指向 int 的指针,而不是 int 本身。第二个*字符将间隔值乘以time.Second,从而得到与指定间隔相等的值(以秒为单位)。将*interval int转换为time.Duration是必需的,以便编译器知道我们正在处理数字。
在前面的代码片段中,我们通过使用goto语句来回顾一下内存中的短暂旅程,以跳出 switch 并阻止循环。我们可以完全不使用goto语句,只需在接收到终止信号时返回,但是这里讨论的模式允许我们在for循环之后运行非延迟代码,如果我们希望的话。
更新 filedb 记录
现在剩下的就是实现check函数,该函数应该调用Monitor类型的Now方法,并在有任何新的哈希值时更新数据库。
在main函数下面,添加以下代码:
func check(m *backup.Monitor, col *filedb.C) {
log.Println("Checking...")
counter, err := m.Now()
if err != nil {
log.Fatalln("failed to backup:", err)
}
if counter > 0 {
log.Printf(" Archived %d directories\n", counter)
// update hashes
var path path
col.SelectEach(func(_ int, data []byte) (bool, []byte, bool) {
if err := json.Unmarshal(data, &path); err != nil {
log.Println("failed to unmarshal data (skipping):", err)
return true, data, false
}
path.Hash, _ = m.Paths[path.Path]
newdata, err := json.Marshal(&path)
if err != nil {
log.Println("failed to marshal data (skipping):", err)
return true, data, false
}
return true, newdata, false
})
} else {
log.Println(" No changes")
}
}
check函数首先告诉用户正在进行检查,然后立即调用Now。如果Monitor类型为我们做了任何工作,即询问它是否归档了任何文件,我们将输出它们给用户,并继续使用新值更新数据库。SelectEach方法允许我们更改集合中的每个记录,如果我们愿意的话,通过返回替换的字节。因此,我们Unmarshal字节以获取路径结构,更新哈希值并返回编组的字节。这确保下次我们启动backupd进程时,它将使用正确的哈希值进行操作。
测试我们的解决方案
让我们看看我们的两个程序是否能很好地配合,以及它们对我们的backup包内部代码产生了什么影响。您可能希望为此打开两个终端窗口,因为我们将运行两个程序。
我们已经向数据库中添加了一些路径,所以让我们使用backup来查看它们:
./backup -db="./backupdata" list
你应该看到这两个测试文件夹;如果没有,可以参考添加路径部分。
= ./test [Not yet archived]
= ./test2 [Not yet archived]
在另一个窗口中,导航到backupd文件夹并创建我们的两个测试文件夹,名为test和test2。
使用通常的方法构建backupd:
go build -o backupd
假设一切顺利,我们现在可以开始备份过程,确保将db路径指向与backup程序相同的路径,并指定我们要使用一个名为archive的新文件夹来存储 ZIP 文件。为了测试目的,让我们指定一个间隔为5秒以节省时间:
./backupd -db="../backup/backupdata/" -archive="./archive" -interval=5
立即,backupd应该检查文件夹,计算哈希值,注意到它们是不同的(尚未归档),并启动两个文件夹的归档过程。它将打印输出告诉我们这一点:
Checking...
Archived 2 directories
打开backup/cmds/backupd内新创建的archive文件夹,并注意它已经创建了两个子文件夹:test和test2。在这些文件夹中是空文件夹的压缩归档版本。随意解压一个并查看;到目前为止并不是很令人兴奋。
与此同时,在终端窗口中,backupd一直在检查文件夹是否有变化:
Checking...
No changes
Checking...
No changes
在您喜欢的文本编辑器中,在test2文件夹中创建一个包含单词test的新文本文件,并将其保存为one.txt。几秒钟后,您会发现backupd已经注意到了新文件,并在archive/test2文件夹中创建了另一个快照。
当然,它的文件名不同,因为时间不同,但是如果您解压缩它,您会注意到它确实创建了文件夹的压缩存档版本。
通过执行以下操作来尝试解决方案:
-
更改
one.txt文件的内容 -
将文件添加到
test文件夹中 -
删除文件
摘要
在本章中,我们成功地为您的代码项目构建了一个非常强大和灵活的备份系统。您可以看到扩展或修改这些程序行为有多么简单。您可以解决的潜在问题范围是无限的。
与上一节不同,我们不是将本地存档目标文件夹,而是想象挂载网络存储设备并使用该设备。突然间,您就可以对这些重要文件进行离站(或至少是离机)备份。您可以轻松地将 Dropbox 文件夹设置为存档目标,这意味着不仅您自己可以访问快照,而且副本存储在云中,甚至可以与其他用户共享。
扩展Archiver接口以支持Restore操作(只需使用encoding/zip包解压文件)允许您构建可以查看存档内部并访问单个文件更改的工具,就像 Time Machine 允许您做的那样。索引文件使您可以在整个代码历史记录中进行全面搜索,就像 GitHub 一样。
由于文件名是时间戳,您可以将旧存档备份到不太活跃的存储介质,或者将更改总结为每日转储。
显然,备份软件已经存在,经过充分测试,并且在全球范围内得到使用,专注于解决尚未解决的问题可能是一个明智的举措。但是,当写小程序几乎不费吹灰之力时,通常值得去做,因为它给予您控制权。当您编写代码时,您可以得到完全符合您要求的结果,而不需要妥协,这取决于每个人做出的决定。
具体来说,在本章中,我们探讨了 Go 标准库如何轻松地与文件系统交互:打开文件进行读取,创建新文件和创建目录。与io包中的强大类型混合在一起的os包,再加上像encoding/zip等功能,清楚地展示了极其简单的 Go 接口如何组合以产生非常强大的结果。
附录 A:稳定的 Go 环境的良好实践
编写 Go 代码是一种有趣且愉快的体验,其中编译时错误——而不是痛苦——实际上会指导您编写健壮、高质量的代码。然而,偶尔会遇到环境问题,开始妨碍并打断您的工作流程。虽然通常可以在一些搜索和一些微调后解决这些问题,但正确设置开发环境可以大大减少问题,使您能够专注于构建有用的应用程序。
在本章中,我们将在新机器上从头开始安装 Go,并讨论我们拥有的一些环境选项以及它们可能在未来产生的影响。我们还将考虑协作如何影响我们的一些决定,以及开源我们的软件包可能会产生什么影响。
具体来说,我们将:
-
获取 Go 源代码并在开发机器上本地构建它
-
了解
GOPATH环境变量的用途,并讨论其合理的使用方法 -
了解 Go 工具以及如何使用它们来保持我们代码的质量
-
学习如何使用工具自动管理我们的导入
-
考虑我们的
.go文件的“保存时”操作,以及我们如何将 Go 工具集成为我们日常开发的一部分。
安装 Go
Go 是一个最初用 C 编写的开源项目,这意味着我们可以轻松地从代码中编译我们自己的版本;这仍然是安装 Go 的最佳选项,出于各种原因。它允许我们在需要稍后查找某些内容时浏览源代码,无论是在标准库 Go 代码中还是在工具的 C 代码中。它还允许我们轻松地更新到 Go 的新版本,或者在发布候选版本出现时进行实验,只需从代码存储库中拉取不同的标签或分支并重新构建。当然,如果需要,我们也可以轻松地回滚到早期版本,甚至修复错误并生成拉取请求发送给 Go 核心团队,以便他们考虑对项目的贡献。
注意
可以在golang.org/doc/install/source上找到一个不断更新的资源,用于在各种平台上从源代码安装 Go,或者通过搜索Install Golang from source。本章将涵盖相同的内容,但如果遇到问题,互联网将成为帮助解决问题的最佳途径。
安装 C 工具
由于 Go 工具链是用 C 编写的,因此在构建 Go 安装时实际上会编译 C 代码。这可能看起来有点反直觉;使用一种不同的编程语言编写了一种编程语言,但当然,当 Go 核心团队开始编写 Go 时,Go 并不存在,但 C 存在。更准确地说,用于构建和链接 Go 程序的工具是用 C 编写的。无论如何,现在我们需要能够编译 C 源代码。
注意
在 2014 年的丹佛科罗拉多州举行的首届 Gophercon 上,Rob Pike 和他的团队表示,他们的目标之一将是用 Go 编写的程序替换 C 工具链,以便整个堆栈都变成 Go。在撰写本文时,这还没有发生,因此我们将需要 C 工具。
要确定是否需要安装 C 工具,请打开终端并尝试使用gcc命令:
gcc -v
如果收到command not found错误或类似错误,则可能需要安装 C 工具。但是,如果您看到gcc的输出给出版本信息(这就是-v标志的作用),则可能可以跳过此部分。
安装 C 工具因各种平台而异,并且随时间可能会发生变化,因此本节应该只被视为帮助您获取所需工具的粗略指南。
在运行 OS X 的 Mac 上,工具随 Xcode 一起提供,可在 App Store 免费获取。安装 Xcode 后,您打开首选项并导航到下载部分。从那里,您可以找到包含构建 Go 所需的 C 工具的命令行工具。
在 Ubuntu 和 Debian 系统上,您可以使用apt-get安装工具:
sudo apt-get install gcc libc6-dev
对于 RedHat 和 Centos 6 系统,您可以使用yum安装工具:
sudo yum install gcc glibc-devel
对于 Windows,MinGW 项目提供了一个 Windows 安装程序,可以为您安装工具。转到www.mingw.org/并按照那里的说明开始。
一旦您成功安装了工具,并确保适当的二进制文件包含在您的PATH环境变量中,当运行gcc -v时,您应该能够看到一些合理的输出:
Apple LLVM version 5.1 (clang-503.0.40) (based on LLVM 3.4svn)
Target: x86_64-apple-darwin13.2.0
Thread model: posix
上述片段是在 Apple Mac 计算机上的输出,最重要的是要查看是否缺少command not found错误。
从源代码下载和构建 Go
Go 源代码托管在 Google Code 的 Mercurial 存储库中,因此我们将使用hg命令克隆它以准备构建。
注意
如果您没有hg命令,您可以从mercurial.selenic.com/downloads下载页面获取 Mercurial。
在终端中,要安装 Go,请转到适当的位置,例如 Unix 系统上的/opt,或 Windows 上的C:\。
通过输入以下命令获取 Go 的最新版本:
hg clone -u release https://code.google.com/p/go
过一会儿,最新的 Go 源代码将下载到一个新的go文件夹中。
转到刚刚创建的go/src文件夹并运行all脚本,这将从源代码构建 Go 的实例。在 Unix 系统上,这是all.bash,在 Windows 上是all.bat。
一旦所有构建步骤完成,您应该注意到所有测试都已成功通过。
配置 Go
现在 Go 已安装,但为了使用工具,我们必须确保它已正确配置。为了更容易调用工具,我们需要将我们的go/bin路径添加到PATH环境变量中。
注意
在 Unix 系统上,您应该将 export PATH=$PATH:/opt/go/bin(确保这是您下载源代码时选择的路径)添加到您的.bashrc文件中。
在 Windows 上,打开系统属性(尝试右键单击我的电脑),在高级下,单击环境变量按钮,并使用 UI 确保PATH变量包含到您的go/bin文件夹的路径。
在终端中(您可能需要重新启动它以使更改生效),您可以通过打印PATH变量的值来确保这一点:
echo $PATH
确保打印的值包含正确的路径到您的go/bin文件夹,例如,在我的机器上打印为:
/usr/local/bin:/usr/bin:/bin:/opt/go/bin
注意
路径之间的冒号(在 Windows 上是分号)表明PATH变量实际上是一个文件夹列表,而不仅仅是一个文件夹。这表明在输入终端命令时,将搜索每个包含的文件夹。
现在我们可以确保我们刚刚构建的 Go 构建成功运行:
go version
执行go命令(可以在您的go/bin位置找到)如下将为我们打印出当前版本。例如,对于 Go 1.3,您应该看到类似于:
go version go1.3 darwin/amd64
获取正确的 GOPATH
GOPATH是另一个环境变量,用于指定 Go 源代码和已编译二进制包的位置(就像前一节中的PATH一样)。在您的 Go 程序中使用import命令将导致编译器在GOPATH位置查找您所引用的包。使用go get和其他命令时,项目将下载到GOPATH文件夹中。
虽然GOPATH位置可以包含一系列以冒号分隔的文件夹,例如PATH,并且您甚至可以根据您正在工作的项目来使用不同的GOPATH值,但强烈建议您为所有内容使用单个GOPATH位置,这是我们假设您将为本书中的项目所做的。
在您的Users文件夹中的某个地方,也许是Work子文件夹中,创建一个名为go的新文件夹。这将是我们的GOPATH目标,也是所有第三方代码和二进制文件的存放地,以及我们将编写 Go 程序和包的地方。使用在上一节设置PATH环境变量时使用的相同技术,将GOPATH变量设置为新的go文件夹。让我们打开一个终端并使用新安装的命令之一来获取一个我们要使用的第三方包:
go get github.com/stretchr/powerwalk
从Stretchr获取powerwalk库实际上会创建以下文件夹结构;$GOPATH/src/github.com/stretchr/powerwalk。您可以看到路径段在 Go 组织事物方面很重要,这有助于命名空间项目并使它们保持唯一。例如,如果您创建了自己的名为powerwalk的包,您不会将其保存在Stretchr的 GitHub 存储库中,因此路径将不同。
当我们在本书中创建项目时,您应该为它们考虑一个合理的GOPATH根目录。例如,我使用了github.com/matryer/goblueprints,如果您要go get它,实际上会在您的GOPATH文件夹中获得本书的所有源代码的完整副本!
Go 工具
Go 核心团队早期做出的决定是,所有 Go 代码应该对每个说 Go 语言的人看起来熟悉和明显,而不是每个代码库都需要额外的学习才能让新程序员理解或处理它。当考虑到开源项目时,这是一个特别明智的做法,其中一些项目有数百名贡献者不断涌入和离开。
有一系列工具可以帮助我们达到 Go 核心团队设定的高标准,我们将在本节中看到其中一些工具的实际应用。
在您的GOPATH位置,创建一个名为tooling的新文件夹,并创建一个包含以下代码的新main.go文件:
package main
import (
"fmt"
)
func main() {
return
var name string
name = "Mat"
fmt.Println("Hello ", name)
}
紧凑的空间和缺乏缩进是故意的,因为我们将要看一个随 Go 一起提供的非常酷的实用工具。
在终端中,导航到您的新文件夹并运行:
go fmt
注意
在 2014 年的 Gophercon 在科罗拉多州丹佛市,大多数人都了解到,与其将这个小三合一发音为“格式”或“f, m, t”,实际上它是作为一个单词发音的。现在试着对自己说:“fhumt”;似乎计算机程序员没有说一个外星语言的话,他们就不够怪异!
您会注意到,这个小工具实际上已经调整了我们的代码文件,以确保我们的程序布局(或格式)符合 Go 标准。新版本要容易阅读得多:
package main
import (
"fmt"
)
func main() {
return
var name string
name = "Mat"
fmt.Println("Hello ", name)
}
go fmt命令关心缩进、代码块、不必要的空格、不必要的额外换行等。以这种方式格式化代码是一个很好的实践,可以确保您的 Go 代码看起来像所有其他 Go 代码。
接下来,我们将对我们的程序进行审查,以确保我们没有犯任何可能令用户困惑的错误或决定;我们可以使用另一个很棒的免费工具来自动完成这个过程:
go vet
我们的小程序的输出指出了一个明显而显眼的错误:
main.go:10: unreachable code
exit status 1
我们在函数顶部调用return,然后尝试在此之后做其他事情。go vet工具已经注意到了这一点,并指出我们的文件中有无法访问的代码。
提示
如果您在运行任何 Go 工具时遇到错误,通常意味着您必须获取该命令才能使用它。但是,在 vet 工具的情况下,您只需打开终端并运行:
go get code.google.com/p/go.tools/cmd/vet
go vet不仅会捕捉到这样的愚蠢错误,它还会寻找程序的更微妙的方面,这将指导您编写尽可能好的 Go 代码。有关 vet 工具将报告的最新列表,请查看godoc.org/code.google.com/p/go.tools/cmd/vet上的文档。
我们将要使用的最后一个工具叫做goimports,由 Brad Fitzpatrick 编写,用于自动修复(添加或删除)Go 文件的import语句。在 Go 中导入一个包而不使用它是一个错误,显然尝试使用一个未导入的包也不会起作用。goimports工具将根据我们代码文件的内容自动重写我们的import语句。首先,让我们使用熟悉的命令安装goimports:
go get code.google.com/p/go.tools/cmd/goimports
更新您的程序以导入一些我们不打算使用的包,并删除fmt包:
import (
"net/http"
"sync"
)
当我们尝试通过调用go run main.go来运行我们的程序时,我们会看到我们得到了一些错误:
./main.go:4: imported and not used: "net/http"
./main.go:5: imported and not used: "sync"
./main.go:13: undefined: fmt
这些错误告诉我们,我们已经导入了我们不使用的包,并且缺少了fmt包,为了继续,我们需要进行更正。这就是goimports发挥作用的地方:
goimports -w *.go
我们正在使用goimports命令和-w写入标志,这将节省我们对所有以.go结尾的文件进行更正的任务。
现在看看您的main.go文件,注意net/http和sync包已被移除,而fmt包已被重新放回。
您可以争论切换到终端运行这些命令比手动操作需要更多时间,而在大多数情况下您可能是正确的,这就是为什么强烈建议您将 Go 工具与您的文本编辑器集成。
在保存时进行清理、构建和运行测试
由于 Go 核心团队为我们提供了fmt、vet、test和goimports等优秀的工具,我们将看一下一个被证明非常有用的开发实践。每当我们保存一个.go文件时,我们希望自动执行以下任务:
-
使用
goimports和fmt来修复我们的导入并格式化代码。 -
检查代码是否有任何错误,并立即告诉我们。
-
尝试构建当前包并输出任何构建错误。
-
如果构建成功,请运行包的测试并输出任何失败。
因为 Go 代码编译速度如此之快(Rob Pike 曾经说过它并不快速构建,但它只是不像其他一切那样慢),所以我们可以在每次保存文件时轻松地构建整个包。对于运行测试也是如此,这有助于我们如果我们以 TDD 风格进行开发,体验非常好。每当我们对代码进行更改时,我们可以立即看到我们是否破坏了某些东西,或者对项目的其他部分产生了意外的影响。我们再也不会看到包导入错误了,因为我们的import语句已经被自动修复了,我们的代码也会在我们眼前被正确格式化。
一些编辑器可能不支持响应特定事件运行代码,比如保存文件,这给您留下了两个选择;您可以切换到更好的编辑器,或者编写自己的脚本文件以响应文件系统的更改。后一种解决方案不在本书的范围之内,相反,我们将专注于如何在流行的文本编辑器中实现这个功能。
Sublime Text 3
Sublime Text 3 是一个在 OS X、Linux 和 Windows 上运行的编写 Go 代码的优秀编辑器,并且具有非常强大的扩展模型,这使得它易于定制和扩展。您可以从www.sublimetext.com/下载 Sublime Text,并在决定是否购买之前免费试用。
感谢DisposaBoy(参见github.com/DisposaBoy),已经为 Go 创建了一个 Sublime 扩展包,实际上为我们提供了许多 Go 程序员实际上错过的功能和功能。我们将安装这个GoSublime包,然后在此基础上添加我们想要的保存功能。
在安装GoSublime之前,我们需要将 Package Control 安装到 Sublime Text 中。前往sublime.wbond.net/,点击Installation链接,获取有关如何安装 Package Control 的说明。在撰写本文时,只需复制单行命令,并将其粘贴到 Sublime 控制台中即可,控制台可以通过从菜单中导航到View | Show Console来打开。
完成后,按shift + command + P,然后键入Package Control: Install Package,选择该选项后按return。稍等片刻(Package Control 正在更新其列表),将出现一个框,允许您通过输入并选择 GoSublime 来搜索并安装 GoSublime,然后按return。如果一切顺利,GoSublime 将被安装,编写 Go 代码将变得更加容易。
提示
现在您已经安装了 GoSublime,您可以按command + .,command + 2(同时按下command键和句点,然后按下command键和数字2)打开一个包含该包详细信息的简短帮助文件。
Tyler Bunnell 是 Go 开源社区中另一个知名人物(参见github.com/tylerb),我们将使用他的自定义来实现我们的保存功能。
按command + .,command + 5打开 GoSublime 设置,并向对象添加以下条目:
"on_save": [
{
"cmd": "gs9o_open",
"args": {
"run": ["sh", "go build . errors && go test -i && go test && go vet && golint"],
"focus_view": false
}
}
]
提示
注意,设置文件实际上是一个 JSON 对象,因此在添加on_save属性时,请确保不要损坏文件。例如,如果在之前和之后有属性,请确保逗号放置在适当的位置。
上述设置将告诉 Sublime Text 在保存文件时构建代码以查找错误,安装测试依赖项,运行测试并检查代码。保存设置文件(暂时不要关闭它),让我们看看它的效果。
从菜单中导航到选择文件 | 打开…并选择要打开的文件夹-现在让我们打开我们的tooling文件夹。Sublime Text 的简单用户界面清楚地表明,我们目前项目中只有一个文件,main.go。单击文件,添加一些额外的换行符,并添加和删除一些缩进。然后从菜单中导航到文件 | 保存,或按command + S。注意代码立即被清理了,只要你没有删除main.go中奇怪放置的return语句,你会注意到控制台已经出现,并且由于go vet的原因报告了问题:
main.go:8: unreachable code
按住command + shift,双击控制台中无法到达的代码行将打开文件并将光标跳转到相关行。随着您继续编写 Go 代码,您会看到这个功能有多么有用。
如果您向文件添加了不需要的导入,您会注意到在使用on_save时会收到有关问题的通知,但它不会自动修复。这是因为我们还需要进行另一个调整。在与您添加on_save属性的相同设置文件中,添加以下属性:
"fmt_cmd": ["goimports"]
这告诉 GoSublime 使用goimports命令而不是go fmt。再次保存此文件并返回到main.go。再次将net/http添加到导入中,删除fmt导入,并保存文件。注意未使用的包已被移除,fmt再次被放回。
摘要
在这个附录中,我们从源代码安装了自己的 Go 构建,这意味着我们可以轻松使用hg命令来保持我们的安装最新,或者在发布之前测试我们的测试功能。在孤独的夜晚,有整个 Go 语言代码供我们浏览也是很不错的。
你了解了GOPATH环境变量,并发现了将一个值用于所有项目的常见做法。这种方法极大地简化了在 Go 项目上的工作,否则你可能会继续遇到棘手的失败。
我们发现了 Go 工具集如何真正帮助我们生成高质量、符合社区标准的代码,任何其他程序员都可以轻松上手并进行开发,几乎不需要额外学习。更重要的是,我们看到了如何自动化使用这些工具意味着我们可以真正专注于编写应用程序和解决问题,这正是开发人员真正想要做的事情。
读累了记得休息一会哦~
公众号:古德猫宁李
-
电子书搜索下载
-
书单分享
-
书友学习交流
网站:沉金书屋 https://www.chenjin5.com
-
电子书搜索下载
-
电子书打包资源分享
-
学习资源分享


浙公网安备 33010602011771号