Language:Chinese VersionEnglish Version

Go 已经成为 CLI 工具的默认语言,而且理由充分。单二进制文件分发、快速编译、出色的标准库,以及真正有效的跨平台编译。但是,从教程级别的解析几个标志的 CLI 工具到处理边缘情况、提供有用错误消息、并按照软件专业人士期望的方式运行的生产工具之间,存在巨大差距。

过去两年中,我用 Go 发布了四个生产级 CLI 工具,从内部部署工具到拥有 2,000+ 用户的开源数据库迁移工具。本文涵盖了我第一个工具发布前希望了解的内容。

从 Cobra 开始,但要理解它的作用

几乎每个严肃的 Go CLI 都使用 Cobra。它处理子命令、标志、帮助生成和 shell 自动补全。标准脚手架看起来是这样的:

// cmd/root.go
package cmd

import (
    "fmt"
    "os"

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

var cfgFile string

var rootCmd = &cobra.Command{
    Use:   "mytool",
    Short: "A production-ready CLI tool",
    Long:  `mytool manages deployments, runs migrations,
and handles configuration for your infrastructure.`,
    PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
        return initConfig()
    },
}

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

func init() {
    rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "",
        "config file (default is $HOME/.mytool.yaml)")
    rootCmd.PersistentFlags().String("output", "text",
        "output format: text, json, yaml")
    viper.BindPFlag("output", rootCmd.PersistentFlags().Lookup("output"))
}

教程中省略的内容:Cobra 是一个框架,而不仅仅是一个库。一旦你采用它,它会塑造你的整个应用程序结构。PersistentPreRunE 钩子是初始化配置、设置日志记录和验证全局状态的地方。做错这一点意味着每个子命令都会有不一致的行为。

配置:三层方法

生产级 CLI 工具需要从三个来源获取配置,按优先级顺序:

  1. 命令行标志(最高优先级)
  2. 环境变量
  3. 配置文件(最低优先级)

Viper 可以自然地处理这种情况,但绑定顺序很重要:

func initConfig() error {
    if cfgFile != "" {
        viper.SetConfigFile(cfgFile)
    } else {
        home, err := os.UserHomeDir()
        if err != nil {
            return fmt.Errorf("查找主目录失败: %w", err)
        }
        viper.AddConfigPath(home)
        viper.AddConfigPath(".")
        viper.SetConfigName(".mytool")
        viper.SetConfigType("yaml")
    }

    // 环境变量:MYTOOL_DATABASE_URL、MYTOOL_API_KEY 等
    viper.SetEnvPrefix("MYTOOL")
    viper.AutomaticEnv()
    viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))

    if err := viper.ReadInConfig(); err != nil {
        if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
            return fmt.Errorf("读取配置失败: %w", err)
        }
        // 配置文件未找到是可以的 — 使用默认值和环境变量
    }

    return nil
}

一个常见错误:没有记录你的工具读取哪些环境变量。用户不应该必须阅读你的源代码才能弄明白 MYTOOL_DATABASE_URL 映射到 --database-url 标志。添加一个 mytool config show 子命令,显示已解析的配置及其来源:

var configShowCmd = &cobra.Command{
    Use:   "show",
    Short: "显示已解析的配置及其来源",
    RunE: func(cmd *cobra.Command, args []string) error {
        settings := viper.AllSettings()
        for key, value := range settings {
            source := "default"
            if viper.InConfig(key) {
                source = "配置文件"
            }
            if os.Getenv("MYTOOL_" + strings.ToUpper(key)) != "" {
                source = "环境变量"
            }
            if cmd.Flags().Changed(key) {
                source = "命令行标志"
            }
            fmt.Fprintf(os.Stdout, "%-20s = %-30v (来源: %s)n",
                key, value, source)
        }
        return nil
    },
}

尊重用户的错误处理

业余和专业 CLI 工具之间最大的质量差距在于错误处理。比较这两个错误消息:

// 不好的:所有教程都会产生的错误
错误:打开 config.yaml:没有这样的文件或目录

// 好的:生产工具应该产生的错误
错误:无法读取配置文件 "config.yaml"

  该文件在预期路径中不存在:
    /home/user/project/config.yaml

  要创建默认配置:
    mytool config init

  要指定不同的配置文件:
    mytool --config /path/to/config.yaml

构建一个携带上下文和建议的自定义错误类型:

type CLIError struct {
    Message    string
    Cause      error
    Suggestion string
    ExitCode   int
}

func (e *CLIError) Error() string {
    var b strings.Builder
    b.WriteString("错误: " + e.Message + "n")
    if e.Cause != nil {
        b.WriteString("n  原因: " + e.Cause.Error() + "n")
    }
    if e.Suggestion != "" {
        b.WriteString("n  " + e.Suggestion + "n")
    }
    return b.String()
}

