14

Go 每日一库之 cli

 4 years ago
source link: https://studygolang.com/articles/29498
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

## 简介

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: "[email protected]",
      },
    },
  }
  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 <[email protected]>

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:smile:

参考

  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】,共同学习,一起进步~

bMFFVjA.jpg!web

欢迎关注我们的微信公众号,每天学习Go知识

FveQFjN.jpg!web

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK