使用 Go 标准库构建 CLI
用 flag、io.Reader/Writer 与可注入依赖构建可测试的最小命令行工具;main 只接 OS 边界。
[!info] 关联笔记
使用 Go 标准库构建 CLI
这个实践为什么会出现
学完语法后需要一个可交付闭环:解析参数、读输入、写输出、非零退出码、不启动子进程也能测核心逻辑。标准库 flag + io + bufio 足够做出干净 CLI,不必先上 Cobra。
[!abstract] 一句话理解 核心逻辑写成依赖
io.Reader/io.Writer的纯函数;main只解析 flag、绑定os.Stdin/Stdout/Stderr、映射退出码。
目标程序:linecount
统计输入中的非空行数量(trim 后非空)。
核心逻辑(可测包)
// 文件:linecount.go(package linecount 或与 main 同包演示)
package main
import (
"bufio"
"fmt"
"io"
"strings"
)
func CountNonEmptyLines(r io.Reader) (int, error) {
sc := bufio.NewScanner(r)
n := 0
for sc.Scan() {
if strings.TrimSpace(sc.Text()) != "" {
n++
}
}
if err := sc.Err(); err != nil {
return n, fmt.Errorf("scan: %w", err)
}
return n, nil
}
func Run(in io.Reader, out io.Writer) error {
n, err := CountNonEmptyLines(in)
if err != nil {
return err
}
_, err = fmt.Fprintln(out, n)
return err
}
main:只接操作系统边界
package main
import (
"flag"
"fmt"
"os"
)
func main() {
// 预留:var verbose = flag.Bool("v", false, "verbose")
flag.Parse()
if err := Run(os.Stdin, os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
测试
package main
import (
"bytes"
"strings"
"testing"
)
func TestRun(t *testing.T) {
in := strings.NewReader("one\n\n \ntwo\n")
var out bytes.Buffer
if err := Run(in, &out); err != nil {
t.Fatal(err)
}
if got, want := out.String(), "2\n"; got != want {
t.Fatalf("got %q want %q", got, want)
}
}
构建与手动验证
go test .
go build -o linecount .
printf 'a\n\nb\n' | ./linecount
# 2
PowerShell:
go test .
go build -o linecount.exe .
"one`n`ntwo" | .\linecount.exe
核心概念与准确模型
可注入 I/O
| 依赖 | 生产 | 测试 |
|---|---|---|
io.Reader | os.Stdin / 文件 | strings.Reader |
io.Writer | os.Stdout | bytes.Buffer |
| 错误输出 | os.Stderr | 可另传 errW |
避免在核心逻辑里直接 os.Exit 或读全局 stdin,否则难测。
flag 包
- 全局
flag.CommandLine适合简单工具 - 多子命令/测 flag 时用独立
flag.NewFlagSet flag.Args()取位置参数
文档:flag。
退出码
| 码 | 含义(约定) |
|---|---|
| 0 | 成功 |
| 1 | 一般错误 |
| 2 | 用法错误(可选) |
main 负责 os.Exit;库函数返回 error。
Scanner 限制
bufio.Scanner 默认 token 大小有上限;超长行需 Buffer 调大或改用 ReadString/Reader。
设计动机
- 标准库优先:少依赖、构建简单、跨平台。
- 测试友好:逻辑与 OS 边界分离是 CLI 与 HTTP 的共同纪律。
- 管道哲学:stdin/stdout 组合进 shell 工作流。
边界情况与反直觉行为
1. 空输入
零非空行应打印 0,退出 0。
2. 仅空白行
不计数。
3. Windows 换行
Scanner 处理 \n;\r\n 通常可工作,极端工具需规范化。
4. 部分写失败
Fprintln 错误要返回,不能静默。
常见误区
[!warning] 常见误区:在库函数里
os.Exit(1)
测试无法断言,defer 不跑完。
[!warning] 常见误区:业务里写死读文件路径
用io.Reader,由 main 打开文件再传入。
[!warning] 常见误区:忽略
scanner.Err()
读失败被当成 EOF。
与相邻概念对比
| 概念 | 差异 |
|---|---|
| Cobra/urfave | 子命令/补全强;依赖更多 |
| HTTP 服务 | 同样可注入,边界是 Request/Response |
| shell 脚本 | 快但弱类型、难测、跨平台差 |
工程实践
- 包布局:
internal/linecount+cmd/linecount。 - 帮助:
flag.Usage写清例子。 - 版本:
-version用 ldflags 注入。 - 文件参数:无
-则读文件,-或默认读 stdin。 - 表驱动测试:空、单行、全空白、错误 Reader。
type errReader struct{}
func (errReader) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF }
可验证实验
实验 1:管道
printf 'a\n\nb\n' | go run . → 2。
实验 2:测试
go test 不启动二进制。
实验 3:错误路径
注入 errReader,断言 Run 返回错误且 main 会非零退出(集成测可选)。
实验 4:FlagSet
为 -n(只计数不输出 debug)加独立 FlagSet 单测。
本节总结
- 本质:把 CLI 写成可注入 I/O 的库 + 薄 main。
- 关键规则:
error返回、os.Exit仅 main、测核心不测进程。 - 最易错:逻辑绑死 stdin、Exit 进库、忽略 Scan 错误。
- 下一步:HTTP 同样骨架;复杂子命令再评估 Cobra。
自测题
概念题
- 为什么
Run(in, out)比内部读os.Stdin更好测? - 库函数是否应调用
os.Exit? bufio.Scanner的经典限制是什么?
代码推理题
Run 成功但 fmt.Fprintln 因关闭的 pipe 失败——程序退出码应如何?
工程思考题
要支持 linecount file1 file2,如何保持核心函数可测?
参考答案
展开
- 测试可塞内存 Reader/Writer,无需管道或临时文件。
- 否;返回 error,由 main 退出。
- 默认最大 token 长度,超长行报错。
代码题:非 0;写输出失败是错误。
工程题:main 打开多个文件拼io.MultiReader或循环调用Count累加,核心仍收io.Reader。
延伸阅读与资料来源
| 资料 | 类型 | 支撑 |
|---|---|---|
| Package io | 标准库 | Reader/Writer |
| Package bufio | 标准库 | Scanner |
| Package flag | 标准库 | 参数 |
| Package os | 标准库 | Exit、Stdin |
| go-io-reader-writer · go-testing · go-error-handling | 本库 | 基础 |