上传一个10M的文件, 真的会用10M的内存吗?文件上传解析

先直接给答案:是也不是 (取决于你的配置和实现方式)

今天看到社区有人问了一个问题:

为什么 PHP 文件上传是直接用 move_uploaded_file 移动一个上传好的文件,而不是从 HTTP Body 中读取出文件内容.

  • 我也对这个问题很感兴趣。查阅了资料,找到一篇鸟哥关联的 PHP 文件上传源码分析 (RFC1867)
  • 但也没有说明具体原因,于是看了一下 Go 的文件上传的实现.(不过基本可以确定是因为内存问题)

Go#

  • Go 中获取上传的文件方式很简单,只要通过 http.Request.FormFile 方法即可拿到上传的文件
package main

import (
    "log"
    "net/http"
)

func main() {

    http.HandleFunc("/files", func(writer http.ResponseWriter, request *http.Request) {

        // 32M
        err := request.ParseMultipartForm(32 << 20)
        if err != nil {
            log.Println(err)
            return
        }

        // 获取上传的文件
        file, handler, err := request.FormFile("file_key")
        log.Println(file, handler, err)
    })
    if err := http.ListenAndServe(":8000", nil); err != nil {
        log.Println(err)
    }
}
  • http.Request.FormFile 的实现也比较简单,直接从一个 map 里拿到想要的数据
  • 所以上传的逻辑,我们还是要看 http.Request.ParseMultipartForm
func (r *Request) FormFile(key string) (multipart.File, *multipart.FileHeader, error) {
    if r.MultipartForm == multipartByReader {
        return nil, nil, errors.New("http: multipart handled by MultipartReader")
    }
    if r.MultipartForm == nil {
        err := r.ParseMultipartForm(defaultMaxMemory)
        if err != nil {
            return nil, nil, err
        }
    }
    if r.MultipartForm != nil && r.MultipartForm.File != nil {
        if fhs := r.MultipartForm.File[key]; len(fhs) > 0 {
            f, err := fhs[0].Open()
            return f, fhs[0], err
        }
    }
    return nil, nil, ErrMissingFile
}
  • http.Request.ParseMultipartForm 方法解析参数,其中又调用了 multipart.Reader.ReadForm 去读取 Body 中的内容
  • 观察此方法不难发现,上传的文件是存储到磁盘还是内存,取决于给定的 maxMemory 参数是否大于上传的文件大小 (多个文件合计计算)
  • 注意的是,表单参数值也受 maxMemory 限制,不过给了 10M. 意思是我们如果设置 maxMemory=32M, 那么提交的 Body 最大只能 42M(上传文件还是 32M)
  • 如果 Body 小于 maxMemory 那么就直接把上传的文件读取到内存中操作,否则写入到临时文件夹 (写入临时文件这个和 PHP 操作一致)
func (r *Reader) ReadForm(maxMemory int64) (*Form, error) {
    return r.readForm(maxMemory)
}

func (r *Reader) readForm(maxMemory int64) (_ *Form, err error) {
    form := &Form{make(map[string][]string), make(map[string][]*FileHeader)}
    defer func() {
        if err != nil {
            form.RemoveAll()
        }
    }()

    // Reserve an additional 10 MB for non-file parts.
    maxValueBytes := maxMemory + int64(10<<20)
    if maxValueBytes <= 0 {
        if maxMemory < 0 {
            maxValueBytes = 0
        } else {
            maxValueBytes = math.MaxInt64
        }
    }

    for {
        p, err := r.NextPart()
        if err == io.EOF {
            break
        }
        if err != nil {
            return nil, err
        }

        name := p.FormName()
        if name == "" {
            continue
        }
        filename := p.FileName()

        var b bytes.Buffer

        // 如果有没有文件名,就是普通的 form 提交表单支
        if filename == "" {
            // value, store as string in memory
            n, err := io.CopyN(&b, p, maxValueBytes+1)
            if err != nil && err != io.EOF {
                return nil, err
            }
            maxValueBytes -= n
            if maxValueBytes < 0 {
                return nil, ErrMessageTooLarge
            }
            form.Value[name] = append(form.Value[name], b.String())
            continue
        }

        // 否则就是上传文件
        // file, store in memory or on disk
        fh := &FileHeader{
            Filename: filename,
            Header:   p.Header,
        }
        n, err := io.CopyN(&b, p, maxMemory+1)
        if err != nil && err != io.EOF {
            return nil, err
        }

        // 这里判断读取的内容是否大于给定的最大字节
        if n > maxMemory {
            // too big, write to disk and flush buffer
            file, err := os.CreateTemp("", "multipart-")
            if err != nil {
                return nil, err
            }
            size, err := io.Copy(file, io.MultiReader(&b, p))
            if cerr := file.Close(); err == nil {
                err = cerr
            }
            if err != nil {
                os.Remove(file.Name())
                return nil, err
            }
            fh.tmpfile = file.Name()
            fh.Size = size
        } else {
            fh.content = b.Bytes()
            fh.Size = int64(len(fh.content))
            maxMemory -= n
            maxValueBytes -= n
        }
        form.File[name] = append(form.File[name], fh)
    }

    return form, nil
}
  • 问题到此就结束了,答案前面说了,取决于你的配置和实现方式.

  • 当文件大于给定的最大字节数时,是怎么实现复制的功能
  • 上面的代码中 io.Copy(file, io.MultiReader(&b, p)), 我们来查看 pb 的来源
  • 首先 b 比较简单,就是从 pcopy 出来 maxValueBytes+1 个字节,所以它是来源于 p
  • p 的来源如下
    • 来源于前面的 multipart.Reader
    • multipart.Reader 来源于 http.request.Body
    • http.request.Body 来源于 http.readTransfer 方法,然后从 http.conn.bufr 读取出来
    • c.bufr 的来源是如下代码,实际上还是连接 c 只不过封装了好几层
c.r = &connReader{conn: c}
c.bufr = newBufioReader(c.r)
c.bufw = newBufioWriterSize(checkConnErrorWriter{c}, 4<<10) 
  • 上传文件的请求连接可以认为就是一个 io.Reader 接口,可以不断从请求中读取出数据.

More#

  • 如果每次请求都附加大文件,就会导致总是解析文件上传,为什么不跳过文件上传,直接解析其它 Body 数据呢?
    • 因为读取 Body 的内容肯定是从上到下,文件可能在最前面,可能在最后面
    • 代码只能一行一行的读取 Body, 如果第一个部分是文件,并且太大的话只能先写到临时文件夹
    • 读取完这一个部分,才能读取接下来的内容
      PS: Go 中的 Request Body 只能读取一次
本作品采用《CC 协议》,转载必须注明作者和本文链接
当神不再是我们的信仰,那么信仰自己吧,努力让自己变好,不辜负自己的信仰!
未填写
文章
42
粉丝
158
喜欢
713
收藏
347
排名:30
访问:22.2 万
私信
所有博文
社区赞助商