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