5、文件上传

文件上传

文件上传是Web应用的常见需求,从技术角度看,它是一种特殊的HTTP请求,具有以下特点:

在HTML中,实现文件上传表单需要:

<form action="/upload" method="post" enctype="multipart/form-data">
    <input type="file" name="file" />
    <input type="submit" value="上传" />
</form>

其中enctype="multipart/form-data"是必不可少的属性,它告诉浏览器使用multipart编码而不是默认的URL编码。

基本概念

multipart/form-data格式

multipart/form-data是一种特殊的HTTP请求体格式,用于发送文件和表单数据。它的特点是:

一个典型的multipart/form-data请求如下:

POST /upload HTTP/1.1
Host: example.com
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="title"

这是文件标题
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="example.jpg"
Content-Type: image/jpeg

[二进制文件数据]
------WebKitFormBoundary7MA4YWxkTrZu0gW--

这种格式允许我们发送任意数量的表单字段,包括文件和文本数据。

Gin中的文件处理接口

Gin框架为文件上传提供了简单而强大的API,主要涉及以下几个接口:

  1. c.FormFile(name string):获取单个上传的文件
  2. c.MultipartForm():获取包含所有上传文件的表单
  3. c.SaveUploadedFile(file, dst):保存上传的文件到指定路径

这些API基于Go标准库的multipart包,但提供了更简洁的使用方式。在处理文件时,Gin会返回以下类型:

单文件上传

func uploadHandler(c *gin.Context) {
    // 获取上传的文件
    file, err := c.FormFile("file")
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "获取文件失败"})
        return
    }
    
    // 文件保存路径
    dst := filepath.Join("./uploads", file.Filename)
    
    // 保存文件
    if err := c.SaveUploadedFile(file, dst); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"})
        return
    }
    
    c.JSON(http.StatusOK, gin.H{"message": "文件上传成功"})
}

在路由中注册这个处理函数:

router.POST("/upload", uploadHandler)
router.MaxMultipartMemory = 8 << 20 // 8 MiB

MaxMultipartMemory用于限制上传文件在内存中的最大大小,超过这个大小的文件会被写入临时文件。

文件信息获取

通过*multipart.FileHeader,我们可以获取上传文件的详细信息:

func getFileInfo(c *gin.Context) {
    file, err := c.FormFile("file")
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "获取文件失败"})
        return
    }
    
    // 获取文件信息
    fileInfo := gin.H{
        "filename": file.Filename,
        "size":     file.Size,
        "header":   file.Header,
    }
    
    // 获取文件内容
    f, err := file.Open()
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "打开文件失败"})
        return
    }
    defer f.Close()
    
    // 读取文件前20个字节
    buffer := make([]byte, 20)
    n, err := f.Read(buffer)
    if err != nil && err != io.EOF {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "读取文件失败"})
        return
    }
    
    fileInfo["contentPreview"] = fmt.Sprintf("%x", buffer[:n])
    
    c.JSON(http.StatusOK, fileInfo)
}

保存上传文件

Gin提供了c.SaveUploadedFile()方法简化文件保存过程,但你也可以自己控制文件保存过程:

func customSaveFile(c *gin.Context) {
    file, header, err := c.Request.FormFile("file")
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "获取文件失败"})
        return
    }
    defer file.Close()
    
    // 自定义文件名(添加时间戳防止重名)
    filename := fmt.Sprintf("%d_%s", time.Now().Unix(), header.Filename)
    
    // 确保目录存在
    uploadDir := "./uploads"
    if err := os.MkdirAll(uploadDir, 0755); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "创建目录失败"})
        return
    }
    
    // 创建目标文件
    dst, err := os.Create(filepath.Join(uploadDir, filename))
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "创建文件失败"})
        return
    }
    defer dst.Close()
    
    // 复制文件内容
    if _, err = io.Copy(dst, file); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件内容失败"})
        return
    }
    
    c.JSON(http.StatusOK, gin.H{
        "message":  "文件上传成功",
        "filename": filename,
    })
}

多文件上传

Gin支持同时上传多个文件,这需要使用c.MultipartForm()方法:

func multipleFilesUpload(c *gin.Context) {
    // 解析multipart表单
    form, err := c.MultipartForm()
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "解析表单失败"})
        return
    }
    
    // 获取所有上传的文件
    files := form.File["files"]
    
    // 确保目录存在
    uploadDir := "./uploads"
    if err := os.MkdirAll(uploadDir, 0755); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "创建目录失败"})
        return
    }
    
    // 处理每个文件
    filenames := []string{}
    for _, file := range files {
        // 自定义文件名
        filename := fmt.Sprintf("%d_%s", time.Now().UnixNano(), file.Filename)
        dst := filepath.Join(uploadDir, filename)
        
        // 保存文件
        if err := c.SaveUploadedFile(file, dst); err != nil {
            c.JSON(http.StatusInternalServerError, gin.H{
                "error": fmt.Sprintf("保存文件 %s 失败", file.Filename),
            })
            return
        }
        
        filenames = append(filenames, filename)
    }
    
    c.JSON(http.StatusOK, gin.H{
        "message":   "所有文件上传成功",
        "filenames": filenames,
        "count":     len(files),
    })
}

