命令行工具:用 Go 构建优雅的 CLI 应用

命令行工具:用 Go 构建优雅的 CLI 应用。Go 语言非常适合构建命令行工具。事实上,很多知名的 CLI 工具都是用 Go 写的:Docker、Kubernetes、Hugo、Terraform……Go 的标准库提供了基础的命令行参数解析功能,而社区也涌现出了很多优秀的第三方库(如 cobra、urfave/cli),让构建复杂的 CLI 应用变得简单。

命令行工具:用 Go 构建优雅的 CLI 应用

Go 语言非常适合构建命令行工具。事实上,很多知名的 CLI 工具都是用 Go 写的:Docker 、Kubernetes 、Hugo、Terraform 、cURL 的现代替代品 httpie 也在用 Go 重写核心模块。为什么 Go 这么适合 CLI?因为:

  1. 编译成单一二进制文件,不依赖运行时,分发极简单
  2. 跨平台编译,一条命令就能编译出 Windows、Linux 、macOS 的可执行文件
  3. 标准库强大,不需要太多依赖就能写出功能完善的工具
  4. goroutine 轻量,适合并发执行批量任务

Go 的标准库提供了基础的命令行参数解析功能,而社区也涌现出了很多优秀的第三方库(如 Cobra、urfave/cli),让构建复杂的 CLI 应用变得简单。今天我们就来学习如何用 Go 构建专业的命令行工具。

基础:os.Args

最简单的方式是直接访问 os.Args,它是一个字符串切片,第一个元素是程序本身的路径:

package main

import (
    "fmt"
    "os"
)

func main() {
    // os.Args[0] 是程序名
    // os.Args[1:] 是命令行参数
    fmt.Println("程序名:", os.Args[0])
    fmt.Println("参数:", os.Args[1:])

    if len(os.Args) < 2 {
        fmt.Println("用法: myapp <name>")
        os.Exit(1)
    }

    name := os.Args[1]
    fmt.Printf("Hello, %s!\n", name)
}

运行:

$ go run main.go World
程序名: /tmp/go-build.../main
参数: [World]
Hello, World!

os.Args 是最底层的方式,适合做一些极简的工具脚本。但当参数变多时,手动解析变得又丑又容易出错。这时候标准库的 flag 包就派上用场了。

flag 包:标准库的参数解析

flag 包提供了灵活且类型安全的命令行参数解析,支持 string、int、bool、float64 和 time.Duration:

package main

import (
    "flag"
    "fmt"
)

func main() {
    // 定义参数(返回的是指针)
    name := flag.String("name", "World", "要问候的人的名字")
    age := flag.Int("age", 0, "年龄")
    verbose := flag.Bool("verbose", false, "是否显示详细信息")

    // 解析参数
    flag.Parse()

    // 使用参数(需要解引用)
    if *verbose {
        fmt.Printf("详细信息: name=%s, age=%d\n", *name, *age)
    }

    fmt.Printf("Hello, %s!", *name)
    if *age > 0 {
        fmt.Printf(" You are %d years old.", *age)
    }
    fmt.Println()

    // 获取非标志参数(在最后一个参数后的所有内容)
    args := flag.Args()
    if len(args) > 0 {
        fmt.Println("其他参数:", args)
    }
}

运行:

$ go run main.go -name=Alice -age=30 -verbose
详细信息: name=Alice, age=30
Hello, Alice! You are 30 years old.

$ go run main.go --help
Usage of main:
  -age int
        年龄
  -name string
        要问候的人的名字 (default "World")
  -verbose
        是否显示详细信息

flag 的多种声明方式

// 方式1:返回指针(最常见)
namePtr := flag.String("name", "World", "名字")

// 方式2:绑定到已有变量
var port int
flag.IntVar(&port, "port", 8080, "服务端口号")

// 方式3:绑定到已有变量 + 短标志
var verbose bool
flag.BoolVarP(&verbose, "verbose", "v", false, "详细输出")

布尔参数的陷阱

布尔参数有一个很容易中招的陷阱:不能直接用 -flag true 的方式给 bool 参数传值:

# ✅ 正确:直接写 flag 名称就表示 true
$ myapp -verbose

# ❌ 错误:这是设置非标志参数为 "true",verbose 仍然是 false
$ myapp -verbose true

