最直接的静态文件下载需手动设置Content-Disposition为attachment,否则浏览器可能内联打开;动态下载应手动控制header与io.Copy;中文文件名须按RFC 5987用filename*=UTF-8''编码;大文件需设超时、避免OOM,并考虑断点续传。

用 http.ServeFile 快速提供静态文件下载
最直接的方式是让 HTTP 服务直接返回文件内容,http.ServeFile 内部会设置合适的 Content-Type 和 Content-Length,但**默认不设 Content-Disposition,浏览器可能内联打开而非下载**。
常见错误:直接调用 http.ServeFile(w, r, "/path/to/file.zip"),结果 PDF 在浏览器里打开,用户得右键“另存为”——这不是真正意义的“下载”。
正确做法是手动写 header 强制触发下载:
func downloadHandler(w http.ResponseWriter, r *http.Request) {
filePath := "/var/data/report.pdf"
fileName := "report-2024.pdf"
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment; filename="+fileName)
w.Header().Set("Content-Transfer-Encoding", "binary")
http.ServeFile(w, r, filePath)
}
注意:Content-Type 设为 application/octet-stream 更稳妥;若用 mime.TypeByExtension 推断类型,某些浏览器仍可能忽略 attachment 指令。
立即学习“go语言免费学习笔记(深入)”;
用 io.Copy + 自定义 header 精确控制流
当需要动态生成文件、加权限校验、或避免 http.ServeFile 的路径安全检查(比如文件不在 web root 下)时,应手动读取并写入响应体。
关键点:
-
os.Open后必须defer f.Close(),否则文件句柄泄漏 - 务必在
io.Copy前设置所有 header,否则会触发http: superfluous response.WriteHeader错误 - 大文件建议加
bufio.NewReader,小文件可省略
func downloadDynamic(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("uid")
if !isValidUser(userID) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
filePath := fmt.Sprintf("/tmp/export_%s.csv", userID)
f, err := os.Open(filePath)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
defer f.Close()
stat, _ := f.Stat()
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="data-export.csv"`)
w.Header().Set("Content-Length", strconv.FormatInt(stat.Size(), 10))
io.Copy(w, f)
}
处理中文文件名的 Content-Disposition
直接拼接中文名如 filename=报表.xlsx 会导致部分浏览器(尤其是旧版 IE/Edge)乱码或截断。RFC 5987 规定需使用 filename*=UTF-8''... 编码格式。
Go 标准库不自动处理,需手动编码:
- 用
url.PathEscape不够,它不满足 RFC 5987 对空格、引号等字符的转义要求 - 推荐用
mime.BEncoding.Encode或第三方库如github.com/gogf/gf/v2/util/gconv,但最轻量的是自己构造:
func encodeFilename(filename string) string {
encoded := url.PathEscape(filename)
return fmt.Sprintf(`filename="%s"; filename*=UTF-8''%s`, filename, encoded)
}
// 使用示例:
w.Header().Set("Content-Disposition", "attachment; "+encodeFilename("销售报表-2024.xlsx"))
注意:双引号包裹普通 filename 是为了兼容老浏览器,filename* 是给支持 RFC 5987 的浏览器用的后备方案。
大文件下载的内存与超时风险
用 io.Copy 默认缓冲 32KB,对 GB 级文件没问题;但若中间加了日志、加密、压缩等逻辑,容易卡住连接或耗尽内存。
必须检查的配置项:
-
http.Server.ReadTimeout/WriteTimeout:默认 0(无限制),生产环境必须设(如 30 秒读,300 秒写) -
http.ServeFile不支持断点续传,大文件建议用Range头配合io.Seeker实现 - 不要用
bytes.Buffer全量读入内存再写,会 OOM
如果业务要求支持断点续传(比如视频、镜像下载),需手动解析 Range header 并用 f.Seek 跳转:
func rangedDownload(w http.ResponseWriter, r *http.Request) {
f, _ := os.Open("large.iso")
defer f.Close()
stat, _ := f.Stat()
ranges, err := parseRange(r.Header.Get("Range"), stat.Size())
if err != nil || len(ranges) == 0 {
http.Error(w, "Range not satisfiable", http.StatusRequestedRangeNotSatisfiable)
return
}
rng := ranges[0]
w.Header().Set("Accept-Ranges", "bytes")
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", rng.start, rng.end, stat.Size()))
w.Header().Set("Content-Length", strconv.FormatInt(rng.length(), 10))
w.WriteHeader(http.StatusPartialContent)
f.Seek(rng.start, 0)
io.CopyN(w, f, rng.length())
}
这里 parseRange 需自行实现或借用 net/http 内部未导出函数(不推荐),更稳妥是用社区封装好的 github.com/elliotchance/range。
真实场景中,文件下载不是“写个 handler 就完事”,权限、日志、限速、断点、CDN 缓存策略都得串起来看——最容易被跳过的其实是 Content-Length 和超时设置,一漏就变成隐蔽的 DoS 风险点。










