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

Go 怎么用 io.Reader 统计上传进度而不复制文件

来源:17golang原创

时间:2026-09-07 00:48:45 127浏览 收藏

上传一个大文件时,进度条不应该先把文件读成 []byte 再计算。更合适的做法是包一层 io.Reader:每次底层 Reader 返回 n 个字节,就把这个数量累加起来,同时原样把数据交给 HTTP 客户端或上传 SDK。这样内存仍按流式缓冲工作,进度值也能被单独读取。

要点速览
  • 进度统计放在 Reader 包装器,统计的是已经被上传调用方读取的字节数。
  • 不要用 io.ReadAllbytes.Buffer 复制整文件;包装器只转发同一批读取缓冲。
  • 上传失败后应重新打开文件、重新创建包装器;并发显示进度时用原子计数器。

上传进度应该统计哪一层

先把“上传了多少”拆成三个概念:本地文件被 Reader 读出了多少,客户端向网络写出了多少,以及服务端最终接收并落盘了多少。io.Reader 包装器只能可靠回答第一个问题,但这正是大多数客户端进度条需要的观察点。它不会把尚未读出的文件提前算进进度,也不会冒充服务端已经提交成功。

边界能说明什么不能说明什么
Reader 返回的 n上传调用方已经拿到的字节数服务端是否收完、是否落盘
HTTP 客户端写请求体客户端发送路径正在消耗数据服务端业务是否接受
服务端响应服务端对本次请求的业务结果客户端此前每一段的实时进度
Go io.Reader 上传进度图,展示本地文件、ProgressReader、已读字节计数、HTTP 上传调用方和服务端接收层的边界关系
图1:把进度观察点放在 Reader 包装层,避免把已读字节误称为服务端已落盘字节。

官方 io.Copy 的语义也是从 Reader 读到 EOF 或错误为止,并返回实际复制的字节数。因此,下面的计数器只围绕 Read 的返回值工作,不需要另建一份文件副本。

先把 io.Reader 包成可观测对象

最小实现只保存源 Reader 和一个累计值。注意要先累加 n 再返回:某些 Reader 可能在返回部分数据的同时返回错误,前面那部分已经交给调用方,不能漏计。

package progress

import (
    "io"
    "sync/atomic"
)

type Reader struct {
    source io.Reader
    read   atomic.Int64
}

func NewReader(source io.Reader) *Reader {
    return &Reader{source: source}
}

func (r *Reader) Read(p []byte) (int, error) {
    n, err := r.source.Read(p)
    // n 表示本次已经交给上传方的数据,哪怕 err 同时出现也要累计。
    r.read.Add(int64(n))
    return n, err
}

func (r *Reader) BytesRead() int64 {
    // 进度显示线程只读取快照,不修改 Reader 状态。
    return r.read.Load()
}

这个类型没有缓存文件内容,也没有改变 Read 的错误语义。它的计数单位是字节,返回值可以直接和 os.File.Stat 得到的文件大小比较。若源数据是压缩流、加密流或服务端会再次编码,分母应改成对应传输层的总长度,不能机械使用原文件大小。

把包装器接到上传请求并控制更新频率

上传请求通常会在 client.Do 内持续读取请求体,所以进度读取要放在另一个观察路径。下面的示例让上传 goroutine 消耗包装器,主流程每 200 毫秒读取一次原子快照;进度刷新频率不影响文件读取速度。

file, err := os.Open("backup.tar"); if err != nil {
    return err
}
defer file.Close()

info, err := file.Stat(); if err != nil {
    return err
}

tracked := progress.NewReader(file)
req, err := http.NewRequest(http.MethodPut, uploadURL, tracked)
if err != nil {
    return err
}
req.ContentLength = info.Size()

result := make(chan error, 1)
go func() {
    // 上传 goroutine 只负责让 HTTP 客户端持续读取请求体。
    resp, err := http.DefaultClient.Do(req)
    if resp != nil {
        defer resp.Body.Close()
    }
    result 

示例中的 ContentLength 只适合请求体长度就是本地文件长度的场景。若上传 SDK 使用 multipart、压缩或分块编码,长度可能未知或大于文件大小,此时显示“已读字节/总字节”前要先确认分母的含义。若 SDK 内部会重试,单个 Reader 是否可重放也要看它的接口约定。

Go ProgressReader 并发进度图,展示 source Reader、atomic.Int64、HTTP 请求体、上传 goroutine 和进度轮询器的静态关系
图2:ProgressReader 一条路径继续提供请求体,另一条路径只读取原子累计值用于进度显示。

失败时怎么回退,哪些数字不能继续用

上传返回错误后,当前计数只能说明请求体读到了哪里,不能当成下次重试的起点。安全的回退路径是关闭旧文件,重新打开源文件,重新创建 progress.Reader,再创建新的请求。不要复用已经读过一部分的文件句柄,也不要把旧计数直接展示成新请求的进度。

  • 部分读取和错误:先累计 n,再向上返回 err;调用方决定是否终止。
  • 未知总大小:显示已读字节和传输速率,不显示伪造的百分比。
  • 并发读取:如果上传和进度展示不在同一 goroutine,累计值必须用原子操作或互斥保护。
  • 服务端失败:收到 4xx/5xx 后把本次进度标记为失败,不能因为 Reader 已读完就显示成功。

上线前的检查清单

检查项通过条件
内存代码没有 io.ReadAll、整文件 bytes.Buffer 或无界缓存。
计数累计的是每次 Read 返回的 n,且 n 与错误同时出现时不丢失。
总量文件大小、multipart 长度和压缩后长度没有混为同一个分母。
重试失败后重新打开源文件、重新创建 Reader 和请求。
结果只有服务端成功响应才把进度状态改成完成。

相关问答

包装 io.Reader 会不会复制文件?

不会。包装器只保存源 Reader 和计数值,数据仍由调用方提供的读取缓冲流过;网络栈和 HTTP 客户端可能有自己的缓冲,但不会因为这个计数器而把整文件读入内存。

为什么不用 io.TeeReader 统计进度?

io.TeeReader 适合把读取到的数据同步写给另一个 Writer,但进度统计只需要保存一个整数。自定义包装器更容易暴露 BytesRead,也不会引入一个只为计数而存在的 Writer。

读取到文件末尾就代表上传成功了吗?

不代表。它只表示上传调用方已经消费完 Reader;网络中断、服务端校验失败或业务提交失败仍可能发生。最终状态必须以客户端收到的成功响应为准。

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