/

Go CLI 教程:Fortune 克隆

Go CLI 教程:Fortune 克隆

我之前写了两篇 CLI 应用的教程,分别是构建 gololcatgocowsay。在这两篇教程中,我都使用了 fortune 作为输入生成器。

在本文中,我将用 Go 完成这个“管道三部曲”的最后一部分 - gofortune

首先,什么是 fortune?参考 维基百科的定义,Fortune 是一款简单的程序,从一个引述数据库中随机显示一条信息。

说简单点,它是一个随机引述生成器。

它的历史可以追溯到 Unix Version 7 (1979)。至今仍然广泛应用。许多 Linux 发行版都默认安装了它,而在 MacOS 上,可以通过 brew install fortune 命令进行安装。

在某些系统上,它还会在使用 shell 时作为欢迎语或告别语。

维基百科上还提到:

许多人选择将 fortune 输入到 cowsay 命令中,以增添对话的幽默感。

而对于我来说,我会使用我的 gocowsay 命令。

好了,先到这里,让我们用 Go 构建一个 Fortune 的克隆版本

下面是程序的功能概述。

fortunes 文件夹的位置与系统和发行版有关,可以使用构建标志硬编码它,或者使用环境变量。但为了锻炼,我要做一件“肮脏”的事情,直接询问 fortune,通过执行 fortune -f 命令,并得到如下输出:

输出的第一行包含 fortunes 文件夹的路径。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import (
"fmt"
"os/exec"
)

func main() {
out, err := exec.Command("fortune", "-f").CombinedOutput()
if err != nil {
panic(err)
}

fmt.Println(string(out))
}

这段代码完全复制了我得到的输出。看起来 fortune -f 是将输出写入到了 stderr,所以我使用了 CombinedOutput 来获取 stdoutstderr 的内容。

但是我只需要第一行,应该如何做呢?下面的代码逐行打印了 stderr 的所有输出内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import (
"bufio"
"fmt"
"os/exec"
)

func main() {
fortuneCommand := exec.Command("fortune", "-f")
pipe, err := fortuneCommand.StderrPipe()
if err != nil {
panic(err)
}
outputStream := bufio.NewScanner(pipe)
for outputStream.Scan() {
fmt.Println(outputStream.Text())
}
}

要取得第一行,我可以去掉 for 循环,只扫描第一行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import (
"bufio"
"fmt"
"os/exec"
)

func main() {
fortuneCommand := exec.Command("fortune", "-f")
pipe, err := fortuneCommand.StderrPipe()
if err != nil {
panic(err)
}
fortuneCommand.Start()
outputStream := bufio.NewScanner(pipe)
outputStream.Scan()
fmt.Println(outputStream.Text())
}

接下来,我们提取这一行中的路径。

在我的系统中,输出的第一行是 100.00% /usr/local/Cellar/fortune/9708/share/games/fortunes。我可以通过在第一个 / 字符出现的位置开始截取子字符串:

1
2
line := outputStream.Text()
path := line[strings.Index(line, "/"):]

现在,我得到了 fortunes 的路径。我可以索引其中的文件。这里有 .dat 二进制文件和纯文本文件。我打算丢弃二进制文件以及 off/ 文件夹,因为后者包含了不文明的引述。

我们首先来索引文件。我使用 path/filepath 包的 Walk 方法来遍历文件树,从 root 开始。我使用它而不是 ioutil.ReadDir() 是因为 fortunes 可能嵌套在子文件夹中。在 WalkFuncvisit 函数中,我使用 filepath.Ext() 来丢弃 .dat 文件,使用 f.IsDir() 来丢弃文件夹文件(例如 /off,但子文件夹中的文件不会被排除),以及所有不文明的引述,方便地位于 /off 下,然后打印每个剩余文件的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
func visit(path string, f os.FileInfo, err error) error {
if strings.Contains(path, "/off/") {
return nil
}
if filepath.Ext(path) == ".dat" {
return nil
}
if f.IsDir() {
return nil
}
files = append(files, path)
return nil
}

func main() {
fortuneCommand := exec.Command("fortune", "-f")
pipe, err := fortuneCommand.StderrPipe()
if err != nil {
panic(err)
}
fortuneCommand.Start()
outputStream := bufio.NewScanner(pipe)
outputStream.Scan()
line := outputStream.Text()
root := line[strings.Index(line, "/"):]

err = filepath.Walk(root, visit)
if err != nil {
panic(err)
}
}

我们将这些文件的值存入切片中,以便稍后随机选择一个。我定义了一个字符串切片 files,并在 visit() 函数中将其添加进去。在 main() 的末尾,我打印得到的文件数量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package main

import (
"bufio"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
)

var files []string

func visit(path string, f os.FileInfo, err error) error {
if err != nil {
log.Fatal(err)
}
if strings.Contains(path, "/off/") {
return nil
}
if filepath.Ext(path) == ".dat" {
return nil
}
if f.IsDir() {
return nil
}
files = append(files, path)
return nil
}

