404 Not Found

404 Not Found


nginx

CLI工具开发

第25课:CLI工具开发

生活类比引入

想象你在一个自动售货机前买东西。你按下一个按钮选择饮料(传入参数),机器根据你的选择出货(执行对应功能),如果按错了按钮它会提示你"无效选择"(参数校验)。CLI 工具就像这台售货机——用户通过命令行输入指令和参数,程序根据输入执行相应操作并输出结果。好的 CLI 工具就像一台设计精良的售货机:按钮布局清晰、操作提示明确、错误反馈友好。


核心概念

Go 语言天生适合构建 CLI 工具——编译为单一二进制文件、跨平台、启动速度快。核心概念如下:

概念 说明
os.Args 原始命令行参数切片,[0] 是程序名
flag 标准库提供的参数解析工具
cobra 第三方 CLI 框架,支持子命令、自动帮助、Shell 补全
子命令 git commitdocker run,程序根据子命令执行不同逻辑
参数校验 验证用户输入是否合法,给出友好的错误提示
交互式输入 运行时读取用户输入,构建交互式 CLI 体验

os.Args 与 flag 包

os.Args 是最原始的参数获取方式,适合简单场景;flag 包提供了类型化的标志解析:

命令行结构:
program [flags] [args]
  ↑         ↑       ↑
程序名    标志参数   位置参数

示例:
go build -o myapp ./cmd
  ↑       ↑     ↑
go     -o myapp  ./cmd

cobra 库

cobra 是 Go 社区最流行的 CLI 框架,被 kubectl、docker、hugo 等项目使用:

cobra 核心组件:
- RootCmd:根命令,程序入口
- SubCmd:子命令,如 "add"、"list"、"delete"
- Run/RunE:命令执行函数
- Flags:绑定到命令的标志参数

基本语法与用法

1. os.Args 基础

GO
package main

import (
    "fmt"
    "os"
)

func main() {
    // os.Args 是字符串切片,第一个元素是程序名
    args := os.Args
    fmt.Printf("参数个数: %d\n", len(args))
    fmt.Printf("程序名: %s\n", args[0])

    // 遍历所有参数
    if len(args) > 1 {
        fmt.Println("传入的参数:")
        for i, arg := range args[1:] {
            fmt.Printf("  [%d] %s\n", i, arg)
        }
    }
}
BASH
# 运行测试
$ go run main.go hello world
参数个数: 3
程序名: /tmp/go-build.../main
传入的参数:
  [0] hello
  [1] world
💡 os.Args[0] 不一定是你输入的程序名——它是操作系统传入的可执行文件路径。跨平台时要注意这个差异。

2. flag 包解析标志

GO
package main

import (
    "flag"
    "fmt"
)

func main() {
    // 定义标志参数
    name := flag.String("name", "世界", "你的名字")
    age := flag.Int("age", 18, "你的年龄")
    verbose := flag.Bool("v", false, "详细输出")

    // 解析命令行参数
    flag.Parse()

    // 使用解析后的值(注意是指针,需要解引用)
    if *verbose {
        fmt.Printf("[调试] name=%s, age=%d\n", *name, *age)
    }
    fmt.Printf("你好, %s! 你今年 %d 岁。\n", *name, *age)

    // 获取非标志参数(位置参数)
    fmt.Printf("剩余参数: %v\n", flag.Args())
}
BASH
$ go run main.go -name=张三 -age=25 -v
[调试] name=张三, age=25
你好, 张三! 你今年 25 岁。
剩余参数: []

$ go run main.go --help
Usage of main:
  -age int
        你的年龄 (default 18)
  -name string
        你的名字 (default "世界")
  -v    详细输出
💡 flag 的两种赋值风格-flag=value-flag value 都可以。但 -flag value 风格中,-flag 必须紧跟值,不能与其他标志合并。

3. 使用变量绑定

GO
package main

import (
    "flag"
    "fmt"
)

func main() {
    // 使用变量绑定,直接操作变量而非指针
    var host string
    var port int
    var debug bool

    flag.StringVar(&host, "host", "localhost", "服务器地址")
    flag.IntVar(&port, "port", 8080, "端口号")
    flag.BoolVar(&debug, "debug", false, "开启调试模式")

    flag.Parse()

    fmt.Printf("连接到 %s:%d (调试: %v)\n", host, port, debug)
}
💡 StringVar/IntVar/BoolVar 绑定到已有变量,适合需要在多处共享配置的场景。对应的 String/Int/Bool 返回指针,适合局部使用。

