前文进行了Request首部字段的解析,已经完整的支持了GET请求。对于POST请求来说,其传送的表单数据在HTTP请求的报文主体body部分,服务端需要进一步IO操作才能读出,为了提高性能我们将POST表单的解析权交给用户,为此我们给Request结构体封装一个Body字段,作为IO的接口。本文需要多加理解,难度较高。
项目结构
今天的项目架构如下:
3
|-- go.mod
|-- httpd
| |-- chunk.go
| |-- conn.go
| |-- header.go
| |-- request.go
| |-- response.go
| |-- server.go
|-- main.go
增加了chunk.go
文件,用于解决http传输中的chunk编码问题,同时相较于上文对request.go
做出小幅修改。
代码传送门
如何知道报文主体的长度
以下一段http协议post请求报文为例:
1
2
3
4
5
6
7
8
9
10
|
POST /index?name=gu HTTP/1.1\r\n #请求行
Content-Type: text/plain\r\n #此处至报文主体为首部字段
User-Agent: PostmanRuntime/7.28.0\r\n
Host: 127.0.0.1:8080\r\n
Accept-Encoding: gzip, deflate, br\r\n
Connection: keep-alive\r\n
Cookie: uuid=12314753; tid=1BDB9E9; HOME=1\r\n
Content-Length: 18\r\n
\r\n
hello,I am client! #报文主体
|
报文主体与首部字段之间通过两个\r\n
(CRLF)为分隔。前文中我们框架已经解析出了首部字段,本文的任务就是解析报文主体部分。
报文主体就是用于携带客户端的额外信息,由于报文主体中能包含任何信息,更是不限长度,所以http协议就不能像首部字段一样以某个字符如CRLF为边界,来标记报文主体的范围。那么客户端是怎么保证服务端能够完整的不多不少的读出报文主体数据呢?
其实很简单,我们只要在首部字段中用一项标记报文主体长度,就解决了问题。就以上述报文为例,首部字段中包含一个Content-Length
字段,其值为18,服务端随后从tcp连接中读取18个字节的数据,就正好把hello,I am client!
这18B的数据读出,恰恰满足我们的需要。
当然除了使用Content-Length之外,http还可以使用chunk编码的方式,这里先埋个伏笔。
需求分析
还是得明确一点,http报文的头部部分很短,上一章中框架将这部分读取并解析后直接交给用户,既省时也省力。
但问题是http的报文主体是不限长度的,框架无脑将这些字节数据读出来,是很糟糕的设计。
最明智的做法是,将这个解析的主动权交给用户,框架只提供方便用户读取解析报文主体的接口而已。
因此在上文中,Request中存在一个Body(io.Reader接口类型)字段,用户对Body的读取就正好能读出对应的报文主体。
仅仅让Body能读取到报文主体还不行,为了防止读多或读少,用户还需要限定只读取上述的Content-Length
的长度,让用户手动指定读取长度的方式很麻烦也极为出错,我们想要的是能够达到以下效果:
1
2
3
4
5
6
7
8
|
func (*myHandler) ServeHTTP(w httpd.ResponseWriter,r *httpd.Request){
// 不需要指定长度就能将报文主体不多不少读出
buf,err:=ioutil.ReadAll(r.Body)
if err!=nil{
return
}
fmt.Printf("%s\n",buf)
}
|
要保证Body达到我们期望的行为,这就意味着Body提供的Read方法能够保证以下两点:
- 对Body读取的这个指针一开始应该指向报文主体的开头,也就是说不能将报文主体前面的首部字段读出。规定了读取的起始。
- 多个http的请求相继在tcp连接上传输,当前http请求的Body就应该只能读取到当前请求的报文主体,即只能读取
Content-Length
长度的数据。规定了读取的结束。
如果单纯保证第一点,完全可以用上一文中conn
结构体的bufr
字段作为Body,因为我们已经将首部字段从bufr中读出,下一次对bufr的读取自然会从报文主体开始。
但这样做,第二点就无法满足。在go语言中,对一个io.Reader
的读取,如果返回io.EOF
错误代表我们将这个Reader中的所有数据读取完了。ioutil.ReadAll
就是利用了这个特点,如果不出现一些异常错误,它会不停的读取数据直至出现io.EOF。而一个网络连接net.Conn
,只有在对端主动将连接关闭后,对net.Conn的Read才会返回io.EOF错误。所以就意味着如果服务端出现以下代码:
1
2
|
conn,_ := listener.Accept()
ioutil.ReadAll(conn)
|
只要客户端不主动关闭连接,我们也不设置超时事件,我们会永久的阻塞在第二行处。
将bufr设置成Body就有这个问题,对bufr的读取最终会落到底层tcp连接(net.Conn)的读取,就算我们把当前请求的报文主体读取出来了,只要客户端不关闭连接,我们会永久阻塞。要是客户端继续发送了下一个http请求,我们当前的body还会顺带把下一个请求报文读出来,这绝对是不满足我们的需求的。
所以必须保证Body这个Reader在将报文主体读取完毕即读取Content-Length
长的数据后,立即返回一个io.EOF错误,通知ReadAll函数我们已经读取完毕了!
现在的任务就是好好思量,Body的Read
方法该如何设置,从而达到想要的效果。
代码实现
思维敏锐的朋友可能已经有了解决方案,那就是用io.LimitReader
!
上文用它来解决无限解析首部字段的问题,这里又派上用场。我们只需要将N设置成Content-Length,在最多读取N字节的长度后,LimitReader就会返回io.EOF错误,问题也迎刃而解。
更改request.go
的setupBody方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
func (r *Request) setupBody() {
if r.Method != "POST" && r.Method != "PUT" {
r.Body = &eofReader{} //POST和PUT以外的方法不允许设置报文主体
}else if cl := r.Header.Get("Content-Length"); cl != "" {
//如果设置了Content-Length
contentLength, err := strconv.ParseInt(cl, 10, 64)
if err != nil {
r.Body = &eofReader{}
return
}
// 允许Body最多读取contentLength的数据
r.Body = io.LimitReader(r.conn.bufr, contentLength)
}else{
r.Body = &eofReader{}
}
}
|
对于非PUT以及POST方法,按照http协议的规定是不允许设置报文主体的,所以我们将Body设置成eofReader
就好,对它的Read只会返回io.EOF错误。
chunk
除了通过Content-Length通知对端报文主体的长度外,http1.1引入了新的编码方式——chunk编码(分块传输编码),顾名思义就是会将报文主体分块后进行传输。
利用Content-Length存在一个问题:我们需要事先知道待发送的报文主体长度,而有些时候我们是希望数据边产生边发送,根本无从知道将要发送多少的数据。因此http1.1相较http1.0除了长连接之外的另一大改进就是引入了chunk编码,客户端需要在请求头部设置Transfer-Encoding: chunked
。
chunk编码的示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
HTTP/1.1 200 OK\r\n
Content-Type: text/plain\r\n
Transfer-Encoding: chunked\r\n
\r\n
# 以下为body
17\r\n #chunk size
hello, this is chunked \r\n #chunk data
D\r\n #chunk size
data sent by \r\n #chunk data
7\r\n #chunk size
client!\r\n #chunk data
0\r\n\r\n #end
|
chunk编码格式可以概括为[chunk size][\r\n][chunk data][\r\n][chunk size][\r\n][chunk data][\r\n][chunk size=0][\r\n][\r\n]。
每一分块中包含两部分:
- 第一部分为chunk size,代表该块chunk data的长度,利用16进制表示。
- 第二部分为chunk data,该区域存储有效载荷,实际欲传输的数据存储在这部分。
chunk size与chunk data之间都利用\r\n
作为分割,通过0\r\n\r\n
来标记报文主体的结束。
chunkReader
显然,我们对Body的设置就需要分类讨论了!
抽象出一个chunkReader结构体,当客户端利用chunk传输报文主体时,我们将Body设置成chunkReader即可。那么这个chunkReader需要满足什么功能呢?
依旧是满足上述Body的两点:规定Body读取的起始以及结尾。起始已经满足,重点考虑结尾的设计,我们这就不能使用LimitReader了,既然chunk编码的结束标志是0\r\n\r\n
,那么我们的Read方法在碰到0\r\n\r\n
时返回io.EOF错误即可,不允许继续向下读,因为后续的字节数据是属于下一个http请求。
如果仅做到将报文主体不多不少读出,但读取的数据包含chunk编码的控制信息(chunk size以及CRLF),而我们只关心chunk data部分,还需要用户手动解码,这也是不可取的。
所以我们的chunkReader还需要具有解码chunk的功能,保证用户调用到的Read方法只读到有效载荷(chunk data):hello, this is chunked data sent by client!
。
新建chunk.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
67
68
69
70
71
72
73
74
75
76
|
type chunkReader struct {
//当前正在处理的块中还剩多少字节未读
n int
bufr *bufio.Reader
//利用done来记录报文主体是否读取完毕
done bool
crlf [2]byte //用来读取\r\n
}
func (cw *chunkReader) Read(p []byte) (n int, err error) {
// 报文主体读取完后,不允许再读
if cw.done {
return 0, io.EOF
}
// 当前块没数据了,准备读取下一块
if cw.n == 0 {
// 先获取chunk Size
cw.n, err = cw.getChunkSize()
if err != nil {
return
}
}
// 获取到的chunkSize为0,说明读到了chunk报文结尾
if cw.n == 0 {
cw.done = true
//将最后的CRLF消费掉,防止影响下一个http报文的解析
err = cw.discardCRLF()
return
}
//如果当前块剩余的数据大于欲读取的长度
if len(p) <= cw.n {
n, err = cw.bufr.Read(p)
cw.n -= n
return n, err
}
//如果当前块剩余的数据不够欲读取的长度,将剩余的数据全部取出返回
n, _ = io.ReadFull(cw.bufr, p[:cw.n])
cw.n = 0
//记得把每个chunkData后的\r\n消费掉
if err = cw.discardCRLF(); err != nil {
return
}
return
}
func (cw *chunkReader) discardCRLF() (err error) {
if _, err = io.ReadFull(cw.bufr, cw.crlf[:]); err == nil {
if cw.crlf[0] != '\r' || cw.crlf[1] != '\n' {
return errors.New("unsupported encoding format of chunk")
}
}
return
}
func (cw *chunkReader) getChunkSize() (chunkSize int, err error) {
line, err := readLine(cw.bufr)
if err != nil {
return
}
//将16进制换算成10进制
for i := 0; i < len(line); i++ {
switch {
case 'a' <= line[i] && line[i] <= 'f':
chunkSize = chunkSize*16 + int(line[i]-'a') + 10
case 'A' <= line[i] && line[i] <= 'F':
chunkSize = chunkSize*16 + int(line[i]-'A') + 10
case '0' <= line[i] && line[i] <= '9':
chunkSize = chunkSize*16 + int(line[i]-'0')
default:
return 0, errors.New("illegal hex number")
}
}
return
}
|
我们用一个结构体成员n来记录当前块剩余未处理的的chunk data长度,一旦该块的数据读取完毕后就通过chunk size获取下一块的长度。
chunk编码的控制信息全部在Read方法内部消费掉了,外界能读取的仅仅只有chunk data。
别忘了更改request.go
中的setupBody
方法:
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
|
// 对端是否使用了chunk编码
func (r *Request) chunked() bool {
te := r.Header.Get("Transfer-Encoding")
return te == "chunked"
}
func (r *Request) setupBody() {
if r.Method != "POST" && r.Method != "PUT" {
r.Body = &eofReader{} //POST和PUT以外的方法不允许设置报文主体
}else if r.chunked(){
r.Body = &chunkReader{bufr: r.conn.bufr}
r.fixExpectContinueReader() //见下文
} else if cl := r.Header.Get("Content-Length"); cl != "" {
//如果设置了Content-Length
contentLength, err := strconv.ParseInt(cl, 10, 64)
if err != nil {
r.Body = &eofReader{}
return
}
r.Body = io.LimitReader(r.conn.bufr, contentLength)
r.fixExpectContinueReader()
}else{
r.Body = &eofReader{}
}
}
|
做到这一点后,不论客户端时使用Content-Length还是chunk编码,用户看到的Body这个Reader都具有一样的行为即不多不少读到有效载荷,用户不需要自己手动分类处理,这就是封装的思想以及go语言接口的妙用。
细心的你应该发现了我们多出了一个fixExpectContinueReader
方法。这是因为为了防止资源的浪费,有些客户端在发送完http首部之后,发送body数据前,会先通过发送Expect: 100-continue
查询服务端是否希望接受body数据,服务端只有回复了HTTP/1.1 100 Continue
客户端才会再次发送body。因此我们也要处理这种情况:
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
|
type expectContinueReader struct{
// 是否已经发送过100 continue
wroteContinue bool
r io.Reader
w *bufio.Writer
}
func (er *expectContinueReader) Read(p []byte)(n int,err error){
//第一次读取前发送100 continue
if !er.wroteContinue{
er.w.WriteString("HTTP/1.1 100 Continue\r\n\r\n")
er.w.Flush()
er.wroteContinue = true
}
return er.r.Read(p)
}
func (r *Request) fixExpectContinueReader() {
if r.Header.Get("Expect") != "100-continue" {
return
}
r.Body = &expectContinueReader{
r: r.Body,
w:r.conn.bufw,
}
}
|
实现也很简单,一旦发现客户端的请求报文的首部中存在Expect: 100-continue
,那么我们在第一次读取body时,也就意味希望接受报文主体,expectContinueReader
会自动发送HTTP/1.1 100 Continue
。
我们的框架目前依旧存在一个问题,如果用户在Handler的回调函数中没有去读取Body的数据,就意味着处理同一个socket连接上的下一个http报文时,Body未消费的数据会干扰下一个http报文的解析。所以我们的框架还需要在Handler结束后,将当前http请求的数据给消费掉。给Request增加一个finishRequest方法,以后的一些善尾工作都将交给它:
1
2
3
4
5
6
7
8
9
|
func (r *Request) finishRequest() (err error){
//将缓存中的剩余的数据发送到rwc中
if err=r.conn.bufw.Flush();err!=nil{
return
}
//消费掉剩余的数据
_,err = io.Copy(ioutil.Discard,r.Body)
return err
}
|
在conn.go的serve方法中调用finishRequest:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
func (c *conn) serve(){
defer func() {
if err:=recover();err!=nil{
log.Printf("panic recoverred,err:%v\n",err)
}
c.close()
}()
//http1.1支持keep-alive长连接,所以一个连接中可能读出
//多个请求,因此实用for循环读取
for{
req,err:=c.readRequest()
if err!=nil{
handleErr(err,c)
return
}
res:=c.setupResponse()
c.svr.Handler.ServeHTTP(res,req)
if err=req.finishRequest();err!=nil{
return
}
}
}
|
测试
目前已经可以很方便的解析报文主体了,我们的测试代码如下:
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
|
package main
import (
"example/httpd"
"fmt"
"io"
"io/ioutil"
)
type myHandler struct{}
// echo回显服务器,将客户端的报文主体原封不动返回
func (*myHandler) ServeHTTP(w httpd.ResponseWriter, r *httpd.Request) {
buf, err := ioutil.ReadAll(r.Body)
if err != nil {
return
}
const prefix = "your message:"
io.WriteString(w, "HTTP/1.1 200 OK\r\n")
io.WriteString(w, fmt.Sprintf("Content-Length: %d\r\n", len(buf)+len(prefix)))
io.WriteString(w, "\r\n")
io.WriteString(w, prefix)
w.Write(buf)
}
func main() {
svr := &httpd.Server{
Addr: "127.0.0.1:80",
Handler: new(myHandler),
}
panic(svr.ListenAndServe())
}
|
利用curl测试,使用Content-Type方式:
1
2
3
4
5
6
7
8
|
# -d用于加上报文主体
curl -d "hello, this is chunked message from client!" http://127.0.0.1 -i
输出:
HTTP/1.1 200 OK
Content-Length: 56
your message:hello, this is chunked message from client!
|
使用chunk编码的方式:
1
2
3
4
5
6
7
|
$ curl -H "Transfer-Encoding: chunked" -d "hello, this is chunked message from client!" http://127.0.0.1 -i
输出:
HTTP/1.1 200 OK
Content-Length: 56
your message:hello, this is chunked message from client!
|
不论是用哪一种方法传输报文主体,服务端都能够正确的解析。
客户端的Form表单以报文主体为载体,所以Body的设置是解析Form表单的基础,下一文将对两种表单:application/x-www-form-urlencoded
以及multipart/form-data
进行解析。
系列目录