Go io.Reader 与 io.Writer

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

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

[!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 生态(osnetbufiocompress/*encoding/*net/http)都建立在这对契约上。学不会契约细节(尤其 EOF 与短读),会出现丢数据、死循环、误判结束。

[!abstract] 一句话理解 Reader 允许短读且必须先处理 n 再处理 errWriter 短写必须带错误;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

结合场景再看三个关注点

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

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

  3. io.EOF 是结束信号,不是业务故障
    成功拷贝后 Copyerr == 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(返回后调用方会复用缓冲)。
  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 视为成功结束,返回 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 == niln == len(buf)
  • 一字节未读到就结束 → EOF
  • 读了部分 → ErrUnexpectedEOF

常见实现

类型ReaderWriter备注
*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 后)。

设计动机

  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 {
	// 结束
}
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 无上限危险;应用 LimitReaderhttp.MaxBytesReader

6. 并发安全

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

7. 缓冲与底层短路

io.Copysrc 实现 WriterTodst 实现 ReaderFrom,会走优化路径;自实现时可考虑这些可选接口。

常见误区

[!warning] 常见误区:假设一次 Read 读满缓冲 错误:r.Read(buf); use(buf) 不看 n
正确:只使用 buf[:n]

[!warning] 常见误区:Copy 后检查 EOF 错误:以为成功 Copy 返回 EOF。
正确:成功是 nil

[!warning] 常见误区:实现 Writer 时静默短写 错误:return n, niln < 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.NewReaderbytes.Buffernet.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

故意 return 0, nil 写多字节,看 io.Copy 如何失败(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 契约
Readers and Writers生态小接口文化(配合 Effective Go)
Effective Go — Interfaces指南接口组合
Go Blog — Errors are values博客I/O 循环中的错误处理模式
Spec — Interface types规范接口满足规则

笔记元信息

  • 建议文件名:go-io-reader-writer.md
  • 所属阶段:标准库与抽象
  • 学习顺序:接口之后
  • 建议下一篇:net/httpJSON
  • 本篇状态:已深化(对齐 io 包官方契约)
创建于 2026/6/25 更新于 2026/7/15