# ✅ 如果你非要显式传值,用等号
$ myapp -verbose=true
$ myapp -verbose=false

自定义标志类型

自定义类型需要实现 flag.Value 接口:

package main

import (
    "flag"
    "fmt"
    "strings"
)

// 自定义类型:逗号分隔的字符串列表
type StringList []string

func (s *StringList) String() string {
    return strings.Join(*s, ", ")
}

func (s *StringList) Set(value string) error {
    *s = strings.Split(value, ",")
    return nil
}

func main() {
    var tags StringList
    flag.Var(&tags, "tags", "标签列表(逗号分隔)")

    flag.Parse()

    fmt.Println("标签:", tags)
}

运行:

$ go run main.go -tags=go,cli,tutorial
标签: [go cli tutorial]

flag 高级用法

使用独立的 FlagSet 可以创建子命令式的解析:

package main

import (
    "flag"
    "fmt"
    "os"
)

func main() {
    // 创建新的 FlagSet(用于子命令解析)
    serveCmd := flag.NewFlagSet("serve", flag.ExitOnError)
    port := serveCmd.Int("port", 8080, "服务端口号")

    configCmd := flag.NewFlagSet("config", flag.ExitOnError)
    path := configCmd.String("path", "config.yaml", "配置文件路径")

    if len(os.Args) < 2 {
        fmt.Println("需要子命令:serve 或 config")
        os.Exit(1)
    }

    switch os.Args[1] {
    case "serve":
        serveCmd.Parse(os.Args[2:])
        fmt.Println("启动服务,端口:", *port)
    case "config":
        configCmd.Parse(os.Args[2:])
        fmt.Println("加载配置:", *path)
    default:
        fmt.Println("未知子命令:", os.Args[1])
        os.Exit(1)
    }
}

虽然标准库的 flag 包功能完善,但当命令越来越复杂时,Cobra 框架会更合适。

Cobra:专业的 CLI 框架

对于复杂的 CLI 应用,推荐使用 Cobra ,它是 Go 社区最流行的 CLI 框架,被 Kubernetes、Hugo、Docker Compose 等大量项目使用。

安装 Cobra

go get -u github.com/spf13/cobra

基础示例

package main

import (
    "fmt"
    "os"

    "github.com/spf13/cobra"
)

var rootCmd = &cobra.Command{
    Use:   "myapp",
    Short: "MyApp 是一个示例 CLI 工具",
    Long:  `MyApp 是一个用 Go 和 Cobra 构建的示例 CLI工具。`,
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Println("Hello from MyApp!")
    },
}

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

添加子命令

package main

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

var rootCmd = &cobra.Command{
    Use:   "myapp",
    Short: "MyApp 是一个示例 CLI 工具",
}

var greetCmd = &cobra.Command{
    Use:   "greet [name]",
    Short: "问候某人",
    Args:  cobra.MaximumNArgs(1),
    Run: func(cmd *cobra.Command, args []string) {
        name := "World"
        if len(args) > 0 {
            name = args[0]
        }

        times, _ := cmd.Flags().GetInt("times")
        for i := 0; i < times; i++ {
            fmt.Printf("Hello, %s!\n", name)
        }
    },
}

func init() {
    greetCmd.Flags().IntP("times", "t", 1, "问候次数")
    rootCmd.AddCommand(greetCmd)
}

func main() {
    rootCmd.Execute()
}

运行:

$ myapp greet Alice -t 3
Hello, Alice!
Hello, Alice!
Hello, Alice!

$ myapp greet --help
问候某人

Usage:
  myapp greet [name] [flags]

Flags:
  -h, --help       help for greet
  -t, --times int  问候次数 (default 1)

持久化标志与 PreRun 钩子

var verbose bool

func init() {
    // 持久化标志在所有子命令中都可用
    rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "详细输出")

    // PreRun 钩子:在执行命令前运行
    rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
        if verbose {
            fmt.Println("详细模式已开启")
        }
    }
}

Cobra 支持多种命令钩子(PersistentPreRun、PreRun、Run、PostRun、PersistentPostRun),可以灵活地在命令执行不同生命周期插入逻辑。

使用 cobra-cli 生成代码

