使用 Go 标准库构建 CLI

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

#type / howto #status / growing #tech / dev #resource / go

[!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.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 大小有上限;超长行需 Buffer 调大或改用 ReadString/Reader

设计动机

  1. 标准库优先:少依赖、构建简单、跨平台。
  2. 测试友好:逻辑与 OS 边界分离是 CLI 与 HTTP 的共同纪律。
  3. 管道哲学: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 脚本快但弱类型、难测、跨平台差

工程实践

  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 不启动二进制。

实验 3:错误路径

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

实验 4:FlagSet

-n(只计数不输出 debug)加独立 FlagSet 单测。

本节总结

  • 本质:把 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;写输出失败是错误。
    工程题: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本库基础

创建于 2026/7/11 更新于 2026/7/15