Go 语言实战:命令行(3)CLI 框架
经过前面两期的介绍,相信大家已经可以写简单的命令行程序,并且能够使用命令行参数。
即使遇到一些困难,建立直观认识和了解关键词之后,在网络上搜索答案也变得相对容易。
接下来介绍 CLI 框架。
命令行程序的前两期:
命令行框架
对于简单的功能,单个 go 文件,几个函数,完全是足够的。没有必要为了像那么回事,硬要分很多个包,每个文件就两行代码。为了框架而框架,属于过早优化。
但反过来说,随着往项目里不断添加特性,代码越来越多,如何更好地组织代码,达到解耦和复用,就成了必须要考虑的问题。
我们当然可以把自己的思考,体现在项目的代码组织上,乃至从中抽取一套框架。但一个深思熟虑,适应各种场景变化的框架,还是有门槛、需要技术和经验积累的。
更便捷的做法,是引入社区热门的框架,利用里面提供的脚手架减少重复劳动,并从中学习它的设计。
对于 CLI 程序而言,我知道的最流行的框架有两个,分别是:
urfave/cli:https://github.com/urfave/cli cobra:https://github.com/spf13/cobra
cobra 的功能会更强大完善。它的作者 Steve Francia(spf13)是 Google 里面 go 语言的 product lead,同时也是 gohugo、viper 等知名项目的作者。
但强大的同时,也意味着框架更大更复杂,在实现一些小规模的工具时,反而会觉得杀鸡牛刀。所以这里只介绍 cli 这个框架,有兴趣的朋友可以自行了解 cobra ,原理大同小异。
urfave/cli 框架
cli 目前已经开发到了 v2.0+。推荐使用最新的稳定版本。
这里使用 go module 模式,那么引入 cli
包只需要在代码开头
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 程序看看:
// 为了编译后不用改名,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!":
>go build
>boom
boom! I say!
另外,框架已经自动生成了默认的帮助信息。在调用 help
子命令,或者发生错误时,会输出:
>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.App
的 Run()
方法,框架就会接管参数的解析和后续的命令执行。
如果是跟着教程一路过来,那么很可能这里是第一次引入第三方包。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
包。)
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)
}
}
cli
的 Flag
跟 flag
包类似,有两种设置方法。既可以设置以后通过 cli.Context
的方法读取值:ctx.Bool("lex")
(string
等其它类型以此类推)。也可以直接把变量地址设置到 Destination
字段,解析后直接访问对应的变量。
这里为减少函数传参,用了后者,把参数值存储到全局(包级)变量。
程序入口改为 cli.App
之后,原来的 main()
函数就改为 sortCmd
,作为 app
的 Action
字段。
// 增加 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()
,对错误信息进行进一步分析处理。
接下来编译执行
>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
有哪些命令:
>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),每个命令下面还有一些专属的参数。
这样的工具,有几个特点:
功能强大,子功能很多,无法用一个命令 + 若干参数完成,一般实现为多个子命令。 既有影响多数子命令的全局选项,也有某些子命令专属的选项。 子命令之间,既相互独立,又共享一部分底层实现。
为了更好地组织程序,项目结构可以是这样子的:
│ 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
:
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
包的公共内容:
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
:
package hash
// MyHash 返回 source 的 hash 结果
func MyHash(source string) string {
// 这是一个假的实现
return "hash of " + source
}
看一下其中一个子命令 add
的代码:
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" ,就会变成下面这样:
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
算是搭出一个空架子,编译执行看看:
>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
这个框架建立一点直观的印象。
更多的例子、更详细的字段用法,可以参考
项目主页:https://github.com/urfave/cli 文档:https://pkg.go.dev/github.com/urfave/cli/v2
最后
在实际写过几个 go 程序之后,相信大家对于 go 已经有一些直观的认识。与此同时,前面只介绍了很少一部分语言特性,在实际编程中可能会产生各种疑惑。
推荐阅读