一段程序打印了 words=3,能说明什么?它至少可能来自三种不同路径:源码被解释为包后构建成可执行文件,再由操作系统启动;go run 帮忙完成了构建和启动;或者某个测试直接调用了统计函数。三条路径的输入、失败出口、留下的证据都不相同。如果连“函数返回错误”和“进程以什么状态退出”都没有分开,后续任务取消、HTTP 应答和数据库提交就容易被混成一件事。

系列将逐步构建同一个文本统计任务服务。当前只有同步 CLI:接收一段文本、计算字段数、输出结果。暂时没有任务 ID、队列、goroutine 或持久化;它们到各自负责的章节才加入。系列导航列出篇序和未验收项目。

先约定输入和成功条件

输入通过 --text 传给程序。这里的“词”有一个刻意收窄的定义:strings.Fields 按 Unicode 空白把字符串切成非空字段,再取字段数量。one two 是两个字段,Go 写 Go 是三个字段。没有空白的 Go语言 只有一个字段;这个数字不代表自然语言分词,更不是字节数、码点数或屏幕上的字符数。不同长度单位留到 03 验证。

一条成功命令必须同时满足:标准输出恰有 words=数字 一行,标准错误为空,进程退出码是 0。纯空白文本与未知选项不产生成功输出,进程退出码是 2。函数 countWords 只负责计算;参数约束与输出由入口函数处理,错误输入在计算前就被拒绝。这一区分能使以后把统计逻辑接入 HTTP 时继续复用计算函数,而不把 CLI 的用法提示变成 HTTP 的响应契约。

代码见本章完整累计工程和原始证据。工程根目录的 count.go 只有计数函数,main.go 用 flag.NewFlagSet 解析参数,run 返回退出码,main 才调用 os.Exit。run 把输出写给注入的 writer,测试不用启动子进程便能断言两条输出流;真实进程的退出码仍需编译后另测。

1
2
3
4
go.mod ──声明 module 与最低 Go 语言版本──┐
count.go + main.go ──同属 package main────┼──go build──可执行文件
│ │
main_test.go ──go test 编译并调用函数───────┘ └──进程:stdout / stderr / 退出码

图中 main_test.go 参与测试构建,不是发布程序里等待用户调用的入口。module example.com/go-language-task 为包提供路径上下文,不是命令名字;本章仅使用标准库,go list -m all 的实际输出只有这个 module,不需要凭空生成 go.sum。go.mod 的 go 1.27.1 约束语言版本,运行命令时另设 GOTOOLCHAIN=local 以禁止工具链自动下载;两者不能由 IDE 上显示的版本代替。

从源码走到进程

本次复现环境是 Ubuntu 24.04.4 LTS、Linux amd64,使用校验通过的官方 Go 1.27.1 压缩包。源码 tag go1.27.1 的 Git SHA 为 862c888e612ac346c7c4d99c9392bdfd265f33b0;SHA 由 Go 镜像查询,官方 Gitiles 当次不可达,尚未完成官方远端交叉核验。下载压缩包 SHA-256 为 63d339f0da5ab53635a56f2490a7984dfe12dfcff22ad749f63edaf590168445,sha256sum -c 实际返回 0。平台 CPU cgroup quota 为 400000/100000,内存上限 8 GiB,运行实验设 GOMAXPROCS=2、CGO_ENABLED=0;冻结细节见包内 VERSIONS.md,未引入的 SQL 驱动不在本章假设之内。

进入工程目录后执行(包内 RUN.md 列出全部命令):

1
2
3
4
5
6
7
export GOTOOLCHAIN=local GOMAXPROCS=2 CGO_ENABLED=0
go version
go mod tidy -diff
go list -m all
go test -count=1 -v ./...
go build -o /tmp/go-language-00-taskstats .
/tmp/go-language-00-taskstats --text 'Go 写 Go'

go version 本次输出 go version go1.27.1 linux/amd64;go mod tidy -diff、构建、测试的退出码均为 0。最后一条标准输出为 words=3、标准错误为空、退出码为 0。ASCII 输入 one two 同样实测输出 words=2。它们是本机原始结果,不是跨平台保证的性能数据。这里先用 go build 生成文件再运行:若把非法参数交给 go run,外层 go 命令会处理子进程失败,不应直接把 go run 的退出状态记作应用本身的退出状态。

