Files
cs-note/hzh/GIN/8-file-upload.md
T
2026-05-24 11:42:38 +08:00

531 lines
17 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 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]] — 表单验证与文件校验可组合使用
- [[部署与运维基础]] — 容器环境下的磁盘和内存限制