bigywater.tech
Gin · 38

多文件上传

一次上传好几个文件,该用 FormFile 还是 MultipartForm?怎么绑定到结构体?客户端给的文件名能不能直接用来保存?SaveUploadedFile 会对目录做什么?这篇用真实的 multipart 请求把这些问题一一实测。

b.txt
c.FormFile("files") 对 3 个同名文件返回的那一个(第一个)
..\..\win.ini
反斜杠的文件名在 Unix 上原样保留,不会被 filepath.Base 处理
1000 → 1001
一个请求最多 1000 个 part,第 1001 个报 multipart: message too large

FormFile 与 MultipartForm

实测 · 本机执行输出
    请求里的顺序:b.txt、a.png、c.bin(都叫 files),另有 other 字段的 o.txt
     FormFile(files) → b.txt 3 字节
     File["files"] = b.txt(3 字节,text/plain) a.png(10 字节,image/png) c.bin(0 字节,application/octet-stream);表单里共有的文件字段名:2 个;Value=map[title:[t]]
    --- 没有这个字段时:
     http: no such file

FormFile(name) 只能拿到该字段的第一个文件;要全部文件用 MultipartForm() 再取 File[name],顺序与请求一致。每个 FileHeader 带文件名、大小和原始的 part 头(包含客户端声明的 Content-Type,不可信)。

绑定到结构体

实测 · 本机执行输出
    全部给出         → Title="t" Avatar=me.png Files=[1.txt 2.txt] err=<nil>
    缺 avatar     → Title="t" Avatar=nil Files=[1.txt] err=Key: 'Up.Avatar' Error:Field validation for 'Avatar' failed on the 'required' tag
    缺 title      → Title="" Avatar=me.png Files=[] err=Key: 'Up.Title' Error:Field validation for 'Title' failed on the 'required' tag
    files 只有一个   → Title="t" Avatar=me.png Files=[only.txt] err=<nil>

表单绑定认识 *multipart.FileHeader 和 []*multipart.FileHeader 两种字段类型,和普通字段、binding 校验标签一起工作。只上传一个文件时切片字段长度为 1,不会变成 nil。

客户端文件名不可信

实测 · 本机执行输出
    客户端发送 filename="report.pdf"               → 服务端读到 "report.pdf"(200)
    客户端发送 filename="../../etc/passwd"         → 服务端读到 "passwd"(200)
    客户端发送 filename="/etc/passwd"              → 服务端读到 "passwd"(200)
    客户端发送 filename="a/b/c.txt"                → 服务端读到 "c.txt"(200)
    客户端发送 filename="..\\..\\win.ini"          → 服务端读到 "..\\..\\win.ini"(200)
    客户端发送 filename="C:\\Users\\x\\a.txt"      → 服务端读到 "C:\\Users\\x\\a.txt"(200)
    客户端发送 filename=".."                       → 服务端读到 ".."(200)
    客户端发送 filename="."                        → 服务端读到 "."(200)
    客户端发送 filename="a\x00b.txt"               → 服务端读到 malformed MIME header line: "Content-Disposition: form-data; name=\"f\"; filename=\"a\x00b.txt\""(400)
    客户端发送 filename="  spaced name .txt"       → 服务端读到 "  spaced name .txt"(200)

Go 在解析时先对文件名做 filepath.Base,所以 ../../etc/passwd 只剩 passwd。但这是按服务器平台的规则:在 Unix 上反斜杠不是分隔符,..\..\win.ini 原样保留;..、. 也原样保留。保存文件时最稳妥的做法是不用客户端的文件名,自己生成(如 UUID + 白名单扩展名)。

SaveUploadedFile 的副作用

实测 · 本机执行输出
  context.go 的 SaveUploadedFile(Gin v1.12.0):
     719 | func (c *Context) SaveUploadedFile(file *multipart.FileHeader, dst string, perm ...fs.FileMode) error {
     720 |     src, err := file.Open()
     721 |     if err != nil {
     722 |         return err
     723 |     }
     724 |     defer src.Close()
     725 | 
     726 |     var mode os.FileMode = 0o750
     727 |     if len(perm) > 0 {
     728 |         mode = perm[0]
     729 |     }
     730 |     dir := filepath.Dir(dst)
     731 |     if err = os.MkdirAll(dir, mode); err != nil {
     732 |         return err
     733 |     }
     734 |     if err = os.Chmod(dir, mode); err != nil {
     735 |         return err
     736 |     }
     737 | 
     738 |     out, err := os.Create(dst)
     739 |     if err != nil {
     740 |         return err
     741 |     }
     742 |     defer out.Close()
     743 | 
     744 |     _, err = io.Copy(out, src)
     745 |     return err
     746 | }
    保存之前 exist 目录权限: drwxr-xr-x
    保存之后 exist 目录权限: drwxr-x---  新建的 new/deep: drwxr-x---  new: drwxr-x---  perm(传了 0700): drwx------
    b.txt 内容长度: 7

它先 MkdirAll 创建多级目录,再对目标目录 Chmod(默认 0750)。已经存在的目录权限会被悄悄收紧——保存到一个 0755 的共享目录,之后这个目录对其它用户不可读了。需要不同权限时给它传 perm 参数,或者自己创建目录再保存。

part 数量上限

实测 · 本机执行输出
     999 个文件 → 200 收到 999 个文件
    1000 个文件 → 200 收到 1000 个文件
    1001 个文件 → 400 multipart: message too large

一个 multipart 请求最多 1000 个 part(包括普通字段),超过时 multipart: message too large。这是 Go 标准库的限制,可以用环境变量 GODEBUG=multipartmaxparts=… 调整(本文没有验证调整)。

分步演示

多文件上传
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew)/ Gin v1.12.0(go.mod 里 require,源码在模块缓存里);用 go run / go test 直接编译运行,需要库时经代理从 proxy.golang.org 下载;HTTP 实验用 httptest 或本机回环端口(9700 以上);计时用 time.Now / testing.B,数字每次运行会有小幅抖动。用真实的 httptest.NewServer 与手工构造的 multipart 请求;文件内容是占位字节;SaveUploadedFile 的目录在临时目录里,实验后删除;每种情形各一次
    • 读过的 Go 源码:mime/multipart/formdata.go(maxParts 默认 1000);没有验证:Windows 服务器上的文件名处理;GODEBUG=multipartmaxparts 的调整;上传文件的内容类型嗅探(http.DetectContentType);SaveUploadedFile 在符号链接目录下的行为;并发保存同名文件的竞争
    京ICP备2026067502号-1京公网安备11010502063487号