别把 CLI 写成一坨 os.Args:Go Cobra 从命令树到实战工具详解

0 阅读16分钟

简介

Go 标准库当然可以写命令行工具:

package main

import (
	"fmt"
	"os"
)

func main() {
	if len(os.Args) < 2 {
		fmt.Println("缺少命令")
		return
	}

	switch os.Args[1] {
	case "version":
		fmt.Println("v1.0.0")
	default:
		fmt.Println("未知命令:", os.Args[1])
	}
}

小工具这样写没问题。一旦命令变成这样,代码很快会乱:

todoctl add "写周报" --priority high --tag work
todoctl list --done=false --limit 20
todoctl done 3
todoctl completion zsh

这时需要处理的东西不只是 os.Args

子命令怎么组织?
全局参数和局部参数怎么区分?
必填参数怎么提示?
参数不合法时怎么返回错误?
help、version、completion 怎么生成?
命令层和业务代码怎么分开?

Cobra 解决的正是这些问题。

一句话概括:

Cobra 把命令行工具组织成一棵命令树,每个节点负责一类动作,每个动作再配自己的参数、校验和执行逻辑。

Cobra 适合解决什么问题

Cobra 是 Go 里常用的 CLI 框架,包名是:

github.com/spf13/cobra

很多命令行程序都会采用类似的结构:

kubectl get pods
kubectl describe pod nginx
docker image ls
docker container stop app

这种结构不是单个命令,而是一棵树:

app
├── user
│   ├── add
│   └── list
├── server
│   ├── start
│   └── stop
└── version

Cobra 的核心价值就是把这棵树建起来,并提供配套能力:

  • 子命令和多级子命令
  • 本地 flag 和持久 flag
  • 位置参数校验
  • --help--version、usage 文案
  • RunE 错误返回
  • 命令执行前后的钩子
  • shell 自动补全脚本
  • 和 Viper 等配置库集成

Command、Args、Flag 三个核心概念

Cobra 里最重要的类型是 cobra.Command

var rootCmd = &cobra.Command{
	Use:   "todoctl",
	Short: "一个待办事项命令行工具",
	Run: func(cmd *cobra.Command, args []string) {
		fmt.Println("hello cobra")
	},
}

一个命令主要由三部分组成:

Command:命令本身,比如 add、list、done
Args:位置参数,比如 add 后面的任务标题
Flag:选项参数,比如 --priority high、--done=false

位置参数不带 -

todoctl add "写周报"

这里的 "写周报" 就是 Args。

flag 带 ---

todoctl add "写周报" --priority high -t work

这里的 --priority-t 就是 Flag。

安装方式

项目里只需要 Cobra 库:

go get github.com/spf13/cobra@latest

如果想用脚手架生成目录,也可以安装 cobra-cli

go install github.com/spf13/cobra-cli@latest

脚手架不是必需品。理解 Cobra 本身时,手写一个小项目更清楚。

第一个最小示例

先看一个单文件版本:

package main

import (
	"fmt"
	"os"

	"github.com/spf13/cobra"
)

