Go 语言实战(8):命令行(3)CLI 框架

经过前面两期的介绍,相信大家已经可以写简单的命令行程序,并且能够使用命令行参数。

即使遇到一些困难,建立直观认识和了解关键词之后,在网络上搜索答案也变得相对容易。

接下来介绍 CLI 框架。

命令行程序的前两期:

本系列完整目录:

Go 语言实战系列

命令行框架

对于简单的功能,单个 go 文件,几个函数,完全是足够的。没有必要为了像那么回事,硬要分很多个包,每个文件就两行代码。为了框架而框架,属于过早优化。

但反过来说,随着往项目里不断添加特性,代码越来越多,如何更好地组织代码,达到解耦和复用,就成了必须要考虑的问题。

我们当然可以把自己的思考,体现在项目的代码组织上,乃至从中抽取一套框架。但一个深思熟虑,适应各种场景变化的框架,还是有门槛、需要技术和经验积累的。

更便捷的做法,是引入社区热门的框架,利用里面提供的脚手架减少重复劳动,并从中学习它的设计。

对于 CLI 程序而言,我知道的最流行的框架有两个,分别是:

cobra 的功能会更强大完善。它的作者 Steve Francia(spf13)是 Google 里面 go 语言的 product lead,同时也是 gohugo、viper 等知名项目的作者。

但强大的同时,也意味着框架更大更复杂,在实现一些小规模的工具时,反而会觉得杀鸡牛刀。所以这里只介绍 cli 这个框架,有兴趣的朋友可以自行了解 cobra ,原理大同小异。

urfave/cli 框架

cli 目前已经开发到了 v2.0+。推荐使用最新的稳定版本。

这里使用 go module 模式,那么引入 cli 包只需要在代码开头

1
import "github.com/urfave/cli/v2"

如果还不熟悉 go module,或者不知道最后面的 v2 代表什么,请看这篇文章:《golang 1.13 - module VS package》。

简单说,go module 使用语义化版本(semver),认为主版本号变更是『不兼容变更(breaking changes)』,需要体现在导入路径上。 v0.x (不稳定版本,可以不兼容)和 v1.x (默认)不需要标,v2.0 及以上的版本,都需要把主版本号标在 module 路径的最后。

但是注意,这个 v2 既不对应实际的文件目录,也不影响包名。在这里,包名仍然是 cli

根据作者提供的例子,实现一个最小的 CLI 程序看看:

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
// 为了编译后不用改名,module name 直接就叫 boom
package main
import (
"fmt"
"log"
"os"
"github.com/urfave/cli/v2"
)
func main() {
app := &cli.App{
Name: "boom",
Usage: "make an explosive entrance",
Action: func(c *cli.Context) error {
fmt.Println("boom! I say!")
return nil
},
}
err := app.Run(os.Args)
if err != nil {
log.Fatal(err)
}
}

这段代码实现了一个叫 boom 的程序,执行的时候会输出 “boom! I say!”:

1
2
3
>go build
>boom
boom! I say!

另外,框架已经自动生成了默认的帮助信息。在调用 help 子命令,或者发生错误时,会输出:

1
2
3
4
5
6
7
8
9
10
11
12
>boom help
NAME:
boom - make an explosive entrance
USAGE:
boom.exe [global options] command [command options] [arguments...]
COMMANDS:
help, h Shows a list of commands or help for one command
GLOBAL OPTIONS:
--help, -h show help (default: false)

这段代码做的事情很简单。初始化一个 cli.App ,设置三个字段:

  • 名字,就是 “boom”。
  • 用途,也是一个字符串,会在 help 信息用到。
  • 动作,也就是执行程序时具体执行什么内容。这里是输出一个字符串。

运行部分,将命令行参数 os.Args 作为参数传递给 cli.AppRun() 方法,框架就会接管参数的解析和后续的命令执行。

如果是跟着教程一路过来,那么很可能这里是第一次引入第三方包。IDE 可以会同时提示好几个关于 “github.com/urfave/cli/v2” 的错误,例如:”github.com/urfave/cli/v2 is not in your go.mod file” 。

可以根据 IDE 的提示修复,或者执行 go mod tidy ,或者直接等 go build 时自动解决依赖。无论选择哪一种,最终都会往 go.mod 里添加一行 require github.com/urfave/cli/v2

重构

当然,实现这么简单的功能,除了帮忙生成帮助信息,框架也没什么用武之地。

