登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

Go io.TeeReader 怎么在读取时同步保存原始数据

来源:17golang原创

时间:2026-09-28 00:35:04 429浏览 收藏

当一份数据只以 io.Reader 形式到达,而程序既要解析它,又要同步保存原始字节时,可以把源 Reader 和保存目标 Writer 交给 io.TeeReader。下游每读取一段,TeeReader 就把同一段写入目标;它不会预读整份数据,也不会在内部缓存完整副本。

官方文档:https://pkg.go.dev/io#TeeReader

TeeReader 是读取适配器,不是后台复制器

我第一次用 TeeReader,是在接收一段请求体时既要反序列化 JSON,又要留一份原始载荷。它最吸引人的地方不是“同时做两件事”,而是保持原来的读取接口:业务代码仍然拿到一个 io.Reader,保存动作被包在读取过程里。

这个模式有三个关键约束:

  • 只有通过 TeeReader 读出的字节,才会写入副本。
  • 写入是同步的,目标 Writer 完成写入后,本次 Read 才会返回。
  • 目标 Writer 的错误会作为读取错误返回给下游。
原始输入、TeeReader、业务读取器和同步副本之间的静态关系
图1:TeeReader 把原始输入包装成新的 Reader,并把下游实际读取到的同一段字节交给同步副本 Writer;这是静态结构说明图,不是运行截图。

最小写法:读取多少,副本里就有多少

先用内存 Writer 看清最基本的行为。这里故意只读取前 5 个字节,副本也只会得到这 5 个字节。

package main

import (
    "bytes"
    "fmt"
    "io"
    "strings"
)

func main() {
    src := strings.NewReader("hello, tee reader")
    var saved bytes.Buffer

    // tee 仍然是 Reader;读取时会把相同字节同步写入 saved。
    tee := io.TeeReader(src, &saved)

    buf := make([]byte, 5)
    // 先处理 n 个有效字节,再检查 err,符合 io.Reader 的通用约定。
    n, err := tee.Read(buf)
    if err != nil && err != io.EOF {
        panic(err)
    }

    fmt.Printf("read=%q saved=%q\n", buf[:n], saved.Bytes())
}

这段代码不会把剩余内容自动补进 saved。如果业务读取器提前结束,副本也会停在同一位置。这个特性既节省内存,也决定了“保存完整原始数据”必须确保最终消费到 EOF。

实际接入:解析 JSON 时先写临时文件

在文件落盘场景里,我更愿意先写临时文件,等读取、解析和关闭都成功后再改名。这样失败时不会留下一个看起来像完整文件的半成品。

package archive

import (
    "encoding/json"
    "fmt"
    "io"
    "os"
)

type Event struct {
    ID   string `json:"id"`
    Type string `json:"type"`
}

func DecodeAndSave(src io.Reader, finalPath string) (Event, error) {
    var event Event

    tmp, err := os.CreateTemp("", "event-*.json")
    if err != nil {
        return event, fmt.Errorf("创建临时文件: %w", err)
    }
    tmpPath := tmp.Name()
    // 只有最终提交成功才保留文件,其他路径统一清理临时数据。
    committed := false
    defer func() {
        if !committed {
            _ = os.Remove(tmpPath)
        }
    }()

    tee := io.TeeReader(src, tmp)
    decoder := json.NewDecoder(tee)
    // 解码器通过 tee 读取的每个字节都会同步进入临时文件。
    if err := decoder.Decode(&event); err != nil {
        _ = tmp.Close()
        return event, fmt.Errorf("解析 JSON: %w", err)
    }

    // 继续读到 EOF,确保解码器未消费的空白或尾部也进入原始副本。
    if _, err := io.Copy(io.Discard, tee); err != nil {
        _ = tmp.Close()
        return event, fmt.Errorf("保存剩余原始数据: %w", err)
    }
    if err := tmp.Sync(); err != nil {
        _ = tmp.Close()
        return event, fmt.Errorf("同步临时文件: %w", err)
    }
    if err := tmp.Close(); err != nil {
        return event, fmt.Errorf("关闭临时文件: %w", err)
    }
    if err := os.Rename(tmpPath, finalPath); err != nil {
        return event, fmt.Errorf("提交原始文件: %w", err)
    }

    committed = true
    return event, nil
}

