简介

cli是一个用于构建命令行程序的库。我们之前也介绍过一个用于构建命令行程序的库cobra。在功能上来说两者差不多,cobra的优势是提供了一个脚手架,方便开发。cli非常简洁,所有的初始化操作就是创建一个cli.App结构的对象。通过为对象的字段赋值来添加相应的功能。

cli与我们上一篇文章介绍的negroni是同一个作者urfave

快速使用

cli需要搭配 Go Modules 使用。创建目录并初始化:

  1. $ mkdir cli && cd cli
  2. $ go mod init github.com/go-quiz/go-daily-lib/cli

安装cli库,有v1v2两个版本。如果没有特殊需求,一般安装v2版本:

  1. $ go get -u github.com/urfave/cli/v2

使用:

  1. package main
  2. import (
  3. "fmt"
  4. "log"
  5. "os"
  6. "github.com/urfave/cli/v2"
  7. )
  8. func main() {
  9. app := &cli.App{
  10. Name: "hello",
  11. Usage: "hello world example",
  12. Action: func(c *cli.Context) error {
  13. fmt.Println("hello world")
  14. return nil
  15. },
  16. }
  17. err := app.Run(os.Args)
  18. if err != nil {
  19. log.Fatal(err)
  20. }
  21. }

使用非常简单,理论上创建一个cli.App结构的对象,然后调用其Run()方法,传入命令行的参数即可。一个空白的cli应用程序如下:

  1. func main() {
  2. (&cli.App{}).Run(os.Args)
  3. }

但是这个空白程序没有什么用处。我们的hello world程序,设置了Name/Usage/ActionNameUsage都显示在帮助中,Action是调用该命令行程序时实际执行的函数,需要的信息可以从参数cli.Context获取。

编译、运行(环境:Win10 + Git Bash):

  1. $ go build -o hello
  2. $ ./hello
  3. hello world

除了这些,cli为我们额外生成了帮助信息:

  1. $ ./hello --help
  2. NAME:
  3. hello - hello world example
  4. USAGE:
  5. hello [global options] command [command options] [arguments...]
  6. COMMANDS:
  7. help, h Shows a list of commands or help for one command
  8. GLOBAL OPTIONS:
  9. --help, -h show help (default: false)

参数

通过cli.Context的相关方法我们可以获取传给命令行的参数信息:

  • NArg():返回参数个数;
  • Args():返回cli.Args对象,调用其Get(i)获取位置i上的参数。

示例:

  1. func main() {
  2. app := &cli.App{
  3. Name: "arguments",
  4. Usage: "arguments example",
  5. Action: func(c *cli.Context) error {
  6. for i := 0; i < c.NArg(); i++ {
  7. fmt.Printf("%d: %s\n", i+1, c.Args().Get(i))
  8. }
  9. return nil
  10. },
  11. }
  12. err := app.Run(os.Args)
  13. if err != nil {
  14. log.Fatal(err)
  15. }
  16. }

这里只是简单输出:

  1. $ go run main.go hello world
  2. 1: hello
  3. 2: world

选项

一个好用的命令行程序怎么会少了选项呢?cli设置和获取选项非常简单。在cli.App{}结构初始化时,设置字段Flags即可添加选项。Flags字段是[]cli.Flag类型,cli.Flag实际上是接口类型。cli为常见类型都实现了对应的XxxFlag,如BoolFlag/DurationFlag/StringFlag等。它们有一些共用的字段,Name/Value/Usage(名称/默认值/释义)。看示例:

  1. func main() {
  2. app := &cli.App{
  3. Flags: []cli.Flag{
  4. &cli.StringFlag{
  5. Name: "lang",
  6. Value: "english",
  7. Usage: "language for the greeting",
  8. },
  9. },
  10. Action: func(c *cli.Context) error {
  11. name := "world"
  12. if c.NArg() > 0 {
  13. name = c.Args().Get(0)
  14. }
  15. if c.String("lang") == "english" {
  16. fmt.Println("hello", name)
  17. } else {
  18. fmt.Println("你好", name)
  19. }
  20. return nil
  21. },
  22. }
  23. err := app.Run(os.Args)
  24. if err != nil {
  25. log.Fatal(err)
  26. }
  27. }

