go语言从0实现net/http标准库(四):multipart表单
前文对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请求存在两种表单:
-
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即可,我们在下一讲中简要介绍。
-
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>
上述代码除了传输
username
和password
文本信息外,还额外上传两个文件。顾名思义,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):
|
|
先通过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结构体:
|
|
因为所有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
方法:
|
|
实现很简单,NextPart首先会将当前的Part关闭,Close方法会将当前Part中用户未消费的数据给消费掉,防止影响下一个Part的读取。接着就是读取一行,将boundary读出,切换到下一个part,并将该part的Header解析,readHeader
是往期文章的辅助函数。这时bufr的读取指针自然指向了part的消息主体部分,解决了part的读取指针初始指向的问题。
下面的重头戏就是Part
结构的Read方法如何保证读取的结束了。
Part
定义Part的结构体:
|
|
字段都很好理解,主要讲讲substituteReader
,如果它不为空,我们对Part的Read则优先交给substituteReader
处理,主要是为了方便引入io.LimiteReader
来凝练我们的代码。substituteReader
不为nil的时机,就是已经能够确定这个part还剩下多少数据可读了。这个见下面代码就很容易理解。
Read方法:
|
|
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首部字段报文自行观看:
|
|
比较简单,不再赘述。
request.go
MultipartReader已经搞定,万事具备,只欠东风。下面要做的就是从http协议的首部字段中解析出boundary,然后交给MultipartReader即可。
在request.go中增加如下方法:
|
|
测试
测试很简单,对于前段传输的multipart表单,如果part传输的是文本,我们将其输出到中断,如果是文件,我们对文件保存到硬盘上。
main.go代码:
|
|
利用curl测试我们的接口:
|
|
服务端输出:
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依旧比较繁琐,下一文会在今天的基础上,再做封装。
系列目录
- 01、go语言从0实现net/http标准库(序):概述
- 02、go语言从0实现net/http标准库(一):框架骨干
- 03、go语言从0实现net/http标准库(二):Request首部字段
- 04、go语言从0实现net/http标准库(三):Request设置Body
- 05、go语言从0实现net/http标准库(四):multipart表单
- 06、go语言从0实现net/http标准库(五):表单API封装
- 07、go语言从0实现net/http标准库(六):response
- 08、go语言从0实现net/http标准库(终):静态路由
- 原文作者:辜飞俊
- 原文链接:https://gufeijun.com/post/httpframe/5/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。
![](https://i.loli.net/2021/07/07/fhS9OH5wyjY7Mzk.png)
![](https://i.loli.net/2021/07/07/MwL6SOzIPhVbYy7.png)