用 Cobra 写一个像样的 Go 命令行工具

写 Go 的命令行工具,标准库 flag 包够用一阵子,但只要命令稍微复杂一点------有子命令、有持久 flag、要自动补全------就得换框架。

这篇文章把日常用到的部分讲清楚:命令、子命令、flag、参数校验、hook、自动补全。


装一下

sh 复制代码
go get -u github.com/spf13/cobra@latest

如果想用脚手架快速起项目,可以装 cobra-cli

sh 复制代码
go install github.com/spf13/cobra-cli@latest
cobra-cli init myapp

但手写一遍更能理解结构。下面我们手动来。


最小例子

Cobra 的核心是 Command 结构体。一个命令 = 一个 Command

go 复制代码
package main

import (
    "fmt"
    "github.com/spf13/cobra"
)

func main() {
    rootCmd := &cobra.Command{
        Use:   "hello",
        Short: "一个最小例子",
        Run: func(cmd *cobra.Command, args []string) {
            fmt.Println("hello, cobra")
        },
    }

    rootCmd.Execute()
}

跑一下:

sh 复制代码
$ go run main.go
hello, cobra

$ go run main.go --help
一个最小例子

Usage:
  hello [flags]

Flags:
  -h, --help   help for hello

--help 是自动生成的,不用自己写。

几个关键字段:

  • Use:命令名 + 参数签名,比如 "login <username>"
  • Short:一句话简介,出现在父命令的子命令列表里
  • Long:完整说明,出现在自己的 --help
  • Run:实际执行的函数

子命令

Cobra 的子命令是挂上去的。每个 Command 都可以挂子命令。

go 复制代码
func main() {
    rootCmd := &cobra.Command{
        Use:   "git",
        Short: "一个伪 git 命令",
    }

    addCmd := &cobra.Command{
        Use:   "add <files>",
        Short: "添加文件到暂存区",
        Args:  cobra.MinimumNArgs(1),
        Run: func(cmd *cobra.Command, args []string) {
            fmt.Println("add:", args)
        },
    }

    commitCmd := &cobra.Command{
        Use:   "commit",
        Short: "提交暂存区",
        Run: func(cmd *cobra.Command, args []string) {
            fmt.Println("commit")
        },
    }

    rootCmd.AddCommand(addCmd, commitCmd)
    rootCmd.Execute()
}
sh 复制代码
$ go run main.go add a.go b.go
add: [a.go b.go]

$ go run main.go
一个伪 git 命令

Usage:
  git [command]

Available Commands:
  add         添加文件到暂存区
  commit      提交暂存区
  help        Help about any command

不传子命令时,Cobra 默认打印 help。如果想让它干点别的(比如默认进入交互),给 rootCmd 设置 Run 就行。


Flag

Cobra 的 flag 来自 pflag,和标准库 flag 的差别主要是支持长短两种写法(-n 和 --name)。

局部 flag

只在当前命令生效。

go 复制代码
commitCmd := &cobra.Command{
    Use:   "commit",
    Short: "提交暂存区",
    Run: func(cmd *cobra.Command, args []string) {
        msg, _ := cmd.Flags().GetString("message")
        amend, _ := cmd.Flags().GetBool("amend")
        fmt.Printf("commit: msg=%s amend=%v\n", msg, amend)
    },
}

commitCmd.Flags().StringP("message", "m", "", "提交信息")
commitCmd.Flags().BoolP("amend", "a", false, "修改上一次提交")
sh 复制代码
$ go run main.go commit -m "fix bug" -a
commit: msg=fix bug amend=true

持久 flag

PersistentFlags 会让 flag 对当前命令以及它所有的子命令都生效。这是配置项最常用的方式。

go 复制代码
rootCmd.PersistentFlags().BoolP("verbose", "v", false, "详细输出")
rootCmd.PersistentFlags().StringP("config", "c", "config.yaml", "配置文件路径")

加完之后,任何子命令里都可以读:

go 复制代码
Run: func(cmd *cobra.Command, args []string) {
    verbose, _ := cmd.Flags().GetBool("verbose")
    if verbose {
        log.Println("doing something...")
    }
},
sh 复制代码
$ go run main.go --verbose commit -m "x"

注意 --verbose 写在子命令前面,因为它是 root 的 flag。Cobra 在解析时会向上找 persistent flag,所以写在后面通常也行,但前面更清晰。

必填 flag

go 复制代码
commitCmd.Flags().StringP("message", "m", "", "提交信息")
commitCmd.MarkFlagRequired("message")

不传就报错:

sh 复制代码
$ go run main.go commit
Error: required flag(s) "message" not set

持久必填用 MarkPersistentFlagRequired


参数校验

Args 字段控制位置参数怎么校验。Cobra 内置了几个:

