大家好,我是极客老墨。
上一篇我们写了个小工具——一个简单的命令行计算器,需要从命令行读参数, 我们直接通过 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}
看到没?每个参数都要手动验证,帮助信息要自己拼,子命令?不存在的,得自己实现。
写个小工具还行,但要做个像 git、docker 那样的专业 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 有多种类型,包括 StringFlag、BoolFlag、IntFlag 等等,它们都实现了顶层接口 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定义了命了的别名,可以简化输入,例如上边的命令输入hello与ho是等价的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 属性,并定义了 Name 为 weather 的子命令。
编译运行命令帮助:
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.Command 的 Category 来指定分组名称,这样可以在帮助信息中归类展示。
现在,我们在添加一个子命令,并将命令分组,代码如下:
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}
可见,其内部包含 App、Command 属性,可以获取 cli.App、cli.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 类型的值,其参数为 Flag 的 Name 而不是别名 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 的基本功后,我们回过头来彻底重构之前那个简陋的计算器。
改造目标
- 子命令模式:不再是
calc 1 + 2,而是变成更规范的子命令,如calc add 1 2。 - 自动帮助信息:用户输入
-h就能看到所有可用操作。 - 更好的错误处理:利用框架的错误返回机制。
核心代码结构
在 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}
为什么这样做更好?
- 专业感:自动生成的
USAGE和COMMANDS列表,让你的工具看起来像个艺术品。 - 一致性:所有的子命令共享同一个解析逻辑,维护极其简单。
- 可扩展性:未来想加个
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 工具,核心就这几点:
- 创建 App:
cli.NewApp()搞定基础框架 - 定义命令:
Commands数组,支持无限嵌套 - 添加 Flag:全局 Flag 和命令 Flag,记得不要重名
- 实现 Action:具体逻辑写在这里
- 生命周期:Before → Action → After,顺序别搞错
相比手撸参数解析,urfave/cli 能省 80% 的代码。而且生成的帮助信息专业、规范,用户体验好。
恭喜你! 从零到撸出一个专业的 CLI 工具,你已经具备了开发工程级项目的基本素质。
如果有任何问题,欢迎在评论区留言或关注公众号「极客老墨」交流。
完整代码参考:
- 基础用法:
go-tutorial-code/16-cli-urfave - 计算器重构:
go-tutorial-code/15-project-example/cmd/calc/main_urfave.go
极客老墨,继续折腾! 我们进阶篇再见。