命令行工具也需要结构
Go 很适合写命令行工具。编译成一个二进制,复制就能运行,启动快,标准库也覆盖了参数解析、文件读写、HTTP 请求和 JSON 编解码。很多团队会用 Go 写内部运维工具、数据迁移脚本、报表生成器和代码生成器。
但小工具也会长大。今天只有一个 -file 参数,明天加 import 和 export 子命令,后天又要支持 JSON 输出和静默模式。如果所有逻辑都堆在 main 函数里,很快就会难以测试、难以复用。
这篇文章讲一个实用结构:main 只处理退出,run 返回错误,命令参数集中解析,输入输出通过 io.Reader 和 io.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 接收 args、stdout、stderr;参数解析使用独立 FlagSet;业务逻辑拆成普通函数;输入输出通过 io.Reader 和 io.Writer 传递。
小工具一开始可以很短,但不要把自己锁死在一个巨大 main.go 里。只要稍微整理边界,后面添加子命令、测试、JSON 输出和文件处理都会轻松很多。
Go 的优势之一就是能快速写出稳定工具。把结构打好,这些工具就不会只是一次性脚本,而能成为团队长期使用的小基础设施。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。