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

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

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

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

我也对这个问题很感兴趣。查阅了资料，找到一篇鸟哥关联的PHP 文件上传源码分析 (RFC1867)

但也没有说明具体原因，于是看了一下Go的文件上传的实现.

Go

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

Copy
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

Copy
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操作一致)

Copy
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)), 我们来查看p和b的来源

首先b比较简单，就是从p中copy出来maxValueBytes+1个字节，所以它是来源于p

而p的来源如下

来源于前面的multipart.Reader

而multipart.Reader来源于http.request.Body

http.request.Body来源于http.readTransfer方法，然后从http.conn.bufr读取出来

c.bufr的来源是如下代码，实际上还是连接c只不过封装了好几层

Copy
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只能读取一次