Featured image of post Go 上传一个10M的文件, 真的会用10M的内存吗?

Go 上传一个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只能读取一次
本作品采用知识共享署名 4.0 国际许可协议进行许可,转载时请注明原文链接,图片在使用时请保留全部内容,可适当缩放并在引用处附上图片所在的文章链接。