如何使用 Go 开发命令行工具

老雷 创作于 2020-03-22
Go cli 全文约 6400 字,预计阅读时间为 16 分钟

相比于 Node.js 和 Python 等脚本语言,使用 Go 开发命令行工具的最大好处是可以直接发布可执行的二进制文件,无需安装脚本语言本身的运行环境以及安装依赖包。不过,会有些许麻烦的是需要为各个运行平台编译对应的二进制文件,比如 Windows 和 Linux 的可执行文件格式是不一样的。

解析命令行参数

当执行命令时,第一步要做的就是对参数进行解析,比如使用 curl 命令发起一个 HTTP 请求:

curl -X POST https://example.com -i -H "accept:text/html"

curl 是命令的名称(即可执行文件名),其后的为参数,我们需要从参数中知道要请求的地址是什么,请求方法是什么,请求头是什么等信息。一般情况下,可以通过 os.Args 取得一个字符串数组的命令行参数,以上面的命令为例,得到的数据应该是这样的:

["curl", "-X", "POST", "https://example.com", "-i", "-H", "accept:text/html"]⏎

数组第一个元素是当前可执行文件的完整路径,其后的元素是简单通过分隔的,如果需要复杂的表达方式就需要一个专门的命令行参数解析库去解析,将这些参数解析更容易读取的方式。

内置的 flag 包

以上面的命令为例,用 Go 内置的 flag 包大概可以这样解析:

package main

import "flag"

func main() {
    method := flag.String("X", "GET", "请求方法")
    include := flag.Bool("i", false, "是否包含完整输出")
    headers := flag.String("H", "", "请求头")
    flag.Parse()
    args := flag.Args() // 得到剩余的 -name 的参数
}

如果只需要解析 -flag-flag value 这样简单的参数,flag 包已经够用了。不过上面例子还有一些问题:

  1. https://example.com 这个参数只能放在最后,如:
curl -X POST -i -H "accept:text/html" https://example.com

因为按照 flag 包的规则,在遇到非 - 开头的参数时,就停止解析了,其后面的参数都原封不动地全部放在 flag.Args()

  1. -H 参数不能有多个,比如 -H "text/html" -H "User-Agent: Go" 本意是想设置多个请求头,但按上面的代码执行的话前面的参数会被后面的覆盖,实际上 headers 变量只能得到 User-Agent: Go

第三方包 github.com/urfave/cli/v2

使用 github.com/urfave/cli/v2 包可以完美解决上文所遇到的问题,以下是示例代码:

package main

import (
    "fmt"
    "os"

    "github.com/urfave/cli/v2"
)

func main() {
    app := &cli.App{
        Flags: []cli.Flag{
            &cli.StringFlag{Name: "X", Value: "GET", Usage: "请求方法"},
            &cli.BoolFlag{Name: "i", Value: false, Usage: "是否包含完整输出"},
            &cli.StringSliceFlag{Name: "H", Usage: "请求头"},
        },
        Action: func(c *cli.Context) error {
            fmt.Println(c.String("X"))
            fmt.Println(c.Bool("i"))
            fmt.Println(c.StringSlice("H"))
            fmt.Println(c.Args().First())
            return nil
        },
    }
    if err := app.Run(os.Args); err != nil {
        panic(err)
    }
}

有些命令行工具比较复杂,支持多个子命令,并且有全局参数和子命令参数之分。可以使用 github.com/urfave/cli/v2 包的 Subcommands 来实现这样的效果,具体可参考官方的文档:https://github.com/urfave/cli/blob/master/docs/v2/manual.md#subcommands

命令行界面交互

有些命令行工具会包含一些简单的交互::

输入与选择

对于简单询问和获取用户输入的场景,使用内置的 fmt 包即可。以下代码来自 https://gist.github.com/albrow/5882501

// askForConfirmation uses Scanln to parse user input. A user must type in "yes" or "no" and
// then press enter. It has fuzzy matching, so "y", "Y", "yes", "YES", and "Yes" all count as
// confirmations. If the input is not recognized, it will ask again. The function does not return
// until it gets a valid response from the user. Typically, you should use fmt to print out a question
// before calling askForConfirmation. E.g. fmt.Println("WARNING: Are you sure? (yes/no)")
func askForConfirmation() bool {
    var response string
    _, err := fmt.Scanln(&response)
    if err != nil {
        log.Fatal(err)
    }
    okayResponses := []string{"y", "Y", "yes", "Yes", "YES"}
    nokayResponses := []string{"n", "N", "no", "No", "NO"}
    if containsString(okayResponses, response) {
        return true
    } else if containsString(nokayResponses, response) {
        return false
    } else {
        fmt.Println("Please type yes or no and then press enter:")
        return askForConfirmation()
    }
}

其他情况下,可能需要 ANSI 转移序列 来控制命令行界面文本输出的位置和样式,从而实现下图这样的效果:

68747470733a2f2f6d656469612e67697068792e636f6d2f6d656469612f78554e6461304e67623571736f674c7342692f67697068792e676966.gif

以下是来自 github.com/manifoldco/promptui 包文档中的列表选择例子:

package main

import (
    "fmt"

    "github.com/manifoldco/promptui"
)

func main() {
    prompt := promptui.Select{
        Label: "Select Day",
        Items: []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday",
            "Saturday", "Sunday"},
    }

    _, result, err := prompt.Run()

    if err != nil {
        fmt.Printf("Prompt failed %v\n", err)
        return
    }

    fmt.Printf("You choose %q\n", result)
}

