乐趣区

Go-每日一库之-cli

## 简介

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

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

快速使用

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

$ mkdir cli && cd cli
$ go mod init github.com/darjun/go-daily-lib/cli

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

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

使用:

package main

import (
  "fmt"
  "log"
  "os"

  "github.com/urfave/cli/v2"
)

func main() {
  app := &cli.App{
    Name:  "hello",
    Usage: "hello world example",
    Action: func(c *cli.Context) error {fmt.Println("hello world")
      return nil
    },
  }

  err := app.Run(os.Args)
  if err != nil {log.Fatal(err)
  }
}

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

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

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

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

$ go build -o hello
$ ./hello
hello world

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

$ ./hello --help
NAME:
   hello - hello world example

USAGE:
   hello [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.Context 的相关方法我们可以获取传给命令行的参数信息:

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

示例:

func main() {
  app := &cli.App{
    Name:  "arguments",
    Usage: "arguments example",
    Action: func(c *cli.Context) error {for i := 0; i < c.NArg(); i++ {fmt.Printf("%d: %s\n", i+1, c.Args().Get(i))
      }
      return nil
    },
  }

  err := app.Run(os.Args)
  if err != nil {log.Fatal(err)
  }
}

这里只是简单输出:

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

选项

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

func main() {
  app := &cli.App{Flags: []cli.Flag{
      &cli.StringFlag{
        Name:  "lang",
        Value: "english",
        Usage: "language for the greeting",
      },
    },
    Action: func(c *cli.Context) error {
      name := "world"
      if c.NArg() > 0 {name = c.Args().Get(0)
      }

      if c.String("lang") == "english" {fmt.Println("hello", name)
      } else {fmt.Println("你好", name)
      }
      return nil
    },
  }

  err := app.Run(os.Args)
  if err != nil {log.Fatal(err)
  }
}

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

$ go build -o flags

# 默认调用
$ ./flags
hello world

# 设置非英语
$ ./flags --lang chinese
你好 world

# 传入参数作为人名
$ ./flags --lang chinese dj
你好 dj

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

$ ./flags --help
NAME:
   flags - A new cli application

USAGE:
   flags [global options] command [command options] [arguments...]

COMMANDS:
   help, h  Shows a list of commands or help for one command

GLOBAL OPTIONS:
   --lang value  language for the greeting (default: "english")
   --help, -h    show help (default: false)

存入变量

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

func main() {
  var language string

  app := &cli.App{Flags: []cli.Flag{
      &cli.StringFlag{
        Name:        "lang",
        Value:       "english",
        Usage:       "language for the greeting",
        Destination: &language,
      },
    },
    Action: func(c *cli.Context) error {
      name := "world"
      if c.NArg() > 0 {name = c.Args().Get(0)
      }

      if language == "english" {fmt.Println("hello", name)
      } else {fmt.Println("你好", name)
      }
      return nil
    },
  }

  err := app.Run(os.Args)
  if err != nil {log.Fatal(err)
  }
}

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

占位值

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

func main() {
  app := & cli.App{Flags : []cli.Flag {
      &cli.StringFlag{
        Name:"config",
        Usage: "Load configuration from `FILE`",
      },
    },
  }

  err := app.Run(os.Args)
  if err != nil {log.Fatal(err)
  }
}

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

$ go build -o placeholder
$ ./placeholder --help
NAME:
   placeholder - A new cli application

USAGE:
   placeholder [global options] command [command options] [arguments...]

COMMANDS:
   help, h  Shows a list of commands or help for one command

GLOBAL OPTIONS:
   --config FILE  Load configuration from FILE
   --help, -h     show help (default: false)

别名

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

func main() {
  app := &cli.App{Flags: []cli.Flag{
      &cli.StringFlag{
        Name:    "lang",
        Aliases: []string{"language", "l"},
        Value:   "english",
        Usage:   "language for the greeting",
      },
    },
    Action: func(c *cli.Context) error {
      name := "world"
      if c.NArg() > 0 {name = c.Args().Get(0)
      }

      if c.String("lang") == "english" {fmt.Println("hello", name)
      } else {fmt.Println("你好", name)
      }
      return nil
    },
  }

  err := app.Run(os.Args)
  if err != nil {log.Fatal(err)
  }
}

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

$ go build -o aliase
$ ./aliase --lang chinese
你好 world
$ ./aliase --language chinese
你好 world
$ ./aliase -l chinese
你好 world
$ ./aliase -l chinese --lang chinese
Cannot use two forms of the same flag: l lang

环境变量

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

func main() {
  app := &cli.App{Flags: []cli.Flag{
      &cli.StringFlag{
        Name:    "lang",
        Value:   "english",
        Usage:   "language for the greeting",
        EnvVars: []string{"APP_LANG", "SYSTEM_LANG"},
      },
    },
    Action: func(c *cli.Context) error {if c.String("lang") == "english" {fmt.Println("hello")
      } else {fmt.Println("你好")
      }
      return nil
    },
  }

  err := app.Run(os.Args)
  if err != nil {log.Fatal(err)
  }
}

编译、运行:

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

文件

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

func main() {
  app := &cli.App{Flags: []cli.Flag{
      &cli.StringFlag{
        Name:     "lang",
        Value:    "english",
        Usage:    "language for the greeting",
        FilePath: "./lang.txt",
      },
    },
    Action: func(c *cli.Context) error {if c.String("lang") == "english" {fmt.Println("hello")
      } else {fmt.Println("你好")
      }
      return nil
    },
  }

  err := app.Run(os.Args)
  if err != nil {log.Fatal(err)
  }
}

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

$ go build -o file
$ ./file
你好

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

选项优先级

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

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

组合短选项

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

func main() {
  app := &cli.App{
    UseShortOptionHandling: true,
    Commands: []*cli.Command{
      {
        Name:  "short",
        Usage: "complete a task on the list",
        Flags: []cli.Flag{&cli.BoolFlag{Name: "serve", Aliases: []string{"s"}},
          &cli.BoolFlag{Name: "option", Aliases: []string{"o"}},
          &cli.BoolFlag{Name: "message", Aliases: []string{"m"}},
        },
        Action: func(c *cli.Context) error {fmt.Println("serve:", c.Bool("serve"))
          fmt.Println("option:", c.Bool("option"))
          fmt.Println("message:", c.Bool("message"))
          return nil
        },
      },
    },
  }

  err := app.Run(os.Args)
  if err != nil {log.Fatal(err)
  }
}

编译运行:

$ go build -o short
$ ./short short -som "some message"
serve: true
option: true
message: true

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

必要选项

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

func main() {
  app := &cli.App{Flags: []cli.Flag{
      &cli.StringFlag{
        Name:     "lang",
        Value:    "english",
        Usage:    "language for the greeting",
        Required: true,
      },
    },
    Action: func(c *cli.Context) error {if c.String("lang") == "english" {fmt.Println("hello")
      } else {fmt.Println("你好")
      }
      return nil
    },
  }

  err := app.Run(os.Args)
  if err != nil {log.Fatal(err)
  }
}

不指定选项 lang 运行:

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

帮助文本中的默认值

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

func main() {
  app := &cli.App{Flags: []cli.Flag{
      &cli.IntFlag{
        Name:     "port",
        Value:    0,
        Usage:    "Use a randomized port",
        DefaultText :"random",
      },
    },
  }

  err := app.Run(os.Args)
  if err != nil {log.Fatal(err)
  }
}

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

$ go build -o default-text
$ ./default-text --help
NAME:
   default-text - A new cli application

USAGE:
   default-text [global options] command [command options] [arguments...]

COMMANDS:
   help, h  Shows a list of commands or help for one command

GLOBAL OPTIONS:
   --port value  Use a randomized port (default: random)
   --help, -h    show help (default: false)

子命令

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

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

func main() {
  app := &cli.App{Commands: []*cli.Command{
      {
        Name:    "add",
        Aliases: []string{"a"},
        Usage:   "add a task to the list",
        Action: func(c *cli.Context) error {fmt.Println("added task:", c.Args().First())
          return nil
        },
      },
      {
        Name:    "complete",
        Aliases: []string{"c"},
        Usage:   "complete a task on the list",
        Action: func(c *cli.Context) error {fmt.Println("completed task:", c.Args().First())
          return nil
        },
      },
      {
        Name:    "template",
        Aliases: []string{"t"},
        Usage:   "options for task templates",
        Subcommands: []*cli.Command{
          {
            Name:  "add",
            Usage: "add a new template",
            Action: func(c *cli.Context) error {fmt.Println("new task template:", c.Args().First())
              return nil
            },
          },
          {
            Name:  "remove",
            Usage: "remove an existing template",
            Action: func(c *cli.Context) error {fmt.Println("removed task template:", c.Args().First())
              return nil
            },
          },
        },
      },
    },
  }

  err := app.Run(os.Args)
  if err != nil {log.Fatal(err)
  }
}

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

$ go build -o subcommand
$ ./subcommand add dating
added task:  dating
$ ./subcommand complete dating
completed task:  dating
$ ./subcommand template add alarm
new task template:  alarm
$ ./subcommand template remove alarm
removed task template:  alarm

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

$ ./subcommand --help
NAME:
   subcommand - A new cli application

USAGE:
   subcommand [global options] command [command options] [arguments...]

COMMANDS:
   add, a       add a task to the list
   complete, c  complete a task on the list
   template, t  options for task templates
   help, h      Shows a list of commands or help for one command

GLOBAL OPTIONS:
   --help, -h  show help (default: false)

$ ./subcommand template --help
NAME:
   subcommand template - options for task templates

USAGE:
   subcommand template command [command options] [arguments...]

COMMANDS:
   add      add a new template
   remove   remove an existing template
   help, h  Shows a list of commands or help for one command

OPTIONS:
   --help, -h  show help (default: false)

分类

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

func main() {
  app := &cli.App{Commands: []*cli.Command{
      {
        Name:  "noop",
        Usage: "Usage for noop",
      },
      {
        Name:     "add",
        Category: "template",
        Usage:    "Usage for add",
      },
      {
        Name:     "remove",
        Category: "template",
        Usage:    "Usage for remove",
      },
    },
  }

  err := app.Run(os.Args)
  if err != nil {log.Fatal(err)
  }
}

编译、运行:

$ go build -o categories
$ ./categories --help
NAME:
   categories - A new cli application

USAGE:
   categories [global options] command [command options] [arguments...]

COMMANDS:
   noop     Usage for noop
   help, h  Shows a list of commands or help for one command
   template:
     add     Usage for add
     remove  Usage for remove

GLOBAL OPTIONS:
   --help, -h  show help (default: false)

看上面的 COMMANDS 部分。

自定义帮助信息

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

func main() {
  cli.AppHelpTemplate = fmt.Sprintf(`%s

WEBSITE: http://darjun.github.io

WECHAT: GoUpUp`, cli.AppHelpTemplate)

  (&cli.App{}).Run(os.Args)
}

编译运行:

$ go build -o help
$ ./help --help
NAME:
   help - A new cli application

USAGE:
   help [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)


WEBSITE: http://darjun.github.io

WECHAT: GoUpUp

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

func main() {
  cli.AppHelpTemplate = `NAME:
  {{.Name}} - {{.Usage}}
USAGE:
  {{.HelpName}} {{if .VisibleFlags}}[global options]{{end}}{{if .Commands}} command [command options]{{end}} {{if .ArgsUsage}}{{.ArgsUsage}}{{else}}[arguments...]{{end}}
  {{if len .Authors}}
AUTHOR:
  {{range .Authors}}{{.}}{{end}}
  {{end}}{{if .Commands}}
COMMANDS:
 {{range .Commands}}{{if not .HideHelp}}   {{join .Names ","}}{{"\t"}}{{.Usage}}{{"\n"}}{{end}}{{end}}{{end}}{{if .VisibleFlags}}
GLOBAL OPTIONS:
  {{range .VisibleFlags}}{{.}}
  {{end}}{{end}}{{if .Copyright}}
COPYRIGHT:
  {{.Copyright}}
  {{end}}{{if .Version}}
VERSION:
  {{.Version}}
{{end}}
 `

  app := &cli.App{Authors: []*cli.Author{
      {
        Name:  "dj",
        Email: "darjun@126.com",
      },
    },
  }
  app.Run(os.Args)
}

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

$ ./help --help
NAME:
  help - A new cli application
USAGE:
  help [global options] command [command options] [arguments...]

AUTHOR:
  dj <darjun@126.com>

COMMANDS:
    help, h  Shows a list of commands or help for one command

GLOBAL OPTIONS:
  --help, -h  show help (default: false)

注意观察 AUTHOR 部分。

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

func main() {cli.HelpPrinter = func(w io.Writer, templ string, data interface{}) {fmt.Println("Simple help!")
  }

  (&cli.App{}).Run(os.Args)
}

编译、运行:

$ ./help --help
Simple help!

内置选项

帮助选项

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

func main() {
  cli.HelpFlag = &cli.BoolFlag{
    Name:    "haaaaalp",
    Aliases: []string{"halp"},
    Usage:   "HALP",
  }

  (&cli.App{}).Run(os.Args)
}

查看帮助:

$ go run main.go --halp
NAME:
   main.exe - A new cli application

USAGE:
   main.exe [global options] command [command options] [arguments...]

COMMANDS:
   help, h  Shows a list of commands or help for one command

GLOBAL OPTIONS:
   --haaaaalp, --halp  HALP (default: false)

版本选项

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

func main() {
  cli.VersionFlag = &cli.BoolFlag{
    Name:    "print-version",
    Aliases: []string{"V"},
    Usage:   "print only the version",
  }

  app := &cli.App{
    Name:    "version",
    Version: "v1.0.0",
  }
  app.Run(os.Args)
}

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

$ go run main.go --print-version
version version v1.0.0

$ go run main.go -V
version version v1.0.0

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

const (Revision = "0cebd6e32a4e7094bbdbf150a1c2ffa56c34e91b")

func main() {cli.VersionPrinter = func(c *cli.Context) {fmt.Printf("version=%s revision=%s\n", c.App.Version, Revision)
  }

  app := &cli.App{
    Name:    "version",
    Version: "v1.0.0",
  }
  app.Run(os.Args)
}

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

$ go run main.go -v
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://darjun.github.io/2020/01/17/godailylib/cobra/
  3. Go 每日一库 GitHub:https://github.com/darjun/go-daily-lib

我的博客:https://darjun.github.io

欢迎关注我的微信公众号【GoUpUp】,共同学习,一起进步~

退出移动版