接下来我们用框架把 gosrot 改造一下,在基本不改变功能的前提下,把 cli 包用上。

因为有了 cli 包处理参数,我们就不用 flag 包了。(其实 cli 里面用到了 flag 包。)

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 main() {
app := &cli.App{
Name: "gosort",
Usage: "a simple command line sort tool",
Action: sortCmd,
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "lex",
Aliases: []string{"l"},
Usage: "sort lexically",
Destination: &lex,
},
// unique 同为 BoolFlag,省略,请自行补完
// ...
&cli.StringFlag{
Name: "from",
Aliases: []string{"f"},
// `FILE` 是占位符,在帮助信息中会输出 -f FILE input from FILE
// 用户能更容易理解 FILE 的用途
Usage: "input from `FILE`",
Destination: &from,
},
// 省略剩余的 StringFlag...
},
}
err := app.Run(os.Args)
if err != nil {
log.Fatal(err)
}
}

cliFlagflag 包类似,有两种设置方法。既可以设置以后通过 cli.Context 的方法读取值:ctx.Bool("lex")string 等其它类型以此类推)。也可以直接把变量地址设置到 Destination 字段,解析后直接访问对应的变量。

这里为减少函数传参,用了后者,把参数值存储到全局(包级)变量。

程序入口改为 cli.App 之后,原来的 main() 函数就改为 sortCmd ,作为 appAction 字段。

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
// 增加 Context 参数 和返回 error,以满足 cli.ActionFunc (Action 字段的类型)签名
func sortCmd(ctx *cli.Context) error {
// 不再需要设置 flag 包
var strs []string
if from != "" {
if !isFile(from) {
return fmt.Errorf("%s is not a file", from)
}
buf, err := ioutil.ReadFile(from)
if err != nil {
return fmt.Errorf("read %s fail, caused by\n\t%w", from, err)
}
// 篇幅关系,省略... 参考之前两期的内容
}
// 省略...
if output == "" {
fmt.Println(res)
} else {
err := ioutil.WriteFile(output, []byte(res), 0666)
if err != nil {
return fmt.Errorf("write result to %s fail, caused by\n\t%w", output, err)
}
}
return nil
}

由于程序被封装成了 cli.App ,程序的执行交给框架处理, sortCmd 内部不再自行调用 os.Exit(1) 退出,而是通过返回 error 类型,将错误信息传递给上层处理。

这里主要使用 fmt.Errorf() 格式化错误信息然后返回。从 1.13 开始,fmt.Errorf() 提供了一个新的格式化动词 %w ,允许将底层的错误信息,包装在新的错误信息里面,形成错误信息链。后续可以通过 errors 包的三个函数 Is() , As()Unwrap() ,对错误信息进行进一步分析处理。

接下来编译执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
>go build
# 不同参数的含义参考上一期内容
>gosort -h
NAME:
gosort - a simple command line sort tool
USAGE:
gosort [global options] command [command options] [arguments...]
COMMANDS:
help, h Shows a list of commands or help for one command
GLOBAL OPTIONS:
--lex, -l sort lexically (default: false)
--unique, -u remove duplicates (default: false)
--from FILE, -f FILE input from FILE
--output FILE, -o FILE output to FILE
--insep value, -i value input seperator
--outSep value, -s value output seperator (default: ",")
--help, -h show help (default: false)
>gosort -u -i=, -s=- 111,111,555,678,333,567,678
111-333-555-567-678

如果完全照着教程的思路重构,到这一步,你可能会发现,代码可以编译和运行,却没有输出。这是因为有一个地方很容易忘记修改。 请尝试自行找到问题所在,并解决。

另起炉灶

框架除了解析参数,自动生成规范的帮助信息,还有一个主要的作用,是子命令(subcommand)的组织和管理。

gosort 主要围绕一个目的(提交号的排序去重)展开,各项功能是组合而不是并列的关系,更适合作为参数,而不是拆分成多个子命令。而且之前的开发容易形成思维定势,下面我们另举一例,不在 gosort 基础上修改。

为了容易理解,接下来用大家比较熟悉的 git 做例子。篇幅关系,只展示项目可能的结构,不(可能)涉及具体的代码实现。

