原创

Go multipart 上传的三个上限:内存阈值、请求总量与临时文件清理

ParseMultipartForm(32 << 20) 不代表整个上传只能有32MiB。这个参数主要决定文件内容在内存里的保留预算,超过部分可以落到临时文件;如果没有另外限制请求体,磁盘仍可能继续增长。

上传接口至少要分清三项约束:整份HTTP请求最多读多少字节,解析器愿意保留多少文件数据在内存,单个业务文件允许多大。它们不能共用一个看起来顺眼的数。

下面固定Go 1.24.1,做一个只读取并返回大小的本地接口,不保存正式文件。测试把内存阈值设得很小,让普通样本也经过临时文件路径,并检查请求结束后没有暂存残留。

处理器先管总量,再解析,最后执行单文件规则

新建目录,保存 go.mod:

module example.invalid/upload-limits

go 1.24.1

保存 main.go:

package main

import (
    "encoding/json"
    "errors"
    "io"
    "log"
    "net/http"
    "time"
)

const requestLimit int64 = 4096
const fileLimit int64 = 256

func upload(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        w.Header().Set("Allow", http.MethodPost)
        http.Error(w, "POST required", http.StatusMethodNotAllowed)
        return
    }
    r.Body = http.MaxBytesReader(w, r.Body, requestLimit)
    defer r.Body.Close()
    err := r.ParseMultipartForm(32)
    if r.MultipartForm != nil {
        defer r.MultipartForm.RemoveAll()
    }
    if err != nil {
        var large *http.MaxBytesError
        if errors.As(err, &large) {
            http.Error(w, "request too large", http.StatusRequestEntityTooLarge)
        } else {
            http.Error(w, "invalid multipart body", http.StatusBadRequest)
        }
        return
    }
    files := r.MultipartForm.File["document"]
    if len(files) != 1 {
        http.Error(w, "exactly one document required", http.StatusBadRequest)
        return
    }
    header := files[0]
    if header.Size > fileLimit {
        http.Error(w, "document too large", http.StatusRequestEntityTooLarge)
        return
    }
    file, err := header.Open()
    if err != nil {
        http.Error(w, "cannot open document", http.StatusInternalServerError)
        return
    }
    defer file.Close()
    data, err := io.ReadAll(io.LimitReader(file, fileLimit+1))
    if err != nil {
        http.Error(w, "cannot read document", http.StatusBadRequest)
        return
    }
    if int64(len(data)) > fileLimit {
        http.Error(w, "document too large", http.StatusRequestEntityTooLarge)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    _ = json.NewEncoder(w).Encode(map[string]int{"bytes": len(data)})
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/upload", upload)
    server := &http.Server{
        Addr: "127.0.0.1:8089", Handler: mux,
        ReadHeaderTimeout: 5 * time.Second,
        ReadTimeout: 10 * time.Second,
        WriteTimeout: 10 * time.Second,
    }
    log.Fatal(server.ListenAndServe())
}

例子的4096、256和32都是用于分离测试路径的字节数,不是通用上传配置。真实协议应根据文件类型与部署容量另定预算;multipart边界和字段头也占请求字节,不能把整份请求上限刚好设成文件上限,还期待最大文件始终能通过。

header.Size 是解析器得到的文件尺寸,先用它拒绝超限。真正读取时又使用limit+1检查,保持消费入口自己的容量合同,不把未来替换文件来源或改造解析流程的安全性全压在一个元数据字段上。

测试要能走到磁盘路径

保存 main_test.go。下面用Unix风格的TMPDIR隔离测试临时目录,适合Linux和macOS;Windows的临时目录选择变量不同,移植测试时应按目标平台调整。

package main

import (
    "bytes"
    "mime/multipart"
    "net/http"
    "net/http/httptest"
    "os"
    "strings"
    "testing"
)

