Go 实战:写一个命令行工具

AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。

Go 1.24

Go 编译出的是单个静态二进制,没有运行时依赖,特别适合写命令行工具。这一篇从零做一个 textstat:读取文件或 stdin,输出行数、词数、最长行。

目标

textstat README.md
textstat -json README.md docs/*.md
cat main.go | textstat
textstat -version

要求:

  • 无参数时读 stdin;
  • -json 输出机器可解析结果;
  • 文件不存在时打印到 stderr,退出码 1;
  • 参数用法错误退出码 2。

初始化

mkdir textstat && cd textstat
go mod init example.com/textstat
textstat/
  go.mod
  main.go
  stats/stats.go
  stats/stats_test.go

把统计逻辑放在 stats 包,main 只负责参数与输出——这样逻辑可以单测,不需要启动进程。

核心逻辑

// stats/stats.go
package stats

import (
    "bufio"
    "io"
    "strings"
    "unicode"
    "unicode/utf8"
)

type Result struct {
    Lines     int `json:"lines"`
    Words     int `json:"words"`
    Longest   int `json:"longest_line"`
    Bytes     int `json:"bytes"`
}

// Count 逐行读取 r,统计行数、词数与最长行字符数。
func Count(r io.Reader) (Result, error) {
    scanner := bufio.NewScanner(r)
    scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // 放宽单行上限

    var res Result
    for scanner.Scan() {
        line := scanner.Text()
        res.Lines++
        res.Bytes += len(line) + 1
        res.Words += len(splitWords(line))
        if n := utf8.RuneCountInString(line); n > res.Longest {
            res.Longest = n
        }
    }

    if err := scanner.Err(); err != nil {
        return res, err
    }
    return res, nil
}

bufio.Scanner 默认单行上限 64KB,超长行会返回 bufio.ErrTooLong;显式放大缓冲,错误也要往外传,不能当「读到结尾」。

词数按空白切分:

func splitWords(line string) []string {
    return strings.FieldsFunc(line, func(r rune) bool {
        return unicode.IsSpace(r)
    })
}

strings.Fields 已经按空白切分,这里用 FieldsFunc 是为了演示中文标点扩展的可能。

参数解析

// main.go
package main

import (
    "encoding/json"
    "errors"
    "flag"
    "fmt"
    "io"
    "os"

    "example.com/textstat/stats"
)

var version = "dev"   // 由 -ldflags 注入

func main() {
    os.Exit(run(os.Args[1:]))
}

func run(args []string) int {
    fs := flag.NewFlagSet("textstat", flag.ContinueOnError)
    fs.SetOutput(os.Stderr)

    jsonOut := fs.Bool("json", false, "以 JSON 输出")
    showVersion := fs.Bool("version", false, "打印版本")

    fs.Usage = func() {
        fmt.Fprintf(fs.Output(), "用法:textstat [选项] [文件...]\n\n选项:\n")
        fs.PrintDefaults()
    }

    if err := fs.Parse(args); err != nil {
        if errors.Is(err, flag.ErrHelp) {
            return 0
        }
        return 2
    }

    if *showVersion {
        fmt.Printf("textstat %s\n", version)
        return 0
    }

    files := fs.Args()
    if len(files) == 0 {
        res, err := stats.Count(os.Stdin)
        if err != nil {
            fmt.Fprintln(os.Stderr, "读取标准输入:", err)
            return 1
        }
        return emit(os.Stdout, "stdin", res, *jsonOut)
    }

    exitCode := 0
    for _, name := range files {
        f, err := os.Open(name)
        if err != nil {
            fmt.Fprintln(os.Stderr, err)
            exitCode = 1
            continue
        }

        res, err := stats.Count(f)
        f.Close()
        if err != nil {
            fmt.Fprintf(os.Stderr, "%s:%v\n", name, err)
            exitCode = 1
            continue
        }

        if emit(os.Stdout, name, res, *jsonOut) != 0 {
            exitCode = 1
        }
    }
    return exitCode
}

要点:

  • run(args []string) int 便于测试:不用 os.Exit,测试里直接调。
  • 一个文件失败不影响其他文件,最后统一用退出码反映。
  • flag.ContinueOnError 让解析错误由我们控制返回码,而不是自动 os.Exit(2)。

输出

func emit(w io.Writer, name string, res stats.Result, asJSON bool) int {
    if asJSON {
        payload := struct {
            File string `json:"file"`
            stats.Result
        }{name, res}

        enc := json.NewEncoder(w)
        if err := enc.Encode(payload); err != nil {
            fmt.Fprintln(os.Stderr, "写出 JSON:", err)
            return 1
        }
        return 0
    }

    fmt.Fprintf(w, "%s: %d 行, %d 词, 最长行 %d 字符\n", name, res.Lines, res.Words, res.Longest)
    return 0
}

json.NewEncoder 直接写流并自动加换行,比 Marshal 再 Write 省一次拷贝;JSON 输出走 stdout,错误信息只走 stderr,管道给 jq 才不会被污染。

版本注入

go build -ldflags "-X main.version=1.2.0" -o textstat .

-X 只能改字符串变量,且需要完整包路径(main.version 在根包可用短名)。放进 Makefile 或脚本:

VERSION=$(git describe --tags --always --dirty)
go build -trimpath -ldflags "-s -w -X main.version=$VERSION" -o bin/textstat .

-s -w 去掉符号表与调试信息,体积更小;-trimpath 去掉本地路径,构建可复现。

测试

package stats

import (
    "strings"
    "testing"
)

func TestCount(t *testing.T) {
    input := "hello world\n第二行内容\n\n"

    got, err := Count(strings.NewReader(input))
    if err != nil {
        t.Fatalf("不期望错误:%v", err)
    }

    if got.Lines != 3 {
        t.Errorf("行数 = %d,期望 3", got.Lines)
    }
    if got.Words != 3 {
        t.Errorf("词数 = %d,期望 3", got.Words)
    }
}
go test ./...

Count 接收 io.Reader,所以测试里用 strings.NewReader,生产里用 os.File,不需要临时文件。

子命令结构

工具长大后通常拆成子命令。标准库没有内置,最省事的做法是:

switch os.Args[1] {
case "count":
    return runCount(os.Args[2:])
case "version":
    fmt.Println(version)
    return 0
default:
    fmt.Fprintf(os.Stderr, "未知子命令 %q\n", os.Args[1])
    return 2
}

需要更完整的体验(自动补全、嵌套子命令、帮助分组)时再引 spf13/cobra;不要一开始就上框架。

常见错误与注意事项

1. 用 flag 却不处理 -h

flag.Parse 遇到 -h 会打印用法并返回 flag.ErrHelp。自己判断一下,返回 0 而不是 2。

2. 参数顺序依赖

标准库 flag 遇到第一个非 flag 参数就停止解析:textstat README.md -json 里的 -json 会被当成文件名。文档里写清「选项在前」,或换用支持混排的解析库。

3. 忘了关闭文件

循环里 os.Open 之后必须 Close,否则文件描述符会耗尽(大量文件时尤其明显)。

4. 错误只打印不返回

脚本里 textstat a.md b.md | wc -l 无法知道是否失败。有失败就设置非零退出码。

5. 用 fmt.Println 输出 JSON

前面夹了日志就解析失败。结构化输出只写 stdout,且一条一个 JSON 对象(NDJSON)。

6. Scanner 超长行报错

默认 64KB 上限,日志文件里常见。放大 Buffer,并检查 scanner.Err()。

7. 路径含中文或空格

os.Open 没问题,但别自己拼 shell 命令。批量处理用 filepath.Glob 或由 shell 展开。

小结

  • main 只做参数与输出,逻辑放独立包并用 io.Reader 接收输入。
  • run(args) int + os.Exit(run(...)) 让退出码可测试。
  • 文件级错误不中断全局,最后汇总退出码。
  • stdout 只放结果,stderr 只放错误;JSON 用 Encoder 直接写流。
  • 版本用 -ldflags -X 注入,构建加 -trimpath。

下一步