首先,我们看一下 git 有哪些命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
>git help
usage: git [--version] [--help] [-C <path>] [-c name=value]
[--exec-path[=<path>]] [--html-path] [--man-path] [--info-path]
[-p | --paginate | --no-pager] [--no-replace-objects] [--bare]
[--git-dir=<path>] [--work-tree=<path>] [--namespace=<name>]
<command> [<args>]
These are common Git commands used in various situations:
start a working area (see also: git help tutorial)
clone Clone a repository into a new directory
init Create an empty Git repository or reinitialize an existing one
work on the current change (see also: git help everyday)
add Add file contents to the index
mv Move or rename a file, a directory, or a symlink
// 篇幅关系,省略余下内容,你可以自己尝试执行 git help 查看

总的来说,就是有一系列的全局选项(global options,跟在 git 后面,command 之前),一系列子命令(subcommand),每个命令下面还有一些专属的参数。

这样的工具,有几个特点:

  • 功能强大,子功能很多,无法用一个命令 + 若干参数完成,一般实现为多个子命令。
  • 既有影响多数子命令的全局选项,也有某些子命令专属的选项。
  • 子命令之间,既相互独立,又共享一部分底层实现。

为了更好地组织程序,项目结构可以是这样子的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
│ go.mod
│ go.sum
│ main.go
├───cmd
│ add.go
│ clone.go
│ common.go
│ init.go
│ mv.go
| ......
└───pkg
├───hash
│ hash.go
├───zip
| zip.go
├───......

main.go 是程序入口,为了保持结构清晰,这里只是初始化并运行 cli.App

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
package main
import (
"log"
"mygit/cmd"
"os"
"github.com/urfave/cli/v2"
)
func main() {
app := &cli.App{
Name: "mygit",
Usage: "a free and open source distributed version control system",
Version: "v0.0.1",
UseShortOptionHandling: true,
Flags: cmd.GlobalOptions,
// Before 在任意命令执行前执行,这里用来处理全局选项
Before: cmd.LoadGlobalOptions,
// 同理,也可以定义 After 来执行收尾操作
// After: xxx
Commands: cmd.Commands,
}
err := app.Run(os.Args)
if err != nil && err != cmd.ErrPrintAndExit {
log.Fatal(err)
}
}

具体的代码实现放到 cmd 包,基本上一个子命令对应一个源文件,代码查找起来非常清晰。

common.go 存放 cmd 包的公共内容:

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
package cmd
import (
"errors"
"fmt"
"github.com/urfave/cli/v2"
)
// Commands 将子命令统一暴露给 main 包
var Commands = []*cli.Command{
cloneCmd,
initCmd,
addCmd,
mvCmd,
// more subcommands ...
}
// GlobalOptions 将全局选项暴露给 main 包
var GlobalOptions = []cli.Flag{
&cli.PathFlag{
Name: "C",
Usage: "Run as if git was started in `path` instead of the current working directory",
},
&cli.PathFlag{
Name: "exec-path",
Usage: "`path` to wherever your core Git programs are installed",
},
&cli.BoolFlag{
Name: "html-path",
Usage: "Print the path, without trailing slash, where Git’s HTML documentation is installed and exit",
},
// 省略 man-path, info-path, paginate, no-pager...
// more ...
}
// ErrPrintAndExit 表示遇到需要打印信息并提前退出的情形,不需要打印错误信息
var ErrPrintAndExit = errors.New("print and exit")
// LoadGlobalOptions 加载全局选项
var LoadGlobalOptions = func(ctx *cli.Context) error {
// 并非实际实现,所以遇到对应的参数只是输出信息,方便观察
// 全局选项既可以在这里读取并设置全局状态(如有)
// 也可以在具体实现处再通过 ctx 读取(参考 add)
if ctx.IsSet("C") {
fmt.Println("started path changed to", ctx.Path("C"))
}
// 省略 exec-path ...
if ctx.Bool("html-path") {
fmt.Println("html-path is xxx")
return ErrPrintAndExit
}
// 省略 man-path, info-path ...
if ctx.Bool("paginate") || !ctx.Bool("no-pager") {
fmt.Println("pipe output into pager like less")
} else {
fmt.Println("no pager")
}
return nil
}
// 子命令分组
const (
cmdGroupStart = "start a working area"
cmdGroupWork = "work on current change"
// ...
)

除了业务相关的公共逻辑放在 common.go,还有一些业务中立的底层公共类库,就可以放在 pkg 下面,例如 hash.go