校验器 含义
cobra.NoArgs 不允许任何参数
cobra.ArbitraryArgs 任意参数(默认行为)
cobra.MinimumNArgs(n) 至少 n 个
cobra.MaximumNArgs(n) 最多 n 个
cobra.ExactArgs(n) 恰好 n 个
cobra.RangeArgs(min, max) min, max 之间
cobra.OnlyValidArgs + ValidArgs 只允许 ValidArgs 列表里的值
go 复制代码
colorCmd := &cobra.Command{
    Use:       "color <name>",
    Short:     "选个颜色",
    Args:      cobra.OnlyValidArgs,
    ValidArgs: []string{"red", "green", "blue"},
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Println("color:", args[0])
    },
}
sh 复制代码
$ go run main.go color yellow
Error: invalid argument "yellow" for "color"

想自定义也简单,Args 是个 func(cmd *cobra.Command, args []string) error

go 复制代码
Args: func(cmd *cobra.Command, args []string) error {
    if len(args) != 1 {
        return errors.New("需要一个参数")
    }
    if !strings.HasPrefix(args[0], "user:") {
        return errors.New("参数必须以 user: 开头")
    }
    return nil
},

Hook:在 Run 前后做事

Cobra 提供了一组 hook,按顺序执行:

复制代码
PersistentPreRun  →  PreRun  →  Run  →  PostRun  →  PersistentPostRun

Persistent* 版本会对子命令也生效。常见用法:

  • PersistentPreRun:读配置文件、初始化日志、建立数据库连接
  • PreRun:当前命令的预处理
  • PostRun:清理、打点
  • PersistentPostRun:通用收尾
go 复制代码
rootCmd := &cobra.Command{
    Use: "myapp",
    PersistentPreRun: func(cmd *cobra.Command, args []string) {
        // 初始化日志
        log.SetFlags(log.LstdFlags | log.Lshortfile)
    },
}

runCmd := &cobra.Command{
    Use: "run",
    PreRun: func(cmd *cobra.Command, args []string) {
        log.Println("准备启动")
    },
    Run: func(cmd *cobra.Command, args []string) {
        log.Println("运行中")
    },
    PostRun: func(cmd *cobra.Command, args []string) {
        log.Println("运行结束")
    },
}

注意:如果子命令定义了自己的 PersistentPreRun,会覆盖父命令的,不会叠加。需要叠加的话自己显式调用父命令的 hook。


配合 viper 读配置

Cobra 和 viper 是一套组合拳,同一个作者。viper 读配置文件、环境变量、命令行 flag,并能把它们绑定到一起。

go 复制代码
var cfgFile string

rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "配置文件路径")
rootCmd.PersistentFlags().String("db-url", "", "数据库连接")

viper.BindPFlag("db.url", rootCmd.PersistentFlags().Lookup("db-url"))

rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
    if cfgFile != "" {
        viper.SetConfigFile(cfgFile)
        viper.ReadInConfig()
    }
    // 命令行没传 --db-url 时,自动回退到配置文件里的 db.url
}

flag → 配置文件 → 默认值,优先级从前到后,调用者不用关心值从哪来。


自动补全

Cobra 内置 shell 补全,几行就生成:

go 复制代码
rootCmd := &cobra.Command{
    Use: "myapp",
    // ...
}

rootCmd.AddCommand(&cobra.Command{
    Use:   "completion [shell]",
    Short: "生成 shell 补全脚本",
    Args:  cobra.ExactValidArgs(1),
    ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
    Run: func(cmd *cobra.Command, args []string) {
        switch args[0] {
        case "bash":
            rootCmd.GenBashCompletion(os.Stdout)
        case "zsh":
            rootCmd.GenZshCompletion(os.Stdout)
        case "fish":
            rootCmd.GenFishCompletion(os.Stdout, true)
        }
    },
})

或者直接:

sh 复制代码
$ myapp completion zsh > /usr/local/share/zsh/site-functions/_myapp

更猛的是动态补全------补全的候选项可以在运行时算。给 flag 注册 RegisterFlagCompletionFunc,给参数注册 ValidArgsFunction

go 复制代码
userCmd.Flags().String("user", "", "用户名")
userCmd.RegisterFlagCompletionFunc("user", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
    // 从数据库查用户列表
    return []string{"alice", "bob", "charlie"}, cobra.ShellCompDirectiveNoFileComp
})

按 tab 时 Cobra 会调用这个函数,把结果作为补全候选项。


推荐的项目结构

工具稍微大一点就建议拆文件。一个常见布局:

csharp 复制代码
myapp/
├── cmd/
│   ├── root.go        # rootCmd 定义 + Execute()
│   ├── serve.go       # serve 子命令
│   ├── login.go       # login 子命令
│   └── user/
│       ├── list.go    # user list
│       └── create.go  # user create
├── internal/
│   └── ...            # 业务逻辑
├── go.mod
└── main.go

main.go 只剩一行:

go 复制代码
package main

import "myapp/cmd"