这个实现强调的是保存边界,而不是宣称 Rename 在所有文件系统和跨目录场景都具有相同语义。生产代码还应保证临时文件与目标文件位于合适的目录,并按业务需要处理权限、覆盖策略和目录同步。

最容易忽略的是“下游读完没有”

很多解析器只读取完成当前任务所需的部分。JSON Decoder 解出一个对象后,流中可能还剩空白、第二个对象或其他尾部。若函数此时返回,TeeReader 不会替你继续读取,保存文件自然不完整。

解决方式取决于业务目标:

  • 只想保存“业务实际消费的字节”,不要额外 drain,副本天然就是消费范围。
  • 必须保存完整输入,在业务处理后继续从 TeeReader 读到 EOF。
  • 还要拒绝多余 JSON 值,应继续用 Decoder 检查尾部,而不是简单丢弃后续数据。

这也是 TeeReader 与“先完整落盘,再从文件解析”的主要取舍:前者延迟低、占用小,但副本完整性与消费行为绑定;后者多一次存储路径,却更容易把完整落盘设为业务前置条件。

TeeReader 的解析边界、保存边界与完整性风险关系
图2:下游解析器、目标 Writer、写入错误、未读尾部和临时文件属于不同边界;这是错误与完整性关系的静态说明图,不是运行结果。

写入错误为什么会表现成读取错误

TeeReader 对外只暴露 Reader,所以目标 Writer 出错时,下游看到的是 Read 返回错误。官方语义还说明它没有内部缓冲:写入必须完成,本次读取才能完成。对我来说,这既是优点也是代价。

  • 优点:保存失败不会悄悄被忽略,业务读取可以及时停止。
  • 代价:慢磁盘、慢网络 Writer 会直接拉低读取吞吐。
  • 边界:若 Writer 接受的字节数少于要求且没有返回明确错误,读取链路会收到短写错误。

因此,不要把一个不可控的远程上传 Writer 直接接在关键解析链路上,除非你就是希望保存端的背压约束读取端。若两边必须独立调度,可以考虑队列、临时文件或 io.Pipe 配合 goroutine,但那会引入缓冲上限、取消、关闭顺序和错误汇聚等新问题。

TeeReader、MultiWriter 和 ReadAll 怎么选

方案适合场景主要代价
io.TeeReader一个输入被业务读取,同时复制到一个 Writer副本完整性依赖下游是否读到 EOF,Writer 会同步阻塞读取
io.MultiWriter调用方主动写入一份数据,并同步写给多个 Writer它解决的是“一写多份”,不是包装 Reader
io.ReadAll 后分别处理数据较小,需要多次随机使用完整字节整份数据进入内存,大输入不合适
先完整落盘再解析完整副本是强前置条件,允许增加一次存储路径延迟和磁盘 I/O 更明显

我的采用判断清单

我会在下面几项都成立时优先用 TeeReader:输入天然是流;业务只需顺序读取;副本 Writer 足够可靠;保存失败应当让读取失败;并且代码能明确决定是否读到 EOF。

如果原始数据必须无条件完整保存、保存端速度不可控,或者多个消费者要独立读取,我通常不会只靠 TeeReader。它最适合的是“同一读取动作顺带复制”,而不是替代消息队列、异步归档或多消费者广播。

相关问题

TeeReader 会把整份数据放进内存吗?

不会。它没有内部整份缓存,只对每次读出的字节执行对应写入。

解析失败后,临时文件一定包含完整原文吗?

不一定。解析失败发生前读到多少,就只写入多少;是否继续读取并保存剩余数据,需要由业务明确决定。

可以把哈希计算器作为 Writer 吗?

可以。许多哈希对象实现 io.Writer,因此可在读取时同步累计摘要;但摘要只覆盖实际经过 TeeReader 的字节。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>