前文对Body进行了设置,不论客户端是使用Content-Type还是chunk编码的方式,服务端都能够正确的解析。本文将在Body功能的基础上,着重完成form表单中的multipart表单的解析。个人认为是整个框架最难实现部分,不过与chunkReader实现的思路类似,可以借鉴实现。

项目架构

今天的项目架构如下:

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

增加了multipart.go文件,负责multipart-form的解析,同时对request.go进行了小幅度的修改。

代码传送门

两种form表单

客户端提交的form表单是利用http请求的报文主体部分携带的,前文已经做到了去轻松读取报文主体的字节数据,今天的任务就是从字节流中解析出表单内容。

按照RFC标准而言,POST请求存在两种表单:

  1. application/x-www-form-urlencoded。只能用来提交纯文本请求参数,与url的queryString字段起一样的作用,例前端的用户名和密码一般通过这个表单传递给后端。

    前端通过以下代码发起该form请求:

    1
    2
    3
    4
    5
    
    <form action="/login" method="post" enctype=”application/x-www-form-urlencoded”>  
        name:<input type="text" name="username"><br>  
         password:<input type="text" name="password"><br>  
        <input type="submit" value="login">  
    </form>  
    

    转化为的报文大致如下:

    1
    2
    3
    4
    5
    6
    7
    
    POST /login HTTP/1.1\r\n
    # 通过Content-Type告知对端传输的是哪种表单
    Content-Type: application/x-www-form-urlencoded	
    Content-Length: 20\r\n
    #other unconcerned headers
    \r\n
    name=gu&password=123	#报文主体
    

    form表单会出现在报文主体部分,以=拼接key、value,以&拼接每一项,与queryString如出一辙。其解析很简单,相信读者坚持到这里后能够自行完成解析,用一个map保存KV即可,我们在下一讲中简要介绍。

  2. multipart/form-data。这个表单的功能比上一个表单强大许多,不仅可以发送文本请求,还可以发送任意数量的文件,当然功能强大的同时会增加解析的复杂度。

    前端通过以下代码发起multipartForm请求:

    1
    2
    3
    4
    5
    6
    7
    
    <form action="/login" method="post" enctype="multipart/form-data">  
        username:<input type="text" name="username"><br>  
        password:<input type="text" name="password"><br>  
        uploadFile:<input type="file" name="file1"><br>
        uploadFile:<input type="file" name="file2"><br>  
        <input type="submit" value="login">  
    </form>  
    

    上述代码除了传输usernamepassword文本信息外,还额外上传两个文件。

    顾名思义,multipart表单会以一块(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
    
    POST /login HTTP/1.1\r\n
    [[ Less interesting headers ... ]]
    Content-Type: multipart/form-data; boundary=---------------------------735323031399963166993862150\r\n
    Content-Length: 414\r\n
    \r\n
    -----------------------------735323031399963166993862150\r\n		#--boundary,注意比上面的boundary多了两个-
    Content-Disposition: form-data; name="username"\r\n					#第一部分,username
    \r\n
    gu\r\n
    -----------------------------735323031399963166993862150\r\n		#--boundary
    Content-Disposition: form-data; name="password"\r\n					#第二部分,password
    \r\n
    123\r\n
    -----------------------------735323031399963166993862150\r\n		#--boundary
    Content-Disposition: form-data; name="file1"; filename="1.txt"\r\n	#第三部分,文件1
    Content-Type: text/plain\r\n
    \r\n
    Content of 1.txt.\r\n
    -----------------------------735323031399963166993862150\r\n		#--boundary
    Content-Disposition: form-data; name="file2"; filename="2.html"\r\n	#第四部分,文件2
    Content-Type: text/html\r\n
    \r\n
    <!DOCTYPE html><title>Content of 2.html.</title>\r\n
    -----------------------------735323031399963166993862150--\r\n		#--bounadry--标识表单结束
    

multipart表单详解

以上面的报文为例,1~5行为报文首部,6~24行为报文主体用于存储表单。

上述表单总共被划分为4个部分,正好对应于html代码中的四项,每一部分之间以\r\n--boundary作为分隔符(delimiter),其中boundary在首部的第3行给出,服务端知道boundary之后就可以区分不同的part。表单的末尾是以\r\n--boundary--为结束。要注意的是分隔符是\r\n--boundary,相较于boundary前面多了两个-(dash)。

按照RFC标准,每个分隔符后面可能会跟上0个或多个空格,最后才跟上CRLF(\r\n),如\r\n--boundary \r\n也是合法的。原文:

The boundary may be followed by zero or more characters of linear whitespace. It is then terminated by either another CRLF and the header fields for the next part, or by two CRLFs, in which case there are no header fields for the next part.

但事实上很多客户端的实现都不会有这些空格,同时为了让代码更紧凑,我们将不考虑出现空格的情况,因此我们的框架并没有严格按照标准,读者须知。

每一个part中又分为两部分,第一个部分为头部信息,第二个部分为消息主体,头部信息遵循MIME标准,头部信息与消息主体之间通过两个CRLF分隔。如果该part用于传输文件,则在头部信息中还会多出filename字段。

扩充:如果想正确解析multipart的话,客户端选择的boundary肯定是不能与part中的数据有重复的,因此理论上应该寻找一种算法保证这种情况不会发生。但具体实现起来消耗资源不现实,所以客户端往往会采用随机算法随机产生一个boundary,比如go的http标准库的client部分。

更多关于multipart的内容可以阅读这篇帖子以及RFC文档。

解析思路

先看看标准库的是如何解析multipart的,我们要做的就是模仿它的API(代码忽略了所有err):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func handleUpload(w http.ResponseWriter, r *http.Request) {
	// 生成一个MultipartReader
	mr, _ := r.MultipartReader()
    // 通过NextPart就可以获取每一个part
	part, _ := mr.NextPart()	// 如果没有了下一个part,err会返回io.EOF
    // 对part读取就能不多不少获取属于该part的消息主体(没有头部信息)
	data, _ := ioutil.ReadAll(part)
    // FileName不为空,则这part承载的是文件
	if part.FileName() != "" {
		fmt.Printf("filename=%s, data:\n%s\n", part.FileName(), data)
	} else if part.FormName() != "" {	// 否则是文本信息
		fmt.Printf("formname=%s, data:\n%s\n", part.FormName(), data)
	}
}

先通过r.MultipartReader创建了一个*multipart.Reader,multipartReader的NextPart方法作为迭代器,不停调用能够得到一个个part(我们演示里只调用了一次),这个part是*multipart.Part类型,实现了Reader接口,我们可以通过调用Read方法读取属于该part的内容。如果NextPart返回io.EOF错误,则代表没有下一个part了,表单处理完毕。part通过判断filename是否为空来判断这个part是传输文件还是文本。

API的使用非常的清晰简单,以part为单位依次处理,也很符合我们的逻辑。

尽管用户使用方便,但标准库肯定把解析的复杂性封装到了内部,可以推测标准库底层做了哪些工作以及存在哪些难点:

  • 前文讲到每个part报文是分头部和消息主体两部分的,但上述代码中对part的读取只能读取消息主体部分,说明了头部信息已经被mr.NextPart()消费、预解析后存取在了part结构体的成员中。

  • part的Read方法只能读取属于该part的消息主体数据,所以必须得规定读取的结束,不能让其无限制对Body随意读从而将下一个part数据读出。每个part是以boundary为分割,所以只要发现boundary就说明该part数据读取完毕了,应该让Read方法返回io.EOF。

  • 问题就出现在将不同的part区分开并不是很容易。对于chunk编码来说,是利用chunk size来标记这一块的长度,因此很轻松就可以区分不同的块。但对于multipart来说,是利用一特殊字符串标记范围的方式,我们需要进行数据的比对,找到boundary的出现位置。

  • 如果我们能一开始把整个http报文主体先读完然后缓存起来,那么找出所有boundary的位置会简单许多,但这显然不现实。我们只能通过开辟固定大小缓存的方式,通过滑动窗口解决问题。

part的Read方法实现的大致思路:

注意:我们会利用bufio.Reader的缓存功能完成我们的需求,能完成下面代码的前提是您能完全了解bufio.Reader,熟悉它的Read以及Peek的原理。

此处确实需要很强的抽象思维,我尽量讲述清楚,读者如果心有困惑可以结合后续代码查看。

我们对上文实现的Body分配4KB的缓存形成一个bufio.Reader,以这个缓存作为滑动窗口,每次从Body中peek出4KB的数据(peek的目的就是预查看),然后检测这4KB数据中是否出现分隔符\r\n--boundary,通过分隔符就可以确立两个part之间的交界,从而就能知道当前part应该读到哪就停止。

  • 如果没有出现分隔符,则代表peek预查看的数据就属于当前的part,我们将这4KB的数据放心读出即可。
  • 如果出现了分隔符,分隔符之前的数据属于该part,分隔符之后的数据属于下一个part,那么这个part只允许将分隔符之前的数据读出,剩下数据等待用户调用mr.NextPart切换到下一个part后读取。

事实上,对于第一种情况就算没有出现分隔符,我们也不能将这4KB全部当作该part数据读出,因为分隔符并不是一个字符,而是一个字符串,如果这个字符串前一半出现在这4KB数据的末尾,还有一半还在IO流中待读出,我们将这前一半也当作part数据读走后,multipart的解析绝对会出现问题,因此我们一次最多只能读取4KB-len(\r\n--boundary)+1的数据。

代码实现

MultipartReader

定义MultipartReader结构体:

 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
const bufSize = 4096	// 滑动窗口大小

type MultipartReader struct {
    // bufr是对Body的封装,方便我们预查看Body上的数据,从而确定part之间边界
    // 每个part共享这个bufr,但只有Body的读取指针指向哪个part的报文,
    // 哪个part才能在bufr上读取数据,此时其他part是无效的
	bufr                 *bufio.Reader		
    // 记录bufr的读取过程中是否出现io.EOF错误,如果发生了这个错误,
    // 说明Body数据消费完毕,表单报文也消费完,不需要再产生下一个part
	occurEofErr          bool
	crlfDashBoundaryDash []byte				//\r\n--boundary--
	crlfDashBoundary     []byte				//\r\n--boundary,分隔符
	dashBoundary         []byte				//--boundary
	dashBoundaryDash     []byte				//--boundary--
	curPart              *Part				//当前解析到了哪个part
	crlf                 [2]byte			//用于消费掉\r\n
}

//传入的r将是Request的Body,boundary会在http首部解析时就得到
func NewMultipartReader(r io.Reader, boundary string) *MultipartReader {
	b := []byte("\r\n--" + boundary + "--")
	return &MultipartReader{
		bufr:                 bufio.NewReaderSize(r, bufSize),	//将io.Reader封装成bufio.Reader
		crlfDashBoundaryDash: b,
		crlfDashBoundary:     b[:len(b)-2],
		dashBoundary:         b[2 : len(b)-2],
		dashBoundaryDash:     b[2:],
	}
}

因为所有Part都会在bufr上读取数据,前面part将属于它的数据消费掉之后,后续的part才能读取自己的数据。因此我们用curPart记录当前是哪个part占有了bufr,方便我们对其管理。

我们的滑动窗口大小为4KB,每次peek固定4KB大小的数据,也就意味着我们可能从bufr的底层Reader中一次最多读出4KB的数据,用来填充缓冲区。既然会对底层的Reader读取,就可能产生错误,对于非io.EOF错误,我们直接返回即可。但对于io.EOF错误,只是意味着bufr底层的Reader流中没有了数据,并不意味着bufr的缓存中没数据,因此我们需要记录是否出现了io.EOF错误,如果出现了这个错误,我们只需要将bufr缓存里的数据处理完即可。

NextPart方法:

 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
// https://www.gufeijun.com
// 生成下一个part Reader
func (mr *MultipartReader) NextPart() (p *Part, err error) {
	if mr.curPart != nil {
        // 将当前的Part关闭掉,即消费掉当前part数据,好让body的读取指针指向下一个part
        // 具体实现见后文
		if err = mr.curPart.Close(); err != nil {
			return
		}
		if err = mr.discardCRLF(); err != nil {
			return
		}
	}
    // 下一行就应该是boundary分割
	line, err := mr.readLine()
	if err != nil {
		return
	}
    // 到multipart报文的结尾了,直接返回
	if bytes.Equal(line, mr.dashBoundaryDash) {
		return nil, io.EOF
	}
	if !bytes.Equal(line, mr.dashBoundary) {
		err = fmt.Errorf("want delimiter %s, but got %s", mr.dashBoundary, line)
		return
	}
    // 这时Body已经指向了下一个part的报文
	p = new(Part)
	p.mr = mr
    // 前文讲到要将part的首部信息预解析,好让part指向消息主体,具体实现见后文
	if err = p.readHeader(); err != nil {
		return
	}
	mr.curPart = p
	return
}

// 消费掉\r\n
func (mr *MultipartReader) discardCRLF() (err error) {
	if _, err = io.ReadFull(mr.bufr, mr.crlf[:]); err == nil {
		if mr.crlf[0] != '\r' && mr.crlf[1] != '\n' {
			err = fmt.Errorf("expect crlf, but got %s", mr.crlf)
		}
	}
	return
}

// 读一行
func (mr *MultipartReader) readLine() ([]byte, error) {
	return readLine(mr.bufr)
}

// 直接利用了解析http报文首部的函数readHeader,很简单
func (p *Part) readHeader() (err error) {
	p.Header, err = readHeader(p.mr.bufr)
	return err
}

// 将当前part剩余的数据消费掉,防止其报文残存在Reader上影响下一个part
func (p *Part) Close() error {
	if p.closed {
		return nil
	}
	_, err := io.Copy(ioutil.Discard, p)
    p.closed = true	//标记状态为关闭
	return err
}

实现很简单,NextPart首先会将当前的Part关闭,Close方法会将当前Part中用户未消费的数据给消费掉,防止影响下一个Part的读取。接着就是读取一行,将boundary读出,切换到下一个part,并将该part的Header解析,readHeader是往期文章的辅助函数。这时bufr的读取指针自然指向了part的消息主体部分,解决了part的读取指针初始指向的问题。

下面的重头戏就是Part结构的Read方法如何保证读取的结束了。

Part

定义Part的结构体:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
type Part struct {
	Header           Header			// 存取当前part的首部
	mr               *MultipartReader
    // 下两者见前面的part报文
	formName         string
	fileName         string			// 当该part传输文件时,fileName不为空
	closed           bool			// part是否关闭
	substituteReader io.Reader		// 替补Reader
	parsed           bool			// 是否已经解析过formName以及fileName
}

字段都很好理解,主要讲讲substituteReader,如果它不为空,我们对Part的Read则优先交给substituteReader处理,主要是为了方便引入io.LimiteReader来凝练我们的代码。substituteReader不为nil的时机,就是已经能够确定这个part还剩下多少数据可读了。这个见下面代码就很容易理解。

Read方法:

 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
// https://www.gufeijun.com
func (p *Part) Read(buf []byte) (n int, err error) {
    // part已经关闭后,直接返回io.EOF错误
	if p.closed {
		return 0, io.EOF
	}
    // 不为nil时,优先让substituteReader读取
	if p.substituteReader != nil {
		return p.substituteReader.Read(buf)
	}
	bufr := p.mr.bufr
	var peek []byte
    //如果已经出现EOF错误,说明Body没数据了,这时只需要关心bufr还剩余已缓存的数据
	if p.mr.occurEofErr {	
		peek, _ = bufr.Peek(bufr.Buffered())	// 将最后缓存数据取出
    } else {
        //bufSize即bufr的缓存大小,强制触发Body的io,填满bufr缓存
		peek, err = bufr.Peek(bufSize)
        //出现EOF错误,代表Body数据读完了,我们利用递归跳转到另一个if分支
		if err == io.EOF {
			p.mr.occurEofErr = true
			return p.Read(buf)
		}
		if err != nil {
			return 0, err
		}
	}
    //在peek出的数据中找boundary
	index := bytes.Index(peek, p.mr.crlfDashBoundary)
    //两种情况:
    //1.即||前的条件,index!=-1代表在peek出的数据中找到分隔符,也就代表顺利找到了该part的Read指针终点,
    //	给该part限制读取长度即可。
    //2.即||后的条件,在前文的multipart报文,是需要boudary来标识报文结尾,然后已经出现EOF错误,
    //  即在没有多余报文的情况下,还没有发现结尾标识,说明客户端没有将报文发送完整,就关闭了链接,
    //  这时让substituteReader = io.LimitReader(-1),逻辑上等价于eofReader即可
	if index != -1 || (index == -1 && p.mr.occurEofErr) {
		p.substituteReader = io.LimitReader(bufr, int64(index))
		return p.substituteReader.Read(buf)
	}
    
    //以下则是在peek出的数据中没有找到分隔符的情况,说明peek出的数据属于当前的part
    //见上文讲解,不能一次把所有的bufSize都当作消息主体读出,还需要减去分隔符的最长子串的长度。
	maxRead := bufSize - len(p.mr.crlfDashBoundary) + 1
	if maxRead > len(buf) {
		maxRead = len(buf)
	}
	return bufr.Read(buf[:maxRead])
}

Body中有数据,我们就每次取固定bufSize滑动窗口大小的数据进行处理;如果表单报文读完、没有数据了即出现io.EOF错误,我们只需要将bufr最后的缓存处理完毕即可。对应于代码的if else分支。

Peek方法出现io.EOF错误有两种情况:①一种异常:客户端主动关闭连接,提前终止。②Body的数据我们正常消费完毕了。不管是那种情况,Body都不能再读取,我们接下来只需要关注已经缓存且未处理的内容,通过bufr.Peek(bufr.Buffered())将最后缓存取出。因为第一种情况是我们不希望看到的(报文没发送完整),第二种情况是正常情况,我们需要区分是那种原因导致了io.EOF,解决办法就是查看最后缓存中是否存在boundary(正常的multipart报文会以boundary标识结束),存在则说明报文发送完整了,是第二种情况。

Part的Read指针应该终止在哪,也对应两种情况,一种是正常碰到该part与下一个part的分隔符,另一种是出现异常错误比如客户端主动断开连接提前终止。对于第一种情况,当前part最多能读取index个字节的数据,即将分隔符前的数据读完,对于第二种情况我们不能再读取即最多能读0B的数据,也就是说不管是哪一种情况,我们都知道这个Read方法最多还能读多少字节,从而比较巧妙地用io.LimitReader进行统一处理,代码优雅许多。对应代码的36~39行。

代码已经写了详细的注释,强烈建议读者自行思考实现一遍,才能真正理会这个解析过程。

关于Part最硬核的操作已经完成,下面就是简单的工作。

为part提供获取formName以及fileName的方法,和http解析首部极为类似,就是简单的字符串处理,对比上述的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
// 获取FormName
func (p *Part) FormName() string {
	if !p.parsed {
		p.parseFormData()
	}
	return p.formName
}

// 获取FileName
func (p *Part) FileName() string {
	if !p.parsed {
		p.parseFormData()
	}
	return p.fileName
}

func (p *Part) parseFormData() {
	p.parsed = true
	cd := p.Header.Get("Content-Disposition")
	ss := strings.Split(cd, ";")
	if len(ss) == 1 || strings.ToLower(ss[0]) != "form-data" {
		return
	}
	for _, s := range ss {
		key, value := getKV(s)
		switch key {
		case "name":
			p.formName = value
		case "filename":
			p.fileName = value
		}
	}
}

func getKV(s string) (key string, value string) {
	ss := strings.Split(s, "=")
	if len(ss) != 2 {
		return
	}
	return strings.TrimSpace(ss[0]), strings.Trim(ss[1], `"`)
}

比较简单,不再赘述。

request.go

MultipartReader已经搞定,万事具备,只欠东风。下面要做的就是从http协议的首部字段中解析出boundary,然后交给MultipartReader即可。

在request.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
type Request struct {
    // 本章给Request新增的属性
	contentType    string
	boundary       string
    // ....
}

func readRequest(c *conn) (r *Request, err error) {
	// ....
    // 记得在readRequest中调用parseContentType
	r.parseContentType()
	return r, nil
}

// boundary是存取在Content-Type字段中
func (r *Request) parseContentType() {
	ct := r.Header.Get("Content-Type")
	//Content-Type: multipart/form-data; boundary=------974767299852498929531610575
    //Content-Type: multipart/form-data; boundary=""------974767299852498929531610575"
	//Content-Type: application/x-www-form-urlencoded
	index := strings.IndexByte(ct, ';')
	if index == -1 {
		r.contentType = ct
		return
	}
	if index == len(ct)-1 {
		return
	}
	ss := strings.Split(ct[index+1:], "=")
	if len(ss) < 2 || strings.TrimSpace(ss[0]) != "boundary" {
		return
	}
    // 将解析到的CT和boundary保存在Request中
	r.contentType, r.boundary = ct[:index], strings.Trim(ss[1],`"`)
	return
}

// 得到一个MultipartReader
func (r *Request) MultipartReader()(*MultipartReader,error){
	if r.boundary==""{
		return nil,errors.New("no boundary detected")
	}
	return NewMultipartReader(r.Body,r.boundary),nil
}

测试

测试很简单,对于前段传输的multipart表单,如果part传输的是文本,我们将其输出到中断,如果是文件,我们对文件保存到硬盘上。

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
package main

import (
	"example/httpd"
	"fmt"
	"io"
	"log"
	"os"
)

type myHandler struct{}

func (*myHandler) ServeHTTP(w httpd.ResponseWriter, r *httpd.Request) {
	mr, err := r.MultipartReader()
	if err != nil {
		log.Println(err)
		return
	}
	var part *httpd.Part
label:
	for {
		part, err = mr.NextPart()
		if err != nil {
			break
		}
        // 判断是文本part还是文件part
		switch part.FileName() {
		case "":	//文本
			fmt.Printf("FormName=%s, FormData:\n", part.FormName())
            // 输出到终端
			if _, err = io.Copy(os.Stdout, part); err != nil {
				break label
			}
			fmt.Println()
		default:	//文件
            // 打印文件信息
			fmt.Printf("FormName=%s, FileName=%s\n", part.FormName(), part.FileName())
			var file *os.File
			if file, err = os.Create(part.FileName()); err != nil {
				break label
			}
			if _, err = io.Copy(file, part); err != nil {
				file.Close()
				break label
			}
			file.Close()
		}
	}
	if err != io.EOF {
		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())
}

利用curl测试我们的接口:

1
2
#发送两个文本,上传两个文件:1.md以及2.md
$ curl -F "username=gu" -F "password=123" -F "file1=@1.md" -F "file2=@2.md" http://127.0.0.1

服务端输出:

FormName=username, FormData:
gu
FormName=password, FormData:
123
FormName=file1, FileName=1.md
FormName=file2, FileName=2.md

查看服务端程序所在目录,正确保存了1.md以及2.md文件,且保存的文件hash值与原文件相同。

本文完成了multipart/form-data表单的解析,难度比较高,需要多加理解。但我们提供的解析表单API依旧比较繁琐,下一文会在今天的基础上,再做封装。

系列目录