上文完成了multipart/form-data表单的解析,目前提供的API使用起来依旧有点麻烦,我们在此基础上进行稍加封装,更方便用户使用。本文难度较低。

项目架构

今天的项目架构如下:

5
|-- go.mod
|-- httpd
|   |-- chunk.go
|   |-- conn.go
|   |-- header.go
|   |-- multipart.go
|   |-- request.go
|   |-- response.go
|   |-- server.go
|-- main.go

未增加任何文件,所有的更改集中在multipart.go以及request.go中。

代码传送门

标准库写法

先看一段通过标准库解析form表单的另一种做法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//处理multipart/form-data
func HandleMultipartForm(w http.ResponseWriter,r *http.Request){
    // 预解析
	if err:=r.ParseMultipartForm(10<<20);err!=nil{
		return
	}
	for key,value:=range r.MultipartForm.Value{
		fmt.Printf("key=%s,value=%s\n",key,value[0])
	}
	for _,fhs:=range r.MultipartForm.File{
		for _,fh:=range fhs{
			dest,_:=os.Create(fh.Filename)
			file,_:=fh.Open()
			io.Copy(dest,file)
			file.Close()
		}
	}
}

//处理application/x-www-form-urlencoded
func HandlePostForm(w http.ResponseWriter,r *http.Request){
    // 预解析
	if err:=r.ParseForm();err!=nil{
		return
	}
	foo1:=r.PostForm.Get("foo1")
	foo2:=r.PostForm.Get("foo2")
	fmt.Printf("foo1=%s,foo2=%s\n",foo1,foo2)
}

不论是哪一种表单,都需要调用提前ParseMultipart或者ParseForm方法进行预解析。

  • ParseForm用于解析url编码的表单,会初始化Request的PostForm字段,是一个map。
  • ParseMultiaprt用于解析multipart表单,会初始化Request的MultipartForm字段,它包含了两个成员一个是Value,一个是File。Value用于存储url编码的文本信息,与PostForm一样;File是一个map,key为表单名,value为文件信息。这两个成员正好对应MultipartForm表单的两种part类型:文本以及文件。

在此之上,标准库继续进行了封装。对于给定的key获取value,两种表单都可以统一地使用PostFormValue(key string)。对于给定表单名,获取对应的文件信息,可以使用FormFile(key string)拿到,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//处理multipart/form-data
func HandleMultipartForm(w http.ResponseWriter,r *http.Request){
	if err:=r.ParseMultipartForm(10<<20);err!=nil{
		return
	}
	foo1:=r.PostFormValue("foo1")
	fmt.Printf("foo1=%s\n",foo1)
	file,fh,_:=r.FormFile("file1")
	defer file.Close()
	f,_:=os.Create(fh.Filename)
	defer f.Close()
	io.Copy(f,file)
}

//处理application/x-www-form-urlencoded
func HandlePostForm(w http.ResponseWriter,r *http.Request){
	if err:=r.ParseForm();err!=nil{
		return
	}
	foo1:=r.PostFormValue("foo1")
	foo2:=r.PostFormValue("foo2")
	fmt.Printf("foo1=%s,foo2=%s\n",foo1,foo2)
}

标准库提供了多种解析表单的方法,但都有一个共同点,那就是必须提前调用ParseMultipart或者ParseForm方法,对表单进行预解析,粗心的程序员就可能会忘记这一步。我们的框架会有所改变,将预解析表单这一步省略掉,由我们的框架隐式调用。实现也很简单,我们不允许用户直接访问Request的PostFormMultipartForm字段,只能通过公有方法的方式,然后在这些公有方法内部我们对表单进行提前解析,这样就能减少程序员出错的可能。

request.go

改变Request结构体:

1
2
3
4
5
6
7
8
type Request struct{
    /*...*/
    postForm      	map[string]string
	multipartForm 	*MultipartForm
	haveParsedForm 	bool
	parseFormErr   	error
    /*...*/
}

