本系列通过解析fasthttp(v1.16.0)源码学习HTTP/1.1

前言

上一篇文章我们学习了HTTP/1.1的请求行(Request Line),下面我们继续学习请求头的解析。

请求头格式

HTTP/1.1消息格式如下:

1
2
3
4
  HTTP-message   = start-line
                   *( header-field CRLF )
                   CRLF
                   [ message-body ]
  • start-line: 起始行
  • *( header-field CRLF ): 头字段+分隔符(\r\n)
  • CRLF: 空行
  • [ message-body ]: 可选的消息体

所有HTTP/1.1消息均包含一个起始行,其后是一系列头字段,由分隔符(\r\n)隔开,接着是一个空行(\r\n),最后是可选的消息体。

上一篇文章学习的请求行就是起始行的一种,还有一种是状态行(Status Line),我们会在之后讲解。

我们可以发现,每一个头字段后面都需要跟随一个分隔符(\r\n),当某一行只有分隔符(\r\n)时,就意味着请求头数据已发送完毕。使用httpie就可以轻松查看一个真实的请求头数据:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ http "www.baidu.com" -p H
# 请求行
GET / HTTP/1.1
# 请求头
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: www.baidu.com
User-Agent: HTTPie/1.0.3

解析请求头

我们再来看看fasthttp是如何解析请求头的。当fasthttp读取到完整的请求头数据后,开始解析请求头,并返回请求头的字节数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func (h *RequestHeader) parse(buf []byte) (int, error) {
	m, err := h.parseFirstLine(buf)
	if err != nil {
		return 0, err
	}

	h.rawHeaders, _, err = readRawHeaders(h.rawHeaders[:0], buf[m:])
	if err != nil {
		return 0, err
	}
	var n int
	n, err = h.parseHeaders(buf[m:])
	if err != nil {
		return 0, err
	}
	return m + n, nil
}

我们看到了熟悉的parseFirstLine,解析请求行,接着继续读取并保存请求头的原始数据h.rawHeaders, _, err = readRawHeaders(h.rawHeaders[:0], buf[m:])。在readRawHeaders中我们可以看到(完整源码):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func readRawHeaders(dst, buf []byte) ([]byte, int, error) {
	n := bytes.IndexByte(buf, '\n')
	if (n == 1 && buf[0] == '\r') || n == 0 {
		// empty headers
		return dst, n + 1, nil
	}
	for {
		m = bytes.IndexByte(b, '\n')
		// ...
		m++
		// ...
		if (m == 2 && b[0] == '\r') || m == 1 {
			// ...
			return dst, n, nil
		}
	}
}

目的就是找到单独的\r\n或者\n,一旦找到,就表示没有更多的请求头数据了。从源码中我们可以发现fasthttp兼容了以\n作为分隔符的数据格式

最后是解析请求头信息n, err = h.parseHeaders(buf[m:]),解析成功后,返回第一行数据和请求头的总字节数。parseHeaders代码比较长,就不再全部贴出来。

fasthttp在解析请求头时,使用了headerScanner这个结构体,调用next方法一行一行解析。

针对指定的头字段:HostUserAgentContent-TypeContent-LengthConnectionTransfer-Encodingfasthttp会做一些特殊操作,我们会一一进行解析。其他头字段信息会存入h.h(源码)中:h.h = appendArgBytes(h.h, s.key, s.value, argsHasValue)

我们先深入分析headerScanner.next()

头字段

首先我们看看头字段的结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
  header-field   = field-name ":" OWS field-value OWS

  field-name     = token
  field-value    = *( field-content / obs-fold )
  field-content  = field-vchar [ 1*( SP / HTAB ) field-vchar ]
  field-vchar    = VCHAR / obs-text

  obs-fold       = CRLF 1*( SP / HTAB )
                 ; obsolete line folding
                 ; see Section 3.2.4

每个头字段均由不区分大小写的字段名,后跟冒号(:),可选的前导空白(OWS),字段值和可选的尾随空白(OWS)组成。

注:OWS指的是optional whitespace详情链接

fasthttpheaderScanner就是头部扫描器,负责扫描请求头数据,解析出头字段的字段名(key)和字段值(value),每次调用next()都解析一行数据。

根据标准,头字段名称紧跟着一个:,所以需要根据:的位置确认头字段名称的值。这里fasthttp加了一个处理:之前不能有\n,因为此时这个头字段肯定是非法的。

解析出头字段名称后,fasthttp根据配置看需不需要标准化头名称,normalizeHeaderKey(s.key, s.disableNormalizing),这是为了统一客户端发送的请求头字段名称格式,防止出现类似cONTENT-lenGTH的字段名而在使用时无法匹配的情况。

一般情况下,每个头字段的值在一行内,但是也有可能放在多行里(obs-fold),举个例子:

1
2
3
4
5
6
# 一行
Header: value1, value2

# 多行
Header: value1,
        value2

这两种格式的值是等价的,想深入了解的可以查看这里fasthttp兼容了这种情况,然后对多行值进行格式化s.value, s.b, s.hLen = normalizeHeaderValue(s.value, oldB, s.hLen)