Cobra 提供了代码生成工具:

# 安装代码生成器
go install github.com/spf13/cobra-cli@latest

# 初始化项目的 CLI 骨架
cobra-cli init myapp

# 添加命令
cobra-cli add serve
cobra-cli add config

代码生成器会自动帮你创建 main.go、cmd 目录结构和命令文件,让项目组织更加规范。

CLI 框架对比

框架GitHub Stars命令标志配置适合场景
flag(标准库)内置需手动类型安全无简单工具、学习理解原理
Cobra38k+完整子命令短标志、必填验证Viper 集成复杂 CLI(推荐)
urfave/cli22k+完整子命令全面且直观内置中等复杂度的 CLI
Kong5k+声明式结构体标签内置强类型偏好
Kingpin3k+完整链式 API外部已停止维护,不推荐新项目

对于新项目,首推 Cobra。它的文档完善、社区活跃、生态系统丰富。

交互式 CLI

读取用户输入

package main

import (
    "bufio"
    "fmt"
    "os"
    "strings"
)

func main() {
    reader := bufio.NewReader(os.Stdin)

    fmt.Print("请输入你的名字: ")
    name, _ := reader.ReadString('\n')
    name = strings.TrimSpace(name)

    fmt.Printf("Hello, %s!\n", name)

    // 确认操作
    fmt.Print("确定要继续吗?(y/n): ")
    confirm, _ := reader.ReadString('\n')
    confirm = strings.TrimSpace(strings.ToLower(confirm))

    if confirm == "y" || confirm == "yes" {
        fmt.Println("继续执行...")
    } else {
        fmt.Println("已取消")
    }
}

密码输入(不回显)

import "golang.org/x/term"

func readPassword() string {
    fmt.Print("请输入密码: ")
    password, _ := term.ReadPassword(int(os.Stdin.Fd()))
    fmt.Println()
    return string(password)
}

彩色输出

使用 fatih/color 库添加颜色:

package main

import (
    "github.com/fatih/color"
)

func main() {
    color.Red("这是红色")
    color.Green("这是绿色")
    color.Blue("这是蓝色")
    color.Yellow("这是黄色")

    bold := color.New(color.Bold)
    bold.Println("这是粗体")

    success := color.New(color.FgGreen, color.Bold)
    success.Println("操作成功")

    errorStyle := color.New(color.FgRed, color.Bold)
    errorStyle.Println("操作失败")
}

错误处理与退出码

命令行工具应该遵循 POSIX 退出码规范:

  • 0:成功
  • 1:通用错误
  • 2:误用命令(如参数错误)
  • 126:命令不可执行
  • 127:命令未找到
func main() {
    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintf(os.Stderr, "错误: %v\n", err)
        os.Exit(1)
    }
}

实战:TODO 管理工具

让我们用 Cobra 构建一个完整的 TODO 管理工具:

package main

import (
    "encoding/json"
    "fmt"
    "os"
    "time"

    "github.com/spf13/cobra"
)

type Todo struct {
    ID        int       `json:"id"`
    Title     string    `json:"title"`
    Done      bool      `json:"done"`
    CreatedAt time.Time `json:"created_at"`
}

type TodoList struct {
    Todos  []Todo `json:"todos"`
    NextID int    `json:"next_id"`
}

const dataFile = "todos.json"

func loadTodos() (*TodoList, error) {
    list := &TodoList{NextID: 1}

    data, err := os.ReadFile(dataFile)
    if err != nil {
        if os.IsNotExist(err) {
            return list, nil
        }
        return nil, err
    }

    err = json.Unmarshal(data, list)
    return list, err
}

func saveTodos(list *TodoList) error {
    data, err := json.MarshalIndent(list, "", "  ")
    if err != nil {
        return err
    }
    return os.WriteFile(dataFile, data, 0644)
}

var rootCmd = &cobra.Command{
    Use:   "todo",
    Short: "一个简单的 TODO 管理工具",
}