上面是一个打招呼的命令行程序,可通过选项lang指定语言,默认为英语。设置选项为非english的值,使用汉语。如果有参数,使用第一个参数作为人名,否则使用world。注意选项是通过c.Type(name)来获取的,Type为选项类型,name为选项名。编译、运行:

  1. $ go build -o flags
  2. # 默认调用
  3. $ ./flags
  4. hello world
  5. # 设置非英语
  6. $ ./flags --lang chinese
  7. 你好 world
  8. # 传入参数作为人名
  9. $ ./flags --lang chinese dj
  10. 你好 dj

我们可以通过./flags --help来查看选项:

  1. $ ./flags --help
  2. NAME:
  3. flags - A new cli application
  4. USAGE:
  5. flags [global options] command [command options] [arguments...]
  6. COMMANDS:
  7. help, h Shows a list of commands or help for one command
  8. GLOBAL OPTIONS:
  9. --lang value language for the greeting (default: "english")
  10. --help, -h show help (default: false)

存入变量

除了通过c.Type(name)来获取选项的值,我们还可以将选项存到某个预先定义好的变量中。只需要设置Destination字段为变量的地址即可:

  1. func main() {
  2. var language string
  3. app := &cli.App{
  4. Flags: []cli.Flag{
  5. &cli.StringFlag{
  6. Name: "lang",
  7. Value: "english",
  8. Usage: "language for the greeting",
  9. Destination: &language,
  10. },
  11. },
  12. Action: func(c *cli.Context) error {
  13. name := "world"
  14. if c.NArg() > 0 {
  15. name = c.Args().Get(0)
  16. }
  17. if language == "english" {
  18. fmt.Println("hello", name)
  19. } else {
  20. fmt.Println("你好", name)
  21. }
  22. return nil
  23. },
  24. }
  25. err := app.Run(os.Args)
  26. if err != nil {
  27. log.Fatal(err)
  28. }
  29. }

与上面的程序效果是一样的。

占位值

