我之前写了两篇 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 文件夹的路径。

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 的所有输出内容:

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 循环,只扫描第一行:

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。我可以通过在第一个 / 字符出现的位置开始截取子字符串:

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 下,然后打印每个剩余文件的值。

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() 的末尾,我打印得到的文件数量。

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 随机数生成器 的功能来从数组中随机选择一个项:

// 返回大于等于 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 文件名。

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

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 命令的许多功能,但这是个起点。

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() 的局部变量中,而不是全局变量中。

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 就完成了。