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

5.8 KiB
Raw Blame History

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

文件上传

概述

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

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

正文

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(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 签名:

func (c *Context) SaveUploadedFile(file *multipart.File, dst string, maxBytes int64, matchers ...mimeType.Matcher) error
  • maxBytes:文件大小限制(0 = 不限制,默认 8MB)
  • matchers:可选的文件类型校验函数

2. 多文件上传

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

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

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

容器化环境的陷阱:

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

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. 服务端合并分片为最终文件
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"})
}

关联笔记