HTTP 103 Early Hints

2023-09-21 ⏳4.3分钟(1.7千字) g

我去年写文章介绍 HTTP 协议演变时提到了最新的 103 Early Hints 状态码1。它是 HTTP/2 Server Push 的简化版本。今天就来介绍 103 状态码的工作原理。

在讲 103 之前得先简单说一下 Server Push。我们都知道,HTTP 是一种请求应答式协议,主动权在客户端。如果客户端不先发起请求,服务端没法主动推送数据。

在有一些场景需我们需要推送机制。就拿下面的 HTML 片断来说:

<html>
  <head>
    <link rel="stylesheet" href="/style.css">
  </head>
  <body>
    <!--...-->
  </body>
</html>

浏览器先下载完整的 HTML 然后解析。在解析的过程中发现<link>标签,然后开始下载 CSS 文件。如果 HTML 是由服务端渲染生成,这个过程会比较慢,额外加载 CSS 文件更会拖慢页面加载速度。

get HTML: |----------|
         parse HTML: |--------------|
                get CSS: |--------|

那怎样缩短整体的渲染时间呢?一个可能的方案就是让浏览器提前下载 CSS 文件:

get HTML: |----------|
get CSS:    |--------|
         parse HTML: |-----------|

也就是说,需要设计一种方案,让浏览器还没下载完 HTML 的时候就知道要提前下载哪些资源文件。这就是 HTTP/2 中 Server Push 最初的来源。

HTTP/2 协议虽然在语义层面跟 HTTP/1.x 没有区别,但底层通信机制完全不同。HTTP/2 协议在同一 TCP 连接上虚拟出同的 stream,每条 stream 都能双向通信,传输的基本单位是 frame。也是从 HTTP/2 开始,服务器终于可以主动给客户端发送数据了。这才有了 Server Push。

Server Push 虽然由服务端主动发起,但从语义上看,它又跟客户端发送的请求很像。服务器如果确定需要向客户端推送内容,则会先发一个 Push Reqeust,用的是 PUSH_PROMISE 帧。该对像说白了就是模拟了一个客户端发起的请求,里面包含域名、资源路径、请求方法,以及各类必要的头信息。客户端收到后可以根所 Reqeust 对象的信息来决定是否加载对应的资源。

当然了,服务端在发送 Push Request 之后还可以继续发送 Push Response,这就跟普通的 HTTP/2 返回值没什么两样了。

这种设计看起来很美好,但在实践上遇到了很大的问题。服务端怎么确定要不要推送数据呢?答案是根本确定不了!因为客户端可能已经缓存了对应的资源文件,重复推送只会造成浪费。所以最新的RFC9113有这么一段:

In practice, server push is difficult to use effectively, because it requires the server to correctly anticipate the additional requests the client will make, taking into account factors such as caching, content negotiation, and user behavior. Errors in prediction can lead to performance degradation, due to the opportunity cost that the additional data on the wire represents. In particular, pushing any significant amount of data can cause contention issues with responses that are more important.

也正因如此,实际上使用 HTTP/2 推送的网站只有 1.25%,所以 Google Chrome 团队决定不再支持该功能2。

但移除 Push 还怎么优化前面的问题呢?这就引出了今天的主角 103 状态码。103 状态码对应的消息是 Early Hints,意思已经很明白:

这样一来就避开服务端无法判断的问题。

但怎么做到提前通知呢?我们前文讲过,服务端在返回结果时要等 Body 都生成后连同状态码(2xx-5xxx)和头信息一同发送给客户端。如果想在状态码之前发送消息,就需要使用 1xx 状态码。

比较有意思的是 100 Continue 状态码。利用它客户端可以让服务器提前验证某些数据,然后再提交,这样可以节约网络资源。比如客户端希望上传文件,但需要服务器提前校验:

PUT /docs HTTP/1.1
Host: www.example.re
Content-Type: application/pdf
Content-Length: 99000
Expect: 100-continue
Authorization: Bearer xxxxx

这里客户端需要传Expect: 100-continue头。服务端收到后完成校验,回复:

HTTP/1.1 100 Continue

客户端收到后继续上传。完成后服务端继续回复:

HTTP/1.1 200 OK

至此整个过程才结束。这里最关键的是服务端在发送 200 之前还发送了 100 状态码。而且在理论上,服务端可以想发多少 1xx 状态码就发多少。这就为 103 Early Hints 提供了实现基础。

于是 IETF 推出 RFC8297,定义 103 状态码来提前通知浏览器加载资源文件。

比如客户端发起请求:

GET / HTTP/1.1
Host: example.com

服务端可以先回复 103:

HTTP/1.1 103 Early Hints
Link: </style.css>; rel=preload; as=style

这里使用标准的Link头来发送资源信息,内容格式跟 HTML 的 <Link> 标签一样。后面的 rel=preload 表示预先加载。对于 Web 字体这种不确定实际要加载哪些部分的场景,可以写成 rel=preconnect,表示让浏览器预选建立好连接。

然后继续渲染 HTML,等完成之后再发送 200 状态码:

HTTP/1.1 200 OK
Date: Fri, 26 May 2017 10:02:11 GMT
Content-Length: 1234
Content-Type: text/html; charset=utf-8
Link: </style.css>; rel=preload; as=style

<!doctype html>
...

整个过程如下图所示:

HTTP 103 Early Hints Demo

Go 语言从 1.21 开始原生支持 103 Early Hints。我们可以先设置 Header 信息,然后写入 103 状态码。net/http 库收到后会立即将给客户端发送对应的信息:

http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
  h := w.Header()
  a.Add("Link", "</style.css>; rel=preload; as=style")
  b. WriteHeader(http.StatusEarlyHints)

  d. Write([]byte("Hello, World!"))
})

HTTP/2 标准 2015 年发布,HTTP/3 标准 2022 年发布,都定义了 Server Push 功能,算是比较新的特性了。但 Chrome 团队在 2020 年 11 月就宣布要移除推送特性。新特性的生命周期也不过五年,而且在生产环境中部署点比也极低。这是一个典型的反2-8原则的案例,为了 20% 场景不得不投入 80% 的成本。最终的结果就是被更加简易的方案取代。截止到今天,各主要浏览器都已支持 103 Early Hints 状态码,大家可以放心启用3。