我们在前几章中构建的聊天应用程序已经准备好让世界刮目相看,但在我们给它一个互联网上的家之前是不可能的。在我们邀请朋友加入对话之前,我们需要选择一个有效、吸引人且可用的域名,我们可以指向运行 Go 代码的服务器。我们将开发一些命令行工具,帮助我们找到合适的域名提供商,而不是坐在我们最喜欢的域名提供商面前连续数小时尝试不同的域名。在这样做的过程中,我们将看到 Go 标准库如何允许我们与终端和其他正在执行的应用程序交互,并探索一些构建命令行程序的模式和实践。
在本章中,您将学习:
- 如何用一个代码文件构建完整的命令行应用程序
- 如何确保我们构建的工具可以与使用标准流的其他工具组合
- 如何与简单的第三方 JSON RESTful API 交互
- 如何利用 Go 规范中的标准进出管
- 如何从流媒体源一次读取一行数据
- 如何构建 WHOIS 客户端来查找域信息
- 如何在环境变量中存储和使用敏感信息或特定于部署的信息
我们将构建一系列的命令行工具使用标准流(stdin
和stdout
与用户和其他工具进行通信。每个工具将通过标准输入管逐行获取输入线,并以某种方式进行处理,然后将输出线逐行打印到标准输出管,供下一个工具或用户使用。
默认情况下,标准输入连接到用户键盘,标准输出打印到运行命令的终端;但是,可以使用重定向元字符重定向两者。可以通过将输出重定向到 Windows 上的NUL
或 Unix 机器上的/dev/null
或重定向到文件来丢弃输出,这将导致输出保存到磁盘。或者,您可以通过管道(使用|
管道字符)将一个程序的输出传输到另一个程序的输入;我们将利用此功能将各种工具连接在一起。例如,您可以使用以下代码将一个程序的输出通过管道传输到终端中另一个程序的输入:
one | two
我们的工具将处理字符串行,其中每行(由换行符分隔)表示一个字符串。在没有任何管道重定向的情况下运行时,我们将能够使用默认的 in 和 out 直接与程序交互,这在测试和调试代码时非常有用。
在本章中,我们将构建五个小程序,最后将它们结合在一起。这些方案的主要特点如下:
- Spread:这个程序将添加一些网络友好的 Spread单词,以增加找到可用域名的机会
- 域名化:该程序将删除不可接受的字符,用连字符替换空格,并在末尾添加适当的顶级域名(如
.com
和.net
),从而确保域名的单词是可接受的 - Coolify:这个程序将通过摆弄元音,将一个枯燥的普通单词放入 Web2.0
- 同义词:此程序将使用第三方 API查找同义词
- 可用:此程序将检查域是否可用,是否使用合适的 WHOIS 服务器
五个程序对于一个章节来说似乎太多了,但别忘了整个程序可以有多小。
我们的第一个程序在输入的单词中添加了一些甜词,以提高找到可用名称的几率。许多公司使用这种方法来保持核心消息的一致性,同时能够支付.com
域的费用。例如,如果我们传入单词chat
,它可能会传出chatapp
;或者,如果我们通过talk
,我们可能会返回talk time
。
Go 的math/rand
软件包允许我们摆脱计算机的可预测性,给我们一个机会或机会参与到我们的程序过程中,让我们的解决方案感觉比实际更加智能。
为使我们的Spread 计划发挥作用,我们将:
-
使用特殊常量定义一个转换数组,以指示原始单词将出现的位置
-
使用
bufio
包扫描stdin
和fmt.Println
的输入,将输出写入stdout
-
Use the
math/rand
package to randomly select which transformation to apply to the word, such as appending "app" or prefixing the term with "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
语句。如果您需要帮助,请参考附录稳定围棋环境的良好实践中提供的提示。
前面的代码代表我们完整的 Spready 程序。它定义了三件事:一个常量、一个变量和一个强制性的main
函数,该函数作为 Spread 的入口点。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
Spready never exits(意思是Scan
方法不会返回false
来中断循环),因为终端仍在运行;在正常执行中,无论哪个程序生成输入,都会关闭 in 管道。要停止程序,请点击Ctrl+C。
在我们继续之前,让我们尝试运行 Spready 指定不同的输入源,我们将使用echo
命令生成一些内容,并使用管道字符将其导入 Spready 程序:
echo "chat" | ./sprinkle
程序将随机转换单词,打印出来,然后退出,因为在终止和关闭管道之前,echo
命令只生成一行输入。
我们已经成功地完成了我们的第一个程序,它有一个非常简单但有用的功能,我们将看到。
作为一个额外的赋值,与其像我们所做的那样对transformations
数组进行硬编码,不如看看是否可以将其外部化为文本文件或数据库。
Spready 输出的一些单词包含空格,可能还有其他一些在域中不允许使用的字符,因此我们将编写一个名为 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 和 Spready 程序之间有一些相似之处:我们使用rand.Seed
设置随机种子,生成一个包裹os.Stdin
读取器的NewScanner
方法,并扫描每一行,直到没有更多输入。
然后,我们将文本转换为小写,并构建一个名为newText
的rune
类型的新片段。rune
类型只包含出现在allowedChars
字符串中的字符,而strings.ContainsRune
让我们知道。如果rune
是我们通过调用unicode.IsSpace
来确定的空格,我们将其替换为连字符,这在域名中是可以接受的做法。
在字符串上进行测距返回每个字符的索引和一个rune
类型,它是一个表示字符本身的数值(特别是int32
。有关符文、字符和字符串的更多信息,请参阅http://blog.golang.org/strings 。
最后,我们将newText
从[]rune
切片转换为字符串,并在末尾添加.com
或.net
,然后使用fmt.Println
打印出来。
构建并运行域化:
go build –o domainify
./domainify
输入这些选项中的一些以查看domainify
的反应:
Monkey
Hello Domainify
"What's up?"
One (two) three!
您可以看到,例如,One (two) three!
可能会产生one-two-three.com
。
我们现在将编写 Spready 和 Domainify,以看到它们协同工作。在您的终端中,导航到sprinkle
和domainify
的父文件夹(可能是$GOPATH/src
,并运行以下命令:
./sprinkle/sprinkle | ./domainify/domainify
在这里,我们运行 Spready 程序,并将输出导入 Domainify 程序。默认情况下,sprinkle
使用终端作为输入,domanify
输出到终端。再次尝试输入几次chat
,注意输出与 Spready 之前输出的内容类似,只是现在这些单词对于域名是可以接受的。正是程序之间的管道使我们能够一起组合命令行工具。
只有支持.com
和.net
顶级域是相当有限的。作为额外的分配,请查看是否可以通过命令行标志接受 TLD 列表。
通常情况下,等常见单词的域名已经被采用,一个常见的解决方案是在单词中使用元音。例如,我们可以删除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 代码看起来与 Spready 和 Domainify 的代码非常相似,但它稍微复杂一些。在代码的最顶端,我们声明了两个常量,duplicateVowel
和removeVowel
,这有助于使 Coolify 代码更具可读性。switch
语句决定我们是复制还是删除元音。此外,使用这些常量,我们能够非常清楚地表达我们的意图,而不仅仅是使用true
或false
。
然后,我们通过要求rand
包生成一个随机数,并检查该数字是否为零,来定义只随机返回true
或false
的randBool
辅助函数。它将是0
或1
,因此有 50/50 的可能性是true
。
Coolify 的main
功能启动方式与 Spready 和 Domainify 的main
功能启动方式相同,即设置rand.Seed
方法,并在为每行输入执行循环体之前创建标准输入流的扫描仪。我们首先调用randBool
来决定是否要对一个单词进行变异,因此 Coolify 只会影响通过它的一半单词。
然后我们在字符串中的每个符文上迭代并查找元音。如果我们的randBool
方法返回true
,我们将元音字符的索引保留在vI
变量中。如果没有,我们会继续在字符串中查找另一个元音,这样我们就可以从单词中随机选择一个元音,而不是总是修改同一个元音。
一旦我们选择了一个元音,我们就会再次使用randBool
来随机决定采取什么行动。
这就是有用常数的来源;考虑下面的替代开关语句:
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
| 描述从单词 slice 开头到所选元音的片段。因为冒号后面的值不包括指定的索引,所以需要使用+1
;相反,它将切片到该值。 |
| word[vI:]
| eprints
| 描述从选定元音开始并包括该元音到片段末尾的片段。 |
| word[:vI]
| blu
| 描述从单词 slice 开头到选定元音(但不包括)的片段。 |
| word[vI+1:]
| prints
| 描述从选定元音后面的项目到片段末尾的片段。 |
修改单词后,使用fmt.Println
打印出来。
让我们构建冷却并使用它,看看它能做什么:
go build –o coolify
./coolify
当 Coolify 在运行时,请尝试键入blueprints
以查看它会做出何种修改:
blueprnts
bleprints
bluepriints
blueprnts
blueprints
bluprints
让我们看看 Coolify 如何通过将 Spready 和 Domainify 的名称添加到我们的管道链中来使用它们。在终端中,导航回(使用cd
命令)父文件夹并运行以下命令:
./coolify/coolify | ./sprinkle/sprinkle | ./domainify/domainify
我们将首先在一个单词中添加额外的片段,并通过调整元音使其更酷,最后将其转换为有效的域名。通过键入几个单词,看看我们的代码给出了什么建议。
到目前为止,我们的程序只修改了单词,但要真正实现我们的解决方案,我们需要能够集成提供单词同义词的第三方 API。这使我们能够在保留原有含义的同时提出不同的域名。与 Spready 和 Domainify 不同,同义词会为每个单词写出多个响应。我们的管道程序架构意味着这没有问题;事实上,我们甚至不必担心它,因为这三个程序中的每一个都能够从输入源读取多行数据。
bighughlabs.com上的Big Hugh 同义词表有一个非常干净和简单的 API,允许我们发出单个 HTTPGET
请求以查找同义词。
如果将来我们使用的 API 发生变化或消失(毕竟,这是互联网!),您将在找到一些选项 https://github.com/matryer/goblueprints 。
在使用 Big Hugh 同义词表之前,您需要一个 API 密钥,您可以通过在注册该服务获得该密钥 http://words.bighugelabs.com/ 。
您的API 密钥是一段敏感的配置信息,您不想与他人共享。我们可以将其存储为const
在我们的代码中,但这不仅意味着我们不能在不共享密钥的情况下共享我们的代码(不好,尤其是如果您喜欢开源项目),而且,也许更重要的是,如果密钥过期或您想使用其他密钥,您必须重新编译您的项目。
更好的解决方案是使用环境变量来存储密钥,因为这将允许您在需要时轻松更改密钥。您还可以为不同的部署使用不同的密钥;也许您有一个用于开发或测试的密钥,另一个用于生产。通过这种方式,您可以为特定的代码执行设置特定的键,因此您可以轻松地切换键,而无需更改系统级设置。无论哪种方式,不同的操作系统都以相似的方式处理环境变量,因此,如果您正在编写跨平台代码,它们是一个完美的选择。
创建一个名为BHT_APIKEY
的新环境变量,并将 API 键设置为其值。
对于运行 bash shell 的机器,您可以修改您的~/.bashrc
文件或类似文件,以包含export
命令,例如:
export BHT_APIKEY=abc123def456ghi789jkl
在 Windows 机器上,您可以导航到计算机的属性,并在高级部分中查找环境变量。
提出请求http://words.bighugelabs.com/apisample.php?v=2 web 浏览器中的&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 Online service。
接下来我们将在程序中使用这个新包。通过将级别备份到$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)
}
}
}
当您再次管理导入时,您将编写一个完整的程序,能够通过集成大型同义词库 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
并点击 return,您可能会看到:
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
您得到的建议的数量实际上取决于同义词的数量,因为它是唯一一个生成比我们给出的多行输出的程序。
我们仍然没有解决我们最大的问题——我们不知道所建议的域名是否真的可用,所以我们仍然必须坐下来,把每一个域名都输入一个网站。在下一节中,我们将讨论这个问题。
我们的最终程序(可用)将连接到 WHOIS 服务器,询问传入其中的域的详细信息。当然,如果没有返回详细信息,我们可以放心地假设该域可供购买。不幸的是,WHOIS 规范(见http://tools.ietf.org/html/rfc3912 )非常小,不包含 WHOIS 服务器在您询问域名详细信息时应如何回复的信息。这意味着以编程方式解析响应会变得很麻烦。为了暂时解决这个问题,我们将只与一个 WHOIS 服务器集成,我们可以确定当它没有域记录时,该服务器将在响应中的某个位置具有No match
。
一个更健壮的解决方案可能是有一个 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
函数通过调用net.Dial
在指定的whoisServer
实例上打开与端口43
的连接,实现 WHOIS 规范中很少的功能。然后,我们推迟关闭连接,这意味着无论函数如何退出(成功退出、出错退出、甚至死机退出),Close()
仍将在连接conn
上被调用。一旦连接打开,我们只需编写域,后跟\r\n
(回车符和换行符)。这是所有的规范告诉我们的,所以从现在起我们就靠自己了。
本质上,我们在的响应中寻找不匹配的内容,这就是我们决定域是否存在的方式(在本例中exists
实际上只是询问 WHOIS 服务器是否有我们指定的域的记录)。我们使用我们最喜欢的bufio.Scanner
方法来帮助我们迭代响应中的行。将连接传递到NewScanner
是有效的,因为net.Conn
实际上也是一个io.Reader
。我们使用strings.ToLower
这样我们就不必担心大小写的敏感性,strings.Contains
来查看是否有任何一行包含不匹配的文本。如果有,我们返回false
(因为域不存在),否则我们返回true
。
com.whois-servers.net
WHOIS 服务支持.com
和.net
的域名,这就是域名化程序只添加这些类型域名的原因。如果您使用的服务器包含更多域的 WHOIS 信息,则可以添加对其他 TLD 的支持。
让我们添加一个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 标准。实际获取这些字符的最佳方法是在 Web 上搜索它们,并使用复制和粘贴将它们引入代码中;除此之外,还有一些依赖于平台的方法来获得这些特殊字符。
在修复了main.go
文件的import
语句后,我们可以尝试可用,看看域名是否可用:
go build –o available
./available
一旦运行可用,请键入一些域名:
packtpub.com
packtpub.com ×
google.com
google.com ×
madeupdomain1897238746234.net
madeupdomain1897238746234.net ✔
正如你所看到的,对于那些显然不可用的域名,我们会得到一个小小的十字标记,但是当我们用随机数组成一个域名时,我们会发现它确实是可用的。
现在我们已经完成了所有五个程序,是时候把它们放在一起了,这样我们就可以使用我们的工具为我们的聊天应用程序找到一个可用的域名。最简单的方法是使用我们在本章中一直使用的技术:在终端中使用管道连接输出和输入。
在终端中,导航到五个程序的父文件夹并运行以下单行代码:
./synonyms/synonyms | ./sprinkle/sprinkle | ./coolify/coolify | ./domainify/domainify | ./available/available
程序运行后,在检查可用性之前,键入起始词并查看它如何生成建议。
例如,键入chat
可能会导致程序执行以下操作:
chat
一词进入synonyms
后出现一系列同义词:confab
confabulation
schmooze
- 同义词流入
sprinkle
,在sprinkle
中增加了网络友好的前缀和后缀,例如:confabapp
goconfabulation
schmooze time
- 这些新词流入
coolify
,元音可能会发生变化:confabaapp
goconfabulatioon
schmoooze time
- 修改后的字流入
domainify
并转换为有效域名:confabaapp.com
goconfabulatioon.net
schmooze-time.com
- 最后,域名流入
available
,并与 WHOIS 服务器进行核对,以查看是否有人已经占用了该域名: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
错误,它只是告诉我们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
将子程序连接在一起。
下表显示了每个程序,并对其输入来源和输出去向进行了说明:
|程序
|
输入(标准输入)
|
输出(标准输出)
|
| --- | --- | --- |
| 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
程序作为命令行同义词表。
我们学习了如何使用标准流来构建这些类型程序的不同流,以及标准输入和标准输出的重定向如何让我们非常轻松地处理不同的流。
当我们需要从 Big Hugh 同义词库中获取同义词时,我们了解了在 Go 中使用 JSON RESTful API web 服务是多么简单。我们一开始通过内联编码保持简单,然后重构代码,将Thesaurus
类型抽象到它自己的包中,该包可以共享。当我们打开与 WHOIS 服务器的连接并通过原始 TCP 写入数据时,我们还使用了非 HTTP API。
我们看到了math/rand
包如何带来一些变化和不可预测性,允许我们在代码中使用伪随机数和决策,这意味着每次运行程序时,我们都会得到不同的结果。
最后,我们构建了domainfinder
超级程序,将所有子程序组合在一起,为我们的解决方案提供了一个简单、干净、优雅的界面。