var addCmd = &cobra.Command{
    Use:   "add [title]",
    Short: "添加一个新的 TODO",
    Args:  cobra.MinimumNArgs(1),
    Run: func(cmd *cobra.Command, args []string) {
        list, err := loadTodos()
        if err != nil {
            fmt.Println("加载失败:", err)
            return
        }

        todo := Todo{
            ID:        list.NextID,
            Title:     args[0],
            Done:      false,
            CreatedAt: time.Now(),
        }
        list.Todos = append(list.Todos, todo)
        list.NextID++

        err = saveTodos(list)
        if err != nil {
            fmt.Println("保存失败:", err)
            return
        }

        fmt.Printf("添加 TODO #%d: %s\n", todo.ID, todo.Title)
    },
}

var listCmd = &cobra.Command{
    Use:   "list",
    Short: "列出所有 TODO",
    Run: func(cmd *cobra.Command, args []string) {
        list, err := loadTodos()
        if err != nil {
            fmt.Println("加载失败:", err)
            return
        }

        if len(list.Todos) == 0 {
            fmt.Println("没有 TODO")
            return
        }

        showDone, _ := cmd.Flags().GetBool("all")

        for _, todo := range list.Todos {
            if !showDone && todo.Done {
                continue
            }

            status := "[ ]"
            if todo.Done {
                status = "[x]"
            }

            fmt.Printf("%s #%d: %s\n", status, todo.ID, todo.Title)
        }
    },
}

var doneCmd = &cobra.Command{
    Use:   "done [id]",
    Short: "标记 TODO 为完成",
    Args:  cobra.ExactArgs(1),
    Run: func(cmd *cobra.Command, args []string) {
        list, err := loadTodos()
        if err != nil {
            fmt.Println("加载失败:", err)
            return
        }

        id := 0
        fmt.Sscanf(args[0], "%d", &id)

        for i, todo := range list.Todos {
            if todo.ID == id {
                list.Todos[i].Done = true
                err = saveTodos(list)
                if err != nil {
                    fmt.Println("保存失败:", err)
                    return
                }
                fmt.Printf("TODO #%d 已完成\n", id)
                return
            }
        }

        fmt.Printf("TODO #%d 不存在\n", id)
    },
}

var deleteCmd = &cobra.Command{
    Use:   "delete [id]",
    Short: "删除 TODO",
    Args:  cobra.ExactArgs(1),
    Run: func(cmd *cobra.Command, args []string) {
        list, err := loadTodos()
        if err != nil {
            fmt.Println("加载失败:", err)
            return
        }

        id := 0
        fmt.Sscanf(args[0], "%d", &id)

        for i, todo := range list.Todos {
            if todo.ID == id {
                list.Todos = append(list.Todos[:i], list.Todos[i+1:]...)
                err = saveTodos(list)
                if err != nil {
                    fmt.Println("保存失败:", err)
                    return
                }
                fmt.Printf("TODO #%d 已删除\n", id)
                return
            }
        }

        fmt.Printf("TODO #%d 不存在\n", id)
    },
}

func init() {
    listCmd.Flags().BoolP("all", "a", false, "显示所有 TODO(包括已完成的)")

    rootCmd.AddCommand(addCmd)
    rootCmd.AddCommand(listCmd)
    rootCmd.AddCommand(doneCmd)
    rootCmd.AddCommand(deleteCmd)
}

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

使用示例:

$ todo add "学习 Go 语言"
添加 TODO #1: 学习 Go 语言

$ todo add "写一个 CLI 工具"
添加 TODO #2: 写一个 CLI 工具

$ todo list
[ ] #1: 学习 Go 语言
[ ] #2: 写一个 CLI 工具

$ todo done 1
TODO #1 已完成

$ todo list
[ ] #2: 写一个 CLI 工具

$ todo list -a
[x] #1: 学习 Go 语言
[ ] #2: 写一个 CLI 工具

实战:Git 仓库统计工具

展示如何用 Go 写一个命令行工具来统计 Git 仓库的贡献者列表:

package main

import (
    "bufio"
    "fmt"
    "os/exec"
    "strings"
)

func getContributors() (map[string]int, error) {
    cmd := exec.Command("git", "log", "--format=%an")
    stdout, err := cmd.StdoutPipe()
    if err != nil {
        return nil, err
    }
    if err := cmd.Start(); err != nil {
        return nil, err
    }

    contributors := make(map[string]int)
    scanner := bufio.NewScanner(stdout)
    for scanner.Scan() {
        name := strings.TrimSpace(scanner.Text())
        contributors[name]++
    }

    if err := cmd.Wait(); err != nil {
        return nil, err
    }
    return contributors, nil
}

