详解 HTTP/2 Server Push——进一步提升页面加载速度

更新时间:2017-07-03 09:52:47点击次数:554次

作为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的net/http。

第二和第三种方案通过设置响应头或者修改HTTP服务器的配置文件,告知HTTP服务器要推送的资源,让HTTP服务器完成资源的推送。

第一种方案更灵活,可以编程决定推送的资源和推送的时机;第二和第三种方案更简单,但是缺乏一定的灵活性。

自行实现HTTP/2服务器

为了方便起见,我将使用Go标准库中的net/http来写一个Server Push的Demo。Go 1.8开始支持Server Push,因此请确保使用了Go 1.8或1.8 以上的版本。

创建自签名证书

鉴于Server Push是HTTP/2的“专利”,目前的浏览器又普遍只支持HTTP/2 over TLS(h2),因此我们需要一张证书。创建自签名证书的方法有很多,这里就不再赘述。如果你不知道怎么创建自签名证书,可以查阅相关资料,或者登录http://www.selfsignedcertificate.com/在线生成、下载。

假设证书的文件名为server.crt和server.key。

写一个HTTP/2服务器

以下代码实现了一个简单的HTTPS服务器。将其保存为server.go,在终端运行go run server.go。

package main

import ( "fmt" "log" "net/http" )

const indexHTML = `
<!doctype html>
<link rel="stylesheet" type="text/css" href="style.css" />
<p>Hello Server Push</p>
`

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 自签名证书不受操作系统和浏览器信任

展开“高级”,点击“继续前往localhost(不安全)”,或者在页面上输入“badidea”,即可看到红色的“Hello Server Push”字样,见图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面板中查看推送成功情况

使用支持Server Push的CDN

2016年4月底,CloudFlare宣布支持HTTP/2 Server Push。要启用Server Push,只需要在响应里加入一个特定格式的Link头:

Link: </style.css>; 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 "</style.css>; rel=preload; as=stylesheet";
}

CloudFlare会自动为我们签发一张证书。如果源站不支持HTTPS,可以在CloudFlare的 Crypto设置中将SSL选项修改为“Flexible”,来允许CloudFlare使用HTTP回源。

图片描述

图4 使用Server Push前后对比

同样是h2协议,使用Server Push后加载时间有所减少,style.css的时间线变化尤为明显,请见图4。查看HTML的响应,其中确实包含有Cf-H2-Pushed头,并且告诉我们CloudFlare 向浏览器推送了style.css。

图片描述

图5 CloudFlare完成了向浏览器推送style.css

可惜的是,目前国内还没有支持Server Push的CDN。如果不使用国外的CDN,就只能放弃CDN,用自己的服务器流量推送资源。

使用支持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一并推送给浏览器。

深入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帧的格式

PUSH_PROMISE帧中包含完整的请求头。然而,如果一个请求带有请求体,服务器就没法用 Server Push推送对这个请求的响应了。构造PUSH_PROMISE帧时,服务器会保留一个可用的流ID,用来在之后发送响应。服务器会通过PUSH_PROMISE帧告知客户端这个流ID,以便让客户端将这个流与推送的响应相关联。服务器发送完PUSH_PROMISE帧之后,就可以开始在之前保留的流上发送响应了。

图片描述

图7 流的状态转移图

图7为流的状态转移图。其中的缩写分别为:

  • H——HEADERS帧
  • PP——PUSH_PROMISE帧
  • ES——END_STREAM标记
  • 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会话过程

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”算法的大致流程

当然,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个接口的情况。从Chrome开发者工具的Timeline面板中可以看到,使用Server Push后页面的整体加载时间变短了,其中减少最明显的是空闲时间。这与我们的想法不谋而合,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可能是现阶段可以绕开这种抉择、效果还不错的少数实践之一。

  • 项目经理 点击这里给我发消息
  • 项目经理 点击这里给我发消息
  • 项目经理 点击这里给我发消息
  • 项目经理 点击这里给我发消息