4. cobra 基本结构

GO
package main

import (
    "fmt"
    "os"

    "github.com/spf13/cobra"
)

func main() {
    // 根命令
    rootCmd := &cobra.Command{
        Use:   "myapp",
        Short: "一个示例 CLI 应用",
        Long:  "这是一个使用 cobra 构建的示例 CLI 应用程序。",
        Run: func(cmd *cobra.Command, args []string) {
            fmt.Println("欢迎使用 myapp!")
        },
    }

    // 子命令
    helloCmd := &cobra.Command{
        Use:   "hello [名字]",
        Short: "打招呼",
        Args:  cobra.MinimumNArgs(1), // 至少需要 1 个参数
        Run: func(cmd *cobra.Command, args []string) {
            fmt.Printf("你好, %s!\n", args[0])
        },
    }

    // 添加子命令到根命令
    rootCmd.AddCommand(helloCmd)

    // 执行
    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
BASH
$ go run main.go
欢迎使用 myapp!

$ go run main.go hello Go
你好, Go!

$ go run main.go --help
一个示例 CLI 应用

Usage:
  myapp [command]

Available Commands:
  hello       打招呼
  help        Help about any command

Flags:
  -h, --help   help for myapp
💡 cobra 自动处理 --help-h,无需手动实现。它还会在参数错误时自动生成用法提示。

5. cobra 标志绑定

GO
package main

import (
    "fmt"
    "os"
    "strings"

    "github.com/spf13/cobra"
)

func main() {
    var name string
    var count int

    rootCmd := &cobra.Command{
        Use:   "greeter",
        Short: "重复问候工具",
        Run: func(cmd *cobra.Command, args []string) {
            for i := 0; i < count; i++ {
                fmt.Printf("你好, %s! (%d/%d)\n", name, i+1, count)
            }
        },
    }

    // 持久性标志(对所有子命令生效)
    rootCmd.PersistentFlags().StringVarP(&name, "name", "n", "世界", "问候的对象")

    // 本地标志(仅对当前命令生效)
    rootCmd.Flags().IntVarP(&count, "count", "c", 1, "重复次数")

    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
BASH
$ go run main.go -n 张三 -c 3
你好, 张三! (1/3)
你好, 张三! (2/3)
你好, 张三! (3/3)

6. 交互式输入

GO
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.Print("请输入年龄 (默认18): ")
    ageInput, _ := reader.ReadString('\n')
    ageInput = strings.TrimSpace(ageInput)

    if ageInput == "" {
        ageInput = "18"
    }

    fmt.Printf("你好, %s! 你今年 %s 岁。\n", name, ageInput)
}
💡 bufio.NewReaderfmt.Scanln 更可靠——它能正确处理包含空格的输入,且不会因为换行符而提前截断。

7. 密码输入(隐藏字符)

GO
package main

import (
    "fmt"
    "os"
    "syscall"

    "golang.org/x/term"
)

func main() {
    fmt.Print("请输入密码: ")

    // 读取密码(不回显字符)
    password, err := term.ReadPassword(int(syscall.Stdin))
    if err != nil {
        fmt.Fprintf(os.Stderr, "读取密码失败: %v\n", err)
        return
    }
    fmt.Println() // 读取后换行

    fmt.Printf("密码长度: %d\n", len(password))
}
💡 golang.org/x/term 是 Go 官方扩展库,提供了跨平台的终端操作,包括密码读取、光标控制等。


示例代码

示例:基础命令行计算器(难度⭐)

GO
package main

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

func main() {
    // 定义标志
    op := flag.String("op", "add", "运算类型: add, sub, mul, div")
    verbose := flag.Bool("v", false, "显示详细信息")

    flag.Parse()

    // 检查位置参数数量
    args := flag.Args()
    if len(args) < 2 {
        fmt.Fprintln(os.Stderr, "错误: 至少需要两个数字作为参数")
        fmt.Fprintln(os.Stderr, "用法: calc -op=add 10 20")
        flag.Usage()
        os.Exit(1)
    }

    // 解析数字
    a, err := strconv.ParseFloat(args[0], 64)
    if err != nil {
        fmt.Fprintf(os.Stderr, "错误: 无法解析数字 %q: %v\n", args[0], err)
        os.Exit(1)
    }
    b, err := strconv.ParseFloat(args[1], 64)
    if err != nil {
        fmt.Fprintf(os.Stderr, "错误: 无法解析数字 %q: %v\n", args[1], err)
        os.Exit(1)
    }

    // 执行运算
    var result float64
    var opName string

    switch *op {
    case "add":
        result = a + b
        opName = "加法"
    case "sub":
        result = a - b
        opName = "减法"
    case "mul":
        result = a * b
        opName = "乘法"
    case "div":
        if b == 0 {
            fmt.Fprintln(os.Stderr, "错误: 除数不能为零")
            os.Exit(1)
        }
        result = a / b
        opName = "除法"
    default:
        fmt.Fprintf(os.Stderr, "错误: 不支持的运算类型 %q\n", *op)
        os.Exit(1)
    }

    // 输出结果
    if *verbose {
        fmt.Printf("运算: %s\n", opName)
        fmt.Printf("表达式: %g %s %g\n", a, map[string]string{
            "add": "+", "sub": "-", "mul": "*", "div": "/",
        }[*op], b)
    }
    fmt.Printf("结果: %g\n", result)
}
▶ 试一试
BASH
$ go run main.go -op=add 10 20
结果: 30

$ go run main.go -op=mul -v 3.5 4
运算: 乘法
表达式: 3.5 * 4
结果: 14

$ go run main.go -op=div 10 0
错误: 除数不能为零

$ go run main.go 10
错误: 至少需要两个数字作为参数

示例:cobra 多子命令工具——文件管理器(难度⭐⭐)

GO
package main

import (
    "fmt"
    "os"
    "path/filepath"
    "strings"
    "time"

    "github.com/spf13/cobra"
)

var verbose bool

func main() {
    rootCmd := &cobra.Command{
        Use:   "filetool",
        Short: "简易文件管理工具",
        Long:  "filetool 是一个用于查看和管理文件的 CLI 工具。",
    }

    // 持久性标志
    rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "详细输出")

    // 添加子命令
    rootCmd.AddCommand(infoCmd())
    rootCmd.AddCommand(listCmd())
    rootCmd.AddCommand(searchCmd())

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

// info 子命令:显示文件详细信息
func infoCmd() *cobra.Command {
    return &cobra.Command{
        Use:   "info <文件路径>",
        Short: "显示文件详细信息",
        Args:  cobra.ExactArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            path := args[0]
            info, err := os.Stat(path)
            if err != nil {
                return fmt.Errorf("无法访问 %q: %w", path, err)
            }

            fmt.Printf("文件名:   %s\n", info.Name())
            fmt.Printf("大小:     %d 字节\n", info.Size())
            fmt.Printf("修改时间: %s\n", info.ModTime().Format("2006-01-02 15:04:05"))
            fmt.Printf("是否目录: %v\n", info.IsDir())
            fmt.Printf("权限:     %s\n", info.Mode())

            if verbose {
                fmt.Printf("绝对路径: ")
                abs, err := filepath.Abs(path)
                if err == nil {
                    fmt.Println(abs)
                }
                fmt.Printf("扩展名:   %s\n", filepath.Ext(path))
            }
            return nil
        },
    }
}

