This repository has been archived on 2026-05-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
obsidian/BACKEND/GIN/8-file-upload.md
T

215 lines
5.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [后端, Go, Gin, 文件上传, 表单]
create time: 2026-04-28 00:00
---
# 文件上传
## 概述
文件上传在 Gin 中通过 `multipart/form-data` 实现——支持单文件和多文件上传、大小限制、类型校验,以及与业务逻辑的结合。理解底层 `MaxMultipartMemory` 行为是避免 OOM 的关键。
思考题:Gin 默认把超过内存阈值的临时文件存在哪里?这在容器化部署中会带来什么问题?(详见第 3 节)
## 正文
### 1. 单文件上传
```go
func uploadSingle(c *gin.Context) {
// 获取表单中的文件(最大 8MB 默认)
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
defer file.Close()
// 获取文件名
filename := header.Filename
// 保存到新位置
dst := filepath.Join("./uploads", filename)
if err := c.SaveUploadedFile(file, dst, 8<<20, nil); err != nil {
c.JSON(500, gin.H{"error": "save failed"})
return
}
c.JSON(200, gin.H{
"filename": filename,
"size": header.Size,
})
}
```
**`SaveUploadedFile` 签名:**
```go
func (c *Context) SaveUploadedFile(file *multipart.File, dst string, maxBytes int64, matchers ...mimeType.Matcher) error
```
- `maxBytes`:文件大小限制(0 = 不限制,默认 8MB)
- `matchers`:可选的文件类型校验函数
### 2. 多文件上传
```go
func uploadMultiple(c *gin.Context) {
// 获取表单中的多个文件(最多 100 个)
form, _ := c.MultipartForm()
files := form.File["files"] // []*multipart.FileHeader
saved := make([]string, 0, len(files))
for _, file := range files {
filename := filepath.Base(file.Filename)
dst := filepath.Join("./uploads", filename)
if err := c.SaveUploadedFile(file, dst, 8<<20, nil); err != nil {
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to save %s", filename)})
return
}
saved = append(saved, filename)
}
c.JSON(200, gin.H{
"saved": saved,
"count": len(saved),
})
}
```
前端传参示例(multipart/form-data):
```
--boundary
Content-Disposition: form-data; name="files"; filename="photo1.jpg"
Content-Type: image/jpeg
[binary data]
--boundary
Content-Disposition: form-data; name="files"; filename="photo2.png"
Content-Type: image/png
[binary data]
--boundary--
```
> **关键区别:** 单文件用 `c.Request.FormFile("key")`;多文件用 `c.MultipartForm()` 拿到完整的 `*multipart.Form`。两者操作的是同一个底层结构,只是访问粒度不同。
### 3. `MaxMultipartMemory` — 内存管理
Gin 底层继承自 `http.Server`,控制内存/磁盘切换的阈值是 `MaxMultipartMemory`(默认 8MB):
```
请求体大小 ≤ MaxMultipartMemory → 全部存内存
请求体大小 > MaxMultipartMemory → 超出部分写入临时文件(os.TempDir())
```
```go
r := gin.Default()
// 调大内存阈值(谨慎使用)
r.MaxMultipartMemory = 32 << 20 // 32MB
srv := &http.Server{
Addr: ":8080",
Handler: r,
MaxHeaderBytes: 1 << 20, // 头部最大 1MB
}
```
**容器化环境的陷阱:**
```mermaid
flowchart LR
A["上传 50MB 文件"] --> B{"≤ 8MB?"}
B -->|"否"| C["超出部分写入 /tmp"]
C --> D["容器 /tmp 空间有限"]
D --> E["磁盘满 → 服务崩溃"]
style E fill:#ffebee,stroke:#c62828
```
**最佳实践:**
- 不要过度放大 `MaxMultipartMemory`——用流式处理代替全量加载
- 容器环境中监控 `/tmp` 用量
- 考虑使用对象存储(S3/OSS)直传,服务端只生成预签名 URL
### 4. 文件类型与大小校验
仅靠大小限制不够,还需要校验实际文件内容类型(MimeType),防止恶意文件伪装:
```go
import "golang.org/x/exp/mime/multipart" // or use net/textproto
func validateFile(file multipart.File) error {
// 读取前 512 字节检测真实 MIME 类型
buf := make([]byte, 512)
_, err := file.Read(buf)
if err != nil {
return err
}
contentType := http.DetectContentType(buf)
// 只允许图片
allowed := map[string]bool{
"image/jpeg": true,
"image/png": true,
"image/webp": true,
}
if !allowed[contentType] {
return fmt.Errorf("unsupported file type: %s", contentType)
}
// 重置读取位置以便后续保存
file = multipart.NopCloser(bytes.NewReader(buf))
_ = file
return nil
}
```
> **安全提醒:** 永远不要用文件扩展名判断类型(如 `.jpg`)。攻击者可以轻松绕过——应该基于文件内容的 magic bytes(魔数)检测。
### 5. 分片上传思路
超大文件(GB 级别)不适合直接上传,应使用分片上传:
```
流程:
1. 客户端将大文件切分为 N 个小片(如每片 5MB)
2. 依次上传每个分片 → POST /upload/chunk?fileName=x&chunkIndex=0
3. 服务端将分片暂存到临时目录
4. 所有分片上传完成后,POST /upload/merge?fileName=x
5. 服务端合并分片为最终文件
```
```go
func uploadChunk(c *gin.Context) {
fileName := c.Query("fileName")
chunkIndex := c.Query("chunkIndex")
totalChunks := c.Query("totalChunks")
file, _ := c.FormFile("chunk")
dst := filepath.Join("./tmp-chunks", fileName, chunkIndex)
os.MkdirAll(filepath.Dir(dst), 0755)
c.SaveUploadedFile(file, dst, 10<<20, nil)
// 检查是否最后一个分片
if chunkIndex == totalChunks {
mergeChunks(fileName, int64(totalChunks))
}
c.JSON(200, gin.H{"status": "chunk uploaded"})
}
```
## 关联笔记
- [[GIN/7-binding-advanced]] — multipart/form-data 是表单绑定的一个特例
- [[GIN/11-static-files]] — 上传后的文件如何通过静态文件服务对外暴露
- [[部署与运维基础]] — 容器环境下的磁盘和内存限制