如何在Golang中实现文件下载_Web文件下载常见写法

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

http.ServeFile 快速提供静态文件下载

最直接的方式是让 HTTP 服务直接返回文件内容,http.ServeFile 内部会设置合适的 Content-TypeContent-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-2025.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 指令。

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("销售报表-2025.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 风险点。