func main() {
	rootCmd := &cobra.Command{
		Use:   "hello",
		Short: "第一个 Cobra 程序",
		Run: func(cmd *cobra.Command, args []string) {
			fmt.Println("Hello Cobra")
		},
	}

	if err := rootCmd.Execute(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

运行:

go run main.go

输出:

Hello Cobra

查看帮助:

go run main.go --help

Cobra 会根据 UseShort、flag、子命令自动生成帮助内容。

Use、Short、Long、Example 怎么写

cobra.Command 里常用的描述字段有四个:

var addCmd = &cobra.Command{
	Use:   "add <title>",
	Short: "新增待办事项",
	Long: `新增一条待办事项。

标题来自位置参数,优先级、标签、完成状态通过 flag 控制。`,
	Example: `  todoctl add "写周报"
  todoctl add "修复支付回调" --priority high --tag backend`,
}

这些字段不是摆设,都会出现在 --help 里。

常见写法:

Use:写命令名和参数形状,比如 add <title>
Short:一句话说明,出现在命令列表里
Long:较长说明,出现在当前命令 help 里
Example:给出真实命令,减少使用成本

Run 和 RunE 的区别

Run 不返回错误:

Run: func(cmd *cobra.Command, args []string) {
	fmt.Println("执行成功")
}

RunE 返回错误:

RunE: func(cmd *cobra.Command, args []string) error {
	if len(args) == 0 {
		return fmt.Errorf("缺少任务标题")
	}
	return nil
}

工程代码更推荐 RunE

原因很简单:命令执行失败时,错误可以统一交给 Execute 处理,业务函数里不需要到处 os.Exit

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

命令层返回错误,入口层决定退出码,这样结构更干净。

本地 Flag 和持久 Flag

Cobra 有两类常见 flag。

本地 flag 只对当前命令生效:

addCmd.Flags().StringVarP(&priority, "priority", "p", "normal", "任务优先级")

持久 flag 对当前命令以及所有子命令生效:

rootCmd.PersistentFlags().StringVar(&dataFile, "data", "tasks.json", "数据文件路径")

例子:

todoctl --data ./tmp/tasks.json add "写周报"
todoctl --data ./tmp/tasks.json list
todoctl --data ./tmp/tasks.json done 1

--data 是全局配置,适合放在 root 的 PersistentFlags 里。

--priority 只和新增任务有关,适合放在 add 命令的 Flags 里。

参数校验 Args

位置参数校验通过 Args 字段完成。

常见内置校验:

Args: cobra.NoArgs
Args: cobra.ExactArgs(1)
Args: cobra.MinimumNArgs(1)
Args: cobra.MaximumNArgs(3)
Args: cobra.RangeArgs(1, 3)

新增任务至少需要一个标题:

Args: cobra.MinimumNArgs(1)

完成任务必须传一个 ID:

Args: cobra.ExactArgs(1)

也可以写自定义校验:

Args: func(cmd *cobra.Command, args []string) error {
	if len(args) != 1 {
		return fmt.Errorf("需要传入任务 ID")
	}
	if _, err := strconv.Atoi(args[0]); err != nil {
		return fmt.Errorf("任务 ID 必须是数字:%s", args[0])
	}
	return nil
}

实战项目:todoctl 待办事项工具

下面写一个可运行的 todoctl

功能:

todoctl add "写周报" --priority high --tag work
todoctl add "修复登录 bug" -p high -t backend -t bug
todoctl list
todoctl list --done=false
todoctl done 1
todoctl completion zsh

数据保存到本地 JSON 文件,方便观察完整流程。

项目结构:

todoctl/
├── cmd/
│   ├── add.go
│   ├── completion.go
│   ├── done.go
│   ├── list.go
│   └── root.go
├── internal/
│   └── task/
│       └── store.go
├── go.mod
└── main.go

初始化项目:

mkdir todoctl
cd todoctl
go mod init todoctl
go get github.com/spf13/cobra@latest

main.go:程序入口

main.go 只做一件事:执行根命令。

package main

import "todoctl/cmd"

func main() {
	cmd.Execute()
}

入口保持薄一点,后面扩展命令时不会影响启动逻辑。

internal/task/store.go:业务和存储

命令层不直接操作 JSON 细节,业务代码放到 internal/task

package task

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"slices"
	"strings"
	"time"
)

type Task struct {
	ID        int       `json:"id"`
	Title     string    `json:"title"`
	Priority  string    `json:"priority"`
	Tags      []string  `json:"tags"`
	Done      bool      `json:"done"`
	CreatedAt time.Time `json:"created_at"`
	DoneAt    time.Time `json:"done_at,omitempty"`
}

type Store struct {
	File string
}

type AddInput struct {
	Title    string
	Priority string
	Tags     []string
}

var allowedPriorities = []string{"low", "normal", "high"}

func (s Store) Load() ([]Task, error) {
	data, err := os.ReadFile(s.File)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return []Task{}, nil
		}
		return nil, fmt.Errorf("读取任务文件失败:%w", err)
	}

	var tasks []Task
	if err := json.Unmarshal(data, &tasks); err != nil {
		return nil, fmt.Errorf("解析任务文件失败:%w", err)
	}
	return tasks, nil
}