// list 子命令:列出目录内容
func listCmd() *cobra.Command {
    var showHidden bool

    cmd := &cobra.Command{
        Use:   "list [目录路径]",
        Short: "列出目录内容",
        Args:  cobra.MaximumNArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            dir := "."
            if len(args) > 0 {
                dir = args[0]
            }

            entries, err := os.ReadDir(dir)
            if err != nil {
                return fmt.Errorf("无法读取目录 %q: %w", dir, err)
            }

            fmt.Printf("目录: %s\n", dir)
            fmt.Println(strings.Repeat("─", 50))

            count := 0
            for _, entry := range entries {
                // 跳过隐藏文件(除非指定了 -a)
                if !showHidden && strings.HasPrefix(entry.Name(), ".") {
                    continue
                }

                info, err := entry.Info()
                if err != nil {
                    continue
                }

                // 目录加 / 后缀
                name := entry.Name()
                if entry.IsDir() {
                    name += "/"
                }

                fmt.Printf("  %-30s %8d  %s\n",
                    name,
                    info.Size(),
                    info.ModTime().Format("2006-01-02 15:04"))
                count++
            }

            fmt.Printf("\n共 %d 个项目\n", count)
            return nil
        },
    }

    cmd.Flags().BoolVarP(&showHidden, "all", "a", false, "显示隐藏文件")
    return cmd
}