具体使用方法可参考 promptui 包官方例子:https://github.com/manifoldco/promptui/tree/master/_examples

进度条

可以通过 github.com/cheggaaa/pb/v3 包实现进度条效果,以下是简单示例:

package main

import (
    "time"

    "github.com/cheggaaa/pb/v3"
)

func main() {
    // 创建进度条,count 是进度条的最大值
    count := 100000
    bar := pb.StartNew(count)
    // 模拟进度,随着 bar.Increment()计数的增加,界面会显示对应的百分比
    for i := 0; i < count; i++ {
        bar.Increment()
        time.Sleep(time.Millisecond)
    }
    // 进度完成,删除进度条
    bar.Finish()
}

执行的效果如下:

6765 / 100000 [-->_______________________________] 6.76% 780 p/s

字符终端下的“图形界面”

命令行界面并不是只能显示黑底白字一行一行的文本字符,早在八十年代就可以在字符终端下实现这样跟现在的图形界面很像的效果了:

image.png

其实原理跟上文提到的“命令行界面交互”是一样,都是利用 ANSI 转移序列 来进行输出控制,只不过更为复杂一些,因为使用字符来绘制各种界面元素,并且需要统一控制用户的键盘操作,比如“输入框”获得焦点,从一个“窗口”切换到另一个“窗口”,因此需要有一个专门的代码库去处理这些杂事,将“图形界面”抽象成功各种组件,通过一定的规则进行“排版”,通过事件回调来处理用户的各种操作。

github.com/gizak/termui 是使用 Go 编写的跨平台字符终端 UI 库,以下是该项目首页给出的效果图:

demo.gif

这是一个简单的 Hello World 例子:

package main

import (
    "log"

    ui "github.com/gizak/termui/v3"
    "github.com/gizak/termui/v3/widgets"
)

func main() {
    if err := ui.Init(); err != nil {
        log.Fatalf("failed to initialize termui: %v", err)
    }
    defer ui.Close()

    p := widgets.NewParagraph()
    p.Text = "Hello World!"
    p.SetRect(0, 0, 25, 5)

    ui.Render(p)

    for e := range ui.PollEvents() {
        if e.Type == ui.KeyboardEvent {
            break
        }
    }
}

在这个例子中,首先创建了一个 Paragraph 组件,然后设置其相应的属性,然后通过 ui.Render() 开始渲染界面。之后,循环地从 ui.PollEvents() 拉取事件来处理。当然,实际使用场景可能会比这个例子复杂得多,详细使用方法可以阅读官方的文档:https://github.com/gizak/termui/wiki

打包发布

很多使用 Go 编写命令行工具文档上都写着使用 go get 命令来安装,如:

go get -u -t github.com/volatiletech/sqlboiler

以上命令会使用本地的 Go 编译器,下载 github.com/volatiletech/sqlboiler 包的源码并编译,然后在可执行文件保存到 GOPATH 环境变量指定的路径(默认为 ~/go)下的 bin 目录。但是这种方式有一个限制,使用者本地需要安装了可以编译该工具的 Go 版本,其次对于国内的使用者来讲,下载该工具源码的过程中可能还需要下载额外的依赖包的源码,某些地区的网络环境可能会带来很大不便。

对使用者最友好的方式应该是开发者直接打包出可执行文件,然后再发布。一般情况下,通过 go build 命令构建出来的可执行文件只能在当前操作系统平台下执行,如果要构建其他操作系统版本的可执行文件,需要指定 GOOSGOARCH 这两个环境变量。详细的列表如下:

GOOS - 目标操作系统 GOARCH - 目标平台
android arm
darwin 386
darwin amd64
darwin arm
darwin arm64
dragonfly amd64
freebsd 386
freebsd amd64
freebsd arm
linux 386
linux amd64
linux arm
linux arm64
linux ppc64
linux ppc64le
linux mips
linux mipsle
linux mips64
linux mips64le
netbsd 386
netbsd amd64
netbsd arm
openbsd 386
openbsd amd64
openbsd arm
plan9 386
plan9 amd64
solaris amd64
windows 386
windows amd64

例如要构建 Windows/amd64 平台下的可执行文件的命令如下:

GOOS=windows GOARCH=adm64 go build -o example example.com/xxx

参考上文的表格,我们只需要将要支持的平台参考上面的构建命令格式,写到一个构建脚本中,即可在一次性构建出多个平台的可执行文件。下面是一个构建脚本的例子(代码来源于 https://www.digitalocean.com/community/tutorials/how-to-build-go-executables-for-multiple-platforms-on-ubuntu-16-04 ):

#!/usr/bin/env bash

package=$1
if [[ -z "$package" ]]; then
  echo "usage: $0 <package-name>"
  exit 1
fi
package_split=(${package//\// })
package_name=${package_split[-1]}

platforms=("windows/amd64" "windows/386" "darwin/amd64")

for platform in "${platforms[@]}"
do
    platform_split=(${platform//\// })
    GOOS=${platform_split[0]}
    GOARCH=${platform_split[1]}
    output_name=$package_name'-'$GOOS'-'$GOARCH
    if [ $GOOS = "windows" ]; then
        output_name+='.exe'
    fi

    env GOOS=$GOOS GOARCH=$GOARCH go build -o $output_name $package
    if [ $? -ne 0 ]; then
        echo 'An error has occurred! Aborting the script execution...'
        exit 1
    fi
done

参考资料