← [ BACK_TO_NOTES ]

Go io.Reader 与 io.Writer

io.Reader/Writer 的官方契约:短读、短写、EOF、Copy/ReadFull、组合装饰器与不保留缓冲区规则。

#concept #growing #dev #go

[!info] related notes

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"))
}

保存为 main.go,在已执行 go mod init 的模块目录运行:

go run .

期望输出:

copied 8 bytes: hello io
chunk "a"
chunk "b"
chunk "c"
done

结合场景再看三个关注点

  1. io.Copy 替你处理短读循环
    上传/下载/转存优先 Copy(或 CopyBuffer),少手写。

  2. 手写时 n 优先于 err
    最后一包数据可能与 EOF 一起返回;先丢掉 n 会丢数据。

  3. io.EOF 是结束信号,不是业务故障
    成功拷贝后 Copy 的 err == nil;自己循环里遇到 EOF 就 break。

核心概念与准确模型

Reader 契约(官方)

Read 从上游读入至多 len(p) 字节到 p:

  1. 返回 0 <= n <= len(p) 与 err。
  2. 短读合法:n < len(p) 且 err == nil 很常见。
  3. 若读到数据后又遇到错误/EOF:可返回 (n > 0, err),或先 (n > 0, nil) 下次再 (0, err)。
  4. 调用方必须先处理 n > 0 字节,再判断 err。
  5. 到达末尾应返回 io.EOF 表示优雅结束。
  6. 实现不得保留 p;调用方会复用缓冲。Reader 可将整个 p 当作暂存空间,即使 n < len(p),调用方也只能消费 p[:n]。\
  7. 鼓励避免无意义的 (0, nil)(len(p)==0 除外);调用方不把 (0, nil) 当 EOF。
  8. len(p)==0 时返回 n==0,错误可选。

Writer 契约(官方)

Write 试图写出 p 的全部字节:

  1. 返回 0 <= n <= len(p)。
  2. 若 n < len(p),必须返回非 nil error(禁止“静默短写”)。
  3. 不得修改 p 的数据(甚至临时也不应)。
  4. 不得保留 p。
  5. 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 且写入没有失败时返回 nil。这里只说明字节流结束;上层若期待完整帧、固定长度或业务终态,仍须自行判断是否截断。

关键辅助 API

io.Copy(dst, src)          // 直到 EOF 或错误;成功 err==nil
io.CopyN(dst, src, n)      // 复制恰好 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()                  // 无内部缓冲的同步内存管道,读写需要相互配合

ReadFull 要点:

  • 读满时返回 n == len(buf)、err == nil;底层在满足长度的同次 Read 附带错误也会被消化。\
  • 非空 buf,且一字节未读到便遇到 EOF → EOF。\
  • 未读满便遇到 EOF → ErrUnexpectedEOF;遇到其他错误则保留该错误,不一概改成 UnexpectedEOF。空 buf 立即返回 0, nil。

常见实现

类型ReaderWriter备注
*os.File是是文件
net.Conn是是网络
*bytes.Buffer是是内存
*bytes.Reader是否从切片读
*strings.Reader是否从字符串读
*bufio.Reader是否读缓冲;可有 WriterTo,但不是 Writer
*bufio.Writer否是写缓冲;需显式 Flush
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)

许多压缩/缓冲包装器接收 Reader;并非所有加密或编解码 API 都采用同一构造形态。上例 hashingWriter 是应用提供的占位类型。

Closer 与 ReadCloser

type Closer interface{ Close() error }
type ReadCloser interface {
	Reader
	Closer
}
  • HTTP body、文件等常需 Close 释放连接/句柄。
  • 二次 Close 行为未统一规定,依赖具体类型文档。
  • 习惯:defer rc.Close()(在成功取得 rc 后)。

设计动机

  1. 最小接口最大组合
    一个方法的接口极易满足。
  2. 调用方提供缓冲区
    减少分配;所有权在调用方。
  3. 流式处理
    不必整份读入内存。
  4. 错误是值
    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 {
	return nil // 在返回 error 的读取函数中结束
}
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 字节后失败,目标可能已发生部分修改;不能直接重试整个 p,否则可能重复数据。重试位置和是否可恢复取决于目标协议。

5. ReadAll 内存炸弹

不可信输入应限制体积与耗时。LimitReader 到限额会返回 EOF,本身不能区分恰好到限和超限;可读 max+1 字节检查长度(见下例),HTTP 请求也可用 MaxBytesReader。

6. 并发安全

默认不假设同一 Reader/Writer 并发可重入;需文档或锁。

7. 缓冲与底层短路

io.Copy 优先调用源的 WriterTo,其次调用目标的 ReaderFrom,否则使用通用复制循环。优化实现仍须遵守契约;Copy 不替你 Close 两端,也不自动 Flush 包装器。

常见误区

[!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 适配

工程实践

  1. 优先 io.Copy / json.NewDecoder(r) 等组合,少手写 Read 循环。
  2. 不可信输入设上限。
  3. 函数参数用接口:func Load(r io.Reader) 而不是 *os.File。
  4. 返回 io.ReadCloser 时文档说明谁负责 Close。
  5. 测试用 strings.NewReader、bytes.Buffer、net.Pipe。
  6. 哈希/审计:io.TeeReader / MultiWriter。
  7. 错误包装在业务层:区分“读失败”与“内容非法”,勿破坏底层 EOF 检测点。
  8. 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

故意让 Writer 对非空 p 返回 0, nil;用只有 Read 方法的源触发 io.Copy 通用路径,预期为 io.ErrShortWrite。ErrNoProgress 不是这里的短写错误。

本节总结

  • Reader/Writer 是 Go I/O 的通用字节流契约。
  • 先 n 后 err;短读合法;短写非法(无 err)。
  • EOF 是优雅结束信号;Copy 成功不返回 EOF。
  • 组合装饰是扩展方式;不保留缓冲是实现铁律。
  • 下一步:bufio、HTTP body、序列化 的流式 API。

自测题

概念题

  1. 为什么 Writer 短写必须返回错误,而 Reader 短读可以不返回错误?
  2. 为何实现不得保留 p?
  3. io.Copy 遇到源 EOF 时返回什么 err?

代码推理题

n, err := r.Read(buf)
if err != nil {
	return err
}
process(buf)

指出两处缺陷。

工程思考题

上传接口用 io.ReadAll(r.Body) 读入内存。有何风险?如何改?

参考答案

展开
  1. 读侧半包/缓冲边界常见,允许进度;写侧若少写又不报错,调用方无法知道失败与重试边界,契约强制 err。
  2. 调用方拥有并复用缓冲;保留会导致数据竞争与静默损坏。
  3. nil(EOF 被定义为成功结束条件)。
    代码题:① 忽略 n>0 可能有效数据;② process(buf) 应用 buf[:n] 而非整个 cap/len。
    工程题:无限制内存与 DoS;用 LimitReader/MaxBytesReader、流式解码、落盘临时文件或分块处理。

延伸阅读与资料来源

资料类型支撑
Package io标准库文档Reader/Writer/EOF/Copy 契约
Effective Go — Interfaces指南接口组合
Go Blog — Errors are values博客I/O 循环中的错误处理模式
Spec — Interface types规范接口满足规则

笔记元信息

  • 建议文件名:go-io-reader-writer.md
  • 所属阶段:标准库与抽象
  • 学习顺序:接口之后
  • 建议下一篇:net/http 或 JSON
  • 本篇状态:已深化(对齐 io 包官方契约)
创建于 2026/6/25 更新于 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
关闭浮层 / 取消命令;输入框内清空文本