// search 子命令:按扩展名搜索文件
func searchCmd() *cobra.Command {
    var maxDepth int

    cmd := &cobra.Command{
        Use:   "search <扩展名>",
        Short: "按扩展名搜索文件",
        Long:  "在当前目录及子目录中搜索指定扩展名的文件。例如: filetool search .go",
        Args:  cobra.ExactArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            ext := args[0]
            if !strings.HasPrefix(ext, ".") {
                ext = "." + ext
            }

            root := "."
            found := 0

            err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
                if err != nil {
                    return nil // 跳过无法访问的文件
                }

                // 检查深度
                if maxDepth > 0 {
                    depth := strings.Count(filepath.Clean(path), string(os.PathSeparator)) -
                        strings.Count(filepath.Clean(root), string(os.PathSeparator))
                    if depth > maxDepth {
                        if info.IsDir() {
                            return filepath.SkipDir
                        }
                        return nil
                    }
                }

                if !info.IsDir() && filepath.Ext(path) == ext {
                    fmt.Printf("  %s (%d 字节, %s)\n",
                        path,
                        info.Size(),
                        info.ModTime().Format("2006-01-02"))
                    found++
                }
                return nil
            })

            if err != nil {
                return fmt.Errorf("搜索出错: %w", err)
            }

            fmt.Printf("\n共找到 %d 个 %s 文件\n", found, ext)
            return nil
        },
    }

    cmd.Flags().IntVarP(&maxDepth, "depth", "d", 0, "最大搜索深度 (0表示不限)")
    return cmd
}
▶ 试一试
BASH
$ go run main.go info main.go
文件名:   main.go
大小:     2048 字节
修改时间: 2025-06-27 14:30:00
是否目录: false
权限:     -rw-r--r--

$ go run main.go list -a
目录: .
──────────────────────────────────────────────────
  .git/                              4096  2025-06-27 14:00
  main.go                            2048  2025-06-27 14:30
  go.mod                              128  2025-06-27 14:00

共 3 个项目

$ go run main.go search .go -d 2
  ./main.go (2048 字节, 2025-06-27)
  ./internal/handler.go (1024 字节, 2025-06-26)

共找到 2 个 .go 文件

示例:完整 Todo CLI 应用(难度⭐⭐⭐)

GO
package main

import (
    "encoding/json"
    "fmt"
    "os"
    "path/filepath"
    "strconv"
    "strings"
    "time"

    "github.com/spf13/cobra"
)

// Todo 待办事项结构
type Todo struct {
    ID        int       `json:"id"`
    Title     string    `json:"title"`
    Done      bool      `json:"done"`
    CreatedAt time.Time `json:"created_at"`
    DoneAt    *time.Time `json:"done_at,omitempty"`
}

// TodoList 待办列表,支持持久化
type TodoList struct {
    Todos    []Todo `json:"todos"`
    NextID   int    `json:"next_id"`
    filePath string
}

// NewTodoList 创建或加载待办列表
func NewTodoList(filePath string) (*TodoList, error) {
    list := &TodoList{
        Todos:  []Todo{},
        NextID: 1,
        filePath: filePath,
    }

    // 如果文件存在,加载数据
    if _, err := os.Stat(filePath); err == nil {
        data, err := os.ReadFile(filePath)
        if err != nil {
            return nil, fmt.Errorf("读取数据文件失败: %w", err)
        }
        if len(data) > 0 {
            if err := json.Unmarshal(data, list); err != nil {
                return nil, fmt.Errorf("解析数据文件失败: %w", err)
            }
        }
    }

    return list, nil
}

// Save 保存到文件
func (tl *TodoList) Save() error {
    // 确保目录存在
    dir := filepath.Dir(tl.filePath)
    if err := os.MkdirAll(dir, 0755); err != nil {
        return fmt.Errorf("创建目录失败: %w", err)
    }

    data, err := json.MarshalIndent(tl, "", "  ")
    if err != nil {
        return fmt.Errorf("序列化数据失败: %w", err)
    }

    return os.WriteFile(tl.filePath, data, 0644)
}

// Add 添加新待办
func (tl *TodoList) Add(title string) Todo {
    todo := Todo{
        ID:        tl.NextID,
        Title:     title,
        Done:      false,
        CreatedAt: time.Now(),
    }
    tl.Todos = append(tl.Todos, todo)
    tl.NextID++
    return todo
}