我们增加了四个字段,利用postForm存放两种表单的普通文本信息,利用multipartForm存储传输的文件信息,haveParsedForm用来记录是否已经解析过表单,parseFormErr用来记录解析过程出现的错误。

我们给Request绑定两个方法,一个用来访问postForm,一个用来访问multipartForm:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func (r *Request) PostForm(name string) string {
	if !r.haveParsedForm {
		r.parseFormErr = r.parseForm()
	}
	if r.parseFormErr != nil || r.postForm == nil {
		return ""
	}
	return r.postForm[name]
}

func (r *Request) MultipartForm() (*MultipartForm, error) {
	if !r.haveParsedForm {
		if err := r.parseForm(); err != nil {
			r.parseFormErr = err
			return nil, err
		}
	}
	return r.multipartForm, r.parseFormErr
}

在每次访问表单之前,我们都会检查表单是否被解析,如果没有则我们会调用parseForm进行解析,parseForm如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func (r *Request) parseForm() error {
	if r.Method != "POST" && r.Method != "PUT" {
		return errors.New("missing form body")
	}
	r.haveParsedForm = true
	switch r.contentType {
	case "application/x-www-form-urlencoded":
		return r.parsePostForm()
	case "multipart/form-data":
		return r.parseMultipartForm()
	default:
		return errors.New("unsupported form type")
	}
}

我们再根据表单类型的不同调用不同的解析方法,parsePostForm会完成Request的postForm字段的设置,parseMultipartForm会完成Request的multipartForm的设置。

接着我们只需要将这两个方法填坑即可。

parsePostForm

该方法对应application/x-www-form-urlencoded表单,其报文格式见上一篇,解析极为简单,与queryString如出一辙:

1
2
3
4
5
6
7
8
func (r *Request) parsePostForm() error {
	data, err := ioutil.ReadAll(r.Body)
	if err != nil {
		return err
	}
	r.postForm = parseQuery(string(data))
	return nil
}

parseMultipartForm

该方法对应multipart/form-data表单,前文提过multipart/form-data表单内存在两种类型的part,一种传输纯文本数据,另一种传输文件,所以我们的MultipartForm结构体就应该存在两个字段进行区分:

1
2
3
4
type MultipartForm struct {
	Value map[string]string
	File  map[string]*FileHeader
}

FileHeader存储的是客户端上传的文件信息,其结构如下:

1
2
3
4
5
6
7
type FileHeader struct {
	Filename string
	Header   Header
	Size     int
	content  []byte
	tmpFile  string
}

前三个字段很好理解,重点是后两个。由于multipart表单可以上传文件,文件可能会很大,如果把用户上传的文件全部缓存在内存里,是极为消耗资源的。所以我们采取的机制是,规定一个内存里缓存最大量:

  • 如果当前缓存量未超过这个值,我们将这些数据存到content这个字节切片里去。
  • 如果超过这个最大值,我们则将客户端上传文件的数据暂时存储到硬盘中去,待用户需要时再读取出来。tmpFile是这个暂时文件的路径。

用户拿到一个FileHeader后,需要提供读取对应数据的方法,应该保证不论文件是存储在硬盘还是内存中,用户都使用相同的api来访问。所以我们提供的open方法的返回值是一个io.ReadCloser,这样用户只需要关心Read以及Close方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func (fh *FileHeader) Open() (io.ReadCloser, error) {
	if fh.inDisk() {
		return os.Open(fh.tmpFile)
	}
	b := bytes.NewReader(fh.content)
	return ioutil.NopCloser(b), nil
}

func (fh *FileHeader) inDisk() bool {
	return fh.tmpFile != ""
}

对于存储在硬盘上的情况,用户在读完这个文件之后有义务将这个文件关闭,所以我们的返回值是一个ReadCloser而不是单纯一个Reader。对于存储在内存里的情况,我们将content切片转为一个bytes.Reader之后,并不需要Close方法,但为了保证编译通过,我们使用ioutil.NopCloser函数给我们的Reader添加一个什么都不做的Close方法,来保证一致性。

