作為HTTP協(xié)議的第二個主要版本,HTTP/2備受矚目。HTTP/2使用了一系列協(xié)議層面的優(yōu)化手段來減少延遲,提升頁面在瀏覽器中的加載速度。其中,Server Push是一項十分重要而吸引人的特性。本文將依次介紹Server Push的背景、使用方法、基本原理和在餓了么的應(yīng)用。
要了解Server Push是什么,以及它能夠解決什么問題,需要對Server Push誕生的背景有一個基本的認知。HTTP協(xié)議通常是在TCP上實現(xiàn)的,昂貴的TCP連接推動我們采取各種優(yōu)化手段來復用連接。HTTP/2的多路復用從協(xié)議層解決了這個問題。
HTTP/1不支持多路復用,瀏覽器通常會與服務(wù)器建立多個底層的TCP連接。TCP連接很昂貴,因此在優(yōu)化性能的時候往往也是從減少請求數(shù)的角度考慮的。比如開啟HTTP持久連接盡可能地復用TCP連接、使用CSS Sprites技術(shù)、內(nèi)聯(lián)靜態(tài)資源等。
這樣的優(yōu)化手段可以極大提升頁面的加載速度,但是也有一些副作用:CSS Sprites增加了一定的復雜度,也讓圖片變得不那么容易維護;內(nèi)聯(lián)靜態(tài)資源更是把靜態(tài)資源的緩存策略與頁面的緩存策略綁在了一起,用之后的頁面加載速度換取首次的加載速度。
可以說,這些優(yōu)化方式多少都含有一些妥協(xié)。然而,即便使用了這些優(yōu)化方式,也不能完全抵消因缺乏多路復用帶來的低下的連接利用率。要治根,只能從協(xié)議本身入手。
隨著HTTPS的普及,連接變得更昂貴了。除了建立和斷開TCP連接的消耗,還需要與服務(wù)器協(xié)商加密算法和交換密鑰。HTTP/2帶來了一系列協(xié)議上的優(yōu)化,包括多路復用、頭部壓縮等等。令人振奮的莫過于多路復用了。
HTTP/2定義了流(Stream)和幀(Frame)。基本協(xié)議單元變小了,從消息(Message)變成了幀;流作為一種虛擬的通道,用來傳輸幀。與創(chuàng)建TCP連接相比,創(chuàng)建流的成本幾乎為零。基本協(xié)議單元的變小也大大提高了連接的利用效率。
可以說,HTTP/2的多路復用大大降低了由于網(wǎng)絡(luò)延遲或者某個響應(yīng)阻塞所帶來的傳輸效率的損耗。如果說網(wǎng)絡(luò)延遲對性能的影響可以通過多路復用減小,那么另一種由于資源之間的依賴關(guān)系導致的“延遲”是難以自動優(yōu)化的。為此,Server Push提供了一種手動優(yōu)化的方案。
通常,只有在瀏覽器請求某個資源的時候,服務(wù)器才會向瀏覽器發(fā)送該資源。Server Push則允許服務(wù)器在收到瀏覽器的請求之前,主動向瀏覽器推送資源。比如說,網(wǎng)站首頁引用了一個CSS文件。瀏覽器在請求首頁時,服務(wù)器除了返回首頁的HTML之外,可以將其引用的 CSS文件也一并推給客戶端。
有些人對Server Push存在一定程度上的誤解,認為這種技術(shù)能夠讓服務(wù)器向瀏覽器發(fā)送“通知”,甚至將其與WebSocket進行比較。事實并非如此,Server Push只是省去了瀏覽器發(fā)送請求的過程。只有當“如果不推送這個資源,瀏覽器就會請求這個資源”的時候,瀏覽器才會使用推送過來的內(nèi)容。如果瀏覽器本身就不會請求某個資源,那么推送這個資源只會白白消耗帶寬。
資源內(nèi)聯(lián)是指將CSS和JavaScript內(nèi)聯(lián)到HTML中。這是一種面對昂貴的連接所達成的妥協(xié),減少了請求數(shù)量,降低了延遲帶來的影響,提升了頁面的首次加載速度,卻讓這些原本可以緩存很久的資源文件遵循與HTML頁面一樣的緩存策略。
Server Push和資源內(nèi)聯(lián)是類似的。Server Push同樣以減少請求數(shù)量和提升頁面加載速度為目標。與資源內(nèi)聯(lián)的不同之處在于,Server Push推送的資源是獨立的、完整的響應(yīng),可以與HTML頁面有著不同的緩存策略,從而更有效地使用緩存。
要使用Server Push,有3種方案可供選擇:
種方案并非是指從零開始實現(xiàn)一個HTTP/2服務(wù)器,僅僅是指從程序入手,直接對外暴露一個支持HTTP/2的服務(wù)器。大多數(shù)情況下,我們會使用現(xiàn)成的HTTP/2庫。比如node-http2,或者是Go 1.8的net/http。
第二和第三種方案通過設(shè)置響應(yīng)頭或者修改HTTP服務(wù)器的配置文件,告知HTTP服務(wù)器要推送的資源,讓HTTP服務(wù)器完成資源的推送。
種方案更靈活,可以編程決定推送的資源和推送的時機;第二和第三種方案更簡單,但是缺乏一定的靈活性。
為了方便起見,我將使用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),因此我們需要一張證書。創(chuàng)建自簽名證書的方法有很多,這里就不再贅述。如果你不知道怎么創(chuàng)建自簽名證書,可以查閱相關(guān)資料,或者登錄http://www.selfsignedcertificate.com/在線生成、下載。
假設(shè)證書的文件名為server.crt和server.key。
以下代碼實現(xiàn)了一個簡單的HTTPS服務(wù)器。將其保存為server.go,在終端運行g(shù)o 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。這是正常的,因為自簽名證書是不受操作系統(tǒng)和瀏覽器信任的。
圖1 自簽名證書不受操作系統(tǒng)和瀏覽器信任
展開“高級”,點擊“繼續(xù)前往localhost(不安全)”,或者在頁面上輸入“badidea”,即可看到紅色的“Hello Server Push”字樣,見圖2。
圖2 運行結(jié)果終頁
在Go語言里,使用Server Push 推送資源很簡單。如果客戶端支持Server Push,傳入的 ResponseWriter會實現(xiàn)Pusher接口。在處理到達首頁的請求時,如果發(fā)現(xiàn)客戶端支持 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)
})
重啟服務(wù)器之后刷新頁面,觀察開發(fā)者工具中的Network面板。如果style.css的Initiator列中含有“Push”字樣,就說明推送成功了,見圖3。
圖3 在開發(fā)者工具的Network面板中查看推送成功情況
2016年4月底,CloudFlare宣布支持HTTP/2 Server Push。要啟用Server Push,只需要在響應(yīng)里加入一個特定格式的Link頭:
Link: </style.css>; rel=preload; as=stylesheet
這源于W3C的Preload草案。草案還算比較很寬松,服務(wù)器可以為這些preload link資源發(fā)起Server Push,也可以提供一個可選的nopush參數(shù)給開發(fā)者使用,以顯式聲明不推送某個資源。
CloudFlare實現(xiàn)了Preload草案中的Server Push,也提供了可選的nopush參數(shù)。當CloudFlare讀到源站服務(wù)器發(fā)來的Link頭時,它會向瀏覽器推送那些資源,然后從Link頭中移除那些資源。除此之外,CloudFlare會在響應(yīng)里增加一個Cf-H2-Pushed頭,其內(nèi)容是推送的資源列表,以方便開發(fā)者調(diào)試。
同樣是上面的例子,配置Nginx添加Link頭。當然,你也可以用別的HTTP服務(wù)器,甚至直接用PHP之類的后端語言做這件事。
server {
server_name server-push-test.codehut.me;
root /path/to/your/website;
add_header Link "</style.css>; rel=preload; as=stylesheet";
}
CloudFlare會自動為我們簽發(fā)一張證書。如果源站不支持HTTPS,可以在CloudFlare的 Crypto設(shè)置中將SSL選項修改為“Flexible”,來允許CloudFlare使用HTTP回源。
圖4 使用Server Push前后對比
同樣是h2協(xié)議,使用Server Push后加載時間有所減少,style.css的時間線變化尤為明顯,請見圖4。查看HTML的響應(yīng),其中確實包含有Cf-H2-Pushed頭,并且告訴我們CloudFlare 向瀏覽器推送了style.css。
圖5 CloudFlare完成了向瀏覽器推送style.css
可惜的是,目前國內(nèi)還沒有支持Server Push的CDN。如果不使用國外的CDN,就只能放棄CDN,用自己的服務(wù)器流量推送資源。
目前,支持Server Push的服務(wù)器軟件并不多。很遺憾,Nginx并不支持。Apache的mod_http2模塊支持Server Push,用法與CloudFlare差不多,同樣是通過設(shè)置Link頭來告訴服務(wù)器需要推送哪些資源。
Caddy是一個打著“Every Site on HTTPS”口號的HTTP/2服務(wù)器。Caddy使用Go語言編寫,今年4月份也正式發(fā)行了支持Server Push的版本。與CloudFlare和Apache不同,Caddy提供了push指令來配置要推送的資源。要實現(xiàn)上面的例子,配置文件只需要三行:
localhost:4000 tls self_signed push / /style.css
行是主機頭和監(jiān)聽的端口號。第二行表明我們希望使用自簽名證書,Caddy會在啟動時自動在內(nèi)存中為我們生成。第三行使用push指令,告訴Caddy在瀏覽器請求首頁的時候,用Server Push把/style.css一并推送給瀏覽器。
HTTP/2與HTTP/1大的不同之處在于,前者在后者的基礎(chǔ)上定義了流和幀,實現(xiàn)了多路復用。這是Server Push的基礎(chǔ)。
HTTP/2的流用于傳輸數(shù)據(jù)。客戶端創(chuàng)建新的流來發(fā)送請求,服務(wù)端則在客戶端請求的流上發(fā)送響應(yīng)。同樣地,Server Push也需要把請求和響應(yīng)“綁定”到某個流上。
HTTP/2定義了10種幀。當服務(wù)器想用Server Push推送資源時,會先向客戶端發(fā)送PUSH_PROMISE幀。規(guī)范規(guī)定推送的響應(yīng)必須與客戶端的某個請求相關(guān)聯(lián),因此服務(wù)器會在客戶端請求的流上發(fā)送PUSH_PROMISE幀。PUSH_PROMISE幀的格式如圖6。其中需要關(guān)注的是Promise流ID和Header塊區(qū)域。
圖6 PUSH_PROMISE幀的格式
PUSH_PROMISE幀中包含完整的請求頭。然而,如果一個請求帶有請求體,服務(wù)器就沒法用 Server Push推送對這個請求的響應(yīng)了。構(gòu)造PUSH_PROMISE幀時,服務(wù)器會保留一個可用的流ID,用來在之后發(fā)送響應(yīng)。服務(wù)器會通過PUSH_PROMISE幀告知客戶端這個流ID,以便讓客戶端將這個流與推送的響應(yīng)相關(guān)聯(lián)。服務(wù)器發(fā)送完P(guān)USH_PROMISE幀之后,就可以開始在之前保留的流上發(fā)送響應(yīng)了。
圖7 流的狀態(tài)轉(zhuǎn)移圖
圖7為流的狀態(tài)轉(zhuǎn)移圖。其中的縮寫分別為:
服務(wù)器必須先發(fā)送PUSH_PROMISE幀,再發(fā)送引用了推送資源的內(nèi)容。比如說,使用Server Push推送頁面上引用的CSS,必須先發(fā)送PUSH_PROMISE幀,再發(fā)送HTML。一旦瀏覽器收到并解析HTML(的一部分),發(fā)現(xiàn)了引用的資源,就會發(fā)起請求。如果無法確保瀏覽器先接收到PUSH_PROMISE幀,那么瀏覽器接收到PUSH_PROMISE幀和瀏覽器開始請求即將被推送的資源之間就出現(xiàn)了競爭。這種競爭會導致服務(wù)器有概率推送失敗,甚至可能浪費帶寬。
使用Chrome的Net-Internals可以更清晰地看到這一過程,幫助我們理解Server Push的原理。在Server Push的行為與預期的不一致時,也可以用它來調(diào)試。
打開Net-Internals(chrome://net-internals/#http2),頁面中會顯示所有的HTTP/2會話。打開測試頁面,選中相應(yīng)的會話,就能在右側(cè)面板可以看到收發(fā)的每一幀,以及相關(guān)聯(lián)的流ID,見圖8。
圖8 Net-Internals中查看HTTP/2會話過程
瀏覽器在主動請求某個資源之前,會優(yōu)先從緩存中取。如果命中了本地緩存,就可以不再請求該資源了。Server Push則不同,服務(wù)器很難根據(jù)客戶端的緩存情況決定是否要推送某個資源。所以,大多數(shù)Server Push的實現(xiàn)不考慮客戶端的緩存,每次收到客戶端的請求,總是會發(fā)起推送。
規(guī)范中考慮到了這種情況。客戶端在收到PUSH_PROMISE幀的時候,如果發(fā)現(xiàn)服務(wù)器要推送的資源命中了本地的緩存,可以在接收推送資源響應(yīng)的流上發(fā)送一個RST_STREAM幀來重置該流,來告知服務(wù)器停止發(fā)送數(shù)據(jù)。然而,服務(wù)器開始推送響應(yīng)和收到客戶端發(fā)來的RST_STREAM幀之間也存在競爭關(guān)系。通常,服務(wù)器收到RST_STREAM幀的時候,已經(jīng)發(fā)送了一部分響應(yīng)了。
為了緩解這種“多推”的情況,一方面,客戶端可以限制推送的數(shù)量、調(diào)整窗口大小,服務(wù)器也可以為流設(shè)置優(yōu)先級和依賴,另一方面,可以使用“緩存感知Server Push”機制。
“緩存感知Server Push”機制的原理類似If-None-Match,只不過為了讓客戶端在發(fā)送頁面請求的同時把資源文件的緩存狀態(tài)也發(fā)給服務(wù)器,服務(wù)器會在推送資源文件時,將資源文件的緩存狀態(tài)更新至客戶端的Cookie中。圖9演示了算法的大致流程。
圖9 “緩存感知Server Push”算法的大致流程
當然,Cookie的空間十分寶貴,Server Push又允許存在有一定的“多推”和“漏推”。具體實現(xiàn)的時候,一般不會把所有的資源和hash(或者版本號)直接放進去。比如,H2O使用 Golomb-compressed sets算法生成指紋,編碼為base64之后存入Cookie。
這種機制可以在一定程度上減少“多推”的情況,不過也存在一些問題:
因此,使用Server Push推送資源依然存在一些問題。在選擇要推送的資源時,應(yīng)當考慮這些問題。保守的做法是,只用Server Push推送原先內(nèi)聯(lián)的資源,即便Server Push存在“多推”的問題,也比內(nèi)聯(lián)資源來得好。當然,如果不太在意流量,也可不必太過擔心“多推”的問題,因為頁面速度的瓶頸往往不在于帶寬,而是延遲。
考慮到國內(nèi)CDN對Server Push的支持和“多推”問題,目前我們不使用Server Push推送靜態(tài)資源,而是推送動態(tài)資源(API 響應(yīng))。與靜態(tài)資源相比較,推送動態(tài)資源有以下區(qū)別:
Server Push只能推送不帶請求體的GET和HEAD方法的請求,不過這也可以滿足我們的需求了。因為自動發(fā)起的API請求,大多是GET方法的。我們的目的是提升頁面加載速度,只需要推送這類API即可。
在使用Server Push之前,我們測試了一下使用Server Push推送API對頁面加載速度的影響。我們選取了PC站的餐廳列表頁來測試。為了讓結(jié)果更準確,我們寫了一個反向代理服務(wù)器,反向代理線上的頁面和API。除此之外,我們禁用了瀏覽器的緩存功能,來模擬用戶首次訪問的情形。
我們分別比較了不使用Server Push和使用Server Push推送4個接口的情況。從Chrome開發(fā)者工具的Timeline面板中可以看到,使用Server Push后頁面的整體加載時間變短了,其中減少明顯的是空閑時間。這與我們的想法不謀而合,Server Push大大縮減了等待瀏覽器發(fā)起請求的時間。
圖10 使用Server Push前、后,頁面加載時間統(tǒng)計結(jié)果
測試的結(jié)果令我們滿意,但隨即我們意識到推送API比推送靜態(tài)資源復雜得多。API是需要帶參數(shù)的。這些參數(shù)可能源于請求的path、query string、Cookie甚至自定義的HTTP頭。這意味著我們很難使用現(xiàn)成的解決方案來推送API。
為此,我們開發(fā)了一個帶基本路由功能的HTTP/2服務(wù)器——Sopush。Sopush的目的不是取代Nginx或者Caddy之類的HTTP服務(wù)器,作為外層,它的主要職責是反向代理和使用Server Push推送資源。它可以像Express、Koa那樣定義路由規(guī)則,解析來自path和query string的參數(shù),也可以自由地設(shè)置PUSH_PROMISE中的請求頭以滿足API的需求。
目前,餓了么已經(jīng)有一些業(yè)務(wù)使用Server Push了,包括PC站。用Chrome打開PC站的餐廳列表頁,即可在Network面板中看到“Push”字樣。
作為HTTP/2的一個重要特性,Server Push有著明顯的優(yōu)勢和不足。一方面,Server Push 能夠提升在高延遲環(huán)境下頁面的加載速度。這種延遲不僅包括網(wǎng)絡(luò)延遲,在復雜的SPA下也把首個XHR請求的發(fā)起時間作為考量之一。另一方面,Server Push的支持依然不算令人滿意,主要表現(xiàn)在目前國內(nèi)各大CDN都不支持Server Push,大多數(shù)移動端的瀏覽器也不支持 Server Push。
就目前而言,國內(nèi)使用Server Push的網(wǎng)站比較少。主要可能還是由于CDN對Server Push的支持不足,使大家面臨使用Server Push和使用CDN之間的抉擇,對比優(yōu)劣后自然是選擇使用CDN了。我們使用Server Push推送API可能是現(xiàn)階段可以繞開這種抉擇、效果還不錯的少數(shù)實踐之一。
本站文章版權(quán)歸原作者及原出處所有 。內(nèi)容為作者個人觀點, 并不代表本站贊同其觀點和對其真實性負責,本站只提供參考并不構(gòu)成任何投資及應(yīng)用建議。本站是一個個人學習交流的平臺,網(wǎng)站上部分文章為轉(zhuǎn)載,并不用于任何商業(yè)目的,我們已經(jīng)盡可能的對作者和來源進行了通告,但是能力有限或疏忽,造成漏登,請及時聯(lián)系我們,我們將根據(jù)著作權(quán)人的要求,立即更正或者刪除有關(guān)內(nèi)容。本站擁有對此聲明的最終解釋權(quán)。