Files
cs-note/hhs/GIN/8-file-upload.md
T

531 lines
17 KiB
Markdown
Raw Normal View History

2026-05-24 11:42:38 +08:00
---
tags: [后端, Go, Gin, 文件上传, 表单]
create time: 2026-04-28 10:52
---
# 文件上传
## 概述
文件上传在 Gin 中通过 `multipart/form-data` 实现——支持单文件和多文件上传、大小限制、类型校验,以及与业务逻辑的结合。理解底层 `MaxMultipartMemory` 行为是避免 OOM 的关键。
思考题:Gin 默认把超过内存阈值的临时文件存在哪里?这在容器化部署中会带来什么问题?(详见第 3 节)
## 总览 — 文件上传的请求生命周期
```mermaid
flowchart LR
subgraph FE["前端 / Client"]
F1["用户选择文件\n(浏览器 / App)"]
F2["构造 multipart/form-data\n请求体"]
end
subgraph GW["网络传输"]
HTTP["HTTPS POST\nContent-Type: multipart/form-data"]
end
subgraph BE["Gin 服务端"]
B1["http.Server\n读取请求头"]
B2{"请求体大小\n≤ MaxMultipartMemory?"}
B2 -->|"是"| B3["全部保留在\n[]byte 内存中"]
B2 -->|"否"| B4["部分写入内存\n超出部分 → /tmp 临时文件"]
B3 --> B5["c.Request.FormFile()\nc.MultipartForm()"]
B4 --> B5
B6["业务逻辑处理\n(类型校验 / 大小限制 / 存 OSS)"]
B5 --> B6
end
B6 --> OUT1["返回 JSON 结果\n{filename, size}"]
B6 --> OUT2["转发到对象存储\n(S3 / OSS)"]
F1 --> F2 --> HTTP --> B1 --> B2
style B3 fill:#e3f2fd
style B4 fill:#fff3e0
style B2 fill:#fff9c4,stroke:#f9a825
style OUT2 fill:#e8f5e9,stroke:#2e7d32
```
> **一句话总结**:前端以 `multipart/form-data` 格式发送二进制数据 → Gin 根据大小决定是否落盘 → 通过 `FormFile()` / `MultipartForm()` 获取文件句柄 → 业务层保存或使用。
---
## 正文
### 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(header, dst); err != nil {
c.JSON(500, gin.H{"error": "save failed"})
return
}
c.JSON(200, gin.H{
"filename": filename,
"size": header.Size,
})
}
```
**`SaveUploadedFile` 签名(Gin 标准版):**
```go
func (c *Context) SaveUploadedFile(file *multipart.FileHeader, dst string, max ...int64) error
```
- `file`:`*multipart.FileHeader`,来自 `FormFile()` 或 `MultipartForm()` 的第二个返回值
- `dst`:目标文件路径
- `max`:可选的大小限制(字节数),超过则拒绝保存
**交互时序:**
```mermaid
sequenceDiagram
participant C as 客户端 (浏览器/App)
participant S as Gin Handler
participant RF as Request.FormFile()
participant FS as File System
C->>S: POST /upload<br/>Content-Type: multipart/form-data
Note over S,S: 解析请求体<br/>≤ 8MB → 内存<br/>> 8MB → 部分落盘 /tmp
S->>RF: FormFile("file")
RF-->>S: (file, header, nil)<br/>header = {Filename, Size, Header}
S->>S: 业务逻辑处理<br/>(类型校验/大小限制)
S->>FS: SaveUploadedFile(header, "./uploads/x.jpg")
FS-->>S: nil (成功)
S-->>C: 200 OK<br/>{filename:"x.jpg", size:102400}
note over C,C: 前端展示上传成功提示
alt 文件过大 (> max 参数)
S->>FS: SaveUploadedFile(header, dst, max)
FS-->>S: err "file too large"
S-->>C: 400 Bad Request<br/>{error:"file too large"}
end
```
> **扩展用法**:Gin v1.7+ 支持在参数末尾追加 `mime.TypeMatcher` 进行类型校验,但第三方库 `minio/mimetype` 更常见。本文第 4 节展示了手动校验方案。
**大小限制最佳实践:** 不要依赖默认 8MB 阈值,应根据业务场景显式指定——图片上传设 5MB,视频上传可设 100MB。
### 2. 多文件上传
```go
func uploadMultiple(c *gin.Context) {
// 获取 multipart form(超过 MaxMultipartMemory 时部分数据写临时文件)
form, err := c.MultipartForm()
if err != nil {
c.JSON(400, gin.H{"error": "form too large or malformed"})
return
}
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); 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`。两者操作的是同一个底层结构,只是访问粒度不同。
**单文件 vs 多文件 — API 对比图:**
```mermaid
flowchart TD
subgraph S1["Single File — c.Request.FormFile"]
SF1["HTTP POST\nmultipart/form-data"] --> SF2["解析指定 key:\nFormFile + file 参数"]
SF2 --> SF3["返回单个: *multipart.FileHeader"]
SF3 --> SF4["SaveUploadedFile(header, dst)"]
end
subgraph S2["Multiple Files — c.MultipartForm"]
SM1["HTTP POST\nmultipart/form-data\n多个 files 字段"] --> SM2["解析全部:\nMultipartForm"]
SM2 --> SM3["返回 *multipart.Form"]
SM3 --> SM4["取 form.File.files\n// []*FileHeader"]
SM4 --> SM5["for 循环: SaveUploadFile per file"]
end
style S1 fill:#e3f2fd,stroke:#1565c0
style S2 fill:#e8f5e9,stroke:#2e7d32
```
> **选型建议**:如果不确定用户上传几个文件,始终走 `MultipartForm()` 路径——它天然兼容单文件场景。
### 3. `MaxMultipartMemory` — 内存管理
Gin 底层继承自 `http.Server`,控制内存/磁盘切换的阈值是 `MaxMultipartMemory`(默认 8MB):
```
请求体大小 ≤ MaxMultipartMemory → 全部存内存
请求体大小 > MaxMultipartMemory → 超出部分写入临时文件(os.TempDir())
```
**内存分配示意图:**
```mermaid
flowchart LR
subgraph SMALL["📦 10MB 上传 (略超阈值)"]
S1["[ 头信息 ~2KB ]"] --> S2["[ 前 8MB → 内存 []byte ]"]
S2 --> S3["[ 后 2MB → /tmp/go**** ]"]
end
subgraph TINY["💾 5MB 上传 (未超阈值)"]
T1["[ 整包 5MB → 内存 []byte ]"]
end
subgraph HUGE["🗄️ 200MB 上传 (大幅超阈值)"]
H1["[ 头信息 ~2KB ]"] --> H2["[ 前 8MB → 内存 []byte ]"]
H2 --> H3["[ 后 192MB → /tmp/go***** ]"]
end
style S3 fill:#fff3e0,stroke:#f57c00
style H3 fill:#ffebee,stroke:#c62828
style T1 fill:#e8f5e9,stroke:#2e7d32
classDef safe fill:#e8f5e9,stroke:#2e7d32
classDef caution fill:#fff3e0,stroke:#f57c00
classDef danger fill:#ffebee,stroke:#c62828
T1:::safe
S3:::caution
H3:::danger
```
```go
r := gin.Default()
// 调大内存阈值(谨慎使用)
r.MaxMultipartMemory = 32 << 20 // 32MB
srv := &http.Server{
Addr: ":8080",
Handler: r,
MaxHeaderBytes: 1 << 20, // 头部最大 1MB
}
```
**容器化环境的陷阱:**
```mermaid
flowchart TD
subgraph CLIENT2["🖥️ 客户端"]
C2["上传 50MB 文件\nPOST /upload"]
end
subgraph SERVER2["🧠 Gin Server"]
S2_1["解析 multipart form<br/>MaxMultipartMemory = 8MB"]
S2_2["写入 42MB → /tmp"]
end
subgraph CONTAINER["🐳 Docker 容器环境"]
TMP2["/tmp 空间有限<br/>或只读文件系统"]
end
CLIENT2 --> SERVER2
S2_2 --> CONTAINER
COND{"/tmp" 有空间?}
CONTAINER --> COND
COND -->|"否"| BAD["error: no space left<br/>→ OOM Killer ❌"]
COND -->|"是"| OK2["✓ 成功返回 200"]
style TMP2 fill:#ffebee,stroke:#c62828
style BAD fill:#ffebee,stroke:#c62828
style OK2 fill:#c8e6c9,stroke:#2e7d32
style COND fill:#fff9c4,stroke:#f9a825
```
**另一个常见陷阱:只读 `/tmp`**
```mermaid
flowchart LR
R1["docker run --read-only ..."] --> R2["容器内所有文件系统为只读"]
R2 --> R3["Gin 尝试写入 /tmp"]
R3 --> R4["error: read-only file system"]
R4 --> R5["请求失败 ❌"]
B1["解决方案: -v tmp:/tmp"] -.->|挂载独立 volume| R3
style R4 fill:#ffebee,stroke:#c62828
style R5 fill:#ffebee,stroke:#c62828
style B1 fill:#e8f5e9,stroke:#2e7d32
```
**最佳实践:**
- 不要过度放大 `MaxMultipartMemory`——用流式处理代替全量加载
- 容器环境中监控 `/tmp` 用量
- 考虑使用对象存储(S3/OSS)直传,服务端只生成预签名 URL
### 4. 文件类型校验
仅靠大小限制不够,还需要校验实际文件内容类型(MimeType),防止恶意文件伪装:
```go
import (
"fmt"
"net/http"
)
func validateFileType(fh *multipart.FileHeader) error {
file, err := fh.Open()
if err != nil {
return err
}
defer file.Close()
// 读取前 512 字节检测真实 MIME 类型(基于 magic bytes)
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)
}
return nil
}
```
> **安全提醒:** 永远不要用文件扩展名判断类型(如 `.jpg`)。攻击者可以轻松绕过——应该基于文件内容的 magic bytes(魔数)检测。
**Magic Bytes 检测原理 — 只看文件头部不看后缀名:**
```mermaid
flowchart TD
subgraph ATTACK["❌ 攻击者上传恶意文件"]
A1["实际内容:<br/><?xml ... PHP shell script ?>"] --> A2["人为改名为<br/>'photo.jpg'"]
A2 --> A3["试图骗过后端<br/>通过扩展名判断"]
end
subgraph VERIFY["✅ Gin 服务端: http.DetectContentType"]
V1["读取前 512 字节"] --> V2["检查 Magic Bytes<br/>(文件头部的固定标识符)"]
V2 --> V3{"匹配哪个签名?"}
V3 -->|"FF D8 FF..."| V4["判定: image/jpeg ✅"]
V3 -->|"89 50 4E..."| V5["判定: image/png ✅"]
V3 -->|"7F 45 4C..."| V6["判定: application/octet-stream ❌"]
V3 -->|"3C 3F 78 6D..."| V7["判定: text/xml; charset=utf-8 ❌"]
end
A1 -.->|"文件名:"photo.jpg""| V1
style ATTACK fill:#ffebee,stroke:#c62828
style VERIFY fill:#e8f5e9,stroke:#2e7d32
style V6 fill:#ffcdd2,stroke:#c62828
style V7 fill:#ffcdd2,stroke:#c62828
style V4 fill:#c8e6c9,stroke:#2e7d32
style V5 fill:#c8e6c9,stroke:#2e7d32
```
**常见文件格式的 Magic Bytes(十六进制):**
| 格式 | Magic Bytes (Hex) | `http.DetectContentType` 返回值 |
|------|-------------------|----------------------------------|
| JPEG | `FF D8 FF E0` 或 `FF D8 FF E1` | `image/jpeg` |
| PNG | `89 50 4E 47` | `image/png` |
| GIF | `47 49 46 38` (`GIF8`) | `image/gif` |
| PDF | `25 50 44 46` (`%PDF`) | `application/pdf` |
| ZIP/DOCX | `50 4B 03 04` | `application/zip` / 需 `minio/mimetype` |
| WebP | `52 49 46 46 ?? ?? ?? ?? 57 45 42 50` | `minio/mimetype` 推荐 |
| 纯文本/HTML | `3C 21 44 4F` (`<!DO`) | `text/html; charset=utf-8` |
| XML | `3C 3F 78 6D` (`<?xm`) | `text/xml; charset=utf-8` |
| PHP | `3C 3F 70 68` (`<?ph`) | `text/xml; charset=utf-8` ⚠️ |
> **注意**:PHP 脚本被识别为 `text/xml`,不是 `application/octet-stream`——所以即使是标准库也能识别出它"不正常"。但更安全的做法是白名单机制(只允许 `image/*`),而非黑名单。
> **进阶推荐**:标准库 `http.DetectContentType` 仅识别常见类型,如需更全面的检测(如 Office 文档、PDF),推荐使用 [`minio/mimetype`](https://github.com/minio/mimetype)。用法类似,只需将 `http.DetectContentType(buf)` 替换为 `mimetype.FromBytes(buf)`,再通过 `mime.Type()` 获取字符串。
### 5. 分片上传思路
超大文件(GB 级别)不适合直接上传,应使用分片上传:
```
流程:
1. 客户端将大文件切分为 N 个小片(如每片 5MB)
2. 依次上传每个分片 → POST /upload/chunk?fileName=x&chunkIndex=0
3. 服务端将分片暂存到临时目录
4. 所有分片上传完成后,POST /upload/merge?fileName=x
5. 服务端合并分片为最终文件
```
**分片上传完整流程图:**
```mermaid
flowchart TD
subgraph CLIENT["🖥️ 客户端 (浏览器/App)"]
C1["原始文件<br/>100MB"] --> C2["切分成 20 个<br/>5MB 的分片"]
C2 --> C3{遍历分片列表<br/>index = 0..19}
C3 -->|"for i in range"| C4["POST /upload/chunk?<br/>fileName=video.mp4<br/>&chunkIndex=i<br/>&totalChunks=20"]
C4 --> C3
C3 --"全部传完?"--> C5["POST /upload/merge\nfileName=video.mp4"]
end
subgraph SERVER["🧠 Gin Server"]
S1["uploadChunk handler"]
S2["保存分片到<br/>./tmp-chunks/video.mp4/i"]
S3{"chunkIndex == total-1?"}
S4["启动 goroutine<br/>mergeChunks()"]
S5["按序读取所有分片"]
S6["顺序写入 → ./uploads/video.mp4"]
S7["删除 ./tmp-chunks/"]
end
C4 --> S1
S1 --> S2
S2 --> S3
S3 -->|"是"| S4
S3 -->|"否"| C3
S4 -- "后台执行" --> S5
S5 --> S6
S6 --> S7
C5 -.->|"通知合并已完成"| S7
style CLIENT fill:#e3f2fd,stroke:#1565c0
style SERVER fill:#fff3e0,stroke:#f57c00
style S3 fill:#fff9c4,stroke:#f9a825
style S4 fill:#e1f5fe,stroke:#0277bd,color:#000
```
**磁盘上的目录结构变化:**
```mermaid
flowchart LR
subgraph AFTER_UPLOADS["① 分片上传完成后 — ./tmp-chunks/"]
AU1["video.mp4/"]
AU1 --> AU2["0"]
AU1 --> AU3["1"]
AU1 --> AU4["..."]
AU1 --> AU5["19"]
end
subgraph AFTER_MERGE["② 合并后 — ./uploads/ + 清理完成"]
AM1["✓ video.mp4<br/>(完整文件已合并)"]
AM2["✗ ./tmp-chunks/<br/>(已删除)"]
end
AU5 -->|"mergeChunks 触发"| AM1
AU5 -->|"os.Remove() 逐个清理"| AM2
style AM1 fill:#c8e6c9,stroke:#2e7d32
style AM2 fill:#ffcdd2,stroke:#c62828
```
**关键注意事项:**
- **并发安全**:`mergeChunks()` 使用 `goroutine` 异步执行,避免阻塞响应线程
- **原子性**:合并过程应保持"要么全部成功,要么全都不做"——建议先写入 `.tmp` 后缀文件,合并完成后再 `os.Rename`
- **容错恢复**:如果某个分片上传失败,客户端应支持断点续传(跳过已成功的 index)
- **资源清理**:生产环境需要定时清理残留的临时分片目录(防 `/tmp` 爆满)
```go
func uploadChunk(c *gin.Context) {
fileName := c.Query("fileName")
chunkIndexStr := c.Query("chunkIndex")
totalChunksStr := c.Query("totalChunks")
fileHeader, err := c.FormFile("chunk")
if err != nil {
c.JSON(400, gin.H{"error": "missing chunk file"})
return
}
file, _ := fileHeader.Open()
defer file.Close()
chunkIndex, _ := strconv.Atoi(chunkIndexStr)
totalChunks, _ := strconv.Atoi(totalChunksStr)
// 创建分片目录(生产环境应先检查错误)
dir := filepath.Join("./tmp-chunks", fileName)
os.MkdirAll(dir, 0755)
dst := filepath.Join(dir, chunkIndexStr)
if err := c.SaveUploadedFile(fileHeader, dst, 10<<20); err != nil {
c.JSON(500, gin.H{"error": "save chunk failed"})
return
}
// 所有分片上传完毕,触发合并(使用整数比较而非字符串)
if chunkIndex == totalChunks-1 {
go mergeChunks(fileName, int64(totalChunks))
}
c.JSON(200, gin.H{"status": "chunk uploaded", "index": chunkIndex})
}
// mergeChunks 在后台合并所有分片为完整文件
func mergeChunks(fileName string, total int64) {
dst := filepath.Join("./uploads", fileName)
f, _ := os.Create(dst)
defer f.Close()
for i := int64(0); i < total; i++ {
chunk := filepath.Join("./tmp-chunks", fileName, strconv.FormatInt(i, 10))
part, _ := os.Open(chunk)
io.Copy(f, part)
part.Close()
os.Remove(chunk) // 清理临时分片
}
}
```
## 关联笔记
- [[GIN/README]] — Gin 全目录索引
- [[GIN/7-binding-advanced]] — multipart/form-data 是表单绑定的一个特例
- [[GIN/11-static-files]] — 上传后的文件如何通过静态文件服务对外暴露
- [[GIN/5-binding-validation]] — 表单验证与文件校验可组合使用
- [[部署与运维基础]] — 容器环境下的磁盘和内存限制