Golang实现文件下载接口的正确方式

http.ServeFile 存在路径遍历和缺乏业务控制风险,应手动校验路径、流式读取并设置兼容性 Content-Disposition 头,同时调优服务器超时配置以支持大文件下载。

http.ServeFile 会出问题吗?

会。直接用 http.ServeFile 暴露文件路径,容易触发路径遍历(如 ../../etc/passwd),且无法统一控制鉴权、日志、限速等逻辑。它只适合静态资源托管,不适合带业务逻辑的下载接口。

正确做法是手动读取文件并写入 ResponseWriter,自己把控路径合法性与响应头。

如何安全读取并返回文件?

核心是三步:校验路径、打开文件、设置响应头后流式写入。重点在于路径必须绝对化、限制根目录、拒绝非法字符。

  • filepath.Absfilepath.Join 构造完整路径,再用 strings.HasPrefix 确保不越界
  • 拒绝含 .././、空字节等危险片段的原始文件名
  • os.Open 而非 ioutil.ReadFile,避免大文件 OOM
  • 务必调用 defer f.Close(),否则句柄泄漏
func downloadHandler(w http.ResponseWriter, r *http.Request) {
	filename := r.URL.Query().Get("name")
	if filename == "" {
		http.Error(w, "missing name", http.StatusBadRequest)
		return
	}

	// 白名单校验 + 路径净化
	if strings.Contains(filename, "..") || strings.HasPrefix(filename, "/") {
		http.Error(w, "invalid filename", http.StatusBadRequest)
		return
	}
	absPath := filepath.Join("/var/uploads", filename)
	if !strings.HasPrefix(absPath, "/var/uploads") {
		http.Error(w, "access denied", http.StatusForbidden)
		return
	}

	f, err := os.Open(absPath)
	if err != nil {
		http.Error(w, "file not found", http.StatusNotFound)
		return
	}
	defer f.Close()

	// 设置 Content-Disposition 强制浏览器下载
	w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
	w.Header().Set("Content-Type", "application/octet-stream")

	// 流式拷贝,不加载全文到内存
	io.Copy(w, f)
}

Content-Disposition 的 filename 为什么有时乱码?

因为 RFC 5987 规定非 ASCII 文件名需用 filename*=UTF-8''... 编码格式,而老浏览器只认 filename。直接拼接中文会导致部分客户端解析失败或截断。

稳妥做法是:ASCII 名字走 filename,非 ASCII 名字走 filename*,两者都设(兼容性最佳)。

  • url.PathEscape 编码 UTF-8 字节序列
  • 注意 filename* 值中不能有双引号,需先去除
  • 不要用 mime.WordEncoder —— 它生成的是 RFC 2047 格式,不适用于 Content-Disposition
func setDownloadHeader(w http.ResponseWriter, filename string) {
	w.Header().Set("Content-Type", "application/octet-stream")
	if isASCII(filename) {
		w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
	} else {
		encoded := url.PathEscape(filename)
		w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"; filename*=UTF-8''`+encoded)
	}
}

大文件下载卡顿或中断怎么办?

常见原因不是代码逻辑,而是 HTTP 中间件(如 Nginx、Cloudflare)或 Go 默认的 http.Server 配置限制了超时或缓冲区。

  • 禁用 http.Transport 的响应体自动解压(如果用了反向代理)
  • http.Server 中显式设置 ReadTimeoutWriteTimeoutIdleTimeout(至少 30 分钟)
  • 避免在 handler 中做耗时计算(如 ZIP 打包、加解密),应提前生成好文件或用协程异步处理
  • 如需限速,用 io.LimitReader 包裹文件 reader,而不是整个 response body

真正难处理的是断点续传 —— 如果没实现 Range 请求支持,客户端重试就会从头开始。除非明确要求,否则别自行实现;用成熟 CDN 或对象存储更可靠。