func (e *CLIError) Unwrap() error {
    return e.Cause
}

// 在命令中的使用
func connectDatabase(dsn string) (*sql.DB, error) {
    db, err := sql.Open("postgres", dsn)
    if err != nil {
        return nil, &CLIError{
            Message:    "连接数据库失败",
            Cause:      err,
            Suggestion: "检查数据库URL是否正确以及服务器是否正在运行。n  当前URL: " + maskPassword(dsn),
            ExitCode:   1,
        }
    }
    return db, nil
}

输出格式:支持人类和机器

每个生产级CLI至少需要两种输出模式:人类可读的文本和机器可解析的JSON。许多工具还添加了YAML和表格格式。

type OutputFormatter interface {
    Format(data interface{}) (string, error)
}

type TextFormatter struct{ Writer io.Writer }
type JSONFormatter struct{ Writer io.Writer; Pretty bool }
type TableFormatter struct{ Writer io.Writer; Columns []string }

func NewFormatter(format string, w io.Writer) OutputFormatter {
    switch format {
    case "json":
        return &JSONFormatter{Writer: w, Pretty: true}
    case "json-compact":
        return &JSONFormatter{Writer: w, Pretty: false}
    case "table":
        return &TableFormatter{Writer: w}
    default:
        return &TextFormatter{Writer: w}
    }
}

// 使用tablewriter进行表格输出
func (f *TableFormatter) Format(data interface{}) (string, error) {
    table := tablewriter.NewWriter(f.Writer)
    table.SetHeader(f.Columns)
    table.SetBorder(false)
    table.SetAutoWrapText(false)

    // 通过反射数据来填充行...
    rows := toRows(data, f.Columns)
    for _, row := range rows {
        table.Append(row)
    }
    table.Render()
    return "", nil
}

JSON输出对脚本化至关重要。用户会将您的CLI管道传输到jq,将其提供给其他工具,并在CI管道中使用它。如果您的JSON输出不一致或缺少字段,您将会收到错误报告。

进度指示和流式输出

长时间运行的操作需要进度反馈。github.com/schollz/progressbar 库是标准选择,但有一个微妙之处:进度条不能干扰 JSON 输出或管道友好行为。

func runMigration(cmd *cobra.Command, args []string) error {
    migrations, err := loadMigrations(args[0])
    if err != nil {
        return err
    }

    // 仅对交互式终端显示进度条
    isTerminal := term.IsTerminal(int(os.Stderr.Fd()))

    var bar *progressbar.ProgressBar
    if isTerminal {
        bar = progressbar.NewOptions(len(migrations),
            progressbar.OptionSetWriter(os.Stderr), // 进度输出到 stderr
            progressbar.OptionSetDescription("运行迁移"),
            progressbar.OptionShowCount(),
            progressbar.OptionClearOnFinish(),
        )
    }

    for _, m := range migrations {
        if err := m.Execute(db); err != nil {
            return &CLIError{
                Message: fmt.Sprintf("迁移 %s 失败", m.Name),
                Cause:   err,
            }
        }
        if bar != nil {
            bar.Add(1)
        }
    }

    // 结果输出到 stdout(可通过管道传输)
    return formatter.Format(MigrationResult{
        Applied: len(migrations),
        Status:  "完成",
    })
}

关键原则:进度和状态输出到 stderr,结果输出到 stdout。这样用户可以执行 mytool migrate ./migrations | jq .applied 而不会让进度条字符破坏 JSON。

测试 CLI 工具

测试 CLI 需要在多个级别进行测试:业务逻辑的单元测试、命令行为的集成测试,以及实际二进制文件的端到端测试。

// 集成测试:测试命令输出而不运行二进制文件
func TestDeployCommand(t *testing.T) {
    // 捕获 stdout
    var stdout bytes.Buffer
    rootCmd.SetOut(&stdout)
    rootCmd.SetErr(&bytes.Buffer{})

    // 设置参数,模拟命令行调用
    rootCmd.SetArgs([]string{"deploy", "--env", "staging", "--dry-run"})

    err := rootCmd.Execute()
    require.NoError(t, err)

    var result DeployResult
    err = json.Unmarshal(stdout.Bytes(), &result)
    require.NoError(t, err)
    assert.Equal(t, "staging", result.Environment)
    assert.True(t, result.DryRun)
}

// 端到端测试:测试实际编译的二进制文件
func TestBinaryE2E(t *testing.T) {
    if testing.Short() {
        t.Skip("在短模式下跳过 e2e 测试")
    }

    binary := buildBinary(t) // go build -o tempdir/mytool
    defer os.Remove(binary)

    cmd := exec.Command(binary, "version", "--output", "json")
    output, err := cmd.CombinedOutput()
    require.NoError(t, err, "二进制文件执行失败: %s", output)

    var version VersionInfo
    require.NoError(t, json.Unmarshal(output, &version))
    assert.NotEmpty(t, version.Version)
    assert.NotEmpty(t, version.Commit)
}

