大家好,我是极客老墨。

上一篇我们写了个小工具——一个简单的命令行计算器,需要从命令行读参数, 我们直接通过 os.Args 来解析命令行参数,这种方式太底层、太原始了,非常容易出错。

说实话,Go 虽然内置了 flag 包,但用起来仍然不是很方便。想要做个像样的 CLI 工具,光靠 flag 远远不够。

今天我们就来聊聊如何用 Go 社区最受欢迎的 urfave/cli 框架,打造真正的极客工具。

为什么不用 flag 包?

Go 标准库的 flag 包确实能解析参数,但问题是:

 1// 用 flag 包写个简单的工具
 2var name = flag.String("name", "", "your name")
 3var age = flag.Int("age", 0, "your age")
 4
 5flag.Parse()
 6
 7if *name == "" {
 8    fmt.Println("name is required")
 9    os.Exit(1)
10}

看到没?每个参数都要手动验证,帮助信息要自己拼,子命令?不存在的,得自己实现。

写个小工具还行,但要做个像 gitdocker 那样的专业 CLI,光靠 flag 真的会疯。

CLI 框架选择

Go 生态里有几个流行的 CLI 框架:

  • cobra:Kubernetes、Hugo 都在用,功能强大但有点重
  • urfave/cli:轻量、简单、够用,老墨的首选
  • survey:专门做交互式 CLI 的,适合问答式工具

今天主要聊 urfave/cli,因为它:

  • 上手快,5 分钟就能写出能用的工具
  • 功能全,命令、子命令、选项、帮助信息都有
  • 代码简洁,不像 cobra 那么啰嗦

官方文档:https://cli.urfave.org/v2/ GitHub:https://github.com/urfave/cli

5 分钟写个 Hello World

先来个最简单的例子,感受一下 urfave/cli 的威力。

初始化项目

1mkdir my-cli
2cd my-cli
3go mod init my-cli
4
5# 安装 urfave/cli v2
6go get github.com/urfave/cli/v2

写代码

创建 main.go

 1package main
 2
 3import (
 4    "fmt"
 5    "os"
 6
 7    "github.com/urfave/cli/v2"
 8)
 9
10func main() {
11    app := cli.NewApp()
12    app.Name = "my-cli"
13    app.Usage = "我的第一个 CLI 工具"
14    app.Version = "0.1.0"
15
16    err := app.Run(os.Args)
17    if err != nil {
18        fmt.Printf("Error: %v\n", err)
19        os.Exit(1)
20    }
21}

就这么简单!运行一下:

 1$ go run main.go
 2NAME:
 3   my-cli - 我的第一个 CLI 工具
 4
 5USAGE:
 6   my-cli [global options] command [command options] [arguments...]
 7
 8VERSION:
 9   0.1.0
10
11COMMANDS:
12   help, h  Shows a list of commands or help for one command
13
14GLOBAL OPTIONS:
15   --help, -h     show help
16   --version, -v  print the version

看到没?帮助信息、版本号、命令列表,全都自动生成了。这要是用 flag 包,得写多少代码?

添加全局Flag

添加全局选项也很简单,在运行程序之前添加代码:

 1var Verbose bool
 2
 3func main() {
 4	// ...
 5	// 全局参数
 6	cliApp.Flags = append(cliApp.Flags, []cli.Flag{
 7	   &cli.BoolFlag{Name: "i", Usage: "show verbose info", Required: false, Destination: &Verbose}, // destination 可以将设置的参数绑定到变量,后续可以直接使用
 8	}...)
 9	// ...
10}

这里我们添加了一个 -i 的全局 bool 选项,用来表示是否输出详细信息。Required 表示是否是必须选项,Destination 这用于将选项值绑定到指定的变量上,这样通过变量即可获得该选项的值。

选项 Flag 有多种类型,包括 StringFlagBoolFlagIntFlag 等等,它们都实现了顶层接口 Flag

再次运行程序,可以通过帮助信息看到全局选项已经添加成功。

1GLOBAL OPTIONS:
2   -i             show verbose info (default: false)
3   --help, -h     show help
4   --version, -v  print the version

添加命令

没有命令的程序毫无用处,现在,我们来添加一个命令,实现问好的功能。

cli.App 添加命令是通过 cliApp.Commands 属性实现的,需要向其指定一个 []*Command,我们编写一个方法返回 []*Command,将其返回值赋值给 cliApp,代码如下:

 1func main() {
 2   // ...
 3   // 系统命令
 4   cliApp.Commands = []*cli.Command{sayHelloCmd()}
 5   // ...
 6}
 7
 8func sayHelloCmd() *cli.Command {
 9   return &cli.Command{
10      Name:    "hello",        // 命令名称,执行时需要指定
11      Aliases: []string{"ho"}, // 命令别名,简化名称
12      Usage:   "向您问好,-h 查看更多帮助信息",
13      Flags: []cli.Flag{
14        &cli.StringFlag{Name: "n", Aliases: []string{"name"}, Usage: "您的姓名`NAME`", Required: true},
15	  },
16      Action: func(ctx *cli.Context) error { // 具体命令的执行逻辑
17		name := ctx.String("n")
18		fmt.Println("hello,", name, "!")
19		return nil
20	  },
21	}
22}

