Gin 框架中如何实现文件的上传与下载?
在实际开发中,文件上传下载是 Web 服务最常见的功能之一——用户头像、报表导出、附件发送都离不开它。Gin 框架在这方面提供了简洁的 API,但也藏着一些容易踩的坑。下面从上传到下载,把关键实现和注意事项讲清楚。
单文件上传
Gin 封装了 c.FormFile 和 c.SaveUploadedFile,单文件上传只需要几行代码:
gofunc uploadFile(c *gin.Context) { file, err := c.FormFile("file") if err != nil { c.JSON(400, gin.H{"error": "获取上传文件失败"}) return } dst := "./uploads/" + file.Filename if err := c.SaveUploadedFile(file, dst); err != nil { c.JSON(500, gin.H{"error": "文件保存失败"}) return } c.JSON(200, gin.H{ "message": "上传成功", "filename": file.Filename, "size": file.Size, }) }
路由注册时,记得给表单内存设个上限,避免大文件把内存吃光:
gorouter.MaxMultipartMemory = 8 << 20 // 8 MiB router.POST("/upload", uploadFile)
这里有个细节容易忽略:MaxMultipartMemory 控制的是内存缓冲区大小,超过这个值的文件会自动写入临时目录,不会直接撑爆内存。
多文件上传
需要同时上传多个文件时,用 c.MultipartForm() 拿到整个表单:
gofunc uploadMultipleFiles(c *gin.Context) { form, err := c.MultipartForm() if err != nil { c.JSON(400, gin.H{"error": "解析表单失败"}) return } files := form.File["files"] var uploaded []string for _, file := range files { dst := "./uploads/" + file.Filename if err := c.SaveUploadedFile(file, dst); err != nil { c.JSON(500, gin.H{"error": "文件保存失败: " + file.Filename}) return } uploaded = append(uploaded, file.Filename) } c.JSON(200, gin.H{ "message": "全部上传成功", "files": uploaded, }) }
注意循环中一旦某个文件保存失败就立即返回,避免部分成功的模糊状态。如果你的场景需要"尽可能多成功",可以改成收集错误列表,最后统一返回。
文件大小与类型校验
上传接口不做校验等于裸奔,最基本的两道关:大小和类型。
大小限制可以用 http.MaxBytesReader 包一层:
gofunc uploadWithLimit(c *gin.Context) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 10<<20) // 10MB file, err := c.FormFile("file") if err != nil { c.JSON(400, gin.H{"error": "文件过大或读取失败"}) return } // 继续处理... }
MaxBytesReader 的好处是超过限制会立即中断读取,不会等整个文件传完才拒绝。
类型校验不能只看扩展名,要用 http.DetectContentType 读文件头判断真实 MIME 类型:
gofunc uploadWithValidation(c *gin.Context) { file, err := c.FormFile("file") if err != nil { c.JSON(400, gin.H{"error": "获取文件失败"}) return } opened, err := file.Open() if err != nil { c.JSON(500, gin.H{"error": "无法读取文件"}) return } defer opened.Close() buf := make([]byte, 512) opened.Read(buf) opened.Seek(0, io.SeekStart) // 重置读指针,后续 SaveUploadedFile 还能正常读 contentType := http.DetectContentType(buf) allowed := map[string]bool{ "image/jpeg": true, "image/png": true, "image/gif": true, } if !allowed[contentType] { c.JSON(400, gin.H{"error": "不支持的文件类型: " + contentType}) return } dst := "./uploads/" + file.Filename c.SaveUploadedFile(file, dst) c.JSON(200, gin.H{"message": "上传成功"}) }
Seek(0, io.SeekStart) 这步别漏了,不然 DetectContentType 消耗了前 512 字节后,SaveUploadedFile 存下来的文件头部会损坏。
路径遍历防护
上面代码直接用 file.Filename 拼路径,这在生产环境很危险——攻击者可以构造类似 ../../etc/passwd 的文件名,把文件写到任意位置。修复方法很简单:
go// 只取文件名部分,去掉目录前缀 dst := "./uploads/" + filepath.Base(file.Filename)
或者更进一步,用 UUID 生成全新文件名,彻底杜绝冲突和路径遍历:
goext := filepath.Ext(file.Filename) newName := uuid.New().String() + ext dst := "./uploads/" + newName
简单文件下载
Gin 提供了 c.File 和 c.FileAttachment,基本下载用它们就够了:
go// 直接返回文件,浏览器会按原文件名显示 func downloadFile(c *gin.Context) { filename := c.Param("filename") filepath := "./uploads/" + filepath.Base(filename) // 同样要做路径清洗 c.File(filepath) } // 强制浏览器弹出下载框,并指定下载文件名 func downloadWithCustomName(c *gin.Context) { filepath := "./uploads/report.pdf" c.FileAttachment(filepath, "月度报表.pdf") }
流式下载大文件
小文件直接 c.File 没问题,但遇到几百 MB 甚至上 GB 的文件,必须用流式传输,避免一次性加载到内存:
gofunc downloadStream(c *gin.Context) { filename := c.Param("filename") path := "./uploads/" + filepath.Base(filename) file, err := os.Open(path) if err != nil { c.JSON(404, gin.H{"error": "文件不存在"}) return } defer file.Close() info, _ := file.Stat() c.Header("Content-Disposition", "attachment; filename="+filename) c.Header("Content-Type", "application/octet-stream") c.Header("Content-Length", strconv.FormatInt(info.Size(), 10)) io.Copy(c.Writer, file) }
io.Copy 会分块读取写入,内存占用可控。如果想要更精细的控制,也可以用 http.ServeContent,它还支持 Last-Modified 协商和 Range 请求。
断点续传下载
对于大文件下载场景(比如 App 更新包),断点续传能大幅提升用户体验。核心是处理 Range 请求头:
gofunc downloadWithResume(c *gin.Context) { filename := c.Param("filename") path := "./uploads/" + filepath.Base(filename) file, err := os.Open(path) if err != nil { c.JSON(404, gin.H{"error": "文件不存在"}) return } defer file.Close() info, _ := file.Stat() rangeHeader := c.GetHeader("Range") if rangeHeader != "" { // 解析 Range: bytes=start-end ranges := strings.Split(rangeHeader, "=")[1] parts := strings.Split(ranges, "-") start, _ := strconv.ParseInt(parts[0], 10, 64) file.Seek(start, 0) remaining := info.Size() - start c.Header("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, info.Size()-1, info.Size())) c.Header("Content-Length", strconv.FormatInt(remaining, 10)) c.Header("Accept-Ranges", "bytes") c.Status(http.StatusPartialContent) io.CopyN(c.Writer, file, remaining) return } c.Header("Accept-Ranges", "bytes") http.ServeContent(c.Writer, c.Request, filename, info.ModTime(), file) }
这段代码只处理了单段 Range 的简单情况。生产环境如果需要完整支持多段 Range,建议直接用 http.ServeContent,它已经内置了完整的 Range 解析逻辑。
生产环境的几个建议
存储方案:本地文件系统只适合开发和测试。上了生产,文件应该存到对象存储(OSS、S3),数据库只保存文件地址。这样应用服务器无状态,扩容和迁移都更方便。
并发控制:上传接口容易成为瓶颈。可以用令牌桶或信号量限制同时处理的上传数量,防止大并发把带宽和磁盘 IO 吃满。
安全清单:
- 始终用
filepath.Base()清洗文件名,防止路径遍历 - 校验文件真实 MIME 类型,不要信任扩展名
- 上传目录不要有执行权限
- 下载接口加鉴权,不要让任意用户能遍历文件
性能优化:
- 大文件用流式处理,别整个读到内存
- 静态资源走 CDN,减少应用服务器压力
- 下载时设置合理的
Content-Length,让浏览器能显示进度条 - 考虑对文本类资源启用 gzip 压缩
以上就是 Gin 框架中文件上传下载的核心实现和实战要点。从单文件到断点续传,从基本校验到生产加固,把这些环节都考虑到,你的文件服务才经得起真实流量的考验。