13 之前的 CLI 用 io.ReadAll 读取文件:一个不受控制的大文件会先进入内存,再做任务统计。引入 stdin 与 JSON 后,读取量、坏 JSON 和 stdout 部分写出还会把原来的「输入有效才写结果」契约拆成几个环节。哪一层负责关闭文件?Writer 只接受了部分字节,程序还能声称调用没有任何输出吗?

同一累计工程本章新增 --stdin、--input-json 与 --json。--file/--stdin 的单份输入最多 65536 字节;--input-json 只允许搭配这两种来源,解码形如 {"text":"Go 写 Go"} 的 JSON。--json 对统计结果输出 JSON 数组,--list-ids 则输出字符串数组。已有 --text、--batch、--lookup 和顺序任务记录不改写成另一个工程。本章完整包与SHA256SUMS可在网站独立下载并复跑。

独立复跑步骤列出本章正反例、源码校验及运行命令。

超限必须多读一个字节

readBounded(input) 在 io.LimitReader(input, maxInputBytes+1) 上调用 io.ReadAll。如果只允许读到 65536,恰好达到上限与后面还有内容的输入就无法区分。多读第 65537 字节以后,len(content) > maxInputBytes 才是超限证据;readBounded 返回包装 ErrInvalidInput 的错误,丢弃读到的内容。io_test.go 分别输入恰好 65536 字节与 65537 字节,前者成功,后者断言错误分类。

同一个测试还用每次只返回一个字节的 Reader 传入 Go 写 Go,实际完整读回相同字符串;不能假设一次 Read 就得到全部字节。文件路径依旧由 readInputFile 打开,再把文件交给 readAndClose:读成功、读取失败或读得超限时均尝试 Close。超限测试额外注入关闭失败,验证首要的读取超限错误未被关闭错误覆盖、Close 恰好调用一次。stdin 的 Reader 由调用者提供,runWithInput 不关闭它;stdout 的 Writer 也由调用者管理。直接从命令行给的 --text、--batch 暂不经过这条字节限制,不能把本章称为所有输入的统一容量预算。

编译二进制后,head -c 65537 /dev/zero | ./taskstats --stdin 返回应用退出 2,stdout 空、stderr 是 input exceeds 65536 bytes;同样长度的文件 --file 也退出 2。原始输入生成、运行命令、双流与退出码保存在 writing-plans/go-language/evidence/14/over-limit.*、large-file.*。这个 65536 是本工程自己选择的教学上限,不是 Go 语言或 io.ReadAll 的默认限制;用 NUL 字节只是构造固定长度的测试输入,不代表有效任务文本。

JSON 不改变谁拥有文件

stdin/文件先按上限读取,之后如果有 --input-json,才调用 json.Unmarshal 解出 text 字段;得到文本后继续沿用原有的空白与上限校验。输入 {"text":"Go 写 Go"} 在这两条路径上配合 --json 均实测输出:

1
[{"id":"task[0]","status":"SUCCEEDED","words":3,"bytes":9,"runes":7}]

最后还有一个换行,stderr 空、退出 0。这里的 SUCCEEDED 是当前顺序计算已经完成后创建的记录字段,不是后续 HTTP 应答发出时后台任务完成的承诺。--batch 'Go|one two' --list-ids --json 实测为 ["task[0]","task[1]"];结果列表和 ID 列表的 JSON 形状刻意区分。尚无稳定的 HTTP API 文档,不能将 CLI 的 JSON 数组直接当成将来的服务响应格式。

坏输入如 {"text":、后面多出非空白内容的 JSON 在 json.Unmarshal 时报错。本章 CLI 对这些输入只打印 invalid JSON input,stdout 空、退出 2;内部测试仍用 errors.Is(err, ErrInvalidInput) 和 errors.As(err, &syntaxError) 断言解析错误链。--text Go --input-json 属于选项组合错误,不能将旗标暗中改成另一种输入来源。当前解码器并未禁止未知 JSON 字段,因此它不是严格 schema 校验;短读测试与格式错误测试分别证明不同的边界。

Writer 只接收一字节时

输出路径先在内存里拼成文本,或者 json.Marshal 得到完整的一份 JSON 字节串,再调用一次 writeResponse(stdout, payload)。测试替身 Writer 故意只接收第一个字节且返回 err=nil。虽然这种返回不遵守 io.Writer 对短写的要求,writeResponse 仍检查 written != len(payload) 并返回 io.ErrShortWrite;CLI 报 output write failed、退出 2,且不会擅自 Close 外部交入的 stdout。测试实测日志为 partial writer: accepted="G" returned=short write,普通写入的 JSON 与原文本输出则保持正常。

已经被 Writer 接受的一个字节无法由这段 CLI 代码撤回。验证正反测试时必须把「在开始写之前完成输入校验、不会故意输出半份统计」和「底层 Writer 出现部分写出时无法回滚」分开叙述。前者由已有内存暂存实现,后者的实际返回码和接受字节在 io_test.go 中有断言;这里没有事务式输出承诺。若接入网络连接、数据库或外部消费者,需要根据各自的确认与重试语义重新设计,不能拿这次 stdout 测试当作外部副作用原子性证明。

两包 go test -count=1 -v ./... 退出 0,测试覆盖文件/标准输入、短读、字节上限、关闭失败、坏 JSON、部分写出与旧 CLI 回归。编译后的旧 --text 'Go 写 Go' 仍输出 words=3 bytes=9 runes=7,--batch 'Go|one two' 两条结果顺序不变。所有本机 Linux amd64/Go 1.27.1 的原始命令、stdout、stderr 和退出码保存在 evidence/14/;下载包经过 SHA-256 核验并独立解包重跑,素材目录 unpack-* 有结果。隔离 Hexo 构建与页面/链接检查另记,不替代 Go/Writer 实验。

练习:假如改为 io.LimitReader(input, 65536) 后直接接受全部读得的内容,什么输入会被错收?答案:实际长度 65537 字节及更长的输入在视图里被截为 65536 字节,看起来合法但丢了尾部,任务统计用的已经不是调用方提交的全文。需要读到上限加一字节才能区分恰好装满和已经截断。

目前只建立顺序调用的输入/输出所有权,没有 goroutine 退出责任、并发队列、HTTP 请求生命周期或 SQL 提交。race、其他平台和发布均 NOT_RUN。工具链源码 tag go1.27.1 与镜像 SHA 862c888e612ac346c7c4d99c9392bdfd265f33b0 已冻结,官方 Gitiles 交叉核验未完成。下一篇将启动第一个 goroutine,先证明谁等待它退出,再谈 channel 与取消。

参考资料