func main() {
    cmd.Execute()
}

cmd/root.go

go 复制代码
package cmd

import "github.com/spf13/cobra"

var rootCmd = &cobra.Command{
    Use:   "myapp",
    Short: "我的应用",
}

func Execute() {
    if err := rootCmd.Execute(); err != nil {
        os.Exit(1)
    }
}

func init() {
    rootCmd.PersistentFlags().BoolP("verbose", "v", false, "详细输出")
}

每个子命令一个文件,用 init() 把自己挂到 rootCmd 上:

go 复制代码
// cmd/serve.go
package cmd

import "github.com/spf13/cobra"

var serveCmd = &cobra.Command{
    Use:   "serve",
    Short: "启动服务",
    Run: func(cmd *cobra.Command, args []string) {
        // ...
    },
}

func init() {
    rootCmd.AddCommand(serveCmd)
}

init() 是 cobra-cli 默认的写法。命令多了之后如果你不喜欢隐式的 init,也可以在 Execute() 里显式 rootCmd.AddCommand(...),更直观。


一些容易踩的坑

RunRunE 的取舍。 Run 没法返回 error,错误只能 os.Exit 或 panic。生产代码用 RunE

go 复制代码
RunE: func(cmd *cobra.Command, args []string) error {
    if err := doStuff(); err != nil {
        return err
    }
    return nil
},

返回的 error 会由 Cobra 统一打印 + 退出,错误信息也更一致。

flag 默认值和"未设置"无法区分。 String flag 默认是空字符串,调用者没传和传了空串分不开。需要区分时,用 *string 指针版本:Flags().StringVarP(&p, "name", "n", nil, "..."),没传时 p 是 nil。

**子命令的 Args 校验在 flag 解析之后。**所以校验函数里可以读 flag 的值。

**--help 不会触发 Run。**测试时如果发现 Run 没执行,先看看是不是不小心传了 -h


完整示例

把上面的东西串起来:一个带子命令、持久 flag、参数校验、RunE 的最小可运行项目。

go 复制代码
package main

import (
    "errors"
    "fmt"
    "log"
    "os"

    "github.com/spf13/cobra"
)

var verbose bool

func main() {
    rootCmd := &cobra.Command{
        Use:   "myapp",
        Short: "一个完整的 cobra 示例",
        PersistentPreRun: func(cmd *cobra.Command, args []string) {
            if verbose {
                log.SetFlags(log.LstdFlags | log.Lshortfile)
            } else {
                log.SetFlags(0)
            }
        },
    }
    rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "详细输出")

    greetCmd := &cobra.Command{
        Use:   "greet <name>",
        Short: "和某人打招呼",
        Args: func(cmd *cobra.Command, args []string) error {
            if len(args) != 1 {
                return errors.New("需要一个名字")
            }
            return nil
        },
        RunE: func(cmd *cobra.Command, args []string) error {
            times, _ := cmd.Flags().GetInt("times")
            for i := 0; i < times; i++ {
                fmt.Printf("hello, %s\n", args[0])
            }
            if verbose {
                log.Printf("打印了 %d 次", times)
            }
            return nil
        },
    }
    greetCmd.Flags().IntP("times", "t", 1, "重复次数")

    rootCmd.AddCommand(greetCmd)

    if err := rootCmd.Execute(); err != nil {
        os.Exit(1)
    }
}
sh 复制代码
$ go run main.go greet Alice -t 2
hello, Alice
hello, Alice

$ go run main.go -v greet Alice -t 2
hello, Alice
hello, Alice
2026/06/29 main.go:48: 打印了 2 次

$ go run main.go greet
Error: 需要一个名字

相关推荐
黑贝是条狗2 小时前
一个从零到一,全部由AI实现的开放平台
go·ai vibe coding
ArixBit4 小时前
从目录分层到交付验证:我的 Go 后端工程实践
go
ArixBit8 小时前
用 Go 手撸 Agent 框架 #9:把最小 Agent 抽成 Runtime
go·agent
江畔柳前堤10 小时前
Go04-控制结构
大数据·人工智能·面试·职场和发展·go·业界资讯
用户6757049885021 天前
别再乱用了!var arr []int 和 arr := []int{} 给整懵了?不就声明个空数组,项目居然崩了?
后端·go
江畔柳前堤2 天前
GO01-Go 语言与主流编程语言深度对比
开发语言·人工智能·后端·微服务·云原生·golang·go
名字还没想好☜2 天前
Go slice 的 append 陷阱:共享底层数组导致的数据串改
开发语言·后端·golang·go·slice
生活、追梦者2 天前
【Windows】 CMD 黑窗口命令大全
windows·命令行·cmd·运维技巧
妙码生花2 天前
从 PHP 到 AI + Golang,程序员自救转型手记(三十五):控制台静态页面、深入研究时间格式化方案
后端·go·ai编程