func (s Store) Save(tasks []Task) error {
	data, err := json.MarshalIndent(tasks, "", "  ")
	if err != nil {
		return fmt.Errorf("序列化任务失败:%w", err)
	}

	if err := os.WriteFile(s.File, data, 0o644); err != nil {
		return fmt.Errorf("写入任务文件失败:%w", err)
	}
	return nil
}

func (s Store) Add(input AddInput) (Task, error) {
	title := strings.TrimSpace(input.Title)
	if title == "" {
		return Task{}, fmt.Errorf("任务标题不能为空")
	}

	priority := strings.TrimSpace(input.Priority)
	if priority == "" {
		priority = "normal"
	}
	if !slices.Contains(allowedPriorities, priority) {
		return Task{}, fmt.Errorf("不支持的优先级:%s", priority)
	}

	tasks, err := s.Load()
	if err != nil {
		return Task{}, err
	}

	task := Task{
		ID:        nextID(tasks),
		Title:     title,
		Priority:  priority,
		Tags:      cleanTags(input.Tags),
		CreatedAt: time.Now(),
	}

	tasks = append(tasks, task)
	if err := s.Save(tasks); err != nil {
		return Task{}, err
	}
	return task, nil
}

func (s Store) MarkDone(id int) (Task, error) {
	tasks, err := s.Load()
	if err != nil {
		return Task{}, err
	}

	for i := range tasks {
		if tasks[i].ID == id {
			tasks[i].Done = true
			tasks[i].DoneAt = time.Now()
			if err := s.Save(tasks); err != nil {
				return Task{}, err
			}
			return tasks[i], nil
		}
	}

	return Task{}, fmt.Errorf("任务不存在:%d", id)
}

func nextID(tasks []Task) int {
	id := 1
	for _, task := range tasks {
		if task.ID >= id {
			id = task.ID + 1
		}
	}
	return id
}

func cleanTags(tags []string) []string {
	result := make([]string, 0, len(tags))
	for _, tag := range tags {
		tag = strings.TrimSpace(tag)
		if tag != "" {
			result = append(result, tag)
		}
	}
	return result
}

这里有几个细节:

  • Store 只关心数据文件路径
  • AddInput 是新增任务的输入结构
  • Load 遇到文件不存在时返回空切片
  • 业务错误用 fmt.Errorf 返回给命令层
  • 命令层不会知道 JSON 怎么读写

cmd/root.go:根命令和全局参数

根命令负责挂载全局参数、版本、错误处理策略。

package cmd

import (
	"fmt"
	"os"

	"github.com/spf13/cobra"
)

var (
	dataFile string
	verbose  bool
)

var rootCmd = &cobra.Command{
	Use:           "todoctl",
	Short:         "管理本地待办事项",
	Long:          "todoctl 是一个基于 Cobra 的本地待办事项命令行工具。",
	Version:       "v1.0.0",
	SilenceUsage:  true,
	SilenceErrors: true,
	RunE: func(cmd *cobra.Command, args []string) error {
		return cmd.Help()
	},
	PersistentPreRun: func(cmd *cobra.Command, args []string) {
		if verbose {
			fmt.Fprintf(os.Stderr, "data file: %s\n", dataFile)
		}
	},
}

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

func init() {
	rootCmd.PersistentFlags().StringVar(&dataFile, "data", "tasks.json", "数据文件路径")
	rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "输出调试信息")
}

SilenceUsageSilenceErrors 很实用。

默认情况下,Cobra 可能在错误时自动打印 usage 或错误文本。项目里通常希望自己控制输出,所以根命令里直接关掉自动输出。

SilenceUsage:  true,
SilenceErrors: true,

Version 会让根命令自动支持:

todoctl --version

cmd/add.go:新增任务

新增任务用到了位置参数、本地 flag、必填校验、RunE

package cmd

import (
	"fmt"
	"strings"

	"todoctl/internal/task"

	"github.com/spf13/cobra"
)

var (
	addPriority string
	addTags     []string
)