parseMultipartForm:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func (r *Request) parseMultipartForm() (err error) {
	mr,err := r.MultipartReader()
	if err!=nil{
		return
	}
	r.multipartForm,err = mr.ReadForm()
    //让PostForm方法也可以访问multipart表单的文本数据
	r.postForm = r.multipartForm.Value
	return
}

接着就是重点讨论ReadForm方法,实现很简单,一个个part按部就班的讨论即可:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
func (mr *MultipartReader) ReadForm() (mf *MultipartForm, err error) {
	mf = &MultipartForm{
		Value: make(map[string]string),
		File:  make(map[string]*FileHeader),
	}
	var part *Part
	var nonFileMaxMemory int64 = 10 << 20 //非文件部分在内存中存取的最大量10MB,超出返回错误
	var fileMaxMemory int64 = 30 << 20    //文件在内存中存取的最大量30MB,超出部分存储到硬盘
	for {
		part, err = mr.NextPart()
		if err == io.EOF {
			break
		}
		if err != nil {
			return
		}
		if part.FormName() == "" {
			continue
		}
		var buff bytes.Buffer
		var n int64
		//non-file part
		if part.FileName()== "" {
            //copy的字节数未nonFileMaxMemory+1,好判断是否超过了内存大小限制
            //如果err==io.EOF,则代表文本数据大小<nonFileMaxMemory+1,并未超过最大限制
			n, err = io.CopyN(&buff, part, nonFileMaxMemory+1)
			if err != nil && err != io.EOF {
				return
			}
			nonFileMaxMemory -= n
			if nonFileMaxMemory < 0 {
				return nil, errors.New("multipart: message too large")
			}
			mf.Value[part.FormName()] = buff.String()
			continue
		}
		//file part
		n, err = io.CopyN(&buff, part, fileMaxMemory+1)
		if err != nil && err != io.EOF {
			return
		}
		fh := &FileHeader{
			Filename: part.FileName(),
			Header:   part.Header,
		}
		//未达到了内存限制
		if fileMaxMemory >= n {
			fileMaxMemory -= n
			fh.Size = int(n)
			fh.content = buff.Bytes()
			mf.File[part.FormName()] = fh
			continue
		}
		//达到内存限制,将数据存入硬盘
		var file *os.File
		file, err = os.CreateTemp("", "multipart-")
		if err != nil {
			return
		}
        //将已经拷贝到buff里以及在part中还剩余的部分写入到硬盘
		n, err = io.Copy(file, io.MultiReader(&buff, part))
		if cerr := file.Close(); cerr != nil {
			err = cerr
		}
		if err != nil {
			os.Remove(file.Name())
			return
		}
		fh.Size = int(n)
		fh.tmpFile = file.Name()
		mf_, ok := mf.File[part.FormName()]
		if ok {
			os.Remove(mf_.tmpFile)
		}
		mf.File[part.FormName()] = fh
	}
	return mf, nil
}

硬盘上的暂时文件也应该在handler结束后删除,防止占用过多硬盘空间,我们提供一个将这些文件删除的方法:

1
2
3
4
5
6
7
8
func (mf *MultipartForm) RemoveAll() {
	for _, fh := range mf.File {
		if fh == nil || fh.tmpFile == "" {
			continue
		}
		os.Remove(fh.tmpFile)
	}
}

用户获取MultipartForm之后,应该调用RemoveAll方法将暂存的文件删除。用户可能在Handler中忘记调用RemoveALL,因此我们在Request的finishRequest方法中做出防备:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func (r *Request) finishRequest() (err error) {
    if r.multipartForm!=nil{
		r.multipartForm.RemoveAll()
	}
	//将缓存中的剩余的数据发送到rwc中
	if err = r.conn.bufw.Flush(); err != nil {
		return
	}
	_, err = io.Copy(ioutil.Discard, r.Body)
	return err
}

继续封装

目前我们给Request结构体绑定了PostForm方法获取文本内容中与key对应的value。