cli可以在Usage字段中为选项设置占位值,占位值通过反引号 ` 包围。只有第一个生效,其他的维持不变。占位值有助于生成易于理解的帮助信息:

  1. func main() {
  2. app := & cli.App{
  3. Flags : []cli.Flag {
  4. &cli.StringFlag{
  5. Name:"config",
  6. Usage: "Load configuration from `FILE`",
  7. },
  8. },
  9. }
  10. err := app.Run(os.Args)
  11. if err != nil {
  12. log.Fatal(err)
  13. }
  14. }

设置占位值之后,帮助信息中,该占位值会显示在对应的选项后面,对短选项也是有效的:

  1. $ go build -o placeholder
  2. $ ./placeholder --help
  3. NAME:
  4. placeholder - A new cli application
  5. USAGE:
  6. placeholder [global options] command [command options] [arguments...]
  7. COMMANDS:
  8. help, h Shows a list of commands or help for one command
  9. GLOBAL OPTIONS:
  10. --config FILE Load configuration from FILE
  11. --help, -h show help (default: false)

别名

选项可以设置多个别名,设置对应选项的Aliases字段即可:

  1. func main() {
  2. app := &cli.App{
  3. Flags: []cli.Flag{
  4. &cli.StringFlag{
  5. Name: "lang",
  6. Aliases: []string{"language", "l"},
  7. Value: "english",
  8. Usage: "language for the greeting",
  9. },
  10. },
  11. Action: func(c *cli.Context) error {
  12. name := "world"
  13. if c.NArg() > 0 {
  14. name = c.Args().Get(0)
  15. }
  16. if c.String("lang") == "english" {
  17. fmt.Println("hello", name)
  18. } else {
  19. fmt.Println("你好", name)
  20. }
  21. return nil
  22. },
  23. }
  24. err := app.Run(os.Args)
  25. if err != nil {
  26. log.Fatal(err)
  27. }
  28. }

使用--lang chinese--language chinese-l chinese效果是一样的。如果通过不同的名称指定同一个选项,会报错:

  1. $ go build -o aliase
  2. $ ./aliase --lang chinese
  3. 你好 world
  4. $ ./aliase --language chinese
  5. 你好 world
  6. $ ./aliase -l chinese
  7. 你好 world
  8. $ ./aliase -l chinese --lang chinese
  9. Cannot use two forms of the same flag: l lang

环境变量

除了通过执行程序时手动指定命令行选项,我们还可以读取指定的环境变量作为选项的值。只需要将环境变量的名字设置到选项对象的EnvVars字段即可。可以指定多个环境变量名字,cli会依次查找,第一个有值的环境变量会被使用。

  1. func main() {
  2. app := &cli.App{
  3. Flags: []cli.Flag{
  4. &cli.StringFlag{
  5. Name: "lang",
  6. Value: "english",
  7. Usage: "language for the greeting",
  8. EnvVars: []string{"APP_LANG", "SYSTEM_LANG"},
  9. },
  10. },
  11. Action: func(c *cli.Context) error {
  12. if c.String("lang") == "english" {
  13. fmt.Println("hello")
  14. } else {
  15. fmt.Println("你好")
  16. }
  17. return nil
  18. },
  19. }
  20. err := app.Run(os.Args)
  21. if err != nil {
  22. log.Fatal(err)
  23. }
  24. }

编译、运行:

  1. $ go build -o env
  2. $ APP_LANG=chinese ./env
  3. 你好

文件

cli还支持从文件中读取选项的值,设置选项对象的FilePath字段为文件路径:

  1. func main() {
  2. app := &cli.App{
  3. Flags: []cli.Flag{
  4. &cli.StringFlag{
  5. Name: "lang",
  6. Value: "english",
  7. Usage: "language for the greeting",
  8. FilePath: "./lang.txt",
  9. },
  10. },
  11. Action: func(c *cli.Context) error {
  12. if c.String("lang") == "english" {
  13. fmt.Println("hello")
  14. } else {
  15. fmt.Println("你好")
  16. }
  17. return nil
  18. },
  19. }
  20. err := app.Run(os.Args)
  21. if err != nil {
  22. log.Fatal(err)
  23. }
  24. }

main.go同级目录创建一个lang.txt,输入内容chinese。然后编译运行程序:

  1. $ go build -o file
  2. $ ./file
  3. 你好

cli还支持从YAML/JSON/TOML等配置文件中读取选项值,这里就不一一介绍了。

选项优先级

上面我们介绍了几种设置选项值的方式,如果同时有多个方式生效,按照下面的优先级从高到低设置:

  • 用户指定的命令行选项值;
  • 环境变量;
  • 配置文件;
  • 选项的默认值。

组合短选项

我们时常会遇到有多个短选项的情况。例如 linux 命令ls -a -l,可以简写为ls -alcli也支持短选项合写,只需要设置cli.AppUseShortOptionHandling字段为true即可:

  1. func main() {
  2. app := &cli.App{
  3. UseShortOptionHandling: true,
  4. Commands: []*cli.Command{
  5. {
  6. Name: "short",
  7. Usage: "complete a task on the list",
  8. Flags: []cli.Flag{
  9. &cli.BoolFlag{Name: "serve", Aliases: []string{"s"}},
  10. &cli.BoolFlag{Name: "option", Aliases: []string{"o"}},
  11. &cli.BoolFlag{Name: "message", Aliases: []string{"m"}},
  12. },
  13. Action: func(c *cli.Context) error {
  14. fmt.Println("serve:", c.Bool("serve"))
  15. fmt.Println("option:", c.Bool("option"))
  16. fmt.Println("message:", c.Bool("message"))
  17. return nil
  18. },
  19. },
  20. },
  21. }
  22. err := app.Run(os.Args)
  23. if err != nil {
  24. log.Fatal(err)
  25. }
  26. }

编译运行:

  1. $ go build -o short
  2. $ ./short short -som "some message"
  3. serve: true
  4. option: true
  5. message: true

需要特别注意一点,设置UseShortOptionHandlingtrue之后,我们不能再通过-指定选项了,这样会产生歧义。例如-langcli不知道应该解释为l/a/n/g 4 个选项还是lang 1 个。--还是有效的。

必要选项

如果将选项的Required字段设置为true,那么该选项就是必要选项。必要选项必须指定,否则会报错:

  1. func main() {
  2. app := &cli.App{
  3. Flags: []cli.Flag{
  4. &cli.StringFlag{
  5. Name: "lang",
  6. Value: "english",
  7. Usage: "language for the greeting",
  8. Required: true,
  9. },
  10. },
  11. Action: func(c *cli.Context) error {
  12. if c.String("lang") == "english" {
  13. fmt.Println("hello")
  14. } else {
  15. fmt.Println("你好")
  16. }
  17. return nil
  18. },
  19. }
  20. err := app.Run(os.Args)
  21. if err != nil {
  22. log.Fatal(err)
  23. }
  24. }

不指定选项lang运行:

  1. $ ./required
  2. 2020/06/23 22:11:32 Required flag "lang" not set

帮助文本中的默认值

默认情况下,帮助文本中选项的默认值显示为Value字段值。有些时候,Value并不是实际的默认值。这时,我们可以通过DefaultText设置:

  1. func main() {
  2. app := &cli.App{
  3. Flags: []cli.Flag{
  4. &cli.IntFlag{
  5. Name: "port",
  6. Value: 0,
  7. Usage: "Use a randomized port",
  8. DefaultText :"random",
  9. },
  10. },
  11. }
  12. err := app.Run(os.Args)
  13. if err != nil {
  14. log.Fatal(err)
  15. }
  16. }

上面代码逻辑中,如果Value设置为 0 就随机一个端口,这时帮助信息中default: 0就容易产生误解了。通过DefaultText可以避免这种情况:

  1. $ go build -o default-text
  2. $ ./default-text --help
  3. NAME:
  4. default-text - A new cli application
  5. USAGE:
  6. default-text [global options] command [command options] [arguments...]
  7. COMMANDS:
  8. help, h Shows a list of commands or help for one command
  9. GLOBAL OPTIONS:
  10. --port value Use a randomized port (default: random)
  11. --help, -h show help (default: false)

子命令

子命令使命令行程序有更好的组织性。git有大量的命令,很多以某个命令下的子命令存在。例如git remote命令下有add/rename/remove等子命令,git submodule下有add/status/init/update等子命令。

cli通过设置cli.AppCommands字段添加命令,设置各个命令的SubCommands字段,即可添加子命令。非常方便!

  1. func main() {
  2. app := &cli.App{
  3. Commands: []*cli.Command{
  4. {
  5. Name: "add",
  6. Aliases: []string{"a"},
  7. Usage: "add a task to the list",
  8. Action: func(c *cli.Context) error {
  9. fmt.Println("added task: ", c.Args().First())
  10. return nil
  11. },
  12. },
  13. {
  14. Name: "complete",
  15. Aliases: []string{"c"},
  16. Usage: "complete a task on the list",
  17. Action: func(c *cli.Context) error {
  18. fmt.Println("completed task: ", c.Args().First())
  19. return nil
  20. },
  21. },
  22. {
  23. Name: "template",
  24. Aliases: []string{"t"},
  25. Usage: "options for task templates",
  26. Subcommands: []*cli.Command{
  27. {
  28. Name: "add",
  29. Usage: "add a new template",
  30. Action: func(c *cli.Context) error {
  31. fmt.Println("new task template: ", c.Args().First())
  32. return nil
  33. },
  34. },
  35. {
  36. Name: "remove",
  37. Usage: "remove an existing template",
  38. Action: func(c *cli.Context) error {
  39. fmt.Println("removed task template: ", c.Args().First())
  40. return nil
  41. },
  42. },
  43. },
  44. },
  45. },
  46. }
  47. err := app.Run(os.Args)
  48. if err != nil {
  49. log.Fatal(err)
  50. }
  51. }

上面定义了 3 个命令add/complete/templatetemplate命令定义了 2 个子命令add/remove。编译、运行:

  1. $ go build -o subcommand
  2. $ ./subcommand add dating
  3. added task: dating
  4. $ ./subcommand complete dating
  5. completed task: dating
  6. $ ./subcommand template add alarm
  7. new task template: alarm
  8. $ ./subcommand template remove alarm
  9. removed task template: alarm

注意一点,子命令默认不显示在帮助信息中,需要显式调用子命令所属命令的帮助(./subcommand template --help):

  1. $ ./subcommand --help
  2. NAME:
  3. subcommand - A new cli application
  4. USAGE:
  5. subcommand [global options] command [command options] [arguments...]
  6. COMMANDS:
  7. add, a add a task to the list
  8. complete, c complete a task on the list
  9. template, t options for task templates
  10. help, h Shows a list of commands or help for one command
  11. GLOBAL OPTIONS:
  12. --help, -h show help (default: false)
  13. $ ./subcommand template --help
  14. NAME:
  15. subcommand template - options for task templates
  16. USAGE:
  17. subcommand template command [command options] [arguments...]
  18. COMMANDS:
  19. add add a new template
  20. remove remove an existing template
  21. help, h Shows a list of commands or help for one command
  22. OPTIONS:
  23. --help, -h show help (default: false)

分类

在子命令数量很多的时候,可以设置Category字段为它们分类,在帮助信息中会将相同分类的命令放在一起展示:

  1. func main() {
  2. app := &cli.App{
  3. Commands: []*cli.Command{
  4. {
  5. Name: "noop",
  6. Usage: "Usage for noop",
  7. },
  8. {
  9. Name: "add",
  10. Category: "template",
  11. Usage: "Usage for add",
  12. },
  13. {
  14. Name: "remove",
  15. Category: "template",
  16. Usage: "Usage for remove",
  17. },
  18. },
  19. }
  20. err := app.Run(os.Args)
  21. if err != nil {
  22. log.Fatal(err)
  23. }
  24. }

编译、运行:

  1. $ go build -o categories
  2. $ ./categories --help
  3. NAME:
  4. categories - A new cli application
  5. USAGE:
  6. categories [global options] command [command options] [arguments...]
  7. COMMANDS:
  8. noop Usage for noop
  9. help, h Shows a list of commands or help for one command
  10. template:
  11. add Usage for add
  12. remove Usage for remove
  13. GLOBAL OPTIONS:
  14. --help, -h show help (default: false)

看上面的COMMANDS部分。

自定义帮助信息

cli中所有的帮助信息文本都可以自定义,整个应用的帮助信息模板通过AppHelpTemplate指定。命令的帮助信息模板通过CommandHelpTemplate设置,子命令的帮助信息模板通过SubcommandHelpTemplate设置。甚至可以通过覆盖cli.HelpPrinter这个函数自己实现帮助信息输出。下面程序在默认的帮助信息后添加个人网站和微信信息:

  1. func main() {
  2. cli.AppHelpTemplate = fmt.Sprintf(`%s
  3. WEBSITE: http://go-quiz.github.io
  4. WECHAT: GoUpUp`, cli.AppHelpTemplate)
  5. (&cli.App{}).Run(os.Args)
  6. }

编译运行:

  1. $ go build -o help
  2. $ ./help --help
  3. NAME:
  4. help - A new cli application
  5. USAGE:
  6. help [global options] command [command options] [arguments...]
  7. COMMANDS:
  8. help, h Shows a list of commands or help for one command
  9. GLOBAL OPTIONS:
  10. --help, -h show help (default: false)
  11. WEBSITE: http://go-quiz.github.io
  12. WECHAT: GoUpUp

我们还可以改写整个模板:

  1. func main() {
  2. cli.AppHelpTemplate = `NAME:
  3. {{.Name}} - {{.Usage}}
  4. USAGE:
  5. {{.HelpName}} {{if .VisibleFlags}}[global options]{{end}}{{if .Commands}} command [command options]{{end}} {{if .ArgsUsage}}{{.ArgsUsage}}{{else}}[arguments...]{{end}}
  6. {{if len .Authors}}
  7. AUTHOR:
  8. {{range .Authors}}{{ . }}{{end}}
  9. {{end}}{{if .Commands}}
  10. COMMANDS:
  11. {{range .Commands}}{{if not .HideHelp}} {{join .Names ", "}}{{ "\t"}}{{.Usage}}{{ "\n" }}{{end}}{{end}}{{end}}{{if .VisibleFlags}}
  12. GLOBAL OPTIONS:
  13. {{range .VisibleFlags}}{{.}}
  14. {{end}}{{end}}{{if .Copyright }}
  15. COPYRIGHT:
  16. {{.Copyright}}
  17. {{end}}{{if .Version}}
  18. VERSION:
  19. {{.Version}}
  20. {{end}}
  21. `
  22. app := &cli.App{
  23. Authors: []*cli.Author{
  24. {
  25. Name: "dj",
  26. Email: "go-quiz@126.com",
  27. },
  28. },
  29. }
  30. app.Run(os.Args)
  31. }

{{.XXX}}其中XXX对应cli.App{}结构中设置的字段,例如上面Authors

  1. $ ./help --help
  2. NAME:
  3. help - A new cli application
  4. USAGE:
  5. help [global options] command [command options] [arguments...]
  6. AUTHOR:
  7. dj <go-quiz@126.com>
  8. COMMANDS:
  9. help, h Shows a list of commands or help for one command
  10. GLOBAL OPTIONS:
  11. --help, -h show help (default: false)

注意观察AUTHOR部分。

通过覆盖HelpPrinter,我们能自己输出帮助信息:

  1. func main() {
  2. cli.HelpPrinter = func(w io.Writer, templ string, data interface{}) {
  3. fmt.Println("Simple help!")
  4. }
  5. (&cli.App{}).Run(os.Args)
  6. }

编译、运行:

  1. $ ./help --help
  2. Simple help!

内置选项

帮助选项

默认情况下,帮助选项为--help/-h。我们可以通过cli.HelpFlag字段设置:

  1. func main() {
  2. cli.HelpFlag = &cli.BoolFlag{
  3. Name: "haaaaalp",
  4. Aliases: []string{"halp"},
  5. Usage: "HALP",
  6. }
  7. (&cli.App{}).Run(os.Args)
  8. }

查看帮助:

  1. $ go run main.go --halp
  2. NAME:
  3. main.exe - A new cli application
  4. USAGE:
  5. main.exe [global options] command [command options] [arguments...]
  6. COMMANDS:
  7. help, h Shows a list of commands or help for one command
  8. GLOBAL OPTIONS:
  9. --haaaaalp, --halp HALP (default: false)

版本选项

默认版本选项-v/--version输出应用的版本信息。我们可以通过cli.VersionFlag设置版本选项 :

  1. func main() {
  2. cli.VersionFlag = &cli.BoolFlag{
  3. Name: "print-version",
  4. Aliases: []string{"V"},
  5. Usage: "print only the version",
  6. }
  7. app := &cli.App{
  8. Name: "version",
  9. Version: "v1.0.0",
  10. }
  11. app.Run(os.Args)
  12. }

这样就可以通过指定--print-version/-V输出版本信息了。运行:

  1. $ go run main.go --print-version
  2. version version v1.0.0
  3. $ go run main.go -V
  4. version version v1.0.0

我们还可以通过设置cli.VersionPrinter字段控制版本信息的输出内容:

  1. const (
  2. Revision = "0cebd6e32a4e7094bbdbf150a1c2ffa56c34e91b"
  3. )
  4. func main() {
  5. cli.VersionPrinter = func(c *cli.Context) {
  6. fmt.Printf("version=%s revision=%s\n", c.App.Version, Revision)
  7. }
  8. app := &cli.App{
  9. Name: "version",
  10. Version: "v1.0.0",
  11. }
  12. app.Run(os.Args)
  13. }

上面程序同时输出版本号和git提交的 SHA 值:

  1. $ go run main.go -v
  2. version=v1.0.0 revision=0cebd6e32a4e7094bbdbf150a1c2ffa56c34e91b

总结

cli非常灵活,只需要设置cli.App的字段值即可实现相应的功能,不需要额外记忆函数、方法。另外cli还支持 Bash 自动补全的功能,对 zsh 的支持也比较好,感兴趣可自行探索。

大家如果发现好玩、好用的 Go 语言库,欢迎到 Go 每日一库 GitHub 上提交 issue😄

参考

  1. cli GitHub:https://github.com/urfave/cli
  2. Go 每日一库之 cobra:https://go-quiz.github.io/2020/01/17/godailylib/cobra/
  3. Go 每日一库 GitHub:https://github.com/go-quiz/go-daily-lib