## 详解 HTTP/2 Server Push 进一步提升页面加载速度 文/陆佳浩 >多路复用,是 HTTP/2 众多协议优化中最令人振奋的特性,它大大降低了网络延迟对性能的影响,而对于资源之间的依赖关系导致的“延迟”, Server Push 则提供了手动优化方案。本文将对 Server Push 进行深度解读,并分享它在饿了么业务中的应用。 作为 HTTP 协议的第二个主要版本,HTTP/2 备受瞩目。 HTTP/2 使用了一系列协议层面的优化手段来减少延迟,提升页面在浏览器中的加载速度。其中,Server Push 是一项十分重要而吸引人的特性。本文将依次介绍 Server Push 的背景、使用方法、基本原理和在饿了么的应用。 ### 背景 要了解 Server Push 是什么,以及它能够解决什么问题,需要对 Server Push 诞生的背景有一个基本的认知。HTTP 协议通常是在 TCP 上实现的,昂贵的 TCP 连接推动我们采取各种优化手段来复用连接。HTTP/2 的多路复用从协议层解决了这个问题。 #### 昂贵的 TCP 连接 HTTP/1 不支持多路复用,浏览器通常会与服务器建立多个底层的 TCP 连接。TCP 连接很昂贵,因此在优化性能的时候往往也是从减少请求数的角度考虑。比如开启 HTTP 持久连接尽可能地复用 TCP 连接、使用 CSS Sprites 技术、内联静态资源等。 这样的优化手段可以极大提升页面的加载速度,但是也有一些副作用:CSS Sprites 增加了一定的复杂度,也让图片变得不那么容易维护;内联静态资源更是把静态资源的缓存策略与页面的缓存策略绑在了一起,用之后的页面加载速度换取首次的加载速度。 可以说,这些优化方式多少都含有一些妥协。然而,即便使用了这些优化方式,也不能完全抵消因缺乏多路复用带来的低下的连接利用率。要治根,只能从协议本身入手。 #### HTTP/2 的多路复用 随着 HTTPS 的普及,连接变得更昂贵了。除了建立和断开 TCP 连接的消耗,还需要与服务器协商加密算法和交换密钥。HTTP/2 带来了一系列协议上的优化,包括多路复用、头部压缩等等。最令人振奋的莫过于多路复用了。 HTTP/2 定义了流(Stream)和帧(Frame)。基本协议单元变小了,从消息(Message)变成了帧;流作为一种虚拟的通道,用来传输帧。与创建 TCP 连接相比,创建流的成本几乎为零。基本协议单元的变小也大大提高了连接的利用效率。 可以说,HTTP/2 的多路复用大大降低了由于网络延迟或者某个响应阻塞所带来的传输效率的损耗。如果说网络延迟对性能的影响可以通过多路复用减小,那么另一种由于资源之间的依赖关系导致的“延迟”是难以自动优化的。为此,Server Push 提供了一种手动优化的方案。 ### 了解 Server Push #### Server Push 是什么 通常,只有在浏览器请求某个资源的时候,服务器才会向浏览器发送该资源。Server Push 则允许服务器在收到浏览器的请求之前,主动向浏览器推送资源。比如说,网站首页引用了一个 CSS 文件。浏览器在请求首页时,服务器除了返回首页的 HTML 之外,可以将其引用的 CSS 文件也一并推给客户端。 有些人对 Server Push 存在一定程度误解,认为这种技术能够让服务器向浏览器发送“通知”,甚至将其与 WebSocket 进行比较。事实并非如此,Server Push 只是省去了浏览器发送请求的过程。只有当“如果不推送这个资源,浏览器就会请求这个资源”的时候,浏览器才会使用推送过来的内容。如果浏览器本身就不会请求某个资源,那么推送这个资源只会白白消耗带宽。 #### Server Push 与资源内联 资源内联是指将 CSS 和 JavaScript 内联到 HTML 中。这是一种面对昂贵的连接所达成的妥协,减少了请求数量,降低了延迟带来的影响,提升了页面的首次加载速度,却让这些原本可以缓存很久的资源文件遵循与 HTML 页面一样的缓存策略。 Server Push 和资源内联是类似的。 Server Push 同样以减少请求数量和提升页面加载速度为目标。与资源内联的不同之处在于, Server Push 推送的资源是独立的、完整的响应,可以与 HTML 页面有着不同的缓存策略,从而更有效地使用缓存。 ### 使用 Server Push 要使用 Server Push ,有3种方案可供选择: 1. 自己实现一个 HTTP/2 服务器; 2. 使用支持 Server Push 的 CDN ; 3. 使用支持 Server Push 的 HTTP/2 服务器。 第一种方案并非是指从零开始实现一个 HTTP/2 服务器,仅仅是指从程序入手,直接对外暴露一个支持 HTTP/2 的服务器。大多数情况下,我们会使用现成的 HTTP/2 库。比如 node-http2,或者是 Go 1.8 的n et/http。 第二和第三种方案通过设置响应头或者修改 HTTP 服务器的配置文件,告知 HTTP 服务器要推送的资源,让 HTTP 服务器完成资源的推送。 第一种方案更灵活,可以编程决定推送的资源和推送的时机;第二和第三种方案更简单,但是缺乏一定的灵活性。 #### 自行实现 HTTP/2 服务器 为了方便起见,我将使用 Go 标准库中的 net/http 来写一个 Server Push 的 Demo。Go 1.8 开始支持 Server Push,因此请确保使用了1.8或以上的版本。 ##### 创建自签名证书 鉴于 Server Push 是 HTTP/2 的“专利”,目前的浏览器又普遍只支持 HTTP/2 over TLS(h2),因此我们需要一张证书。创建自签名证书的方法有很多,这里就不再赘述。如果你不知道怎么创建自签名证书,可以查阅相关资料,或者登录 http://www.selfsignedcertificate.com/ 在线生成、下载。 ##### 写一个 HTTP/2 服务器 假设证书的文件名为 server.crt 和 server.key,以下代码实现了一个简单的 HTTPS 服务器。将其保存为 server.go,在终端运行 go run server.go。 ``` package main import ( "fmt" "log" "net/http" ) const indexHTML = `

