相比于 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 包已经够用了。不过上面例子还有一些问题:
https://example.com
这个参数只能放在最后,如:
curl -X POST -i -H "accept:text/html" https://example.com
因为按照 flag 包的规则,在遇到非 -
开头的参数时,就停止解析了,其后面的参数都原封不动地全部放在 flag.Args()
。
-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
命令行界面交互
有些命令行工具会包含一些简单的交互::
- 询问用户是否继续操作,输入 Y 或者 N 确认,比如使用 yum 命令按照软件时需要输入 y 回车确认;
- 需要用户输入或者选择信息,比如很多脚手架工具需要用户选择和录入必要的信息;
- 显示进度条;
输入与选择
对于简单询问和获取用户输入的场景,使用内置的 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 转移序列 来控制命令行界面文本输出的位置和样式,从而实现下图这样的效果:
以下是来自 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
字符终端下的“图形界面”
命令行界面并不是只能显示黑底白字一行一行的文本字符,早在八十年代就可以在字符终端下实现这样跟现在的图形界面很像的效果了:
其实原理跟上文提到的“命令行界面交互”是一样,都是利用 ANSI 转移序列 来进行输出控制,只不过更为复杂一些,因为使用字符来绘制各种界面元素,并且需要统一控制用户的键盘操作,比如“输入框”获得焦点,从一个“窗口”切换到另一个“窗口”,因此需要有一个专门的代码库去处理这些杂事,将“图形界面”抽象成功各种组件,通过一定的规则进行“排版”,通过事件回调来处理用户的各种操作。
github.com/gizak/termui 是使用 Go 编写的跨平台字符终端 UI 库,以下是该项目首页给出的效果图:
这是一个简单的 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 命令构建出来的可执行文件只能在当前操作系统平台下执行,如果要构建其他操作系统版本的可执行文件,需要指定 GOOS 和 GOARCH 这两个环境变量。详细的列表如下:
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
参考资料
- Go by Example: Command-Line Flags, Mark McGranaghan
- cli v2 manual, urfave
- Go (golang): How to ask for user confirmation via command line, albrow
- improve your command-line Go application with promptui, Luiz Branco
- termui README, gizak
- How To Build Go Executables for Multiple Platforms on Ubuntu 16.04, Marko Mudrinić
- ANSI 转移序列,维基百科
- 如何开发富文本的终端 UI 应用,TechMojotv