// Complete 标记完成
func (tl *TodoList) Complete(id int) error {
    for i := range tl.Todos {
        if tl.Todos[i].ID == id {
            if tl.Todos[i].Done {
                return fmt.Errorf("任务 #%d 已经完成了", id)
            }
            tl.Todos[i].Done = true
            now := time.Now()
            tl.Todos[i].DoneAt = &now
            return nil
        }
    }
    return fmt.Errorf("未找到任务 #%d", id)
}

// Delete 删除待办
func (tl *TodoList) Delete(id int) error {
    for i, todo := range tl.Todos {
        if todo.ID == id {
            tl.Todos = append(tl.Todos[:i], tl.Todos[i+1:]...)
            return nil
        }
    }
    return fmt.Errorf("未找到任务 #%d", id)
}

// Filter 返回筛选后的列表
func (tl *TodoList) Filter(showDone bool) []Todo {
    var result []Todo
    for _, todo := range tl.Todos {
        if showDone || !todo.Done {
            result = append(result, todo)
        }
    }
    return result
}

// dataFilePath 获取默认数据文件路径
func dataFilePath() string {
    home, err := os.UserHomeDir()
    if err != nil {
        return ".todo.json"
    }
    return filepath.Join(home, ".todo.json")
}

func main() {
    dataFile := dataFilePath()

    rootCmd := &cobra.Command{
        Use:   "todo",
        Short: "命令行待办事项管理工具",
        Long: `todo 是一个简洁的命令行待办事项管理工具。

数据保存在 ~/.todo.json 文件中。`,
    }

    // add 子命令
    var addCmd = &cobra.Command{
        Use:   "add <任务标题>",
        Short: "添加新任务",
        Args:  cobra.MinimumNArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            title := strings.Join(args, " ")
            list, err := NewTodoList(dataFile)
            if err != nil {
                return err
            }
            todo := list.Add(title)
            if err := list.Save(); err != nil {
                return err
            }
            fmt.Printf("✓ 已添加任务 #%d: %s\n", todo.ID, todo.Title)
            return nil
        },
    }

    // done 子命令
    var doneCmd = &cobra.Command{
        Use:   "done <任务ID>",
        Short: "标记任务为完成",
        Args:  cobra.ExactArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            id, err := strconv.Atoi(args[0])
            if err != nil {
                return fmt.Errorf("无效的任务ID: %q", args[0])
            }
            list, err := NewTodoList(dataFile)
            if err != nil {
                return err
            }
            if err := list.Complete(id); err != nil {
                return err
            }
            if err := list.Save(); err != nil {
                return err
            }
            fmt.Printf("✓ 任务 #%d 已完成!\n", id)
            return nil
        },
    }

    // list 子命令
    var showAll bool
    var listCmd = &cobra.Command{
        Use:   "list",
        Short: "查看任务列表",
        RunE: func(cmd *cobra.Command, args []string) error {
            list, err := NewTodoList(dataFile)
            if err != nil {
                return err
            }

            todos := list.Filter(showAll)
            if len(todos) == 0 {
                if showAll {
                    fmt.Println("暂无任务。")
                } else {
                    fmt.Println("没有未完成的任务! 使用 -a 查看所有任务。")
                }
                return nil
            }

            fmt.Printf("%-4s %-6s %-30s %s\n", "ID", "状态", "标题", "创建时间")
            fmt.Println(strings.Repeat("─", 60))

            for _, todo := range todos {
                status := "○"
                if todo.Done {
                    status = "✓"
                }
                fmt.Printf("%-4d %-6s %-30s %s\n",
                    todo.ID,
                    status,
                    truncate(todo.Title, 28),
                    todo.CreatedAt.Format("01-02 15:04"))
            }

            // 统计
            total := len(list.Todos)
            done := 0
            for _, t := range list.Todos {
                if t.Done {
                    done++
                }
            }
            fmt.Printf("\n共 %d 个任务,已完成 %d,未完成 %d\n", total, done, total-done)
            return nil
        },
    }
    listCmd.Flags().BoolVarP(&showAll, "all", "a", false, "显示所有任务(包括已完成)")

    // delete 子命令
    var deleteCmd = &cobra.Command{
        Use:   "delete <任务ID>",
        Short: "删除任务",
        Args:  cobra.ExactArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            id, err := strconv.Atoi(args[0])
            if err != nil {
                return fmt.Errorf("无效的任务ID: %q", args[0])
            }
            list, err := NewTodoList(dataFile)
            if err != nil {
                return err
            }
            if err := list.Delete(id); err != nil {
                return err
            }
            if err := list.Save(); err != nil {
                return err
            }
            fmt.Printf("✓ 任务 #%d 已删除\n", id)
            return nil
        },
    }

    // clear 子命令
    var clearCmd = &cobra.Command{
        Use:   "clear",
        Short: "清除所有已完成的任务",
        RunE: func(cmd *cobra.Command, args []string) error {
            list, err := NewTodoList(dataFile)
            if err != nil {
                return err
            }

            before := len(list.Todos)
            var remaining []Todo
            for _, todo := range list.Todos {
                if !todo.Done {
                    remaining = append(remaining, todo)
                }
            }
            list.Todos = remaining

            removed := before - len(list.Todos)
            if removed == 0 {
                fmt.Println("没有已完成的任务需要清除。")
                return nil
            }

            if err := list.Save(); err != nil {
                return err
            }
            fmt.Printf("✓ 已清除 %d 个已完成的任务\n", removed)
            return nil
        },
    }

    rootCmd.AddCommand(addCmd, doneCmd, listCmd, deleteCmd, clearCmd)

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