Hello Server Push

` const styleCSS = ` p { color: red; } ` func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, indexHTML) }) http.HandleFunc("/style.css", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/css") fmt.Fprint(w, styleCSS) }) log.Fatal(http.ListenAndServeTLS(":4000", "server.crt", "server.key", nil)) } ``` 运行后终端不会有任何提示。用浏览器打开 https://localhost:4000,会提示不是私密连接,见图1。这是正常的,因为自签名证书是不受操作系统和浏览器信任的。 图1 自签名证书不受操作系统和浏览器信任 图1 自签名证书不受操作系统和浏览器信任 展开“高级”,点击“继续前往 localhost(不安全)”,或者在页面上输入“badidea”,即可看到红色的“Hello Server Push ”字样,见图2。 图2 运行结果最终页 图2 运行结果最终页 ##### 使用 Server Push 推送资源 在 Go 语言里,使用 Server Push 推送资源很简单。如果客户端支持 Server Push ,传入的 ResponseWriter 会实现 Pusher 接口。在处理到达首页的请求时,如果发现客户端支持 Server Push ,就把 style.css 也推回去。 ``` http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if pusher, hasPusher := w.(http.Pusher); hasPusher { pusher.Push("/style.css", nil) } fmt.Fprint(w, indexHTML) }) ``` 重启服务器之后刷新页面,观察开发者工具中的 Network 面板。如果 style.css 的 Initiator 列中含有“Push”字样,就说明推送成功了,见图3。 图3 在开发者工具的Network面板中查看推送成功情况 图3 在开发者工具的 Network 面板中查看推送成功情况 #### 使用支持 Server Push 的 CDN 2016年4月底,CloudFlare 宣布支持 HTTP/2 Server Push 。要启用 Server Push ,只需要在响应里加入一个特定格式的 Link 头: ``` Link: ; rel=preload; as=stylesheet ``` 这源于 W3C 的 Preload 草案。草案还算比较宽松,服务器可以为这些 preload link 资源发起 Server Push,也可以提供一个可选的 nopush 参数给开发者使用,以显式声明不推送某个资源。 CloudFlare 实现了 Preload 草案中的 Server Push,也提供了可选的 nopush 参数。当 CloudFlare 读到源站服务器发来的 Link 头时,它会向浏览器推送那些资源,然后从 Link 头中移除那些资源。除此之外,CloudFlare 会在响应里增加一个 Cf-H2-Pushed 头,其内容是推送的资源列表,以方便开发者调试。 同样是上面的例子,配置 Nginx 添加 Link 头。当然,你也可以用别的 HTTP 服务器,甚至直接用 PHP 之类的后端语言做这件事。 ``` server { server_name server-push-test.codehut.me; root /path/to/your/website; add_header Link "; rel=preload; as=stylesheet"; } ``` CloudFlare 会自动为我们签发一张证书。如果源站不支持 HTTPS,可以在 CloudFlare 的 Crypto 设置中将 SSL 选项修改为“Flexible”,来允许 CloudFlare 使用 HTTP 回源。 同样是 h2 协议,使用 Server Push 后加载时间有所减少,style.css 的时间线变化尤为明显,请见图4。查看 HTML 的响应,其中确实包含有 Cf-H2-Pushed 头,并且告诉我们 CloudFlare 向浏览器推送了 style.css。 可惜的是,目前国内还没有支持 Server Push 的 CDN 。如果不使用国外的 CDN ,就只能放弃 CDN ,用自己的服务器流量推送资源。 图4  使用 Server Push 前后对比 图4 使用 Server Push 前后对比 #### 使用支持 Server Push 的 HTTP/2 服务器 目前,支持 Server Push 的服务器软件并不多。很遗憾,Nginx 并不支持。Apache 的 mod _ http2 模块支持 Server Push ,用法与 CloudFlare 差不多,同样是通过设置 Link 头来告诉服务器需要推送哪些资源。 Caddy 是一个打着“Every Site on HTTPS”口号的 HTTP/2 服务器。Caddy 使用 Go 语言编写,今年4月份也正式发行了支持 Server Push 的版本。与 CloudFlare 和 Apache 不同,Caddy 提供了 push 指令来配置要推送的资源。要实现上面的例子,配置文件只需要三行: ``` localhost:4000 tls self_signed push / /style.css ``` 第一行是主机头和监听的端口号。第二行表明我们希望使用自签名证书,Caddy 会在启动时自动在内存中为我们生成。第三行使用 push 指令,告诉 Caddy 在浏览器请求首页的时候,用 Server Push 把 /style.css 一并推送给浏览器。 图5  CloudFlare完成了向浏览器推送style.css 图5 CloudFlare 完成了向浏览器推送 style.css ### 深入 Server Push HTTP/2 与 HTTP/1 最大的不同之处在于,前者在后者的基础上定义了流和帧,实现了多路复用。这是 Server Push 的基础。 #### Server Push 原理 HTTP/2 的流用于传输数据。客户端创建新的流来发送请求,服务端则在客户端请求的流上发送响应。同样地,Server Push 也需要把请求和响应“绑定”到某个流上。 HTTP/2 定义了10种帧。当服务器想用 Server Push 推送资源时,会先向客户端发送 PUSH _ PROMISE 帧。规范规定推送的响应必须与客户端的某个请求相关联,因此服务器会在客户端请求的流上发送 PUSH _ PROMISE 帧。PUSH _ PROMISE 帧的格式如图6。其中需要关注的是 Promise 流 ID 和 Header 块区域。 图6  PUSH_PROMISE帧的格式 图6 PUSH _ PROMISE 帧的格式 PUSH _ PROMISE 帧中包含完整的请求头。然而,如果一个请求带有请求体,服务器就没法用 Server Push 推送对这个请求的响应了。构造 PUSH _ PROMISE 帧时,服务器会保留一个可用的流 ID,用来在之后发送响应。服务器会通过 PUSH _ PROMISE 帧告知客户端这个流 ID,以便让客户端将这个流与推送的响应相关联。服务器发送完 PUSH _ PROMISE 帧之后,就可以开始在之前保留的流上发送响应了。 图7  流的状态转移图 图7 流的状态转移图 图7为流的状态转移图。其中的缩写分别为: 1. H——HEADERS 帧 2. PP——PUSH_PROMISE 帧 3. ES——END_STREAM 标记 4. R——RST_STREAM 帧 服务器必须先发送 PUSH _ PROMISE 帧,再发送引用了推送资源的内容。比如说,使用 Server Push 推送页面上引用的 CSS,必须先发送 PUSH _ PROMISE 帧,再发送 HTML。一旦浏览器收到并解析 HTML(的一部分),发现了引用的资源,就会发起请求。如果无法确保浏览器先接收到 PUSH _ PROMISE 帧,那么浏览器接收到 PUSH _ PROMISE 帧和浏览器开始请求即将被推送的资源之间就出现了竞争。这种竞争会导致服务器有概率推送失败,甚至可能浪费带宽。 使用 Chrome 的 Net-Internals 可以更清晰地看到这一过程,帮助我们理解 Server Push 的原理。在 Server Push 的行为与预期的不一致时,也可以用它来调试。 打开 Net-Internals(chrome://net-internals/#http2),页面中会显示所有的 HTTP/2 会话。打开测试页面,选中相应的会话,就能在右侧面板可以看到收发的每一帧,以及相关联的流 ID,见图8。 图8  Net-Internals中查看 HTTP/2 会话过程 图8 Net-Internals 中查看 HTTP/2 会话过程 #### Server Push 存在的问题 浏览器在主动请求某个资源之前,会优先从缓存中取。如果命中了本地缓存,就可以不再请求该资源了。Server Push 则不同,服务器很难根据客户端的缓存情况决定是否要推送某个资源。所以,大多数 Server Push 的实现不考虑客户端的缓存,每次收到客户端的请求,总是会发起推送。 规范中考虑到了这种情况。客户端在收到 PUSH _ PROMISE 帧的时候,如果发现服务器要推送的资源命中了本地的缓存,可以在接收推送资源响应的流上发送一个 RST _ STREAM 帧来重置该流,来告知服务器停止发送数据。然而,服务器开始推送响应和收到客户端发来的 RST _ STREAM 帧之间也存在竞争关系。通常,服务器收到 RST _ STREAM 帧的时候,已经发送了一部分响应了。 为了缓解这种“多推”的情况,一方面,客户端可以限制推送的数量、调整窗口大小,服务器也可以为流设置优先级和依赖,另一方面,可以使用“缓存感知 Server Push ”机制。 “缓存感知 Server Push ”机制的原理类似 If-None-Match,只不过为了让客户端在发送页面请求的同时把资源文件的缓存状态也发给服务器,服务器会在推送资源文件时,将资源文件的缓存状态更新至客户端的 Cookie 中。图9演示了算法的大致流程。 图9 “缓存感知 Server Push ”算法的大致流程 图9 “缓存感知 Server Push ”算法的大致流程 当然,Cookie 的空间十分宝贵,Server Push 又允许存在有一定的“多推”和“漏推”。具体实现的时候,一般不会把所有的资源和 hash(或者版本号)直接放进去。比如,H2O 使用 Golomb-compressed sets 算法生成指纹,编码为 base64 之后存入 Cookie。 这种机制可以在一定程度上减少“多推”的情况,不过也存在一些问题: 1. 需要使用 Cookie,占用 Cookie 一定的空间; 2. 不能自动遵循 Cache-Control,需要自行实现缓存策略; 3. 难以完全避免“多推”的情况,还可能会出现“漏推”。 因此,使用 Server Push 推送资源依然存在一些问题。在选择要推送的资源时,应当考虑这些问题。最保守的做法是,只用 Server Push 推送原先内联的资源,即便 Server Push 存在“多推”的问题,也比内联资源来得好。当然,如果不太在意流量,也可不必太过担心“多推”的问题,因为页面速度的瓶颈往往不在于带宽,而是延迟。 ### Server Push 在饿了么的应用 考虑到国内 CDN 对 Server Push 的支持和“多推”问题,目前我们不使用 Server Push 推送静态资源,而是推送动态资源(API 响应)。与静态资源相比较,推送动态资源有以下区别: 1. 更难被浏览器发现,浏览器只有在接收和解析完 JavaScript 文件,执行到相关语句的时候,才会发送请求; 2. 不需要缓存,也就不存在“多推”问题。 Server Push 只能推送不带请求体的 GET 和 HEAD 方法的请求,不过这也可以满足我们的需求了。因为自动发起的 API 请求,大多是 GET 方法的。我们的目的是提升页面加载速度,只需要推送这类 API 即可。 在使用 Server Push 之前,我们测试了一下使用 Server Push 推送 API 对页面加载速度的影响。我们选取了PC站的餐厅列表页来测试。为了让结果更准确,我们写了一个反向代理服务器,反向代理线上的页面和 API。除此之外,我们禁用了浏览器的缓存功能,来模拟用户首次访问的情形。 我们分别比较了不使用 Server Push 和使用 Server Push 推送4个接口的情况(见图10)。从 Chrome 开发者工具的 Timeline 面板中可以看到,使用 Server Push 后页面的整体加载时间变短了,其中减少最明显的是空闲时间。这与我们的想法不谋而合,Server Push 大大缩减了等待浏览器发起请求的时间。 图10   使用 Server Push 前、后,页面加载时间统计结果 图10 使用 Server Push 前、后,页面加载时间统计结果 测试的结果令我们满意,但随即我们意识到推送 API 比推送静态资源复杂得多。API 是需要带参数的。这些参数可能源于请求的 path、query string、Cookie 甚至自定义的 HTTP 头。这意味着我们很难使用现成的解决方案来推送 API。 为此,我们开发了一个带基本路由功能的 HTTP/2 服务器——Sopush。Sopush 的目的不是取代 Nginx 或者 Caddy 之类的 HTTP 服务器,作为最外层,它的主要职责是反向代理和使用 Server Push 推送资源。它可以像Express、Koa 那样定义路由规则,解析来自 path 和 query string 的参数,也可以自由地设置 PUSH _ PROMISE 中的请求头以满足 API 的需求。 目前,饿了么已经有一些业务使用 Server Push 了,包括 PC 站。用 Chrome 打开 PC 站的餐厅列表页,即可在 Network 面板中看到“Push”字样。 ### 总结 作为 HTTP/2 的一个重要特性,Server Push 有着明显的优势和不足。一方面,Server Push 能够提升在高延迟环境下页面的加载速度。这种延迟不仅包括网络延迟,在复杂的 SPA 下也把首个 XHR 请求的发起时间作为考量之一。另一方面,Server Push 的支持依然不算令人满意,主要表现在目前国内各大 CDN 都不支持 Server Push ,大多数移动端的浏览器也不支持 Server Push 。 就目前而言,国内使用 Server Push 的网站比较少。主要可能还是由于 CDN 对 Server Push 的支持不足,使大家面临使用 Server Push 和使用 CDN 之间的抉择,对比优劣后自然是选择使用 CDN 了。我们使用 Server Push 推送 API 可能是现阶段可以绕开这种抉择、效果还不错的少数实践之一。 最后,衷心希望这篇文章让你对 Server Push 有了进一步的了解。