Go 命令行程序结构:从一个 main.go 整理成可维护工具

本文讲解 Go 命令行程序的 run 函数、参数解析、输入输出注入、退出码、子命令雏形和测试方式,帮助初学者写出可维护工具。

命令行工具也需要结构

Go 很适合写命令行工具。编译成一个二进制,复制就能运行,启动快,标准库也覆盖了参数解析、文件读写、HTTP 请求和 JSON 编解码。很多团队会用 Go 写内部运维工具、数据迁移脚本、报表生成器和代码生成器。

但小工具也会长大。今天只有一个 -file 参数,明天加 importexport 子命令,后天又要支持 JSON 输出和静默模式。如果所有逻辑都堆在 main 函数里,很快就会难以测试、难以复用。

这篇文章讲一个实用结构:main 只处理退出,run 返回错误,命令参数集中解析,输入输出通过 io.Readerio.Writer 注入。这样工具从几十行长到几百行时,仍然能保持清楚。

main 函数保持薄

不要把所有逻辑写在 main

func main() {
	file := flag.String("file", "", "input file")
	flag.Parse()

	data, err := os.ReadFile(*file)
	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}

	fmt.Println(string(data))
}

它能运行,但不好测试。更好的结构:

func main() {
	if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
}

run 接收参数和输出目标:

func run(args []string, stdout io.Writer, stderr io.Writer) error {
	fs := flag.NewFlagSet("tool", flag.ContinueOnError)
	fs.SetOutput(stderr)

	file := fs.String("file", "", "input file")
	if err := fs.Parse(args); err != nil {
		return err
	}
	if *file == "" {
		return fmt.Errorf("file is required")
	}

	data, err := os.ReadFile(*file)
	if err != nil {
		return fmt.Errorf("read file: %w", err)
	}

	fmt.Fprintln(stdout, string(data))
	return nil
}

这样 run 可以在测试里直接调用,不需要真的启动新进程。

为什么使用 flag.NewFlagSet

标准库里也有全局 flag.CommandLine,但命令行工具更推荐创建自己的 FlagSet。它的好处是测试隔离,不会和其他包的 flag 混在一起,也能控制错误输出。

fs := flag.NewFlagSet("notes", flag.ContinueOnError)
fs.SetOutput(stderr)

flag.ContinueOnError 表示解析失败时返回错误,而不是直接退出程序。这样 run 可以把错误交给 main 统一处理。

定义参数:

format := fs.String("format", "text", "output format: text or json")
verbose := fs.Bool("verbose", false, "enable verbose output")

解析后使用:

if *verbose {
	fmt.Fprintln(stderr, "verbose mode enabled")
}

命令行参数是用户界面的一部分。默认值、帮助文本、错误消息都应该清楚。不要让用户猜某个参数是否必填。

用 io.Writer 提高可测试性

输出不要直接写死 fmt.Println。传入 io.Writer 后,测试可以用 bytes.Buffer 接收输出:

func printItems(w io.Writer, items []string) {
	for _, item := range items {
		fmt.Fprintln(w, item)
	}
}

测试:

func TestPrintItems(t *testing.T) {
	var buf bytes.Buffer
	printItems(&buf, []string{"a", "b"})

	got := buf.String()
	want := "a\nb\n"
	if got != want {
		t.Fatalf("output = %q, want %q", got, want)
	}
}

输入也可以用 io.Reader

func countLines(r io.Reader) (int, error) {
	scanner := bufio.NewScanner(r)
	count := 0
	for scanner.Scan() {
		count++
	}
	if err := scanner.Err(); err != nil {
		return 0, err
	}
	return count, nil
}

测试时传入 strings.NewReader("a\nb\n") 即可。这样的函数不依赖文件系统,更容易测试。

子命令雏形

当工具有多个动作时,可以用第一个参数做子命令:

func run(args []string, stdout io.Writer, stderr io.Writer) error {
	if len(args) == 0 {
		return fmt.Errorf("command is required")
	}

	switch args[0] {
	case "add":
		return runAdd(args[1:], stdout, stderr)
	case "list":
		return runList(args[1:], stdout, stderr)
	default:
		return fmt.Errorf("unknown command: %s", args[0])
	}
}

每个子命令有自己的 FlagSet

func runAdd(args []string, stdout io.Writer, stderr io.Writer) error {
	fs := flag.NewFlagSet("add", flag.ContinueOnError)
	fs.SetOutput(stderr)

	title := fs.String("title", "", "note title")
	if err := fs.Parse(args); err != nil {
		return err
	}
	if strings.TrimSpace(*title) == "" {
		return fmt.Errorf("title is required")
	}

	fmt.Fprintf(stdout, "added: %s\n", *title)
	return nil
}

这样结构继续长也不乱。等子命令变得很多时,可以再考虑 Cobra 这类库。入门阶段先用标准库写一次,能让你更理解命令行程序的运行模型。

退出码和错误消息

main 决定退出码:

func main() {
	if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
}

成功默认退出码是 0。失败用 1。更复杂工具可能区分不同错误码,但入门阶段先保持简单。

错误消息写给人看,应该明确:

return fmt.Errorf("title is required")

比下面这种更好:

return fmt.Errorf("bad args")

如果是底层错误,要包装上下文:

return fmt.Errorf("read config %s: %w", path, err)

命令行工具没有 Web 页面,错误文本就是用户界面的一部分。

测试 run 函数

测试成功输出:

func TestRunAdd(t *testing.T) {
	var stdout bytes.Buffer
	var stderr bytes.Buffer

	err := run([]string{"add", "-title", "学习 Go"}, &stdout, &stderr)
	if err != nil {
		t.Fatalf("run() error = %v", err)
	}

	if !strings.Contains(stdout.String(), "学习 Go") {
		t.Fatalf("stdout = %q", stdout.String())
	}
}

测试错误:

func TestRunAddMissingTitle(t *testing.T) {
	var stdout bytes.Buffer
	var stderr bytes.Buffer

	err := run([]string{"add"}, &stdout, &stderr)
	if err == nil {
		t.Fatal("expected error")
	}
}

这种测试不需要执行外部进程,速度很快,也不依赖终端环境。

小结

Go 命令行程序的可维护结构很简单:main 负责调用 run、打印错误和设置退出码;run 接收 argsstdoutstderr;参数解析使用独立 FlagSet;业务逻辑拆成普通函数;输入输出通过 io.Readerio.Writer 传递。

小工具一开始可以很短,但不要把自己锁死在一个巨大 main.go 里。只要稍微整理边界,后面添加子命令、测试、JSON 输出和文件处理都会轻松很多。

Go 的优势之一就是能快速写出稳定工具。把结构打好,这些工具就不会只是一次性脚本,而能成为团队长期使用的小基础设施。

继续阅读

探索更多技术文章

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

全部文章 返回首页