必须用io.teereader包装resp.body实现实时计数,因其在每次读取时同步写入计数器;content-length不可靠需兼容未知大小;进度回调须异步批量上报,临时文件+os.rename保障原子覆盖,且resp.body.close()需在状态码校验后defer。

用 io.TeeReader 绑定进度计数器,别直接 io.Copy
直接 io.Copy(dst, resp.Body) 无法感知字节流动,进度条永远静止。必须把响应流“分叉”:一边写文件,一边喂给计数器或进度条对象。io.TeeReader 正是干这事的——它返回一个新 Reader,每次 Read 时自动把读到的数据同步写入你指定的 Writer(比如一个计数器或 progressbar 实例)。
常见错误是传错 Writer 类型:io.TeeReader(resp.Body, &count) 中 &count 必须实现 Write([]byte) (int, error);裸指针不满足,得包装成 io.Writer,例如用 atomic.Value 或自定义结构体。
- 正确姿势:用
bar := progressbar.DefaultBytes(total)创建进度条,再reader := io.TeeReader(resp.Body, bar) - 错误姿势:
io.Copy(file, resp.Body)后再手动调bar.Add(n)—— 你根本拿不到每次Read的n - 如果自己写计数器,
Write方法里必须用atomic.AddInt64更新,否则多 goroutine 下会丢字节
Content-Length 拿不到就别硬设总长,先 HEAD 或降级为无总长模式
progressbar.DefaultBytes 第一个参数是总字节数,但 http.Response.ContentLength 可能为 -1(服务器没发、用了 chunked、或响应被代理截断)。硬填 0 或瞎猜会导致百分比爆炸或进度条卡死在 0%。
稳妥做法分两步:先发 HEAD 请求取 Content-Length,失败则退到无总长模式(progressbar.Default(-1)),此时进度条只显示已读字节数和速率,不显示百分比和 ETA。
- HEAD 请求可能被服务器禁用,所以必须有超时:
client := &http.Client{Timeout: 3 * time.Second} - 即使
ContentLength > 0,也要对比最终写入文件大小,差太多说明传输中断(如网络闪断后连接未重置) - 某些 CDN 或反向代理会删掉
Content-Length头,此时只能靠io.Copy完事后再统计,无法实时反馈
终端重定向或非交互环境会吃掉 \r,进度条要检测 os.Stdout.Fd()
命令行进度条依赖 \r 回车覆盖同一行,但一旦输出被重定向(如 ./dl > log.txt)或跑在 CI/SSH 非 TTY 环境,\r 就变成普通字符,导致日志里堆满重复行甚至乱码。
所有成熟库(如 progressbar/v3、mpb/v8)都会调 isatty.IsTerminal(os.Stdout.Fd()) 判断是否真终端。你自己手撸时也得加这层判断:不是终端就改用简单日志输出(如每秒打印一次当前字节数),而不是强刷 \r。
- 别信
os.Getenv("TERM"),它在重定向时可能还存在;os.Stdout.Fd()+isatty才可靠 - 某些 IDE 内置终端(如 VS Code 的 integrated terminal)偶尔会缓存不刷屏,可加
os.Stdout.Sync()强制刷新 - Windows 控制台对 ANSI 转义序列支持较弱,颜色主题要降级(比如关掉
OptionEnableColorCodes)
并发下载时每个 worker 必须独占进度条,别共用一个 bar
多个 goroutine 同时往同一个 progressbar 实例调 Add 是线程安全的(schollz/progressbar/v3 内部用了 sync.Mutex),但语义上错:你看到的是「所有文件累计进度」,不是单个文件的完成度。用户想知「file-a 还剩 20%」,不是「总共下了 3.2GB」。
正确方式是每个下载任务初始化自己的 bar,并用 mpb/v8 这类支持嵌套 tracker 的库做聚合展示;或者干脆放弃聚合,每个任务单独打一行进度(用 \r + 行号控制光标位置,但需注意终端高度限制)。
- 漏掉
defer bar.Finish()会导致最后一行残留未完成符号(如[===> ]) - 用
sync.WaitGroup等所有下载结束时,别等bar,而应等实际的io.Copy或io.ReadFull完成 - 并发数超过 10 时,密集刷屏反而让终端卡顿,建议限制每秒最多刷新 5–10 次(用
time.Tick节流)
进度条动起来不难,难的是它动得准、不崩、不骗人——尤其当 Content-Length 缺失、终端不可靠、或并发交织时,那些看似“不影响功能”的小分支,恰恰是线上最常报障的点。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











