Go io.Reader 与 io.Writer
io.Reader/Writer 的官方契约:短读、短写、EOF、Copy/ReadFull、组合装饰器与不保留缓冲区规则。
[!info] 关联笔记
Go io.Reader 与 io.Writer
这个概念为什么会出现
程序要处理的数据源极多:文件、套接字、标准输入、内存缓冲、压缩流、加密流、HTTP body……若每个 API 都绑定具体类型,组合会爆炸。
Go 标准库用两个极小接口统一“拉字节”与“推字节”:
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
几乎整个 I/O 生态(os、net、bufio、compress/*、encoding/*、net/http)都建立在这对契约上。学不会契约细节(尤其 EOF 与短读),会出现丢数据、死循环、误判结束。
[!abstract] 一句话理解
Reader允许短读且必须先处理n再处理err;Writer短写必须带错误;io.EOF表示优雅结束且通常不包装;io.Copy成功时err == nil。
最小可运行示例
先把示例放进两个业务场景,再看代码:
场景 A:把上传流拷到处理缓冲
网关收到一段 body(这里用内存 strings.Reader 模拟),
要原样写入下游处理器的缓冲(strings.Builder 模拟)。
工程上几乎总是 io.Copy,而不是自己手写 for 循环——
Copy 已经按契约处理短读与 EOF。
场景 B:慢速/分片来源的“短读”
某些 Reader 一次只给出很少字节(慢网、自定义协议、加密块)。
手写读取循环时必须:先处理 n > 0 的数据,再看 err;
io.EOF 表示优雅结束,不是需要包装的故障。
package main
import (
"fmt"
"io"
"os"
"strings"
)
// byteAtATime 模拟“一次只吐 1 字节”的上游。
// 业务类比:极慢的网络帧、按字节调试的假 Reader。
// 教学点:短读合法——Read 不必填满 p。
type byteAtATime struct {
s string
i int
}
func (b *byteAtATime) Read(p []byte) (int, error) {
if b.i >= len(b.s) {
// 没有更多数据:优雅结束
return 0, io.EOF
}
if len(p) == 0 {
return 0, nil
}
// 故意每次只写 1 字节,逼调用方正确处理短读
p[0] = b.s[b.i]
b.i++
return 1, nil
}
func main() {
// --- 场景 A:整段 body 拷到处理缓冲 ---
src := strings.NewReader("hello io")
dst := &strings.Builder{}
written, err := io.Copy(dst, src)
if err != nil {
// Copy 已消化正常 EOF;这里出现 err 才是真故障
fmt.Println("copy err:", err)
return
}
fmt.Printf("copied %d bytes: %s\n", written, dst.String())
// --- 场景 B:手写循环消费短读 Reader ---
r := &byteAtATime{s: "abc"}
buf := make([]byte, 8) // 缓冲很大,但上游一次只给 1 字节
for {
n, err := r.Read(buf)
// 关键:先落地 n>0 的数据,再判断 err(可能同时带 EOF)
if n > 0 {
fmt.Printf("chunk %q\n", buf[:n])
}
if err == io.EOF {
break // 正常读完
}
if err != nil {
fmt.Println("read err:", err)
return
}
}
_, _ = os.Stdout.Write([]byte("done\n"))
}
建议运行:
go run .
期望输出:
copied 8 bytes: hello io
chunk "a"
chunk "b"
chunk "c"
done
结合场景再看三个关注点
-
io.Copy替你处理短读循环
上传/下载/转存优先Copy(或CopyBuffer),少手写。 -
手写时
n优先于err
最后一包数据可能与EOF一起返回;先丢掉n会丢数据。 -
io.EOF是结束信号,不是业务故障
成功拷贝后Copy的err == nil;自己循环里遇到 EOF 就 break。
核心概念与准确模型
Reader 契约(官方)
Read 从上游读入至多 len(p) 字节到 p:
- 返回
0 <= n <= len(p)与err。 - 短读合法:
n < len(p)且err == nil很常见。 - 若读到数据后又遇到错误/EOF:可返回
(n > 0, err),或先(n > 0, nil)下次再(0, err)。 - 调用方必须先处理
n > 0字节,再判断err。 - 到达末尾应返回
io.EOF表示优雅结束。 - 实现不得保留
p(返回后调用方会复用缓冲)。 - 鼓励避免无意义的
(0, nil)(len(p)==0除外);调用方不把(0, nil)当 EOF。 len(p)==0时返回n==0,错误可选。
Writer 契约(官方)
Write 试图写出 p 的全部字节:
- 返回
0 <= n <= len(p)。 - 若
n < len(p),必须返回非 nil error(禁止“静默短写”)。 - 不得修改
p的数据(甚至临时也不应)。 - 不得保留
p。 io.ErrShortWrite用于“接受了更少字节却没给明确错误”的情况(工具层)。
EOF 与 UnexpectedEOF
var EOF = errors.New("EOF")
var ErrUnexpectedEOF = errors.New("unexpected EOF")
| 符号 | 含义 |
|---|---|
io.EOF | 输入优雅结束;比较用 == / errors.Is 到 EOF 本身;契约要求 Reader 返回 EOF 本身而非随意包装,以便调用方识别 |
io.ErrUnexpectedEOF | 固定大小/结构化读取中途结束 |
io.Copy 读到 EOF 视为成功结束,返回 err == nil。
关键辅助 API
io.Copy(dst, src) // 直到 EOF 或错误;成功 err==nil
io.CopyN(dst, src, n) // 最多 n 字节
io.ReadFull(r, buf) // 恰好读满 buf
io.ReadAll(r) // 读尽(注意内存)
io.WriteString(w, s) // 若实现 StringWriter 可优化
io.LimitReader(r, n) // 最多暴露 n 字节
io.TeeReader(r, w) // 读的同时写入 w(如日志/哈希)
io.MultiReader(r1, r2, …) // 顺序拼接
io.MultiWriter(w1, w2, …) // 扇出写
io.Pipe() // 内存管道 Reader/Writer 对
ReadFull 要点:
- 读满 ⇔
err == nil且n == len(buf)。 - 一字节未读到就结束 →
EOF。 - 读了部分 →
ErrUnexpectedEOF。
常见实现
| 类型 | Reader | Writer | 备注 |
|---|---|---|---|
*os.File | 是 | 是 | 文件 |
net.Conn | 是 | 是 | 网络 |
*bytes.Buffer | 是 | 是 | 内存 |
*bytes.Reader | 是 | 否 | 从切片读 |
*strings.Reader | 是 | 否 | 从字符串读 |
*bufio.Reader/Writer | 是 | 是 | 缓冲装饰 |
http.Request.Body | 是 | 否 | 需 Close |
http.ResponseWriter | 否 | 是 | 响应 |
组合优于继承
小接口使装饰器自然:
var r io.Reader = file
r = io.LimitReader(r, 1<<20)
r = io.TeeReader(r, &hashingWriter{})
data, err := io.ReadAll(r)
压缩、加密、缓冲都是 NewReader(r io.Reader) 形态。
Closer 与 ReadCloser
type Closer interface{ Close() error }
type ReadCloser interface {
Reader
Closer
}
- HTTP body、文件等常需
Close释放连接/句柄。 - 二次 Close 行为未统一规定,依赖具体类型文档。
- 习惯:
defer rc.Close()(在成功取得 rc 后)。
设计动机
- 最小接口最大组合
一个方法的接口极易满足。 - 调用方提供缓冲区
减少分配;所有权在调用方。 - 流式处理
不必整份读入内存。 - 错误是值
EOF 也是 error 值,但是“成功结束”的信号,需特殊对待。
边界情况与反直觉行为
1. 只看 err 不看 n → 丢数据
n, err := r.Read(buf)
if err != nil {
return err // 错误:可能丢掉了 buf[:n]
}
正确:
n, err := r.Read(buf)
if n > 0 {
// 处理 buf[:n]
}
if err == io.EOF {
// 结束
}
if err != nil {
return err
}
2. 把 EOF 当失败向上返回
许多 API 应把 EOF 消化为“正常结束”。包装层乱 fmt.Errorf("%w", io.EOF) 会破坏 == io.EOF 判断;若必须包装,调用方应 errors.Is(err, io.EOF),但 Reader 实现仍应直接返回 EOF。
3. (0, nil) 不是结束
可能表示暂时无数据(少见)或实现质量问题;循环中应继续或按协议处理,不能当 EOF。
4. Writer 部分成功
已写出 n 字节后失败:调用方需知道德状态机(尤其连接、文件截断场景)。
5. ReadAll 内存炸弹
不可信输入上 io.ReadAll 无上限危险;应用 LimitReader 或 http.MaxBytesReader。
6. 并发安全
默认不假设同一 Reader/Writer 并发可重入;需文档或锁。
7. 缓冲与底层短路
io.Copy 若 src 实现 WriterTo 或 dst 实现 ReaderFrom,会走优化路径;自实现时可考虑这些可选接口。
常见误区
[!warning] 常见误区:假设一次 Read 读满缓冲 错误:
r.Read(buf); use(buf)不看n。
正确:只使用buf[:n]。
[!warning] 常见误区:Copy 后检查 EOF 错误:以为成功 Copy 返回 EOF。
正确:成功是nil。
[!warning] 常见误区:实现 Writer 时静默短写 错误:
return n, nil且n < len(p)。
正确:必须非 nil error。
[!warning] 常见误区:Read 实现里保存 p 切片 错误:异步以后再写 p。
正确:返回前完成使用;要留数据就复制。
[!warning] 常见误区:忘记 Close body 错误:HTTP 响应体不关导致连接泄露。
正确:defer resp.Body.Close()。
与相邻概念对比
| 概念 | 差异 |
|---|---|
| 切片 | Read/Write 的载体;注意 n 与 len |
| 接口 | Reader/Writer 是小接口典范 |
| 通道 | 并发消息;I/O 接口是字节流拉取/推送 |
| 字符串 | 不可变;常用 strings.NewReader 适配 |
工程实践
- 优先
io.Copy/json.NewDecoder(r)等组合,少手写 Read 循环。 - 不可信输入设上限。
- 函数参数用接口:
func Load(r io.Reader)而不是*os.File。 - 返回
io.ReadCloser时文档说明谁负责 Close。 - 测试用
strings.NewReader、bytes.Buffer、net.Pipe。 - 哈希/审计:
io.TeeReader/MultiWriter。 - 错误包装在业务层:区分“读失败”与“内容非法”,勿破坏底层 EOF 检测点。
- bufio:小读多时加缓冲,避免频繁系统调用。
func loadConfig(r io.Reader) ([]byte, error) {
const max = 1 << 20
data, err := io.ReadAll(io.LimitReader(r, max+1))
if err != nil {
return nil, err
}
if len(data) > max {
return nil, fmt.Errorf("config too large")
}
return data, nil
}
可验证实验
实验 1:短读
实现一次 1 字节的 Reader,用 io.ReadAll 与手写循环对比。
实验 2:Copy 的 err
对 strings.NewReader 做 Copy,打印 err 是否为 nil。
实验 3:ReadFull
对短数据 ReadFull 长缓冲,观察 ErrUnexpectedEOF。
实验 4:LimitReader
超大源 + Limit,确认长度。
实验 5:违规 Writer
故意 return 0, nil 写多字节,看 io.Copy 如何失败(ErrNoProgress 等)。
本节总结
- Reader/Writer 是 Go I/O 的通用字节流契约。
- 先 n 后 err;短读合法;短写非法(无 err)。
- EOF 是优雅结束信号;
Copy成功不返回 EOF。 - 组合装饰是扩展方式;不保留缓冲是实现铁律。
- 下一步:
bufio、HTTP body、序列化 的流式 API。
自测题
概念题
- 为什么 Writer 短写必须返回错误,而 Reader 短读可以不返回错误?
- 为何实现不得保留
p? io.Copy遇到源 EOF 时返回什么 err?
代码推理题
n, err := r.Read(buf)
if err != nil {
return err
}
process(buf)
指出两处缺陷。
工程思考题
上传接口用 io.ReadAll(r.Body) 读入内存。有何风险?如何改?
参考答案
展开
- 读侧半包/缓冲边界常见,允许进度;写侧若少写又不报错,调用方无法知道失败与重试边界,契约强制 err。
- 调用方拥有并复用缓冲;保留会导致数据竞争与静默损坏。
nil(EOF 被定义为成功结束条件)。
代码题:① 忽略n>0可能有效数据;②process(buf)应用buf[:n]而非整个 cap/len。
工程题:无限制内存与 DoS;用LimitReader/MaxBytesReader、流式解码、落盘临时文件或分块处理。
延伸阅读与资料来源
| 资料 | 类型 | 支撑 |
|---|---|---|
| Package io | 标准库文档 | Reader/Writer/EOF/Copy 契约 |
| Readers and Writers 等 | 生态 | 小接口文化(配合 Effective Go) |
| Effective Go — Interfaces | 指南 | 接口组合 |
| Go Blog — Errors are values | 博客 | I/O 循环中的错误处理模式 |
| Spec — Interface types | 规范 | 接口满足规则 |