1
2
3
4
5
6
7
package hash
// MyHash 返回 source 的 hash 结果
func MyHash(source string) string {
// 这是一个假的实现
return "hash of " + source
}

看一下其中一个子命令 add 的代码:

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
package cmd
import (
"fmt"
"mygit/pkg/hash"
"github.com/urfave/cli/v2"
)
var addCmd = &cli.Command{
Name: "add",
Usage: "Add file contents to the index",
Category: cmdGroupWork, // 子命令分组
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "verbose",
Aliases: []string{"v"},
Usage: "Be verbose",
},
&cli.BoolFlag{
Name: "force",
Aliases: []string{"f"},
Usage: "Allow adding otherwise ignored files",
},
// more options ...
},
Action: func(ctx *cli.Context) error {
// 仅输出信息,查看效果,不是真实实现
// 这里也能读取全局选项
if ctx.IsSet("C") {
// do something
}
items := ctx.Args().Slice()
if ctx.Bool("verbose") {
for _, item := range items {
fmt.Println("add", item, ", hash is [", hash.MyHash(item), "]")
}
}
fmt.Println("add", items, "successfully.")
return nil
},
}

拥有相同 Category 字段的命令会自动分组。这里在 common.go 预定义了一系列的分组,然后直接引用。之所以不是直接用字面量,是因为在多处引用字面量,非常容易出错,也不利于后续修改。

举例说,如果不小心在组名里输入多了一个 “s” ,就会变成下面这样:

1
2
3
4
5
6
7
8
9
COMMANDS:
help, h Shows a list of commands or help for one command
start a working area:
clone Clone a repository into a new directory
init Create an empty Git repository or reinitialize an existing one
work on current change:
add Add file contents to the index
work on current changes:
mv Move or rename a file, a directory, or a symlink

好了,一个连低仿都不算的 git 算是搭出一个空架子,编译执行看看:

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
>go build
# help 命令和 --help, --version 框架会自动添加,如果不需要可以通过特定的字段关闭
>mygit help
pipe output into pager like less
NAME:
mygit - a free and open source distributed version control system
USAGE:
mygit [global options] command [command options] [arguments...]
VERSION:
v0.0.1
COMMANDS:
help, h Shows a list of commands or help for one command
start a working area:
clone Clone a repository into a new directory
init Create an empty Git repository or reinitialize an existing one
work on current change:
add Add file contents to the index
mv Move or rename a file, a directory, or a symlink
GLOBAL OPTIONS:
-C path Run as if git was started in path instead of the current working directory
--exec-path path path to wherever your core Git programs are installed
--html-path Print the path, without trailing slash, where Git’s HTML documentation is installed and exit (default: false)
--man-path Print the manpath (see man(1)) for the man pages for this version of Git and exit (default: false)
--info-path Print the path where the Info files documenting this version of Git are installed and exit (default: false)
--paginate, -p Pipe all output into less (or if set, $PAGER) if standard output is a terminal (default: false)
--no-pager Do not pipe Git output into a pager (default: false)
--help, -h show help (default: false)
--version, -v print the version (default: false)
# help 命令连子命令的帮助信息也自动生成了
>mygit help add
pipe output into pager like less
NAME:
mygit add - Add file contents to the index
USAGE:
mygit add [command options] [arguments...]
CATEGORY:
work on current change
OPTIONS:
--verbose, -v Be verbose (default: false)
--force, -f Allow adding otherwise ignored files (default: false)
>mygit -C here add a b c
started path changed to here
pipe output into pager like less
started path changed to here
add [a b c] successfully.
>mygit add -v a b c
pipe output into pager like less
add a , hash is [ hash of a ]
add b , hash is [ hash of b ]
add c , hash is [ hash of c ]
add [a b c] successfully.

光看帮助信息是不是感觉还挺像回事。

希望通过这个粗糙的例子,能让大家对 urfave/cli 这个框架建立一点直观的印象。

更多的例子、更详细的字段用法,可以参考

最后

到这里,鸽了很久的 CLI (命令行程序)部分暂告一段落。

在实际写过几个 go 程序之后,相信大家对于 go 已经有一些直观的认识。与此同时,前面只介绍了很少一部分语言特性,在实际编程中可能会产生各种疑惑。后面几期回归基础知识讲解,希望能解开其中一部分疑惑。

最后的最后,关于 CLI 程序,推荐一篇文章:《做一个命令行工具,是一件挺酷的事儿


知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。