Go 的 io.Reader/Writer 组合实战:io.Copy、TeeReader、MultiWriter 优雅处理数据流

Go 的 io.Reader/Writer 组合实战:io.Copy、TeeReader、MultiWriter 优雅处理数据流

写 Go 处理数据的时候,新手容易犯一个习惯病:不管三七二十一先 io.ReadAll 把数据全读进 []byte,再在内存里搓。文件小没事,一旦碰上几百 MB 的上传、下载或日志流,内存直接吃满,OOM 找上门。

Go 标准库的 io.Reader / io.Writer 是整个 IO 生态的基石,配上 io.Copyio.TeeReaderio.MultiWriter 这几个组合器,能用流式、常量内存的方式处理任意大小的数据。这篇把这套组合拳讲透。

先理解两个接口有多小

io.Readerio.Writer 的定义简单到只有一个方法:

go 复制代码
type Reader interface {
    Read(p []byte) (n int, err error)
}
type Writer interface {
    Write(p []byte) (n int, err error)
}

正因为够小,Go 里几乎一切"数据源"和"数据去处"都实现了它们:*os.Filenet.Conn*bytes.Buffer、HTTP 的请求体和响应体、gzip.Writer......只要面向接口写代码,同一段逻辑就能处理文件、网络、内存,不用改一行。

朴素写法:先看看它怎么爆内存的

go 复制代码
// 反面教材:把整个响应体读进内存再写文件
func downloadBad(url, path string) error {
    resp, err := http.Get(url)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    data, err := io.ReadAll(resp.Body) // 大文件在这里把内存吃光
    if err != nil {
        return err
    }
    return os.WriteFile(path, data, 0644)
}

下载 1GB 文件,这段代码就得占用 1GB 内存。问题在于 io.ReadAll 把流"物化"成了一整块内存。

正确写法:io.Copy 流式搬运

io.Copy(dst, src) 内部用一个小缓冲区(默认 32KB)循环 Read 一块、Write 一块,内存占用恒定,和文件大小无关。

go 复制代码
func downloadGood(url, path string) error {
    resp, err := http.Get(url)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    out, err := os.Create(path)
    if err != nil {
        return err
    }
    defer out.Close()

    // 从网络流 → 文件流,全程只占 32KB 缓冲,1GB 文件也不涨内存
    _, err = io.Copy(out, resp.Body)
    return err
}

resp.Bodyio.Reader,outio.Writer,io.Copy 把它们接上。这就是流式处理的核心:不落地成 \[\]byte,数据边读边写。

TeeReader:读的同时"分叉"一份出去

需求升级:下载文件的同时,要算出它的 SHA-256 校验和。难道读两遍?不用。io.TeeReader(r, w) 返回一个新的 Reader------每次从它读数据,都会顺手把读到的同一份写进 w,像三通水管。

go 复制代码
import (
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "io"
    "os"
)

func downloadWithChecksum(reader io.Reader, path string) (string, error) {
    out, err := os.Create(path)
    if err != nil {
        return "", err
    }
    defer out.Close()

    hasher := sha256.New()
    // 从 reader 读数据时,同一份数据会被"抄送"给 hasher
    tee := io.TeeReader(reader, hasher)

    // io.Copy 消费 tee:数据一边落盘,一边喂进 hasher
    if _, err := io.Copy(out, tee); err != nil {
        return "", err
    }
    // 全部读完,hasher 里就是完整数据的哈希
    return hex.EncodeToString(hasher.Sum(nil)), nil
}

妙处在于:数据只流过一遍,却同时完成了"落盘"和"算哈希"两件事。日志审计、边下载边校验、边上传边统计字节数,全靠它。

MultiWriter:一份数据写到多个去处

反过来的需求:一份日志既要写文件,又要打到控制台,还要发到远程收集器。io.MultiWriter(w1, w2, ...) 把多个 Writer 合成一个------写一次,同时写进所有目标

go 复制代码
func setupLogging(logPath string) (io.Writer, error) {
    file, err := os.Create(logPath)
    if err != nil {
        return nil, err
    }
    // 同一份日志同时写到:文件 + 标准输出
    multi := io.MultiWriter(file, os.Stdout)
    return multi, nil
}

