← [ BACK_TO_NOTES ]

使用 Go 标准库构建 CLI

用 flag、io.Reader/Writer 与可注入依赖构建可测试的最小命令行工具;main 只接 OS 边界。

#howto #growing #dev #go

[!info] related notes

使用 Go 标准库构建 CLI

这个实践为什么会出现

学完语法后需要一个可交付闭环:解析参数、读输入、写输出、非零退出码、不启动子进程也能测核心逻辑。标准库 flag + io + bufio 足够做出干净 CLI,不必先上 Cobra。

[!abstract] 一句话理解 核心逻辑写成依赖 io.Reader/io.Writer 的可测试函数(仍有 I/O 副作用);main 只解析 flag、绑定 os.Stdin/Stdout/Stderr、映射退出码。

目标程序:linecount

统计标准输入中的非空行数量(trim 后非空)。本例不接受文件名或其他位置参数;每行受 Scanner 默认 token 上限约束,超限返回错误。

在新目录执行 go mod init example.com/linecount,把下面三个代码块分别保存为 linecount.go、main.go、linecount_test.go;三者均属于 package main。

核心逻辑(可测包)

// 文件:linecount.go
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:只接操作系统边界

// 文件:main.go
package main

import (
	"flag"
	"fmt"
	"os"
)

func main() {
	// 预留:var verbose = flag.Bool("v", false, "verbose")
	flag.Parse()
	if flag.NArg() != 0 {
		fmt.Fprintln(os.Stderr, "usage: linecount < input")
		os.Exit(2)
	}

	if err := Run(os.Stdin, os.Stdout); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

测试

// 文件:linecount_test.go
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.Readeros.Stdin / 文件strings.Reader
io.Writeros.Stdoutbytes.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 缓冲为 64 KiB,实际允许行长还受换行等开销影响。本例保留此界限并返回 scan 错误;需要更大输入时应先确定业务上限,再配置 Buffer 或设计 Reader 的分段处理,不能直接无限积累整行。

设计动机

  1. 标准库优先:少依赖、构建简单、跨平台。
  2. 测试友好:逻辑与 OS 边界分离是 CLI 与 HTTP 的共同纪律。
  3. 管道哲学:stdin/stdout 组合进 shell 工作流。

边界情况与反直觉行为

1. 空输入

零非空行应打印 0,退出 0。

2. 仅空白行

不计数。

3. Windows 换行

默认 ScanLines 识别 \n,移除行尾可选的 \r,并返回末尾没有换行的最后一行;不把单独 \r 当作通用换行分隔符。

4. 部分写失败

Fprintln 错误要返回,不能静默。

常见误区

[!warning] 常见误区:在库函数里 os.Exit(1)
测试无法断言,defer 不跑完。

[!warning] 常见误区:业务里写死读文件路径
用 io.Reader,由 main 打开文件再传入。

[!warning] 常见误区:忽略 scanner.Err()
读失败被当成 EOF。

与相邻概念对比

概念差异
Cobra/urfave子命令/补全强;依赖更多
HTTP 服务同样可注入,边界是 Request/Response
shell 脚本快但弱类型、难测、跨平台差

工程实践

  1. 包布局:internal/linecount + cmd/linecount。
  2. 帮助:flag.Usage 写清例子。
  3. 版本:-version 用 ldflags 注入。
  4. 可选扩展文件参数:本例尚未实现。增加后需明确 -/无参数读 stdin、文件顺序及跨文件边界语义,不能悄悄忽略参数。
  5. 表驱动测试:空、单行、全空白、错误 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 会编译并运行测试二进制;上面的 TestRun 直接调用 Run,不另行启动 linecount CLI 子进程。

实验 3:错误路径

注入 errReader,断言 Run 返回错误且 main 会非零退出(集成测可选)。

实验 4:FlagSet

如把参数解析提取到独立 FlagSet,测试帮助、未知 flag 和不支持的位置参数;本例使用全局 flag,CLI 验收也应覆盖这些退出路径。

本节总结

  • 本质:把 CLI 写成可注入 I/O 的库 + 薄 main。
  • 关键规则:error 返回、os.Exit 仅 main、测核心不测进程。
  • 最易错:逻辑绑死 stdin、Exit 进库、忽略 Scan 错误。
  • 下一步:HTTP 同样骨架;复杂子命令再评估 Cobra。

自测题

概念题

  1. 为什么 Run(in, out) 比内部读 os.Stdin 更好测?
  2. 库函数是否应调用 os.Exit?
  3. bufio.Scanner 的经典限制是什么?

代码推理题

Run 成功但 fmt.Fprintln 因关闭的 pipe 失败——程序退出码应如何?

工程思考题

要支持 linecount file1 file2,如何保持核心函数可测?

参考答案

展开
  1. 测试可塞内存 Reader/Writer,无需管道或临时文件。
  2. 否;返回 error,由 main 退出。
  3. 默认最大 token 长度,超长行报错。
    代码题:非 0;写输出失败是错误。
    工程题:若每个文件独立计行,应分别调用 CountNonEmptyLines 再累加。直接 io.MultiReader 拼接会把前一个文件没有末尾换行的内容与后一个文件首行合成一行,语义不同;核心仍可接收 io.Reader。

延伸阅读与资料来源

资料类型支撑
Package io标准库Reader/Writer
Package bufio标准库Scanner
Package flag标准库参数
Package os标准库Exit、Stdin
go-io-reader-writer · go-testing · go-error-handling本库基础

创建于 2026/7/11 更新于 2026/10/6
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本