批量处理文件

对于多个文件的上传,我们通常需要批量处理,可以使用并发处理提高效率:

func concurrentFilesUpload(c *gin.Context) {
    form, err := c.MultipartForm()
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "解析表单失败"})
        return
    }
    
    files := form.File["files"]
    
    // 创建上传目录
    uploadDir := "./uploads"
    if err := os.MkdirAll(uploadDir, 0755); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "创建目录失败"})
        return
    }
    
    // 创建等待组和结果通道
    var wg sync.WaitGroup
    results := make(chan gin.H, len(files))
    
    // 启动多个goroutine处理文件上传
    for i, file := range files {
        wg.Add(1)
        
        go func(idx int, fileHeader *multipart.FileHeader) {
            defer wg.Done()
            
            // 自定义文件名
            filename := fmt.Sprintf("%d_%s", time.Now().UnixNano(), fileHeader.Filename)
            dst := filepath.Join(uploadDir, filename)
            
            // 保存文件
            err := c.SaveUploadedFile(fileHeader, dst)
            
            // 发送处理结果
            results <- gin.H{
                "index":    idx,
                "filename": fileHeader.Filename,
                "saved_as": filename,
                "success":  err == nil,
                "error":    err,
            }
        }(i, file)
    }
    
    // 等待所有goroutine完成
    go func() {
        wg.Wait()
        close(results)
    }()
    
    // 收集结果
    fileResults := []gin.H{}
    for result := range results {
        fileResults = append(fileResults, result)
    }
    
    c.JSON(http.StatusOK, gin.H{
        "message": "文件处理完成",
        "results": fileResults,
        "count":   len(fileResults),
    })
}

这段代码使用goroutine并发处理多个文件上传,适合处理大量文件的场景。

文件验证与安全

为了安全起见,通常需要验证上传文件的类型,有以下几种方法:

检查文件扩展名

func validateFileExtension(filename string, allowedExts []string) bool {
    ext := strings.ToLower(filepath.Ext(filename))
    for _, allowedExt := range allowedExts {
        if ext == allowedExt {
            return true
        }
    }
    return false
}

// 使用示例
func uploadWithExtensionCheck(c *gin.Context) {
    file, err := c.FormFile("file")
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "获取文件失败"})
        return
    }
    
    // 检查文件扩展名
    allowedExts := []string{".jpg", ".jpeg", ".png", ".gif"}
    if !validateFileExtension(file.Filename, allowedExts) {
        c.JSON(http.StatusBadRequest, gin.H{
            "error": "不支持的文件类型,仅支持 JPG、JPEG、PNG 和 GIF",
        })
        return
    }
    
    // ... 保存文件 ...
}

检查MIME类型

func validateMimeType(file *multipart.FileHeader, allowedTypes []string) (bool, error) {
    f, err := file.Open()
    if err != nil {
        return false, err
    }
    defer f.Close()
    
    // 读取文件前512字节用于类型检测
    buffer := make([]byte, 512)
    _, err = f.Read(buffer)
    if err != nil && err != io.EOF {
        return false, err
    }
    
    // 检测内容类型
    contentType := http.DetectContentType(buffer)
    
    for _, allowedType := range allowedTypes {
        if strings.HasPrefix(contentType, allowedType) {
            return true, nil
        }
    }
    
    return false, nil
}

// 使用示例
func uploadWithMimeCheck(c *gin.Context) {
    file, err := c.FormFile("file")
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "获取文件失败"})
        return
    }
    
    // 检查MIME类型
    allowedTypes := []string{"image/jpeg", "image/png", "image/gif"}
    valid, err := validateMimeType(file, allowedTypes)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "检验文件类型失败"})
        return
    }
    
    if !valid {
        c.JSON(http.StatusBadRequest, gin.H{
            "error": "不支持的文件类型,仅支持JPEG、PNG和GIF图片",
        })
        return
    }
    
    // ... 保存文件 ...
}

文件大小限制

控制上传文件的大小对于防止服务器资源耗尽至关重要:

// 设置全局上传文件大小限制
router.MaxMultipartMemory = 8 << 20 // 8 MiB

// 在处理函数中验证文件大小
func uploadWithSizeCheck(c *gin.Context) {
    file, err := c.FormFile("file")
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "获取文件失败"})
        return
    }
    
    // 检查文件大小
    const maxSize = 5 * 1024 * 1024 // 5 MB
    if file.Size > maxSize {
        c.JSON(http.StatusBadRequest, gin.H{
            "error": "文件太大,大小不能超过5MB",
        })
        return
    }
    
    // ... 保存文件 ...
}