Go 实战:写一个命令行工具
AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。
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。