func TestUploadLimitsAndCleanup(t *testing.T) {
    folder := t.TempDir()
    t.Setenv("TMPDIR", folder)
    cases := []struct {
        name string
        size int
        want int
    }{
        {"normal spills beyond memory threshold", 128, 200},
        {"single file too large", 512, 413},
        {"entire request too large", 5000, 413},
    }
    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            var body bytes.Buffer
            writer := multipart.NewWriter(&body)
            part, err := writer.CreateFormFile("document", "demo.bin")
            if err != nil { t.Fatal(err) }
            if _, err := part.Write([]byte(strings.Repeat("x", tc.size))); err != nil {
                t.Fatal(err)
            }
            if err := writer.Close(); err != nil { t.Fatal(err) }
            request := httptest.NewRequest(http.MethodPost, "/upload", &body)
            request.Header.Set("Content-Type", writer.FormDataContentType())
            response := httptest.NewRecorder()
            upload(response, request)
            if response.Code != tc.want {
                t.Fatalf("status=%d want=%d body=%s", response.Code,
                    tc.want, response.Body.String())
            }
            entries, err := os.ReadDir(folder)
            if err != nil { t.Fatal(err) }
            if len(entries) != 0 {
                t.Fatalf("temporary upload files remain: %v", entries)
            }
        })
    }
}

执行 go test -v ./...,三项都应通过。正常文件超过32字节的文件内存阈值,却小于256字节业务上限,因此接受;512字节样本违反单文件规则;5000字节样本违反整个请求预算。每一条路径结束后,独立临时目录都应为空。

测试没有并行执行,因为它修改了进程环境来定位临时目录。不要给这类子测试随手加Parallel,再与其他测试争用全局环境。生产服务更不能按每个请求去修改TMPDIR,进程级配置与请求状态必须分开。

文件内存阈值,不是解析器全部内存预算

ParseMultipartForm的maxMemory针对文件部分的内存保留总量,不是给每个文件各发一份相同额度。多文件表单会共同消耗这份预算;一个正常文件超过阈值落盘,不表示它已经违反业务规则。

普通文本字段与元数据还有自己的解析开销。Go1.24.1的multipart表单实现为非文件内容保留额外内存预算,因此把maxMemory改成32,并不意味着整个解析器只用32字节。这里用MaxBytesReader把实际可读取请求限制在4096字节,才让全部输入有一个更外层的硬边界。

这也提醒调用方:只观察内存没有增长,不代表上传无限制。文件可能已经流向临时目录;磁盘容量、请求并发和清理延迟都属于不同资源维度。示例检查临时目录,是为了把落盘后的退出路径也纳入证据。

上限还必须在首次解析以前安装。中间件若先调用FormFile、FormValue或某个自动绑定器,可能已经触发解析;之后再设置较小maxMemory,不会重新解析并撤销之前的读取。ParseMultipartForm成功执行过以后,后续调用不按新参数重做。统一日志若会读取表单,也需要遵守同一入口顺序。

可以在测试的正常请求上先手动ParseMultipartForm,读取临时目录确认存在文件,再RemoveAll确认清空,以单独观察解析器行为;接口测试则继续验证完整请求退出后无残留。前者证明真的走过落盘路径,后者证明业务封装没有丢掉清理责任,两个测试各有目的。

RemoveAll 不能用 Close 代替

打开的文件对象与解析器创建的临时文件名属于不同资源。file.Close() 关闭本次读取句柄,MultipartForm.RemoveAll() 删除解析过程留下的临时文件。只完成前一个动作,目录里的暂存文件仍可能保留。

defer按逆序执行,因此例子先登记RemoveAll,后登记file.Close,退出时先关闭句柄再清理文件名。这比在错误分支到处复制删除逻辑更容易覆盖正常和异常路径。

ParseMultipartForm可能已经部分工作才返回错误,所以代码只要拿到了MultipartForm就登记清理。解析器内部也有自己的失败回收,但调用方不应把已经交付给自己的表单资源丢掉。

这里没有按用户文件名建立正式路径。文件名只是请求元数据,不能直接当服务器存储路径;真正保存时应分配受控名称,完成内容验证后再发布。扩展名和Content-Type也没有在这个大小实验里获得真实性保证。

需要真正流式处理时,再换解析入口

ParseMultipartForm会把整个表单解析完,再交给业务读取。如果场景要求边收到文件边写受控存储,可以考虑MultipartReader逐部分处理,但那也意味着自己负责字段规则、逐部分限额、未读数据和失败清理。

更换API不意味着总量限制可以删除。流式入口仍需要MaxBytesReader或等价读取预算,解压和格式转换以后还可能有新的膨胀尺寸。所谓“没有全放内存”只回答一个问题,不能推导磁盘与执行时间也都受控。

在这个最小接口里,最后有效结果很简单:128字节返回200,两个超限样本返回413,目录没有残留。把这三项证据保留下来,再按业务增加正式存储步骤,才能在以后调整内存阈值时确认没有顺手放开请求上限。

正文到此结束
评论插件初始化中...
Loading...
本文目录