// truncate 截断字符串
func truncate(s string, maxLen int) string {
    runes := []rune(s)
    if len(runes) <= maxLen {
        return s
    }
    return string(runes[:maxLen-2]) + ".."
}
▶ 试一试
BASH
$ todo add 学习Go语言CLI开发
✓ 已添加任务 #1: 学习Go语言CLI开发

$ todo add 完成课后练习
✓ 已添加任务 #2: 完成课后练习

$ todo add 写技术博客
✓ 已添加任务 #3: 写技术博客

$ todo list
ID   状态   标题                           创建时间
────────────────────────────────────────────────────────────
1    ○      学习Go语言CLI开发               06-27 14:30
2    ○      完成课后练习                     06-27 14:31
3    ○      写技术博客                       06-27 14:32

共 3 个任务,已完成 0,未完成 3

$ todo done 1
✓ 任务 #1 已完成!

$ todo list -a
ID   状态   标题                           创建时间
────────────────────────────────────────────────────────────
1    ✓      学习Go语言CLI开发               06-27 14:30
2    ○      完成课后练习                     06-27 14:31
3    ○      写技术博客                       06-27 14:32

共 3 个任务,已完成 1,未完成 2

$ todo clear
✓ 已清除 1 个已完成的任务

实际应用场景

场景1:参数校验与自定义验证

GO
package main

import (
    "fmt"
    "net"
    "os"
    "strconv"
    "strings"

    "github.com/spf13/cobra"
)

// PortValidator 端口号验证器
func PortValidator(s string) error {
    port, err := strconv.Atoi(s)
    if err != nil {
        return fmt.Errorf("端口必须是数字,收到: %q", s)
    }
    if port < 1 || port > 65535 {
        return fmt.Errorf("端口必须在 1-65535 之间,收到: %d", port)
    }
    return nil
}

// EmailValidator 简易邮箱格式验证
func EmailValidator(email string) error {
    if !strings.Contains(email, "@") {
        return fmt.Errorf("邮箱格式不正确,缺少 @ 符号: %q", email)
    }
    parts := strings.SplitN(email, "@", 2)
    if len(parts[0]) == 0 || len(parts[1]) == 0 {
        return fmt.Errorf("邮箱格式不正确: %q", email)
    }
    if !strings.Contains(parts[1], ".") {
        return fmt.Errorf("邮箱域名格式不正确: %q", email)
    }
    return nil
}

// HostPortValidator 验证 host:port 格式
func HostPortValidator(addr string) error {
    host, port, err := net.SplitHostPort(addr)
    if err != nil {
        return fmt.Errorf("格式应为 host:port,收到: %q,错误: %v", addr, err)
    }
    if host == "" {
        return fmt.Errorf("主机名不能为空: %q", addr)
    }
    return PortValidator(port)
}

