Go http.ServeContent 入门:文件下载、修改时间和断点续传

用报表下载接口讲 http.ServeContent 的基本用法,包括文件名、Content-Type、修改时间、Range 请求和权限检查。

很多 Go 初学者写文件下载接口时,会直接 os.ReadFile 把文件读进内存,再 w.Write 返回。文件很小时这样能跑,但报表、日志包、图片、压缩文件一大,就会浪费内存,也错过了 HTTP 已经提供的能力:修改时间、Range 请求、缓存协商、内容长度。标准库的 http.ServeContent 正是为这类场景准备的。

本文用“下载导出报表”的接口讲 ServeContent。它比 http.ServeFile 更灵活,因为你可以自己做权限检查、设置下载文件名,然后把一个实现了 io.ReadSeeker 的对象交给标准库处理。

最小下载接口

假设报表已经生成到磁盘:

func downloadReport(w http.ResponseWriter, r *http.Request) {
	path := "/var/app/reports/monthly.csv"
	f, err := os.Open(path)
	if err != nil {
		http.Error(w, "not found", http.StatusNotFound)
		return
	}
	defer f.Close()

	info, err := f.Stat()
	if err != nil {
		http.Error(w, "stat file", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "text/csv; charset=utf-8")
	w.Header().Set("Content-Disposition", `attachment; filename="monthly.csv"`)
	http.ServeContent(w, r, "monthly.csv", info.ModTime(), f)
}

ServeContent 的最后一个参数需要 io.ReadSeeker*os.File 满足这个接口,所以可以直接传。它会根据请求方法、Range 头、If-Modified-Since 等信息处理响应。你不用自己实现断点续传。

为什么不直接 ReadFile

直接读文件:

data, err := os.ReadFile(path)
if err != nil {
	return err
}
w.Write(data)

这个写法的问题是文件会完整进入内存。如果很多人同时下载 100MB 文件,内存压力会非常明显。ServeContent 会按流式方式发送,而且还能处理 Range 请求。下载接口应该尽量避免把大文件整体搬进内存。

权限检查放在打开文件前

不要用户传一个路径就直接打开:

path := r.URL.Query().Get("path")
os.Open(path) // 不推荐

更稳的做法是用业务 ID 查报表记录:

func (h *Handler) DownloadReport(w http.ResponseWriter, r *http.Request) {
	reportID := r.PathValue("id")
	userID := CurrentUserID(r.Context())

	report, err := h.reports.GetForUser(r.Context(), userID, reportID)
	if err != nil {
		http.Error(w, "not found", http.StatusNotFound)
		return
	}
	f, err := os.Open(report.Path)
	if err != nil {
		http.Error(w, "open report", http.StatusInternalServerError)
		return
	}
	defer f.Close()
	info, _ := f.Stat()
	w.Header().Set("Content-Disposition", contentDisposition(report.FileName))
	http.ServeContent(w, r, report.FileName, info.ModTime(), f)
}

用户只提交业务 ID,服务端决定真实路径。权限检查要在打开文件前完成,避免路径遍历和越权下载。

安全的文件名

Content-Disposition 里放文件名时,不要把用户输入原样拼进去。简单处理:

func contentDisposition(name string) string {
	name = filepath.Base(name)
	name = strings.ReplaceAll(name, `"`, "")
	if name == "" || name == "." {
		name = "download"
	}
	return fmt.Sprintf(`attachment; filename="%s"`, name)
}

完整的国际化文件名处理会更复杂,可以使用 filename*。入门阶段至少要避免引号、路径和空文件名。响应头也是输出边界,不能随便拼用户输入。

内存内容也能 ServeContent

如果内容在内存里,可以用 bytes.Reader

data := []byte("id,name\n1,Alice\n")
reader := bytes.NewReader(data)
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
http.ServeContent(w, r, "users.csv", time.Now(), reader)

bytes.Reader 也实现了 ReadSeek。不过如果内容很大,就不该先放进 []byte。这时要考虑临时文件、对象存储重定向,或者直接流式响应。

测试下载响应

func TestDownloadReport(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "report.csv")
	if err := os.WriteFile(path, []byte("id,name\n1,Alice\n"), 0644); err != nil {
		t.Fatal(err)
	}

	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		f, err := os.Open(path)
		if err != nil {
			t.Fatal(err)
		}
		defer f.Close()
		info, _ := f.Stat()
		http.ServeContent(w, r, "report.csv", info.ModTime(), f)
	})

	req := httptest.NewRequest(http.MethodGet, "/reports/1", nil)
	rec := httptest.NewRecorder()
	handler.ServeHTTP(rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d", rec.Code)
	}
	if !strings.Contains(rec.Body.String(), "Alice") {
		t.Fatalf("body = %q", rec.Body.String())
	}
}

还可以测试 Range:

req.Header.Set("Range", "bytes=0-1")

这样能验证下载接口不是简单一次性写出,而是支持标准 HTTP 行为。

小结

http.ServeContent 适合文件下载和可 seek 内容响应。它能利用修改时间、Range 请求和标准 HTTP 缓存语义,比手写 os.ReadFilew.Write 更稳。使用时先做权限检查,设置合适的 Content-TypeContent-Disposition,再把 io.ReadSeeker 交给标准库。

文件下载看似简单,实际涉及内存、权限、文件名和缓存。入门阶段先掌握 ServeContent,可以少走很多弯路。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页