经过前面两期的介绍,相信大家已经可以写简单的命令行程序,并且能够使用命令行参数。
即使遇到一些困难,建立直观认识和了解关键词之后,在网络上搜索答案也变得相对容易。
接下来介绍 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
| 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.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
包。)
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, }, &cli.StringFlag{ Name: "from", Aliases: []string{"f"}, Usage: "input from `FILE`", Destination: &from, }, }, } 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
字段。
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
| func sortCmd(ctx *cli.Context) error { 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: cmd.LoadGlobalOptions, 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" ) var Commands = []*cli.Command{ cloneCmd, initCmd, addCmd, mvCmd, } 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", }, } var ErrPrintAndExit = errors.New("print and exit") var LoadGlobalOptions = func(ctx *cli.Context) error { if ctx.IsSet("C") { fmt.Println("started path changed to", ctx.Path("C")) } if ctx.Bool("html-path") { fmt.Println("html-path is xxx") return ErrPrintAndExit } 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 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", }, }, Action: func(ctx *cli.Context) error { if ctx.IsSet("C") { } 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 >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) >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)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。