嘿,大家好,我是网管。
我们平时做的Go项目除了写的各种API接口外,还经常会写任务脚本、命令行程序、定时任务等,其实这几个是一个东西,你写的任务脚本支持接受指令传参,那它不就是命令行程序了?再把程序部署到服务器用Go Cron加个任务就是定时任务了。

Go 官方有一个 flags 库提供了最基础的命令行参数支持,不过确实不好用,今天带你认识一个超赞的库——urfave/cli,它能让你用一种简单优雅的方式来构建命令行程序。
urfave/cli 是一个用 Go 编写的、简单、快速且有趣的库,用于构建命令行应用程序。无论是小工具还是复杂的大型 CLI 程序,它都能轻松应对。它的设计哲学是让我们用声明式的方式来定义命令、子命令、标志(Flags),然后它会自动帮你处理参数解析、帮助文档生成等所有繁琐的工作,听起来是不是很棒?
运行以下命令来安装 urfave/cli 的 v2 版本:
go get github.com/urfave/cli/v2
我们从经典的 "Hello, World!" 开始,创建一个 main.go 文件,然后敲入以下代码:
package main
import (
"log"
"os"
"github.com/urfave/cli/v2"
)
func main() {
app := &cli.App{
Name: "greet",
Usage: "向世界打个招呼!",
Action: func(c *cli.Context) error {
println("Hello, world!")
returnnil
},
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}
运行命令程序
go run main.go
你会看到终端输出了 Hello, world!,当然我们也可以 build 后用真正的命令去运行
# build
go build -o greet ./main.go
# 运行命令
./greet
urfave/cli 自动为我们生成了帮助信息。上面这个命令运行时添加 --help 就能在控制台输出帮助信息。
只会说 "Hello, world!" 可不够,我们希望它能跟指定的人打招呼。这就要用到“标志”(Flags)了。
我们来修改一下代码,添加一个 --name的标志:
package main
import (
"fmt"
"log"
"os"
"github.com/urfave/cli/v2"
)
func main() {
app := &cli.App{
Name: "greet",
Usage: "向世界或某人打个招呼!",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "name",
Value: "world", // 默认值
Usage: "指定打招呼的对象",
Aliases: []string{"n"}, // 别名,-n 等同于 --name
},
},
Action: func(c *cli.Context) error {
name := c.String("name")
fmt.Printf("Hello, %s!\n", name)
returnnil
},
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}
现在重新打包构建一下这个命令
$ go build -o greet ./main.go
# 不带任何参数,使用默认值
$ ./greet
Hello, world!
# 使用 --name 标志
$ ./greet --name Gopher
Hello, Gopher!
# 使用别名 -n
$ ./greet -n 狗蛋
Hello, 狗蛋!
当你的工具功能越来越复杂时,就需要引入“命令” 和 “子命令”来组织功能了。这就像 git 有 commit、push、pull 等子命令一样。我们来模拟一个简单的文件处理工具 filetool。
package main
import (
"fmt"
"log"
"os"
"github.com/urfave/cli/v2"
)
func main() {
app := &cli.App{
Name: "filetool",
Usage: "一个简单的文件处理工具",
Commands: []*cli.Command{
{
Name: "hash",
Aliases: []string{"h"},
Usage: "计算文件的哈希值",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "file",
Aliases: []string{"f"},
Usage: "指定输入文件",
Required: true, // 这是一个必填项!
},
},
Action: func(c *cli.Context) error {
filePath := c.String("file")
// 这里的 hashFile 是我们自己实现的逻辑函数
fmt.Printf("正在为文件 '%s' 计算哈希...\n", filePath)
returnnil
},
},
},
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}
上面是添加了命令,对于复杂的命令行程序,尤其是在业务系统里用作处理数据的命令行程序,往往还需要子命令的支持。这样我们可以把处理一个大类数据的任务都划分到同一个命令下,每个细分任务在写成命令的子命令。
下面是一个添加子命令的简单例子:
var Word = &cli.Command{
Name: "word",
Aliases: []string{"w"},
Usage: "Word文档处理相关命令",
Subcommands: []*cli.Command{
{
Name: "parse",
Usage: "解析Word文档",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "input",
Aliases: []string{"i"},
Usage: "输入文件路径",
Required: true,
},
&cli.StringFlag{
Name: "output",
Aliases: []string{"o"},
Usage: "输出文件路径",
Required: true,
},
},
Action: func(c *cli.Context) error {
return logic.NewWordLogic(c.Context).ParseWord(c.String("input"), c.String("output"))
},
},
},
}
我们把这个子命令加到上面的
func main() {
app := &cli.App{
Name: "filetool",
Usage: "一个简单的文件处理工具",
Commands: []*cli.Command{
// ......
word,
// 添加更多命令
},
}
// ......
}
上面这个子命令的调用方式如下:
$ go build -o filetool ./main.go;
./filetool word parse -i input.docx -o output.txt
基础用法已经掌握了,但要构建一个健壮、可维护的命令行工具,我们还需要借鉴一些真实项目中的经验。下面这些技巧,能让你的代码质量提升一个台阶。
你可能希望在每个命令执行前后都做一些固定的操作,比如初始化日志、设置链路追踪、上报监控数据或者记录执行时间等。urfave/cli 提供了 Before 和 After 钩子函数,来解决这个问题。
下面是我的专栏项目使用 urfave/cli 时添加的钩子:
func main() {
app := &cli.App{
Name: "gm-tools",
Usage: "Go Mall 工具集",
Before: func(c *cli.Context) error {
// 为每个命令创建带有追踪信息的上下文
ctx := context.Background()
spanId := util.GenerateSpanID(util.GetLocalIP())
ctx = context.WithValue(ctx, "spanid", spanId)
c.Context = ctx
cmdName := strings.Join(c.Args().Slice(), " ")
logger.Info(ctx, fmt.Sprintf("定时任务【%s】开始执行. 时间=【%s】)", cmdName, time.Now().Format(enum.TimeFormatHyphenedYMDHIS)))
returnnil
},
After: func(c *cli.Context) error {
// 记录执行的错误
if c.Context.Err() != nil {
logger.Error(c.Context, "定时任务执行失败", c.Context.Err())
}
cmdName := strings.Join(c.Args().Slice(), " ")
logger.Info(c.Context, fmt.Sprintf("定时任务【%s】执行完成. 时间=【%s", cmdName, time.Now().Format(enum.TimeFormatHyphenedYMDHIS)))
returnnil
},
Commands: []*cli.Command{
commands.Word,
// 添加更多工具命令
},
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}
这样无论你运行哪个命令,Before 和 After 里的日志都会被打印出来。更重要的是,我们将一个带有追踪信息的 Go context.Context 注入到了 cli.Context 中,在后续的 Action 函数里,我们可以通过 c.Context 取出这个上下文,并把它传递给业务逻辑,实现了全链路的追踪!
所有代码在扫码加入我的专栏《Go项目整洁开发实战》后都能获取相应的实战案例。
当你的工具有几十个命令时,把所有定义都堆在 main.go 不是一个好的选择。正确的做法是,将每个主命令或一组相关的命令放到单独的文件或包里, 比如上一个例子中的命令注册方式,就是因为我把每个命令拆到了commands 目录的。
Commands: []*cli.Command{
commands.Word,
// 添加更多工具命令
},
这样也利于我们做职责单一的逻辑分层,比如下面是我付费专栏的项目中做的命令行程序模块的逻辑分层

通过 urfave/cli 的声明式API,我们可以轻松定义命令、标志和层级关系。而结合 Before/After 钩子、代码模块化以及逻辑分层等最佳实践,即使是再复杂的CLI应用,我们也能游刃有余地进行开发和维护。
Go 社区还有一个 cobra 也是做命令行程序用的,但使用起来比我们今天介绍的 urfave/cli 更复杂一些。
结尾推荐一下我的专栏课程,专栏中除了今天介绍的命令行程序实战外,还会教你怎么用Go做好项目的开发和设计,搭建出一个实用、适合自己的Go项目的基础框架、怎么在写业务代码时做好项目的分层和解耦,欢迎扫下方二维码订阅专栏。
《Go项目搭建和整洁开发实战》专栏分为五大部分,重点章节如下