最终获取到s.keys.value

处理头字段键值对

获取到头字段键值对后,对于一般的头字段,fasthttp将他们存储在h.h []argsKV切片中(h.h = appendArgBytes(h.h, s.key, s.value, argsHasValue)),argsKV具体结构如下:

1
2
3
4
5
type argsKV struct {
	key     []byte
	value   []byte
	noValue bool
}

为什么不直接存在map中呢?我们之后会讲解,主要是为了延迟解析以及内存复用。现在就想知道原因的可以查看youtu.be 13:42

接下来我们讲讲几个特殊的头字段,以及fasthttp对它们的处理。不过在此之前,我们需要看懂这行代码:

1
switch s.key[0] | 0x20

翻译一下就是将s.key[0],即头字段名称的首字母,转为小写。原理也很简单,用一段代码解释:

1
2
3
4
5
A := 0b01000001
a := 0b01100001
x := 0b00100000 // 0x20
println(A|x == a) // true
println(a|x == a) // true

Host

Host头字段(详情)提供了目标URI的主机和端口信息,使服务器能够区分资源,同时为单个IP地址上的多个主机名的请求提供服务。Host具有指定的格式:

1
  Host = uri-host [ ":" port ] ; Section 2.7.1

RFC7230中指明Host是必须字段。若Host不合法(未提供,提供多个,格式非法),服务端必须响应400 Bad Request

通过caseInsensitiveCompare(s.key, strHost)比较头字段名称和常量strHost,确定是否将其存入请求头的host字段:h.host = append(h.host[:0], s.value...)

注:caseInsensitiveCompare也用到了0x20的技巧(源码)。

UserAgent

User-Agent头字段(详情)包含有关发起请求的用户代理的信息,服务器通常使用该信息来帮助识别报告的互操作性问题的范围,解决或调整响应以避免特定的用户代理限制以及进行分析有关浏览器或操作系统的使用。用户代理应该在每个请求中发送一个User-Agent字段。

它的结构如下:

1
  User-Agent = product *( RWS ( product / comment ) )

该字段的值直接存入请求头的userAgent字段中。

ContentType

Content-Type头字段(详情)指定了请求的媒体类型

常见的例子有:

1
2
  Content-Type: text/html; charset=ISO-8859-4
  Content-Type: application/json; charset=utf-8

该字段的值直接存入请求头的contentType字段中。

ContentLength

当请求中没有Transfer-Encoding头字段时,可以设置Content-Length头字段,为潜在的消息体提供预期的大小(八位字节的十进制数);若包含Transfer-Encoding头字段,则无法设置Content-Length

fasthttp使用parseContentLength(源码)解析具体的数值,并处理了溢出的情况,避免了通过协议元素长度产生的攻击

若解析成功,fasthttp也会将原始的字节数据保存在h.contentLengthBytes = append(h.contentLengthBytes[:0], s.value...)中。

Connection

Connection头字段(详情)允许客户端控制当前连接。

Connection值是不区分大小写的,所以fasthttp使用bytes.Equal(s.value, strClose)来比较连接值是否为常量strClose("close"),并保存到connectionClose字段中,同时将原始的字节数据保存在h.h = appendArgBytes(h.h, s.key, s.value, argsHasValue)中。

TransferEncoding

Transfer-Encoding头字段(详情)列出与已经(或将要)应用于有效载荷主体以形成消息主体的传输编码序列相对应的传输编码名称。传输编码在这里定义,有兴趣的读者可以自行了解。

我们看看fasthttp的处理逻辑:

1
2
3
4
if !bytes.Equal(s.value, strIdentity) {
	h.contentLength = -1
	h.h = setArgBytes(h.h, strTransferEncoding, strChunked, argsHasValue)
}

因为identity已经被移除,所以fasthttp忽略了identity,且根据我们在Content-Length中学到的知识,此时应该忽略contentLength,所以将其设为-1,对应了Content-Length头字段的if h.contentLength != -1 {...}(源码)处理。最后把Transfer-Encoding头字段的值设为chunked

收尾操作

解析完所有的头字段后,fasthttp进行了一些收尾操作:

1
2
3
4
5
6
7
8
if h.contentLength < 0 {
	h.contentLengthBytes = h.contentLengthBytes[:0]
}
if h.noHTTP11 && !h.connectionClose {
	// close connection for non-http/1.1 request unless 'Connection: keep-alive' is set.
	v := peekArgBytes(h.h, strConnection)
	h.connectionClose = !hasHeaderValue(v, strKeepAlive)
}
  1. 未设置h.contentLength时,清空h.contentLengthBytes
  2. 不是HTTP/1.1的情况下重新设置h.connectionClose的值(排除Connection: keep-alive的情况)

总结

请求头解析部分到这里就告一段落了,我们学习了不少请求头相关的知识,稍微总结:

  • 每个请求头是根据分隔符(\r\n)分隔的,但可以包含多行值
  • 空行代表请求头数据的终止
  • 一些具体的请求头概念
  • 使用scanner解析数据流
  • 字母字符比较时使用0x20的小技巧

敬请期待之后的系列文章! 👋