func main() {
    w, err := setupLogging("app.log")
    if err != nil {
        panic(err)
    }
    log.SetOutput(w) // 标准库 log 直接吃 io.Writer
    log.Println("这条日志会同时出现在 app.log 和终端")
}

组合拳:边下载、边落盘、边校验、边显示进度

真正体现接口组合威力的是把它们串起来。下面这段:从网络下载,同时算哈希(TeeReader),同时把字节写到文件和进度统计器(MultiWriter),全程常量内存。

go 复制代码
// 一个实现了 io.Writer 的进度计数器
type progressCounter struct{ total int64 }

func (p *progressCounter) Write(b []byte) (int, error) {
    p.total += int64(len(b))
    fmt.Printf("\r已下载: %d KB", p.total/1024)
    return len(b), nil
}

func downloadAll(url, path string) (string, error) {
    resp, err := http.Get(url)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()

    out, _ := os.Create(path)
    defer out.Close()

    hasher := sha256.New()
    counter := &progressCounter{}

    // MultiWriter:数据同时写到文件 + 进度计数器
    dst := io.MultiWriter(out, counter)
    // TeeReader:从响应体读时,顺手抄一份给 hasher
    src := io.TeeReader(resp.Body, hasher)

    // 一次 Copy 驱动全部:读一块 → 抄给 hasher → 写进 out 和 counter
    if _, err := io.Copy(dst, src); err != nil {
        return "", err
    }
    fmt.Println()
    return hex.EncodeToString(hasher.Sum(nil)), nil
}

四件事(下载、落盘、校验、进度)在一次 io.Copy 里完成,数据只流过一遍,内存占用还是那 32KB。这就是 Go IO 接口组合的优雅之处。

一个坑:别忘了检查 io.Copy 的返回值和 Close

io.Copy 返回 (written int64, err error)。写文件时,defer out.Close() 是不够的 ------Close 可能因为磁盘满、缓冲刷新失败而报错,而 defer 里的错误默认被丢弃。对数据完整性要求高的场景,要显式 Close 并检查:

go 复制代码
if err := out.Close(); err != nil {
    return fmt.Errorf("关闭文件失败,数据可能不完整: %w", err)
}

小结

  • io.Reader/io.Writer 是 Go IO 的通用接口,面向它们写代码,文件、网络、内存无缝切换。
  • io.Copy :流式搬运,常量内存,取代 io.ReadAll + WriteFile 的爆内存写法。
  • io.TeeReader:读的同时把数据抄送一份(算哈希、审计、统计),数据只流一遍。
  • io.MultiWriter:一份数据同时写到多个去处(文件 + 控制台 + 远程)。
  • 三者可自由组合,一次 io.Copy 驱动多个副作用;写文件记得显式检查 Close 的错误。
  • 一句话记忆:别把流读成 \[\]byte,把它接起来让数据流过去------Reader 是入口,Writer 是出口,Copy/Tee/Multi 是三通接头。
相关推荐
郝学胜-神的一滴1 小时前
Qt 高级编程 042:进度条从入门到自定义美化
开发语言·c++·qt·软件开发·用户界面
鸿芯微控科技1 小时前
压电纳米定位分辨率怎么测?噪声、带宽、最小可检测位移与Python分析
开发语言·python·噪声分析·压电执行器·纳米定位·位移测量
喵个咪1 小时前
GoWind Shop 架构剖析:一个 REST 请求穿越三服务 BFF 的七环链路
vue.js·后端·go
喵个咪1 小时前
GoWind Shop 安全设计:JWT 鉴权、Ent 行级隔离、防篡改审计与四个真实漏洞修复
vue.js·后端·go
喵个咪1 小时前
GoWind Shop:一个 proto 文件如何生成后端、前端、文档、校验六路代码
vue.js·后端·go
用户7783366132111 小时前
从 0 搭一个关键词排名监控:核心思路 + 可运行代码
后端·python·api
喵个咪1 小时前
GoWind Shop 出海实战:多语言商品内容怎么存、怎么录、怎么按 locale 取
vue.js·后端·go
@小匠2 小时前
Spring Boot Nacos绑定 Map 时中文 key 导致启动失败:一次从复现到源码的排查实录
java·开发语言·前端
Knight_AL2 小时前
Lombok @Builder 踩坑:build() 前后对象类型不一样
android·java·开发语言