var addCmd = &cobra.Command{
	Use:   "add <title>",
	Short: "新增待办事项",
	Long:  "新增一条待办事项,标题来自位置参数,优先级和标签来自 flag。",
	Example: `  todoctl add "写周报"
  todoctl add "修复登录 bug" --priority high --tag backend --tag bug`,
	Args: cobra.MinimumNArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		store := task.Store{File: dataFile}
		created, err := store.Add(task.AddInput{
			Title:    strings.Join(args, " "),
			Priority: addPriority,
			Tags:     addTags,
		})
		if err != nil {
			return err
		}

		fmt.Printf("已新增 #%d [%s] %s\n", created.ID, created.Priority, created.Title)
		return nil
	},
}

func init() {
	rootCmd.AddCommand(addCmd)

	addCmd.Flags().StringVarP(&addPriority, "priority", "p", "normal", "任务优先级:low、normal、high")
	addCmd.Flags().StringSliceVarP(&addTags, "tag", "t", nil, "任务标签,可多次传入")
}

运行:

go run . add "写周报" --priority high --tag work

输出:

已新增 #1 [high] 写周报

再添加一条:

go run . add "修复登录 bug" -p high -t backend -t bug

输出:

已新增 #2 [high] 修复登录 bug

StringSliceVarP 支持逗号和值重复两种写法:

todoctl add "整理文档" -t doc -t work
todoctl add "整理文档" -t doc,work

cmd/list.go:列表查询

列表命令展示本地 flag 的另一个用法:过滤和分页。

package cmd

import (
	"fmt"
	"os"
	"strings"
	"text/tabwriter"

	"todoctl/internal/task"

	"github.com/spf13/cobra"
)

var (
	listDone     string
	listLimit    int
	listPriority string
)

var listCmd = &cobra.Command{
	Use:   "list",
	Short: "查看待办事项列表",
	Args:  cobra.NoArgs,
	RunE: func(cmd *cobra.Command, args []string) error {
		store := task.Store{File: dataFile}
		tasks, err := store.Load()
		if err != nil {
			return err
		}

		rows := filterTasks(tasks)
		if listLimit > 0 && len(rows) > listLimit {
			rows = rows[:listLimit]
		}
		if len(rows) == 0 {
			fmt.Println("暂无任务")
			return nil
		}

		w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
		fmt.Fprintln(w, "ID\t状态\t优先级\t标题\t标签")
		for _, item := range rows {
			status := "待办"
			if item.Done {
				status = "完成"
			}
			fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n",
				item.ID,
				status,
				item.Priority,
				item.Title,
				strings.Join(item.Tags, ","),
			)
		}
		return w.Flush()
	},
}

func init() {
	rootCmd.AddCommand(listCmd)

	listCmd.Flags().StringVar(&listDone, "done", "", "按完成状态过滤:true、false")
	listCmd.Flags().StringVarP(&listPriority, "priority", "p", "", "按优先级过滤")
	listCmd.Flags().IntVarP(&listLimit, "limit", "n", 0, "最多显示条数")
}

func filterTasks(tasks []task.Task) []task.Task {
	rows := make([]task.Task, 0, len(tasks))
	for _, item := range tasks {
		if listDone == "true" && !item.Done {
			continue
		}
		if listDone == "false" && item.Done {
			continue
		}
		if listPriority != "" && item.Priority != listPriority {
			continue
		}
		rows = append(rows, item)
	}
	return rows
}

运行:

go run . list

输出类似:

ID  状态  优先级  标题           标签
1   待办  high    写周报         work
2   待办  high    修复登录 bug   backend,bug

只看未完成任务:

go run . list --done=false

只看高优先级任务:

go run . list --priority high

cmd/done.go:完成任务

完成任务需要一个数字 ID。这里用自定义 Args 校验,让错误更明确。

package cmd

import (
	"fmt"
	"strconv"

	"todoctl/internal/task"

	"github.com/spf13/cobra"
)

