詳解 HTTP/2 Server Push——進一步提升頁面加載速度

作者:陸佳浩,就職於餓了麼大前端部,目前負責開發和維護Sopush。
責編:陳秋歌,關注前端領域,歡迎投稿至[email protected]

導讀:多路複用,是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的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可能是現階段可以繞開這種抉擇、效果還不錯的少數實踐之一。

最後,衷心希望這篇文章讓你對Server Push有了進一步的瞭解。

本文爲《程序員》原創文章,未經允許不得轉載,更多精彩文章請訂閱《程序員》


2017年7月8日(星期六),「“前端開發創新實踐”線上峯會」將在 CSDN 學院召開。本次峯會集結來自Smashing Magazine、美國Hulu、美團、廣發證券、去哪兒網、百度的多位國內外知名前端開發專家、資深架構師,主題涵蓋響應式佈局、Redux、Mobx、狀態管理、構建方案、代碼複用、個性化圖表定製度等前端開發重難點技術話題。技術解析加項目實戰,幫你開拓解決問題的思路,增強技術探索實踐能力。全天六場深度技術分享,現在僅需169元,限時優惠中,詳情點擊峯會官網

圖片描述

發佈了342 篇原創文章 · 獲贊 104 · 訪問量 46萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章