func main() {
    var email string
    var port int
    var serverAddr string

    rootCmd := &cobra.Command{
        Use:   "server",
        Short: "启动服务器",
        PreRunE: func(cmd *cobra.Command, args []string) error {
            // 在执行前统一校验所有参数
            if err := EmailValidator(email); err != nil {
                return fmt.Errorf("管理员邮箱: %w", err)
            }
            if err := HostPortValidator(serverAddr); err != nil {
                return fmt.Errorf("服务器地址: %w", err)
            }
            return nil
        },
        Run: func(cmd *cobra.Command, args []string) {
            fmt.Printf("服务器启动中...\n")
            fmt.Printf("  地址: %s\n", serverAddr)
            fmt.Printf("  管理员: %s\n", email)
            fmt.Printf("  端口: %d\n", port)
        },
    }

    rootCmd.Flags().StringVarP(&email, "email", "e", "admin@example.com", "管理员邮箱")
    rootCmd.Flags().IntVarP(&port, "port", "p", 8080, "端口号")
    rootCmd.Flags().StringVarP(&serverAddr, "addr", "a", "localhost:8080", "服务器地址 (host:port)")

    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintf(os.Stderr, "错误: %v\n", err)
        os.Exit(1)
    }
}
BASH
$ go run main.go --addr "invalid"
错误: 服务器地址: 格式应为 host:port,收到: "invalid",错误: address invalid: missing port in address

$ go run main.go --email "not-email"
错误: 管理员邮箱: 邮箱格式不正确,缺少 @ 符号: "not-email"

$ go run main.go -a "0.0.0.0:9090" -e "admin@mysite.com"
服务器启动中...
  地址: 0.0.0.0:9090
  管理员: admin@mysite.com
  端口: 8080

场景2:进度条与彩色输出

GO
package main

import (
    "fmt"
    "math"
    "os"
    "strings"
    "time"

    "github.com/spf13/cobra"
)

// ANSI 颜色常量
const (
    ColorReset  = "\033[0m"
    ColorRed    = "\033[31m"
    ColorGreen  = "\033[32m"
    ColorYellow = "\033[33m"
    ColorBlue   = "\033[34m"
    ColorCyan   = "\033[36m"
    ColorBold   = "\033[1m"
)

// ProgressBar 进度条
type ProgressBar struct {
    total   int
    current int
    width   int
    label   string
}

// NewProgressBar 创建进度条
func NewProgressBar(total int, label string) *ProgressBar {
    return &ProgressBar{
        total: total,
        width: 40,
        label: label,
    }
}

// Update 更新进度并显示
func (p *ProgressBar) Update(current int) {
    p.current = current
    percent := float64(p.current) / float64(p.total)
    filled := int(math.Round(percent * float64(p.width)))

    bar := strings.Repeat("█", filled) + strings.Repeat("░", p.width-filled)

    // 颜色随进度变化
    color := ColorYellow
    if percent >= 0.8 {
        color = ColorGreen
    } else if percent >= 0.5 {
        color = ColorCyan
    }

    fmt.Fprintf(os.Stderr, "\r%s %s[%s%s%s] %s%d/%d%s (%.0f%%)",
        p.label,
        ColorBold,
        color, bar, ColorReset,
        ColorBold, p.current, p.total, ColorReset,
        percent*100)

    if p.current >= p.total {
        fmt.Fprintf(os.Stderr, "\n")
    }
}

func main() {
    rootCmd := &cobra.Command{
        Use:   "downloader",
        Short: "模拟文件下载器(演示进度条)",
        Run: func(cmd *cobra.Command, args []string) {
            files := []string{
                "go1.21.0.linux-amd64.tar.gz",
                "docs.tar.gz",
                "examples.zip",
            }

            totalSteps := 100

            for _, file := range files {
                fmt.Printf("\n%s下载: %s%s\n", ColorBlue, file, ColorReset)

                bar := NewProgressBar(totalSteps, "  进度")

                for i := 0; i <= totalSteps; i++ {
                    bar.Update(i)
                    // 模拟下载延迟
                    time.Sleep(20 * time.Millisecond)
                }

                fmt.Printf("  %s✓ 下载完成%s\n", ColorGreen, ColorReset)
            }

            fmt.Printf("\n%s%s全部下载完成!%s\n", ColorBold, ColorGreen, ColorReset)
        },
    }

    if err := rootCmd.Execute(); err != nil {
        os.Exit(1)
    }
}
BASH
$ go run main.go

下载: go1.21.0.linux-amd64.tar.gz
  进度 [████████████████████████████████████████] 100/100 (100%)
  ✓ 下载完成

下载: docs.tar.gz
  进度 [████████████████████████████████████████] 100/100 (100%)
  ✓ 下载完成