var doneCmd = &cobra.Command{
	Use:   "done <id>",
	Short: "标记任务为完成",
	Args: func(cmd *cobra.Command, args []string) error {
		if len(args) != 1 {
			return fmt.Errorf("需要传入一个任务 ID")
		}
		if _, err := strconv.Atoi(args[0]); err != nil {
			return fmt.Errorf("任务 ID 必须是数字:%s", args[0])
		}
		return nil
	},
	RunE: func(cmd *cobra.Command, args []string) error {
		id, _ := strconv.Atoi(args[0])

		store := task.Store{File: dataFile}
		updated, err := store.MarkDone(id)
		if err != nil {
			return err
		}

		fmt.Printf("已完成 #%d %s\n", updated.ID, updated.Title)
		return nil
	},
}

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

运行:

go run . done 1

输出:

已完成 #1 写周报

再次查看:

go run . list

输出类似:

ID  状态  优先级  标题           标签
1   完成  high    写周报         work
2   待办  high    修复登录 bug   backend,bug

参数错误时:

go run . done abc

输出:

任务 ID 必须是数字:abc

cmd/completion.go:生成自动补全脚本

Cobra 支持生成 bash、zsh、fish、powershell 补全脚本。

package cmd

import (
	"fmt"
	"os"

	"github.com/spf13/cobra"
)

var completionCmd = &cobra.Command{
	Use:   "completion [bash|zsh|fish|powershell]",
	Short: "生成 shell 自动补全脚本",
	Args:  cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		switch args[0] {
		case "bash":
			return rootCmd.GenBashCompletion(os.Stdout)
		case "zsh":
			return rootCmd.GenZshCompletion(os.Stdout)
		case "fish":
			return rootCmd.GenFishCompletion(os.Stdout, true)
		case "powershell":
			return rootCmd.GenPowerShellCompletion(os.Stdout)
		default:
			return fmt.Errorf("不支持的 shell:%s", args[0])
		}
	},
}

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

生成 zsh 补全:

go run . completion zsh > _todoctl

实际安装路径和 shell 配置有关,命令本身只负责把脚本输出到标准输出。

运行完整 demo

完整项目写好后,可以按下面流程验证:

go run . --help
go run . --version
go run . add "写周报" --priority high --tag work
go run . add "修复登录 bug" -p high -t backend -t bug
go run . list
go run . done 1
go run . list --done=true
go run . completion bash

也可以编译成二进制:

go build -o todoctl .
./todoctl add "打包发布" -p normal
./todoctl list

命令树怎么组织

Cobra 命令靠 AddCommand 组织父子关系。

rootCmd.AddCommand(addCmd)
rootCmd.AddCommand(listCmd)
rootCmd.AddCommand(doneCmd)
rootCmd.AddCommand(completionCmd)

如果有更复杂的业务模块,可以继续嵌套:

rootCmd.AddCommand(userCmd)
userCmd.AddCommand(userCreateCmd)
userCmd.AddCommand(userListCmd)

对应命令:

app user create
app user list

父命令通常只做分组,不一定执行具体业务。

var userCmd = &cobra.Command{
	Use:   "user",
	Short: "用户管理",
	RunE: func(cmd *cobra.Command, args []string) error {
		return cmd.Help()
	},
}

flag 读取方式:绑定变量和临时读取

flag 有两种常见读取方式。

第一种是绑定变量:

var port int

serveCmd.Flags().IntVarP(&port, "port", "p", 8080, "监听端口")

执行时直接用变量:

fmt.Println(port)

第二种是在 RunE 里读取:

port, err := cmd.Flags().GetInt("port")
if err != nil {
	return err
}
fmt.Println(port)

简单命令常用绑定变量,动态命令或测试场景常用 GetXxx

注意持久 flag 的读取方式:

value, err := cmd.Flags().GetString("data")

Cobra 会把继承来的持久 flag 合并到当前命令可见的 flag 集合中,所以子命令里也能这样读取。

必填 flag

有些参数适合作为 flag,但必须传。

比如发布命令必须带镜像名:

var image string

deployCmd.Flags().StringVar(&image, "image", "", "镜像名称")
_ = deployCmd.MarkFlagRequired("image")

运行:

app deploy

会提示:

required flag(s) "image" not set

MarkFlagRequired 返回错误,示例里用 _ = 是为了简化。实际项目可以在初始化阶段检查这个错误。

PreRun、Run、PostRun 生命周期

Cobra 支持命令执行前后钩子:

