很多 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 也实现了 Read 和 Seek。不过如果内容很大,就不该先放进 []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.ReadFile 再 w.Write 更稳。使用时先做权限检查,设置合适的 Content-Type 和 Content-Disposition,再把 io.ReadSeeker 交给标准库。
文件下载看似简单,实际涉及内存、权限、文件名和缓存。入门阶段先掌握 ServeContent,可以少走很多弯路。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。