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

17 KiB
Raw Blame History

tags, create time
tags create time
后端
Go
Gin
文件上传
表单
2026-04-28 10:52

文件上传

概述

文件上传在 Gin 中通过 multipart/form-data 实现——支持单文件和多文件上传、大小限制、类型校验,以及与业务逻辑的结合。理解底层 MaxMultipartMemory 行为是避免 OOM 的关键。

思考题:Gin 默认把超过内存阈值的临时文件存在哪里?这在容器化部署中会带来什么问题?(详见第 3 节)

总览 — 文件上传的请求生命周期

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. 单文件上传

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 标准版):

func (c *Context) SaveUploadedFile(file *multipart.FileHeader, dst string, max ...int64) error
  • file:*multipart.FileHeader,来自 FormFile() 或 MultipartForm() 的第二个返回值
  • dst:目标文件路径
  • max:可选的大小限制(字节数),超过则拒绝保存

交互时序:

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. 多文件上传

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 对比图:

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())

内存分配示意图:

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
r := gin.Default()

// 调大内存阈值(谨慎使用)
r.MaxMultipartMemory = 32 << 20 // 32MB

srv := &http.Server{
    Addr:         ":8080",
    Handler:      r,
    MaxHeaderBytes: 1 << 20, // 头部最大 1MB
}

容器化环境的陷阱:

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

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),防止恶意文件伪装:

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 检测原理 — 只看文件头部不看后缀名:

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。用法类似,只需将 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. 服务端合并分片为最终文件

分片上传完整流程图:

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

磁盘上的目录结构变化:

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 爆满)
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) // 清理临时分片
    }
}

关联笔记