上文完成了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的PostForm
、MultipartForm
字段,只能通过公有方法的方式,然后在这些公有方法内部我们对表单进行提前解析,这样就能减少程序员出错的可能。
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
}
|
该方法对应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进行设置。
系列目录