func main() {
fortuneCommand := exec.Command("fortune", "-f")
pipe, err := fortuneCommand.StderrPipe()
if err != nil {
panic(err)
}
fortuneCommand.Start()
outputStream := bufio.NewScanner(pipe)
outputStream.Scan()
line := outputStream.Text()
root := line[strings.Index(line, "/"):]

err = filepath.Walk(root, visit)
if err != nil {
panic(err)
}

println(len(files))
}

现在,我使用 Go 随机数生成器 的功能来从数组中随机选择一个项:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 返回大于等于 min,小于 max 的 int
func randomInt(min, max int) int {
return min + rand.Intn(max-min)
}

func main() {
//...

rand.Seed(time.Now().UnixNano())
i := randomInt(1, len(files))
randomFile := files[i]
println(randomFile)
}

我们的程序现在在每次运行时都会打印一个随机 fortune 文件名。

现在我要做的是扫描文件中的引述,并打印一条随机引述。在每个文件中,引述由位于单独一行上的 % 字符分隔。我可以轻松地检测到这个规律,并将每个引述扫描到一个数组中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
file, err := os.Open(randomFile)
if err != nil {
panic(err)
}
defer file.Close()

b, err := ioutil.ReadAll(file)
if err != nil {
panic(err)
}

quotes := string(b)

quotesSlice := strings.Split(quotes, "%")
j := randomInt(1, len(quotesSlice))

fmt.Print(quotesSlice[j])

这并不是很高效,因为我在切片中扫描了整个 fortune 文件,然后选择了一个随机项,但它可以工作。

那么,这是我们非常基础的 fortune 克隆版本的最终代码。它没有原始 fortune 命令的许多功能,但这是个起点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
package main

import (
"bufio"
"fmt"
"io/ioutil"
"log"
"math/rand"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)

var files []string

// 返回大于等于 min,小于 max 的 int
func randomInt(min, max int) int {
return min + rand.Intn(max-min)
}

func visit(path string, f os.FileInfo, err error) error {
if err != nil {
log.Fatal(err)
}
if strings.Contains(path, "/off/") {
return nil
}
if filepath.Ext(path) == ".dat" {
return nil
}
if f.IsDir() {
return nil
}
files = append(files, path)
return nil
}

func main() {
fortuneCommand := exec.Command("fortune", "-f")
pipe, err := fortuneCommand.StderrPipe()
if err != nil {
panic(err)
}
fortuneCommand.Start()
outputStream := bufio.NewScanner(pipe)
outputStream.Scan()
line := outputStream.Text()
root := line[strings.Index(line, "/"):]

err = filepath.Walk(root, visit)
if err != nil {
panic(err)
}

rand.Seed(time.Now().UnixNano())
i := randomInt(1, len(files))
randomFile := files[i]

file, err := os.Open(randomFile)
if err != nil {
panic(err)
}
defer file.Close()

b, err := ioutil.ReadAll(file)
if err != nil {
panic(err)
}

quotes := string(b)

quotesSlice := strings.Split(quotes, "%")
j := randomInt(1, len(quotesSlice))

fmt.Print(quotesSlice[j])
}

总结一下,我将 visit 转为了 filepath.Walk 的内联函数参数,并将 files 移动到了 main() 的局部变量中,而不是全局变量中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
package main

import (
"bufio"
"fmt"
"io/ioutil"
"log"
"math/rand"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)

// 返回大于等于 min,小于 max 的 int
func randomInt(min, max int) int {
return min + rand.Intn(max-min)
}

func main() {
var files []string

fortuneCommand := exec.Command("fortune", "-f")
pipe, err := fortuneCommand.StderrPipe()
if err != nil {
panic(err)
}
fortuneCommand.Start()
outputStream := bufio.NewScanner(pipe)
outputStream.Scan()
line := outputStream.Text()
root := line[strings.Index(line, "/"):]

err = filepath.Walk(root, func(path string, f os.FileInfo, err error) error {
if err != nil {
log.Fatal(err)
}
if strings.Contains(path, "/off/") {
return nil
}
if filepath.Ext(path) == ".dat" {
return nil
}
if f.IsDir() {
return nil
}
files = append(files, path)
return nil
})
if err != nil {
panic(err)
}

rand.Seed(time.Now().UnixNano())
i := randomInt(1, len(files))
randomFile := files[i]

file, err := os.Open(randomFile)
if err != nil {
panic(err)
}
defer file.Close()

b, err := ioutil.ReadAll(file)
if err != nil {
panic(err)
}

quotes := string(b)

quotesSlice := strings.Split(quotes, "%")
j := randomInt(1, len(quotesSlice))

fmt.Print(quotesSlice[j])
}

现在,可以使用 go build; go install,我们的三部曲 gofortunegocowsaygololcat 就完成了。

tags: [“go”, “CLI”, “fortune”]