分发:交叉编译与发布

Go 的交叉编译是一项超能力。GoReleaser 自动化了整个发布流程:

# .goreleaser.yaml
version: 2
builds:
  - env:
      - CGO_ENABLED=0
    goos:
      - linux
      - darwin
      - windows
    goarch:
      - amd64
      - arm64
    ldflags:
      - -s -w
      - -X main.version={{.Version}}
      - -X main.commit={{.Commit}}
      - -X main.date={{.Date}}

archives:
  - formats: [tar.gz]
    format_overrides:
      - goos: windows
        formats: [zip]
    name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"

brews:
  - repository:
      owner: yourorg
      name: homebrew-tap
    homepage: "https://github.com/yourorg/mytool"
    description: "Your CLI tool description"

nfpms:
  - package_name: mytool
    vendor: YourOrg
    homepage: "https://github.com/yourorg/mytool"
    maintainer: "You "
    formats:
      - deb
      - rpm

这会为 6 种平台/架构组合生成二进制文件,创建 Homebrew 公式,并构建 .deb 和 .rpm 包 —— 所有这些都只需一个 goreleaser release 命令。

Shell 自动补全:专业之选

Cobra 可以自动生成 shell 自动补全,但自定义补全能让你的工具感觉更加完善:

var deployCmd = &cobra.Command{
    Use:   "deploy [environment]",
    Short: "Deploy to an environment",
    // Dynamic completion for environment names
    ValidArgsFunction: func(cmd *cobra.Command, args []string,
        toComplete string) ([]string, cobra.ShellCompDirective) {
        if len(args) != 0 {
            return nil, cobra.ShellCompDirectiveNoFileComp
        }
        envs, err := listEnvironments()
        if err != nil {
            return nil, cobra.ShellCompDirectiveError
        }
        return envs, cobra.ShellCompDirectiveNoFileComp
    },
    RunE: runDeploy,
}

使用以下命令安装自动补全:mytool completion bash > /etc/bash_completion.d/mytoolmytool completion zsh > "${fpath[1]}/_mytool"

信号、优雅关闭与上下文

生产环境 CLI 工具必须优雅地处理中断。如果用户在数据库迁移过程中按下 Ctrl+C,你需要完成当前的迁移步骤,而不是让数据库处于半迁移状态:

func runWithGracefulShutdown(fn func(ctx context.Context) error) error {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)

    errCh := make(chan error, 1)
    go func() {
        errCh <- fn(ctx)
    }()

    select {
    case err := <-errCh:
        return err
    case sig := <-sigCh:
        fmt.Fprintf(os.Stderr,
            "n接收到 %s,正在完成当前操作...n", sig)
        cancel()
        // 等待优雅完成,带超时
        select {
        case err := <-errCh:
            return err
        case <-time.After(30 * time.Second):
            return fmt.Errorf("操作在关闭过程中超时")
        }
    }
}

版本信息:嵌入构建元数据

每个 CLI 工具都需要一个 version 命令来显示构建版本、提交哈希和构建日期。使用 Go 链接器标志在构建时嵌入这些信息:

// main.go
var (
    version = "dev"
    commit  = "none"
    date    = "unknown"
)

var versionCmd = &cobra.Command{
    Use:   "version",
    Short: "打印版本信息",
    Run: func(cmd *cobra.Command, args []string) {
        info := map[string]string{
            "version":    version,
            "commit":     commit,
            "built":      date,
            "go_version": runtime.Version(),
            "os/arch":    runtime.GOOS + "/" + runtime.GOARCH,
        }
        formatter.Format(info)
    },
}

检查清单

在发布 Go CLI 工具之前,请验证:

  • 三层配置(标志 > 环境变量 > 配置文件)工作正常
  • 错误包含上下文和可操作的建议
  • JSON 和文本输出模式工作正常,进度信息显示在 stderr 上
  • 已生成并记录了 shell 自动补全功能
  • 对于长时间运行的操作,已优雅地处理 Ctrl+C
  • version 命令显示构建元数据
  • 跨平台二进制文件构建正确(在 CI 上测试)
  • 每个子命令都有 man 页面或完整的 --help 说明

Go 使得构建 CLI 工具变得简单。但要使它们达到生产就绪状态,需要关注教程中跳过的细节。本文中的工具和模式是向真实用户发布真实软件的结果——尽早采用它们,你的 CLI 工具从第一天起就会显得专业。

By

Leave a Reply

Your email address will not be published. Required fields are marked *

You missed