[博客翻译]HTTP服务器如何计算内容长度?


原文地址:https://aarol.dev/posts/go-contentlength/


任何实现过简单 HTTP 1.1 服务器的人都会告诉你,它其实是一个非常简单的协议。基本上,它是一个文本文件,有一些特定的规则使其更容易解析。

所有 HTTP 请求看起来都是这样的:

GET /path HTTP/1.1\r\n
Host: aarol.dev\r\n
Accept-Language: en,fi-FI\r\n
Accept-Encoding: gzip, deflate\r\n
\r\n

第一行是“请求行”,包含请求的方法、路径和 HTTP 版本。接下来的行是头部,每行以“回车换行符”字符终止。还有一个额外的 CRLF 来标记头部分的结束。之后,消息体可以包含你想发送的任何数据。

下面是一个使用原始 TCP 套接字作为客户端的简单 Go 程序来说明这一点:

func main() {
	// 设置一个 HTTP 服务器来响应路径 "/test"
	http.HandleFunc("GET /test", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("Hello"))
		w.Write([]byte(" world!"))
	})
	go http.ListenAndServe("localhost:2024", nil)

	// 使用 TCP 连接到服务器
	conn, err := net.Dial("tcp", "localhost:2024")
	if err != nil {
		panic(err)
	}
	// 写入 HTTP 请求(没有正文,只有请求行和 Host 头)
	_, err = conn.Write([]byte("GET /test HTTP/1.1\r\nHost: localhost\r\n\r\n"))
	if err != nil {
		panic(err)
	}

	buf := make([]byte, 1024) // 1 kb
	// 读取响应
	n, err := conn.Read(buf)
	if err != nil {
		panic(err)
	}
	fmt.Println(string(buf[:n]))
}

当我尝试时,得到了以下响应:

HTTP/1.1 200 OK
Date: Sun, 06 Oct 2024 14:51:13 GMT
Content-Length: 12
Content-Type: text/plain; charset=utf-8

Hello world!

关于这一点有一个有趣之处,即 Content-Length 头部其值为 12。这正好是 "Hello world!" 在 UTF-8 编码中的长度。在上面的 Go 代码中,写响应分为两个部分:首先我们写 "Hello",然后写 " world!"。请注意,我们无需调用任何函数来写入头部,但它们仍然存在于响应中。在 Go 的 http 包中,如果在调用 w.Write() 之前没有调用 w.WriteHeader(),则状态码 200 和头部将自动写入。此后的任何 w.WriteHeader() 调用都会无用,并且输出警告。

请记住,在 HTTP 中头部始终在正文之前写入。在没有将 “Hello” 写入连接之前,服务器已经知道了响应的长度是如何可能的?如果我想写一个 “Hello”,然后再写一千个感叹号怎么办?或者一百万个呢?服务器是否需要在发送之前知道每个响应的长度?这意味着每个响应都需要在整个处理器期间保留在内存中。

我想弄清楚这个问题,而且并不需要深入研究太多。我在标准库 net/http/client.go 文件中找到了这条 精彩评论。基本上,这条评论说如果响应足够小以适应单个“分块缓冲区”,则可以很容易地计算其长度,并一次性发送。如果响应大于缓冲区,则分段发送。在实践中意味着什么?我已经修改了上面的代码来展示这一点:

func main() {
	http.HandleFunc("GET /test", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("Hello"))
		w.Write([]byte(strings.Repeat("!", 3000)))
	})

	go http.ListenAndServe("localhost:2024", nil)
	// 省略所有错误处理以使代码简洁
	conn, _ := net.Dial("tcp", "localhost:2024")

	conn.Write([]byte("GET /test HTTP/1.1\r\nHost: localhost\r\n\r\n"))

	buf := make([]byte, 1024) // 1 kb
	for {
		conn.SetReadDeadline(time.Now().Add(1 * time.Second))
		n, err := conn.Read(buf)
		if err != nil {
			break
		}

		fmt.Println(string(buf[:n]))
	}
}

处理程序现在返回 “Hello!!!!!!!!!!! …” 加上 3000 个感叹号。这大于配置的分块大小,因此我们可以看到会发生什么。这是响应内容:

HTTP/1.1 200 OK
Date: Sun, 06 Oct 2024 16:43:28 GMT
Content-Type: text/plain; charset=utf-8
Transfer-Encoding: chunked

800
Hello!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!    !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
3bd
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!        
0

响应中现在已经没有 Content-Length 头部了。相反,我们现在有了一个 Transfer-Encoding: chunked。我们的消息被“分块”发送,以多个部分形式传送,因此服务器不需要一次将整个东西放入内存中。真聪明!

响应数据的第一行现在是一个数字 “800”。为什么会是 800?实际上,这个数字是十六进制数,0x800 是十进制中的 2048。同样,0x3bd 是十进制中的 957。2048+957=3005,这就是我们消息的完整长度!在实际消息之前先发送消息长度是一种常用的高效传输未知长度数据的方法。例如 Redis 协议 就使用了这种方法。

分块传输编码在 HTTP 1.1 中被添加。[2] 这意味着它非常老并且几乎所有 HTTP 服务器和客户端都支持它。发送分块响应还允许所谓的“拖尾”,也就是在正文之后发送的头部。在 Go 中,需要在发送前显式声明拖尾:

http.HandleFunc("GET /test", func(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Trailer", "My-Trailer")
	w.Write([]byte("Hello"))
	w.Write([]byte(strings.Repeat("!", 3000)))
	w.Header().Add("My-Trailer", "test")
})

这对于数字签名之类的事情很有用,因为它们需要从响应正文计算得出。在 Go 的 http 处理器中,添加拖尾也会自动使响应分块,即使它本来不需要这样。

HTTP/2 和 HTTP/3 不支持分块传输编码,因为它们有自己的流机制。[2]

这是在使用 Go 语言中的 net/http 包时在后台发生的事情之一。http.ResponseWriter 是一个非常简单的接口,只有 3 个方法。在这个 API 的背后有很多魔法,包括 Content-Type 侦测 和我上面提到的隐式写入响应头。自动处理这些事情是很棒的,但在编程中我认为始终了解下一层发生了什么是很重要的。