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

用 io.TeeReader 包装 resp.Body 才能实时计数
直接 io.Copy(dst, resp.Body) 是静默流式写入,进度完全不可见。必须把原始响应体“截下来”,在每次读取时同步触发计数——io.TeeReader 就是干这事的:它返回一个新 io.Reader,每次从底层 resp.Body 读一块数据,就立刻往你传入的 io.Writer(比如一个计数器)里写一份。
常见错误是把 io.TeeReader 当成装饰器反复套用,或对它调用多次 io.Copy;它只能被消费一次,且必须和最终写文件共用同一个读取源。
- 正确姿势:
reader := io.TeeReader(resp.Body, counter),然后io.Copy(outFile, reader) - 别写
io.Copy(counter, resp.Body)——counter是io.Writer,不是io.Reader - 不要在
counter.Write里做阻塞操作(如频繁打印、发 HTTP 请求),会拖慢整个下载流
Content-Length 不可靠,得兼容未知大小场景
很多服务(尤其是 chunked 编码或动态生成内容)根本不返回 Content-Length header,resp.ContentLength 会是 -1。硬拿它当总大小,进度条要么卡死在 0%,要么除零 panic。
优先检查 resp.Header.Get("Content-Length"),转成 int64;解析失败或为空时,设为 -1,后续只显示“已下载 XXX B”,不渲染百分比。
- HEAD 请求不一定可用,别默认先发 HEAD——有些 CDN 或 API 禁用 HEAD
- 别用
io.ReadAll去试探长度,大文件直接 OOM - 如果真需要预知大小,且服务支持 Range,可发 HEAD + 检查
Accept-Ranges: bytes,但属于断点续传范畴,不是进度显示必需
进度回调必须异步批量上报,不能每字节都刷屏
终端刷新太勤快,\r 会打架,尤其重定向到文件或 SSH 会话中断重连后,旧进度行残留、换行错乱是常态。这不是你代码 bug,是终端行为本身不可靠。
计数器 Write 方法里只做累加,用 channel 或 goroutine 聚合更新:比如每 64KB 或每 100ms 统一推送一次当前值,再由主线程决定是否刷新 UI。
- 别在
Write里直接fmt.Printf("\r...")—— 一旦 stdout 被重定向(./dl > log.txt),就会写进日志,还可能 panic - 用
term.IsTerminal(int(os.Stdout.Fd()))判断是否真在终端,否则退化为纯日志输出 - 进度结构体里存
uint64总字节数,避免 32 位溢出(2GB 以上文件常见)
临时文件 + os.Rename 是原子覆盖的关键
下载中途失败或被 kill,不能让目标路径残留损坏文件。标准做法是写到 filepath + ".tmp",成功后再 os.Rename 替换原文件——这个操作在同文件系统下是原子的。
注意:os.Rename 跨文件系统会失败,此时要 fallback 到 io.Copy + os.Remove,但需确保失败时清理 tmp 文件。
- 创建 tmp 文件后立即
defer out.Close(),但out.Close()必须在io.Copy完成后显式调用,否则可能丢最后一批缓冲数据 -
resp.Body.Close()一定要在检查resp.StatusCode之后再 defer,否则 4xx/5xx 响应体没读完就关了,连接无法复用 - 别省略
defer resp.Body.Close()—— 这是连接泄漏最常见原因,尤其高频下载场景
Content-Length 的盲目信任。这些点不抠清楚,跑几天就出问题。golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