go.mod 在第一次尝试时含一个与 go 指令同版的 toolchain 指令;go mod tidy -diff 要求移除,该次 go test 退出 1,提示 updates to go.mod needed。保留这条 initial-test 原始记录后,删去冗余指令并重新运行,测试才通过。这次失败是模块元数据尚未整理,不是统计函数的边界反例。

让错误真的停在成功路径外

编译所得程序对纯空白输入的实测结果如下。输出文件把 stdout 与 stderr 分开保存;表中的“退出码”是程序本身的进程状态。

输入 stdout stderr 退出码
--text 'one two' words=2 空 0
--text 'Go 写 Go' words=3 空 0
--text ' ' 空 usage: taskstats --text 'words separated by whitespace' 2
--invalid 空 flag provided but not defined: -invalid 及帮助文本 2

这四个结果分别对应素材包中 evidence/00/cli-ascii.*、cli-chinese.*、cli-invalid.*、cli-unknown.* 的原始流与退出码。进程代码通过 os.Exit(run(...)) 把 run 的整数转换成进程状态;仅从函数的 return 2 推断外部命令退出码,缺少编译后调用这一步的证据。将空输入当成功输出 words=0 并非本章规则:没有提交任务时不能制造看似有效的计算结果。统计函数自身仍允许 countWords("\t \n") == 0,这是函数输入和 CLI 输入契约的不同层次。

测试到底识别了什么错误

main_test.go 同时检查计数和入口:ASCII、中文、纯空白给定固定期望;正常参数检查 stdout 与退出码;纯空白、未知选项及多余参数检查失败出口。go test -count=1 -v ./... 本次显示这七个子测试通过,退出码 0。-count=1 让这次命令实际运行测试,而不是只看可能存在的测试缓存;这仍不能证明未提供的输入也正确。

为了确认测试不是无论实现如何都显示绿色,仅在隔离副本中把计数改成 len(strings.Fields(text)) + 1。相同的测试命令这次退出 1,原始日志中有 countWords("Go 写 Go") = 4; want 3,CLI 测试还指出本该为 words=3 却得到 words=4。故障副本的唯一改动保存在 mutation.diff。原工程没有保留这行错误实现,其后的测试与素材包解包测试都重新通过。

失败实验能证明这些已执行断言识别了这项特定错误;它不保证任意实现错误都会被发现。本章未运行 race detector,顺序测试通过不能写成并发安全,也无法替代后续对取消与退出确认的分别验证。

复跑边界与练习

博客发布时只部署 source/ 内容,不保证旁边的 examples/ 同时出现在站点。素材包因此保存截至 00 的完整工程、RUN.md、VERSIONS.md 和 evidence/00/。下载后先使用校验文件,本次压缩包的 SHA-256 为 9de0c0738f5942b1ebf8ef00d37fcac078f3520656cfe75b9fd1d447b9f9ada0。本机已单独解包,在解包目录重跑 go test、构建、成功和失败命令;退出码分别为 0、0、0、2,原始记录在素材目录的 unpack-* 文件中。这个复跑验证了压缩包不是一个空目录或指向本机工程的符号链接;它没有代替其他平台的执行。

练习一:预测 --text 'Go语言' 的输出,并说明这能不能推出输入含两个自然语言词。答案:按 strings.Fields 的公开契约推导,应为 words=1,因为内部没有空白;“两个自然语言词”不在函数的定义内。该输入本章没有单独执行,答案是规则推演而不是实测输出。

练习二:如果把计算函数错误地统一加一,哪些检查能最先指出问题?答案:TestCountWords 的 ASCII、中文和空白断言以及 TestRun/valid 均会失败;隔离副本实际跑出的失败信息见 mutation.stdout。而 TestRun/empty 在计算前返回,不能凭它检出计数错误。准备继续拆 package 前,应先确认原工程测试恢复到退出 0。

下一篇把计数函数从 main 包移到独立 package,并从干净目录构建,检查导入路径和依赖图;本篇的输出、失败出口和快照会作为对照,而不是再造一套互不关联的 CLI。

参考资料