绑定了MultipartForm方法获取一个MultiaprtForm类型的实例,再访问File属性就可以得到对应的FileHeader,通过FileHeader的open方法就得到一个ReadCloser,从而可以对客户端传输的文件进行读取。

你会发现文件的读取还是比较麻烦,用户还需要对MultipartForm的具体结构进行了解才能使用。我们对其简化:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func (r *Request) FormFile(key string)(fh* FileHeader,err error){
	mf,err := r.MultipartForm()
	if err!=nil{
		return
	}
	fh,ok:=mf.File[key]
	if !ok{
		return nil,errors.New("http: missing multipart file")
	}
	return
}

用户一看到FormFile这个方法名就能知道,这个方法专门用于访问客户端上传的文件,对开发者更友好。

有很多时候,用户希望将客户端上传的文件保存到硬盘的某个位置。用户拿到一个FileHeader后,还需要调用Open方法,Create一个文件,然后进行Copy,使用比较麻烦,我们给FileHeader增加一个Save方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func (fh *FileHeader) Save(dest string)(err error){
	rc,err:=fh.Open()
	if err!=nil{
		return
	}
	defer rc.Close()
	file,err:=os.Create(dest)
	if err!=nil{
		return
	}
	defer file.Close()
	_,err = io.Copy(file,rc)
	if err!=nil{
		os.Remove(dest)
	}
	return
}

这样之后,用户就可以利用更为方便的API访问HTTP请求的form表单了。

测试

main.go代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
//测试FormFile。 将文件文本输出到终端
func handleTest1(w httpd.ResponseWriter, r *httpd.Request) (err error) {
	fh, err := r.FormFile("file1")
	if err != nil {
		return
	}
	rc, err := fh.Open()
	if err != nil {
		return
	}
	defer rc.Close()
	buf, err := ioutil.ReadAll(rc)
	if err != nil {
		return
	}
	fmt.Printf("%s\n", buf)
	return
}

//测试Save。 将文件保存到硬盘
func handleTest2(w httpd.ResponseWriter, r *httpd.Request) (err error) {
	mr, err := r.MultipartForm()
	if err != nil {
		return
	}
	for _, fh := range mr.File {
		err = fh.Save(fh.Filename)
	}
	return err
}

//测试PostForm
func handleTest3(w httpd.ResponseWriter, r *httpd.Request) (err error) {
	value1 := r.PostForm("foo1")
	value2 := r.PostForm("foo2")
	fmt.Printf("foo1=%s,foo2=%s\n", value1, value2)
	return nil
}

func (*myHandler) ServeHTTP(w httpd.ResponseWriter, r *httpd.Request) {
	var err error
	switch r.URL.Path {
	case "/test1":
		err = handleTest1(w, r)
	case "/test2":
		err = handleTest2(w, r)
	case "/test3":
		err = handleTest3(w, r)
	}
	if err != nil {
		fmt.Println(err)
	}
    
    // 手动构建响应报文
	io.WriteString(w, "HTTP/1.1 200 OK\r\n")
	io.WriteString(w, fmt.Sprintf("Content-Length: %d\r\n", 0))
	io.WriteString(w, "\r\n")
}

func main() {
	svr := &httpd.Server{
		Addr:    "127.0.0.1:80",
		Handler: new(myHandler),
	}
	panic(svr.ListenAndServe())
}

test1:

1
$ curl -F "file1=@文件名" http://127.0.0.1/test1

test2:

1
$ curl -F "file1=@文件名" -F "file2=@文件名" http://127.0.0.1/test2

test3:

1
2
3
4
# 使用application/x-www-form-urlencoded表单
$ curl -d "foo1=bar1&foo2=bar2" http://127.0.0.1/test3
# 使用multipart/form-data表单
$ curl -F "foo1=bar1" -F "foo2=bar2" http://127.0.0.1/test3

都能得到预期的结果。

本文对Form进行了封装,目前我们的框架已经将http的请求部分全部完成,以目前的完成度来说,用户需要自己手动构造响应报文,使用极为麻烦,下一篇我们将对response进行设置。

系列目录