func main() {
    contributors, err := getContributors()
    if err != nil {
        fmt.Println("Error:", err)
        return
    }

    fmt.Println("贡献者统计:")
    for name, count := range contributors {
        fmt.Printf("  %s: %d commits\n", name, count)
    }
}

进度条

package main

import (
    "fmt"
    "time"

    "github.com/schollz/progressbar/v3"
)

func main() {
    bar := progressbar.Default(100, "处理中")

    for i := 0; i < 100; i++ {
        bar.Add(1)
        time.Sleep(50 * time.Millisecond)
    }

    fmt.Println("\n完成!")
}

常见问题 FAQ

Q1: flag 的布尔参数为什么不能传 -flag true?
这是 flag 包的设计决策。布尔标志如果存在就是 true,不存在就是默认值。要显式传值必须用等号 -flag=true。这也是 flag 包最容易被新手踩的坑。

Q2: Cobra 和 urfave/cli 怎么选?
如果你需要子命令(如 kubectl get、docker build),选 Cobra。如果只是一个简单的单命令工具,urfave/cli 的 API 更直观。新项目一般推荐 Cobra,因为生态更成熟。

Q3: 如何给 CLI 工具添加版本号?
使用 go build -ldflags 注入版本信息:

var version = "dev"
// 构建时:go build -ldflags "-X main.version=1.0.0"

Q4: 如何读取配置文件?
推荐用 spf13/viper,它天然支持 Cobra,可以无缝解析 YAML/JSON/TOML/环境变量。配置文件路径通常放在 $HOME/.myapp/config.yaml。

Q5: 如何测试 CLI 应用?

  1. 通过 cobra.Command.SetArgs() 设置参数,然后调用 ExecuteC();
  2. 或者直接调用 Run 函数,传入自定义的 *cobra.Command;
  3. 使用标准库的 os/exec 在子进程中测试完整的二进制文件。

Q6: 发布 CLI 工具的最佳方式是什么?

  1. GoReleaser(自动构建多平台二进制文件并发布到 GitHub Release);
  2. go install(要求用户有 Go 环境);
  3. Homebrew(针对 macOS/Linux 用户);
  4. Docker 镜像(跨平台但启动稍慢);
  5. Snap / Flatpak(Linux 通用包管理)。

Q7: 为什么 flag.Parse() 要在 flag 定义之后调用?
flag.Parse() 会扫描 os.Args[1:] 并填充已定义的 flag 变量。如果在定义 flag 之前调用 Parse,新定义的 flag 不会被解析。惯例是在 main() 开头或 init() 中定义所有 flag,然后在流程开始时统一 Parse。

Q8: 如何给 CLI 工具自动生成 Shell 自动补全?
Cobra 内置了对 bash、zsh、fish、PowerShell 的自动补全生成支持:myapp completion bash > /etc/bash_completion.d/myapp。

最佳实践

  1. 单一职责:每个子命令只做一件事,功能保持内聚
  2. 合理的默认值:所有可选标志都应该有合理的默认值
  3. 明确的错误信息:出错时告诉用户"为什么"和"怎么修复"
  4. 使用退出码:遵循 POSIX 约定,成功返回 0,失败返回非 0
  5. 提供 --help 和 -h:Cobra 会自动帮你生成,但内容需要写清楚
  6. 版本号管理:通过构建时注入,方便排查用户问题
  7. 支持管道:设计命令时考虑与其他工具的管道协作
  8. 不输出来自其他库的日志:除非在 verbose 模式下
  9. 避免使用 emoji:某些终端环境不支持 emoji 渲染
  10. 测试你的 CLI:像测试正常函数一样测试 Run 函数体

延伸阅读

如果你想深入学习 Go CLI 开发的相关技能,这些文章会对你很有帮助:

练习时间

  1. 实现一个文件统计工具,统计目录下的文件数量和总大小
  2. 创建一个密码生成器,支持自定义长度和字符集
  3. 构建一个支持子命令的 git 统计工具(commits、authors、lines)
  4. 实现一个支持进度条的大文件下载工具

我们下篇见!

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练