var serveCmd = &cobra.Command{
	Use: "serve",
	PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
		fmt.Println("初始化配置")
		return nil
	},
	PreRunE: func(cmd *cobra.Command, args []string) error {
		fmt.Println("检查端口")
		return nil
	},
	RunE: func(cmd *cobra.Command, args []string) error {
		fmt.Println("启动服务")
		return nil
	},
	PostRunE: func(cmd *cobra.Command, args []string) error {
		fmt.Println("释放资源")
		return nil
	},
}

常见用途:

PersistentPreRunE:读取配置、初始化日志、创建客户端
PreRunE:检查当前命令自己的前置条件
RunE:执行主逻辑
PostRunE:清理临时资源、输出统计信息

不要把耗时初始化放在 init() 里。init() 适合注册命令和 flag,真正需要执行的动作放到钩子或 RunE

错误处理怎么放

推荐结构:

RunE: func(cmd *cobra.Command, args []string) error {
	result, err := service.DoSomething()
	if err != nil {
		return err
	}
	fmt.Println(result)
	return nil
}

不推荐在每个命令里直接退出:

Run: func(cmd *cobra.Command, args []string) {
	if err := service.DoSomething(); err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
}

原因:

RunE 方便测试
RunE 方便统一错误输出
RunE 方便上层决定退出码
RunE 避免业务层直接调用 os.Exit

入口层统一处理:

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

命令层不要塞太多业务

命令层适合做这些事:

解析 flag
校验 Args
组装输入结构
调用 service
把结果打印成 CLI 输出

业务层适合做这些事:

读写文件
访问数据库
调用 HTTP API
执行领域规则
返回结构化结果和错误

对比一下:

RunE: func(cmd *cobra.Command, args []string) error {
	store := task.Store{File: dataFile}
	created, err := store.Add(task.AddInput{
		Title:    strings.Join(args, " "),
		Priority: addPriority,
		Tags:     addTags,
	})
	if err != nil {
		return err
	}
	fmt.Printf("已新增 #%d [%s] %s\n", created.ID, created.Priority, created.Title)
	return nil
}

这段命令代码只负责“把命令行输入转成业务输入”,没有直接读写 JSON。

Cobra 和 Viper 怎么搭配

Cobra 负责命令和参数,Viper 负责配置。

典型场景:

flag:--port 8080
环境变量:APP_PORT=8080
配置文件:config.yaml
默认值:8080

简化示例:

package main

import (
	"fmt"
	"os"

	"github.com/spf13/cobra"
	"github.com/spf13/viper"
)

var cfgFile string