这里定义了一个非常简单的命令,cli.Command 结构代表了一个命令:

  • Name 属性表示命令的名称,执行命令时需要输入该名称或者其别名
  • Aliases 定义了命了的别名,可以简化输入,例如上边的命令输入 helloho 是等价的
  • Flags 定义命令的选项,是一个 []cli.Flag 类型,可以定义多个选项,这里我们定义了一个 -n 的选项,类型为 cli.StringFlag 表示字符串Flag,用来输入被问候者的名称
  • Action 定义命令的执行逻辑,是一个 ActionFunc 类型,底层其实是一个 func(*Context) error 函数,*cli.Context 参数表示CLI程序上下文,可以通过它来获取应用和命令的信息

当然,可以使用 cli.Commands 来简化命令集合的定义,它是一个 []*cli.Command 类型表示多个命令的集合。

现在,我们编译代码:

1go build -o cli_demo .

然后执行命令:

1./cli_demo -h

可以看到显示了定义的命令:

1COMMANDS:
2   hello, ho  向您问好,-h 查看更多帮助信息
3   help, h    Shows a list of commands or help for one command

键入 ./cli_demo hello -h 可以查看当前命令的帮助,此时会显示当前命令的子命令、选项等信息。要执行 hello 命令,键入:

1$ ./cli_demo hello -n hank
2hello, hank !

添加子命令

有时候,命令下还可能会有很多子命令,来实现不同的子功能,此时,我们需要用到子命令。

命令支持层层嵌套,cli.Command 类型支持 Subcommands []*Command 属性来嵌套子命令。这里,我们定一个 weather 子命令来问好并报告天气情况,代码如下:

 1var weathers = []string{"sunny", "windy", "cloudy", "rainy"}
 2
 3func main() {
 4	// ...
 5}
 6
 7func sayHelloCmd() *cli.Command {
 8   return &cli.Command{
 9      Name:    "hello",        // 命令名称,执行时需要指定
10      Aliases: []string{"ho"}, // 命令别名,简化名称
11      Usage:   "向您问好,-h 查看更多帮助信息",
12      Flags: []cli.Flag{
13         &cli.StringFlag{Name: "n", Aliases: []string{"name"}, Usage: "您的姓名 `NAME`", Required: true},
14      },
15      Subcommands: cli.Commands{
16         &cli.Command{
17            Name:    "weather",     // 命令名称,执行时需要指定
18            Aliases: []string{"w"}, // 命令别名,简化名称
19            Usage:   "报告天气情况,-h 查看更多帮助信息",
20            Flags: []cli.Flag{},
21            Action: func(ctx *cli.Context) error {
22               name := ctx.String("n")
23               rd := rand.New(rand.NewSource(time.Now().UnixNano()))
24               weatherCmd := weathers[rd.Intn(len(weathers))]
25               fmt.Printf("hello %s, today is a %s day!\n", name, weatherCmd)
26               return nil
27            },
28         },
29      }, // 子命令
30      Action: func(ctx *cli.Context) error { // 具体命令的执行逻辑
31         name := ctx.String("n")
32         fmt.Println("hello,", name, "!")
33         return nil
34      },
35   }
36}

与前边一节的区别是,这里添加了 SubCommands 属性,并定义了 Nameweather 的子命令。

编译运行命令帮助:

 1$ ./cli_demo hello -h
 2NAME:
 3   demo-cli hello - 向您问好,-h 查看更多帮助信息
 4
 5USAGE:
 6   demo-cli hello command [command options] [arguments...]
 7
 8COMMANDS:
 9   weather, w  报告天气情况,-h 查看更多帮助信息
10   help, h     Shows a list of commands or help for one command
11
12OPTIONS:
13   -n NAME, --name NAME  您的姓名 NAME
14   --help, -h            show help

可以看到此时 COMMANDS 显示的是子命令,运行 weather:

1$ ./cli_demo hello -n hank w
2hello hank, today is a cloudy day!

命令分组

命令或者子命令太多,不便于阅读,可以通过 cli.CommandCategory 来指定分组名称,这样可以在帮助信息中归类展示。