全部下载完成!

❓ 常见问题

Q1:flag 包如何处理重复的标志名?

GO
// flag 包不允许重复定义同名标志,会 panic
// 但可以在不同子命令中定义同名标志(如果自己实现子命令逻辑)

// 使用 cobra 时,每个命令的 Flags 是独立的,不会冲突
cmd1.Flags().StringVar(&name, "name", "", "命令1的name")
cmd2.Flags().StringVar(&name, "name", "", "命令2的name") // 完全OK

Q2:如何让 cobra 支持持久化配置文件(如 ~/.config/myapp.yaml)?

GO
import (
    "github.com/spf13/cobra"
    "github.com/spf13/viper"
)

func initConfig() {
    viper.SetConfigName("config")  // 配置文件名(不含扩展名)
    viper.SetConfigType("yaml")
    viper.AddConfigPath("$HOME/.config/myapp")
    viper.AddConfigPath(".")

    // 环境变量覆盖
    viper.AutomaticEnv()

    // 读取配置文件(允许不存在)
    if err := viper.ReadInConfig(); err == nil {
        fmt.Fprintln(os.Stderr, "使用配置文件:", viper.ConfigFileUsed())
    }
}

func main() {
    rootCmd := &cobra.Command{
        Use: "myapp",
        PersistentPreRun: func(cmd *cobra.Command, args []string) {
            initConfig()
        },
    }
    // ...
}
💡 viper 是 cobra 的最佳搭档,提供配置文件、环境变量、命令行标志的统一读取,优先级为:命令行标志 > 环境变量 > 配置文件 > 默认值。

Q3:os.Argsflag.Args() 有什么区别?

GO
// os.Args 包含所有原始参数,包括程序名
os.Args     // ["myapp", "-v", "hello", "world"]

// flag.Parse() 之后:
// flag.Args() 只包含非标志参数(位置参数)
flag.Args() // ["hello", "world"]

// flag 已经消费了 -v,不会出现在 Args() 中

Q4:如何处理子命令的 Tab 补全?

BASH
# cobra 内置了 Shell 补全生成
$ todo completion bash > /etc/bash_completion.d/todo
$ todo completion zsh > "${fpath[1]}/_todo"
$ todo completion fish > ~/.config/fish/completions/todo.fish

# 对于自定义的 flag,可以注册补全函数
cmd.RegisterFlagCompletionFunc("format", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
    return []string{"json", "yaml", "table"}, cobra.ShellCompDirectiveNoFileComp
})

📖 小节

本课我们学习了 Go 语言 CLI 工具开发的核心内容:

主题 工具 核心要点
原始参数 os.Args 字符串切片,[0] 是程序名
标志解析 flag 类型化标志、自动帮助、位置参数
CLI 框架 cobra 子命令、持久化标志、自动补全
参数校验 自定义函数 PreRunE/Args 验证器
交互输入 bufio/term 读取行输入、隐藏密码输入

关键要点


📝 作业

练习1:环境变量查看器

编写一个 CLI 工具 envtool,支持以下功能:

BASH
# 列出所有环境变量
$ envtool list

# 搜索包含关键词的环境变量
$ envtool search PATH

# 获取指定环境变量的值
$ envtool get GOPATH

# 设置环境变量(仅当前进程)
$ envtool set MY_VAR=hello

要求使用 cobra 实现,支持 --format=json|table 标志控制输出格式。

练习2:密码生成器

编写 CLI 密码生成器 passgen

BASH
# 生成默认密码(16位,包含大小写字母和数字)
$ passgen

# 指定长度和字符集
$ passgen -l 32 -s "abc123!@#"

# 批量生成
$ passgen -n 5

# 排除易混淆字符(0/O, 1/l/I)
$ passgen --no-ambiguous

要求:

练习3:文件批处理工具

编写 batch 工具,支持批量文件操作:

BASH
# 批量重命名:添加前缀
$ batch rename --prefix "2025_" *.jpg

# 批量转换大小写
$ batch case --to lower *.TXT

# 批量统计
$ batch stats ./docs

# 批量查找替换(模拟)
$ batch replace --old "foo" --new "bar" *.go

要求使用 cobra 子命令实现,每个操作为独立子命令。


下一课

下一课我们将学习 REST API开发,了解如何使用 Go 构建 RESTful API 服务,包括路由设计、中间件、JSON 处理、数据库集成等实用技能。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