func main() {
	rootCmd := &cobra.Command{
		Use:          "server",
		SilenceUsage: true,
		PreRunE: func(cmd *cobra.Command, args []string) error {
			if cfgFile != "" {
				viper.SetConfigFile(cfgFile)
			} else {
				viper.SetConfigName("config")
				viper.SetConfigType("yaml")
				viper.AddConfigPath(".")
			}
			viper.SetEnvPrefix("APP")
			viper.AutomaticEnv()
			return viper.ReadInConfig()
		},
		Run: func(cmd *cobra.Command, args []string) {
			fmt.Println("port:", viper.GetInt("port"))
		},
	}

	rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "配置文件路径")
	rootCmd.Flags().Int("port", 8080, "监听端口")
	_ = viper.BindPFlag("port", rootCmd.Flags().Lookup("port"))

	if err := rootCmd.Execute(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

配置文件:

port: 9090

运行:

go run . --config config.yaml

Viper 不是 Cobra 必需组件。只有配置来源变多时,才有必要引入。

测试 Cobra 命令

Cobra 命令可以直接测试。关键是给命令设置参数和输出缓冲区。

package cmd

import (
	"bytes"
	"testing"

	"github.com/spf13/cobra"
)

func TestVersionCommand(t *testing.T) {
	cmd := &cobra.Command{
		Use:     "app",
		Version: "v1.0.0",
	}

	var out bytes.Buffer
	cmd.SetOut(&out)
	cmd.SetErr(&out)
	cmd.SetArgs([]string{"--version"})

	if err := cmd.Execute(); err != nil {
		t.Fatal(err)
	}
	if got := out.String(); got != "app version v1.0.0\n" {
		t.Fatalf("unexpected output: %q", got)
	}
}

业务命令测试时,可以把数据文件指向临时目录:

dataFile = t.TempDir() + "/tasks.json"

这样测试不会污染本地文件。

常见错误一:把 flag 和 args 混在一起

这个命令:

todoctl add "写周报" --priority high

拆开看:

add:命令
"写周报":位置参数 args
--priority high:flag

标题这种核心对象通常适合做位置参数。

筛选、开关、配置这类修饰信息通常适合做 flag。

常见错误二:所有命令共用一堆全局变量

可以用全局变量绑定 flag,但不要把所有业务状态都堆进去。

适合全局变量的内容:

flag 绑定变量
rootCmd、addCmd 等命令对象
版本号

不适合全局变量的内容:

数据库连接
请求上下文
中间计算结果
业务缓存

后者更适合放在 service、client、store 里,在 RunE 或钩子里创建和传递。

常见错误三:父命令没有行为

父命令只做分组时,直接运行可能没有输出。

更友好的做法是显示帮助:

var userCmd = &cobra.Command{
	Use:   "user",
	Short: "用户管理",
	RunE: func(cmd *cobra.Command, args []string) error {
		return cmd.Help()
	},
}

这样执行:

app user

会看到 user 下面有哪些子命令。

常见错误四:在 init 里做真实业务

init() 适合做注册:

func init() {
	rootCmd.AddCommand(addCmd)
	addCmd.Flags().StringVarP(&addPriority, "priority", "p", "normal", "任务优先级")
}

不适合做这些事:

读取配置文件
连接数据库
请求远程接口
创建大对象缓存

这些动作会在包加载时发生,测试和命令补全都可能被拖慢。更合适的位置是 PersistentPreRunEPreRunERunE

常见错误五:错误时打印一大段 usage

参数错了就打印完整帮助,很容易淹没真正的错误。

根命令可以这样设置:

SilenceUsage:  true,
SilenceErrors: true,

然后统一输出错误:

if err := rootCmd.Execute(); err != nil {
	fmt.Fprintln(os.Stderr, err)
	os.Exit(1)
}

需要帮助时,使用者主动执行:

todoctl add --help

工程实践建议

Cobra 项目可以按这个思路组织:

cmd/
  root.go          根命令、全局 flag、版本、Execute
  add.go           add 子命令
  list.go          list 子命令
  completion.go    补全命令
internal/
  service/         业务服务
  repository/      数据访问
  config/          配置加载
main.go            程序入口

几个实用原则:

  • 命令文件按子命令拆分
  • cmd 包只做命令行适配
  • 复杂逻辑放到 internal
  • 业务函数返回 error,命令层用 RunE 传递
  • 全局配置放 PersistentFlags
  • 单命令配置放 Flags
  • 位置参数用 Args 明确校验
  • 父命令只分组时返回 cmd.Help()
  • 需要配置文件时再引入 Viper

什么时候不需要 Cobra

不是所有命令行程序都需要 Cobra。

下面这类场景,用标准库可能更轻:

只有一两个参数
没有子命令
不会长期维护
只是一次性脚本

标准库 flag 足够处理简单参数。

Cobra 更适合这些场景:

有多个子命令
需要清晰 help
需要补全脚本
需要版本命令
需要复杂参数校验
命令行工具会长期扩展

总结

Cobra 的重点不是“省几行参数解析代码”,而是让 CLI 程序有清晰结构。

可以把它理解成三层:

命令树:root、子命令、多级子命令
输入层:args、flags、校验、help、completion
执行层:RunE、钩子、错误返回、业务调用

写小工具时,os.Args 或标准库 flag 很顺手。

写可维护的 CLI 应用时,Cobra 更像一个骨架:命令怎么挂、参数怎么收、错误怎么返回、帮助怎么生成,都有固定位置。

真正好维护的 Cobra 项目,一般不是命令写得多炫,而是边界清楚:

cmd 负责命令行输入输出
internal 负责真实业务
RunE 负责把两边接起来

抓住这条线,Cobra 就不会变成另一坨复杂代码。