做者:陸佳浩,就任於餓了麼大前端部,目前負責開發和維護Sopush。css
導讀:多路複用,是HTTP/2衆多協議優化中最使人振奮的特性,它大大下降了網絡延遲對性能的影響,而對於資源之間的依賴關係致使的「延遲」,Server Push則提供了手動優化方案。本文將對Server Push進行深度解讀,並分享它在餓了麼業務中的應用。html
做爲HTTP協議的第二個主要版本,HTTP/2備受矚目。HTTP/2使用了一系列協議層面的優化手段來減小延遲,提高頁面在瀏覽器中的加載速度。其中,Server Push是一項十分重要而吸引人的特性。本文將依次介紹Server Push的背景、使用方法、基本原理和在餓了麼的應用。前端
要了解Server Push是什麼,以及它可以解決什麼問題,須要對Server Push誕生的背景有一個基本的認知。HTTP協議一般是在TCP上實現的,昂貴的TCP鏈接推進咱們採起各類優化手段來複用鏈接。HTTP/2的多路複用從協議層解決了這個問題。node
HTTP/1不支持多路複用,瀏覽器一般會與服務器創建多個底層的TCP鏈接。TCP鏈接很昂貴,所以在優化性能的時候每每也是從減小請求數的角度考慮的。好比開啓HTTP持久鏈接儘量地複用TCP鏈接、使用CSS Sprites技術、內聯靜態資源等。web
這樣的優化手段能夠極大提高頁面的加載速度,可是也有一些反作用:CSS Sprites增長了必定的複雜度,也讓圖片變得不那麼容易維護;內聯靜態資源更是把靜態資源的緩存策略與頁面的緩存策略綁在了一塊兒,用以後的頁面加載速度換取首次的加載速度。算法
能夠說,這些優化方式多少都含有一些妥協。然而,即使使用了這些優化方式,也不能徹底抵消因缺少多路複用帶來的低下的鏈接利用率。要治根,只能從協議自己入手。chrome
隨着HTTPS的普及,鏈接變得更昂貴了。除了創建和斷開TCP鏈接的消耗,還須要與服務器協商加密算法和交換密鑰。HTTP/2帶來了一系列協議上的優化,包括多路複用、頭部壓縮等等。最使人振奮的莫過於多路複用了。編程
HTTP/2定義了流(Stream)和幀(Frame)。基本協議單元變小了,從消息(Message)變成了幀;流做爲一種虛擬的通道,用來傳輸幀。與建立TCP鏈接相比,建立流的成本幾乎爲零。基本協議單元的變小也大大提升了鏈接的利用效率。後端
能夠說,HTTP/2的多路複用大大下降了因爲網絡延遲或者某個響應阻塞所帶來的傳輸效率的損耗。若是說網絡延遲對性能的影響能夠經過多路複用減少,那麼另外一種因爲資源之間的依賴關係致使的「延遲」是難以自動優化的。爲此,Server Push提供了一種手動優化的方案。瀏覽器
一般,只有在瀏覽器請求某個資源的時候,服務器纔會向瀏覽器發送該資源。Server Push則容許服務器在收到瀏覽器的請求以前,主動向瀏覽器推送資源。好比說,網站首頁引用了一個CSS文件。瀏覽器在請求首頁時,服務器除了返回首頁的HTML以外,能夠將其引用的 CSS文件也一併推給客戶端。
有些人對Server Push存在必定程度上的誤解,認爲這種技術可以讓服務器向瀏覽器發送「通知」,甚至將其與WebSocket進行比較。事實並不是如此,Server Push只是省去了瀏覽器發送請求的過程。只有當「若是不推送這個資源,瀏覽器就會請求這個資源」的時候,瀏覽器纔會使用推送過來的內容。若是瀏覽器自己就不會請求某個資源,那麼推送這個資源只會白白消耗帶寬。
資源內聯是指將CSS和JavaScript內聯到HTML中。這是一種面對昂貴的鏈接所達成的妥協,減小了請求數量,下降了延遲帶來的影響,提高了頁面的首次加載速度,卻讓這些本來能夠緩存好久的資源文件遵循與HTML頁面同樣的緩存策略。
Server Push和資源內聯是相似的。Server Push一樣以減小請求數量和提高頁面加載速度爲目標。與資源內聯的不一樣之處在於,Server Push推送的資源是獨立的、完整的響應,能夠與HTML頁面有着不一樣的緩存策略,從而更有效地使用緩存。
要使用Server Push,有3種方案可供選擇:
本身實現一個HTTP/2服務器;
使用支持Server Push的CDN;
使用支持Server Push的HTTP/2服務器。
第一種方案並不是是指從零開始實現一個HTTP/2服務器,僅僅是指從程序入手,直接對外暴露一個支持HTTP/2的服務器。大多數狀況下,咱們會使用現成的HTTP/2庫。好比node-http2,或者是Go 1.8的net/http。
第二和第三種方案經過設置響應頭或者修改HTTP服務器的配置文件,告知HTTP服務器要推送的資源,讓HTTP服務器完成資源的推送。
第一種方案更靈活,能夠編程決定推送的資源和推送的時機;第二和第三種方案更簡單,可是缺少必定的靈活性。
爲了方便起見,我將使用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.selfsignedcertific...在線生成、下載。
假設證書的文件名爲server.crt和server.key。
如下代碼實現了一個簡單的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 運行結果最終頁
在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面板中查看推送成功狀況
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的服務器軟件並很少。很遺憾,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一併推送給瀏覽器。
HTTP/2與HTTP/1最大的不一樣之處在於,前者在後者的基礎上定義了流和幀,實現了多路複用。這是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的實現不考慮客戶端的緩存,每次收到客戶端的請求,老是會發起推送。
規範中考慮到了這種狀況。客戶端在收到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。
這種機制能夠在必定程度上減小「多推」的狀況,不過也存在一些問題:
須要使用Cookie,佔用Cookie必定的空間;
不能自動遵循Cache-Control,須要自行實現緩存策略;
難以徹底避免「多推」的狀況,還可能會出現「漏推」。
所以,使用Server Push推送資源依然存在一些問題。在選擇要推送的資源時,應當考慮這些問題。最保守的作法是,只用Server Push推送原先內聯的資源,即使Server Push存在「多推」的問題,也比內聯資源來得好。固然,若是不太在乎流量,也可沒必要太過擔憂「多推」的問題,由於頁面速度的瓶頸每每不在於帶寬,而是延遲。
考慮到國內CDN對Server Push的支持和「多推」問題,目前咱們不使用Server Push推送靜態資源,而是推送動態資源(API 響應)。與靜態資源相比較,推送動態資源有如下區別:
更難被瀏覽器發現,瀏覽器只有在接收和解析完JavaScript文件,執行到相關語句的時候,纔會發送請求;
不須要緩存,也就不存在「多推」問題。
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有了進一步的瞭解。
本文首發於:「前端開發者說」公衆號(ID:bigfrontend)。本公衆號專一前端開發領域,播報熱點新聞、共享同行高手鑽研成果、展示企業最佳實踐及研發歷程,幫廣大前端開發者走好技術成長中的每一步。