CLI工具开发
第25课:CLI工具开发
生活类比引入
想象你在一个自动售货机前买东西。你按下一个按钮选择饮料(传入参数),机器根据你的选择出货(执行对应功能),如果按错了按钮它会提示你"无效选择"(参数校验)。CLI 工具就像这台售货机——用户通过命令行输入指令和参数,程序根据输入执行相应操作并输出结果。好的 CLI 工具就像一台设计精良的售货机:按钮布局清晰、操作提示明确、错误反馈友好。
核心概念
Go 语言天生适合构建 CLI 工具——编译为单一二进制文件、跨平台、启动速度快。核心概念如下:
| 概念 | 说明 |
|---|---|
os.Args |
原始命令行参数切片,[0] 是程序名 |
flag 包 |
标准库提供的参数解析工具 |
cobra 库 |
第三方 CLI 框架,支持子命令、自动帮助、Shell 补全 |
| 子命令 | 如 git commit、docker 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.NewReader 比 fmt.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.Args 和 flag.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 |
读取行输入、隐藏密码输入 |
关键要点:
- 简单工具用
flag包足够,复杂工具用cobra cobra的PersistentFlags对所有子命令生效,Flags仅对当前命令- 参数校验应在
PreRunE中统一处理,保持Run函数简洁 - 数据持久化用 JSON 文件,存放在用户目录下
- 进度条和彩色输出能显著提升 CLI 工具的用户体验
📝 作业
练习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
要求:
- 使用
flag包实现 - 密码强度需评估并显示(弱/中/强)
- 生成结果可复制到剪贴板(可选)
练习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 处理、数据库集成等实用技能。