现在,我们在添加一个子命令,并将命令分组,代码如下:

 1// ...
 2Subcommands: cli.Commands{
 3   &cli.Command{
 4      Name:    "weather",     // 命令名称,执行时需要指定
 5      Aliases: []string{"w"}, // 命令别名,简化名称
 6      Usage:   "报告天气情况,-h 查看更多帮助信息",
 7      Before: func(context *cli.Context) error {
 8         fmt.Println("sayHello weatherCmd 子命令 Before...")
 9         return nil
10      },
11      Flags: []cli.Flag{},
12      Action: func(ctx *cli.Context) error {
13         name := ctx.String("n")
14         rd := rand.New(rand.NewSource(time.Now().UnixNano()))
15         weatherCmd := weathers[rd.Intn(len(weathers))]
16         fmt.Printf("hello %s, today is a %s day!\n", name, weatherCmd)
17         return nil
18      },
19      Category: "weather",  // 命令分组
20   },
21   &cli.Command{
22      Name:    "complain-weather", // 命令名称,执行时需要指定
23      Aliases: []string{"cw"},     // 命令别名,简化名称
24      Usage:   "Complains the weather today",
25      Before: func(ctx *cli.Context) error {
26         return nil
27      },
28      Flags: []cli.Flag{},
29      Action: func(ctx *cli.Context) error {
30         return nil
31      },
32      Category: "weather",  // 命令分组
33   },
34   // ...

运行帮助,此时命令将分组显示:

1COMMANDS:
2   help, h  Shows a list of commands or help for one command
3   weather:
4     weather, w            报告天气情况,-h 查看更多帮助信息
5     complain-weather, cw  Complains the weather today

生命周期方法

不论是 cli.App 还是 cli.Command 都支持三个生命周期方法:

  • Before BeforeFunc :对于 cli.App,在 cli.Context 准备就绪而且任何命令执行前调用;对于 cli.Command,在 cli.Context 准备就绪而且当前命令和子命令执行前调用
  • After AfterFunc :对于 cli.App ,命令运行后会执行,即使 Action 方法 panic;对于 cli.Command,命令执行完成后调用,即使 Action 方法 panic 也会执行
  • Action ActionFunc :对于 cli.App ,如果没有定义任何命令会执行 Action 方法;对于 cli.Command,当前命令执行时调用

通常,Before 方法用来初始化环境,After 方法可以用于清理资源等。

一个简单的 Before 方法示例如下:

1cliApp.Before = func(ctx *cli.Context) error {
2   fmt.Println("Before app run ...")
3   return nil
4}

Context

cli.Context 表示程序运行的上下文,其定义如下:

1type Context struct {
2   context.Context
3   App           *App
4   Command       *Command
5   shellComplete bool
6   flagSet       *flag.FlagSet
7   parentContext *Context
8}

可见,其内部包含 AppCommand 属性,可以获取 cli.Appcli.Command 的信息,flagSet 属性定义了 cli.Flag 的集合,会在运行时解析并装载当前命令和全局定义的 Flag,由于需要区分不同的 Flag,因此,一个 flagSet 中的选项必须唯一,否则会 panic。也就是说,对于某一个具体命令,app的全局 Flag 不能与其 Flag 重名,而不同的命令间(包括子命令)可以重复。

一般会通过 cli.Context 获取 Flag 的值,比如前边的 hello 命令,要获取 -n 选项传入的名称,可以这样:

1Action: func(ctx *cli.Context) error {
2   name := ctx.String("n")
3   // ...
4   return nil
5},

直接通过 ctx.String("n") 获取 StringFlag 类型的值,其参数为 FlagName 而不是别名 Aliases

老墨踩过的坑

坑 1:Flag 名称冲突

全局 Flag 和命令 Flag 不能重名,否则会 panic:

 1// ❌ 错误:全局和命令都有 -n
 2app.Flags = []cli.Flag{
 3    &cli.StringFlag{Name: "n"},
 4}
 5
 6app.Commands = []*cli.Command{
 7    {
 8        Name: "hello",
 9        Flags: []cli.Flag{
10            &cli.StringFlag{Name: "n"}, // panic!
11        },
12    },
13}

解决方案:不同层级的 Flag 用不同名称,或者只在需要的层级定义。

坑 2:忘记传 Context

子命令想访问父命令的 Flag,结果拿不到:

1// ❌ 错误:子命令拿不到父命令的 Flag
2Action: func(ctx *cli.Context) error {
3    name := ctx.String("name") // 空值!
4    return nil
5},

原因:每个命令都有自己的 Context,要访问父命令的 Flag 需要用 ctx.Parent()

正确做法

1Action: func(ctx *cli.Context) error {
2    name := ctx.String("name")
3    if name == "" && ctx.Parent() != nil {
4        name = ctx.Parent().String("name")
5    }
6    return nil
7},

坑 3:Before 返回错误后 Action 还执行

以为 Before 返回错误就会中断,结果 Action 照样执行:

1// ❌ 错误理解
2Before: func(ctx *cli.Context) error {
3    return errors.New("validation failed") // Action 还会执行!
4},

真相Before 返回错误后,Action 不会执行,但 After 还是会执行。

实战经验

1. 用 Destination 简化代码

不要每次都 ctx.String("name"),直接绑定到变量:

 1var name string
 2
 3app.Flags = []cli.Flag{
 4    &cli.StringFlag{
 5        Name:        "name",
 6        Destination: &name, // 直接绑定
 7    },
 8}
 9
10app.Action = func(ctx *cli.Context) error {
11    fmt.Println(name) // 直接用变量
12    return nil
13}

2. 用 EnvVars 支持环境变量

让工具更灵活,支持环境变量配置:

1&cli.StringFlag{
2    Name:    "token",
3    EnvVars: []string{"MY_CLI_TOKEN"}, // 自动读取环境变量
4},

3. 用 Category 组织命令

命令多了用分类,帮助信息更清晰:

1&cli.Command{
2    Name:     "start",
3    Category: "server", // 分组
4},
5&cli.Command{
6    Name:     "stop",
7    Category: "server",
8},

4. 自定义帮助模板

默认帮助信息太长?自己定制:

 1cli.AppHelpTemplate = `NAME:
 2   {{.Name}} - {{.Usage}}
 3
 4USAGE:
 5   {{.HelpName}} {{if .VisibleFlags}}[options]{{end}} command
 6
 7COMMANDS:
 8{{range .VisibleCategories}}{{if .Name}}
 9   {{.Name}}:{{end}}{{range .VisibleCommands}}
10     {{join .Names ", "}}{{"\t"}}{{.Usage}}{{end}}
11{{end}}
12OPTIONS:
13   {{range .VisibleFlags}}{{.}}
14   {{end}}
15`

终极实战:用 urfave/cli 重构计算器

掌握了 urfave/cli 的基本功后,我们回过头来彻底重构之前那个简陋的计算器。

改造目标

  1. 子命令模式:不再是 calc 1 + 2,而是变成更规范的子命令,如 calc add 1 2
  2. 自动帮助信息:用户输入 -h 就能看到所有可用操作。
  3. 更好的错误处理:利用框架的错误返回机制。

核心代码结构

go-tutorial-code/15-project-example 中,我们新增了一个 main_urfave.go 入口,其核心逻辑如下:

 1app := &cli.App{
 2    Name:    "calc",
 3    Usage:   "使用 urfave/cli 重构的专业计算器",
 4    Commands: []*cli.Command{
 5        {
 6            Name:    "add",
 7            Aliases: []string{"a"},
 8            Usage:   "执行加法运算",
 9            Action: func(c *cli.Context) error {
10                // 利用 c.Args() 获取参数并进行计算...
11                return nil
12            },
13        },
14        // subtract, multiply, divide 逻辑类似...
15    },
16}

为什么这样做更好?

  • 专业感:自动生成的 USAGECOMMANDS 列表,让你的工具看起来像个艺术品。
  • 一致性:所有的子命令共享同一个解析逻辑,维护极其简单。
  • 可扩展性:未来想加个 history 命令查看记录?只需在数组里加一项即可。

跑起来,你就可以看到区别了:

 1$ go run cmd/calc/main_urfave.go
 2NAME:
 3   calc - 使用 urfave/cli 重构的专业计算器
 4
 5USAGE:
 6   calc [global options] command [command options]
 7
 8VERSION:
 9   v2.0.0
10
11AUTHOR:
12   极客老墨 <hankmo@example.com>
13
14COMMANDS:
15   add, a   执行加法运算
16   sub, s   执行减法运算
17   mul, m   执行乘法运算
18   div, d   执行除法运算
19   history  查看计算历史记录
20   help, h  Shows a list of commands or help for one command
21
22GLOBAL OPTIONS:
23   --help, -h     show help
24   --version, -v  print the version

怎么样?做专业事情还是要专业的工具,帮助命令一目了然,非常方便。

总结

urfave/cli 开发 CLI 工具,核心就这几点:

  1. 创建 Appcli.NewApp() 搞定基础框架
  2. 定义命令Commands 数组,支持无限嵌套
  3. 添加 Flag:全局 Flag 和命令 Flag,记得不要重名
  4. 实现 Action:具体逻辑写在这里
  5. 生命周期:Before → Action → After,顺序别搞错

相比手撸参数解析,urfave/cli 能省 80% 的代码。而且生成的帮助信息专业、规范,用户体验好。

恭喜你! 从零到撸出一个专业的 CLI 工具,你已经具备了开发工程级项目的基本素质。


如果有任何问题,欢迎在评论区留言或关注公众号「极客老墨」交流。

完整代码参考:

  • 基础用法:go-tutorial-code/16-cli-urfave
  • 计算器重构:go-tutorial-code/15-project-example/cmd/calc/main_urfave.go

极客老墨,继续折腾! 我们进阶篇再见。


相关阅读