215 lines
5.8 KiB
Markdown
215 lines
5.8 KiB
Markdown
---
|
||
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]] — 上传后的文件如何通过静态文件服务对外暴露
|
||
- [[部署与运维基础]] — 容器环境下的磁盘和内存限制
|