前言
為了降低載入時間,相信大多數人都做過如下嘗試
- Keep-alive: TCP持久連線,增加了TCP連線的複用性,但只有當上一個請求/響應完全完成後,client才能傳送下一個請求
- Pipelining: 可同時傳送多個請求,但是伺服器必須嚴格按照請求的先後順序返回響應,若第一個請求的響應遲遲不能返回,那後面的響應都會被阻塞,也就是所謂的隊頭阻塞
- 請求合併:雪碧圖,css/js內聯、css/js合併等,然而請求合併又會帶來快取失效、解析變慢、阻塞渲染、木桶效應等諸多問題
- 域名雜湊:繞過了同域名最多6個TCP的限制,但增加了DNS開銷和TCP開銷,也會大幅降低快取的利用率
- ……
不可否認,這些優化在一定程度上降低了網站載入時間,但對於一個web應用龐大的請求量來說,這些只是冰上一角、隔靴搔癢。
以上問題歸根結底是HTTP1.1協議本身的問題,若要從根本上解決HTTP1.1的低效,只能從協議本身入手。為此Google開發了SPDY協議,主要是為了降低傳輸時間;基於SPDY協議,IETF和SPDY組全體成員共同開發了HTTP/2,並在2015年5月以RFC 7504正式發表。SPDY或者HTTP/2並不是一個全新的協議,它只是修改了HTTP的請求與應答在網路上的傳輸方式,增加了一個spdy傳輸層,用於處理、標記、簡化和壓縮HTTP請求,所以它們並不會破壞現有程式的工作,對於支援的場景,使用新特性可以獲得更快的速度,對於不支援的場景,也可以實現平穩退化。
HTTP/2繼承了spdy的多路複用、優先順序排序等諸多優秀特性,也額外做了不少改進。其中較為顯著的改進是HTTP/2使用了一份經過定製的壓縮演算法,以此替代了SPDY的動態流壓縮演算法,用於避免對協議的Oracle攻擊。
多數主流瀏覽器已在2015年底支援了該標準(劃重點)。具體支援度如下:
可以看到國內有58.55%的瀏覽器已經完全支援HTTP/2,而全球的支援度更是高達85.66%。這麼高的支援度,so,你心動了嗎
why HTTP/2
二進位制格式傳輸
我們知道HTTP/1.1的頭資訊肯定是文字(ASCII編碼),資料體可以是文字,也可以是二進位制(需要做自己做額外的轉換,協議本身並不會轉換)。而在HTTP/2中,新增了二進位制分幀層,將資料轉換成二進位制,也就是說HTTP/2中所有的內容都是採用二進位制傳輸。
使用二進位制有什麼好處嗎?當然!效率會更高,而且最主要的是可以定義額外的幀,如果用文字實現幀傳輸,解析起來將會十分麻煩。HTTP/2共定義了十種幀,較為常見的有資料幀、頭部幀、PING幀、SETTING幀、優先順序幀和PUSH_PROMISE幀等,為將來的高階應用打好了基礎。
如上圖,Binary Framing就是新增的二進位制分幀層。
多路複用
二進位制分幀層把資料轉換為二進位制的同時,也把資料分成了一個一個的幀。幀是HTTP/2中資料傳輸的最小單位;每個幀都有stream_ID
欄位,表示這個幀屬於哪個流,接收方把stream_ID
相同的所有幀組合到一起就是被傳輸的內容了。而流是HTTP/2中的一個邏輯上的概念,它代表著HTTP/1.1中的一個請求或者一個響應,協議規定client發給server的流的stream_ID
為奇數,server發給client的流ID是偶數。需要注意的是,流只是一個邏輯概念,便於理解和記憶的,實際並不存在。
理解了幀和流的概念,完整的HTTP/2的通訊就可以被形象地表示為這樣:
可以發現,在一個TCP連結中,可以同時雙向地傳送幀,而且不同流中的幀可以交錯傳送,不需要等某個流傳送完,才傳送下一個。也就是說在一個TCP連線中,可以同時傳輸多個流,即可以同時傳輸多個HTTP請求和響應,這種同時傳輸不需要遵循先入先出等規定,因此也不會產生阻塞,效率極高。
在這種傳輸模式下,HTTP請求變得十分廉價,我們不需要再時刻顧慮網站的http請求數是否太多、TCP連線數是否太多、是否會產生阻塞等問題了。
HPACK 首部壓縮
為什麼需要壓縮?
在 HTTP/1 中,HTTP 請求和響應都是由「狀態行、請求 / 響應頭部、訊息主體」三部分組成。一般而言,訊息主體都會經過 gzip 壓縮,或者本身傳輸的就是壓縮過後的二進位制檔案(例如圖片、音訊),但狀態行和頭部卻沒有經過任何壓縮,直接以純文字傳輸。
隨著 Web 功能越來越複雜,每個頁面產生的請求數也越來越多,根據 HTTP Archive 的統計,當前平均每個頁面都會產生上百個請求。越來越多的請求導致消耗在頭部的流量越來越多,尤其是每次都要傳輸 UserAgent、Cookie 這類不會頻繁變動的內容,完全是一種浪費。
為了減少冗餘的頭部資訊帶來的消耗,HTTP/2採用HPACK 演算法壓縮請求和響應的header。下面這張圖非常直觀地表達了HPACK頭部壓縮的原理:
具體規則可以描述為:
- 通訊雙方共同維護了一份靜態表,包含了常見的頭部名稱與值的組合
- 根據先入先出的原則,維護一份可動態新增內容的動態表
- 用基於該靜態哈夫曼碼錶的哈夫曼編碼資料
當要傳送一個請求時,會先將其頭部和靜態表對照,對於完全匹配的鍵值對,可以直接使用一個數字表示,如上圖中的2:method: GET
,對於頭部名稱匹配的鍵值對,可以將名稱使用一個數字傳輸,如上圖中的19:path: /resource
,同時告訴服務端將它新增到動態表中,以後的相同鍵值對就用一個數字表示了。這樣,像cookie這些不經常變動的值,只用傳送一次就好了。
server push
在開始HTTP/2 server push 前,我們先來看看一個HTTP/1.1的頁面是如何載入的。
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="style.css">
<script src="user.js"></script>
</head>
<body>
<h1>hello http2</h1>
</body>
</html>
複製程式碼
- 瀏覽器向伺服器請求
/user.html
- 伺服器處理請求,把
/user.html
發給瀏覽器 - 瀏覽器解析收到的
/user.html
,發現還需要請求/user.js
和style.css
靜態資源 - 分別傳送兩個請求,獲取
/user.js
和style.css
- 伺服器分別響應兩個請求,傳送資源
- 瀏覽器收到資源,渲染頁面
至此,這個頁面才載入完畢,可以被使用者看到。可以發現在步驟3和4中,伺服器一直處於空閒等待狀態,而瀏覽器到第6步才能得到資源渲染頁面,這使頁面的首次載入變得緩慢。
而HTTP/2的server push允許伺服器在未收到請求時就向瀏覽器推送資源。即伺服器傳送/user.html
時,就可以主動把/user.js
和style.css
push給瀏覽器,使資源提前達到瀏覽器;除了靜態檔案,還可以推送比較耗時的API,只是需要提前將引數和cookie等資訊通過某個方式告知服務端(如和路由關聯)。Apache、GO的net/http、node-spdy都實現了server push(但ngnix沒有=_=),本文後面的實踐部分用node-spdy寫了一個極為簡陋的例子,有興趣的小夥伴可以動手嘗試一下。
Server push是HTTP/2協議裡面唯一一個需要開發者自己配置的功能。其他功能都是伺服器和瀏覽器自動實現,無需開發者介入。
在HTTP1.1時代,也有提前獲取資源的方法,如preload和prefetch,前者是在頁面解析初期就告訴瀏覽器,這個資源是瀏覽器馬上要用到的,可以立刻傳送對資源的請求,當需要用到該資源時就可以直接用而不用等待請求和響應的返回了;後者是當前頁面用不到但下一頁面可能會用到的資源,優先順序較低,只有當瀏覽器空閒時才會請求prefetch標記的資源。從應用層面上看,preload和server push並沒有什麼區別,但是server push減少瀏覽器請求的時間,略優於preload,在一些場景中,可以將兩者結合使用。
實戰
紙上談兵終覺淺,來實踐一下吧!親手搭建自己的 HTTP/2 demo,並抓包驗證。
spdy這個庫實現了 HTTP/2,同時也提供了對express的支援,所以這裡我選用spdy + express搭建demo。demo原始碼
路徑說明:
- ca/ 證照、祕鑰等檔案
- src/
- img/
- js/
- page1.html
- server.js
複製程式碼
HTTPS 祕鑰和證照
雖然HTTP/2有加密(h2)和非加密(h2c)兩種形式,但大多主流瀏覽器只支援h2-基於TLS/1.2或以上版本的加密連線,所以在搭建demo前,我們首先要自頒發一個證照,這樣就可以在瀏覽器訪問中使用https了,你可以自行搜尋證照頒發方法,也可以按照下述步驟去生成
首先要安裝open-ssl,然後執行以下命令
$ openssl genrsa -des3 -passout pass:x -out server.pass.key 2048
....
$ openssl rsa -passin pass:x -in server.pass.key -out server.key
writing RSA key
$ rm server.pass.key
$ openssl x509 -req -sha256 -days 365 -in server.csr -signkey server.key -out server.crt
....
$ openssl x509 -req -sha256 -days 365 -in server.csr -signkey server.key -out server.crt
複製程式碼
然後你就會得到三個檔案server.crt
, server.csr
, server.key
,將它們拷貝到ca資料夾中,稍後會用到。
搭建HTTP/2服務
express是一個Node.js框架,這裡我們用它宣告瞭路由/
,返回的html檔案page1.html
中引用了js和圖片等靜態資源。
// server.js
const http2 = require('spdy')
const express = require('express')
const app = express()
const publicPath = 'src'
app.use(express.static(publicPath))
app.get('/', function (req, res) {
res.setHeader('Content-Type', 'text/html')
res.sendFile(__dirname + '/src/page1.html')
})
var options = {
key: fs.readFileSync('./ca/server.key'),
cert: fs.readFileSync('./ca/server.crt')
}
http2.createServer(options, app).listen(8080, () => {
console.log(`Server is listening on https://127.0.0.1:8080 .`)
})
複製程式碼
用瀏覽器訪問https://127.0.0.1:8080/
,開啟控制檯可以看所有的請求和它們的瀑布圖:
可以清楚地看到,當第一個請求,也就是對document的請求完全返回並解析後,瀏覽器才開始發起對js和圖片等靜態資源的的請求。前面說過,server push允許伺服器主動向瀏覽器推送資源,那麼是否可以在第一個請求未完成時,就把接下來所需的js和img推送給瀏覽器呢?這樣不僅充分利用了HTTP/2的多路複用,還減少了伺服器的空閒等待時間。
對路由的處理函式進行改造:
app.get('/', function (req, res) {
+ push('/img/yunxin1.png', res, 'image/png')
+ push('/img/yunxin2.png', res, 'image/png')
+ push('/js/log3.js', res, 'application/javascript')
res.setHeader('Content-Type', 'text/html')
res.sendFile(__dirname + '/src/page1.html')
})
function push (reqPath, target, type) {
let content = fs.readFileSync(path.join(__dirname, publicPath, reqPath))
let stream = target.push(reqPath, {
status: 200,
method: 'GET',
request: { accept: '*/*' },
response: {
'content-type': type
}
})
stream.on('error', function() {})
stream.end(content)
}
複製程式碼
來看下應用了server push的瀑布圖:
很明顯,被push的靜態資源可以很快地被使用,而沒有被push的資源,如log1.js
和log2.js
則需要經過較長的時間才能被使用。
瀏覽器控制檯看到的東西畢竟很有限,我們來玩點更有意思的~
wireshark 抓包驗證
wireshark是一款可以識別HTTP/2的抓包工具,它的原理是直接讀取並分析網路卡資料,我們用它來驗證是否真正實現了HTTP/2以及其底層通訊原理。
首先去官網下載安裝包並安裝wireshark,這一步沒啥好說的。
我們知道,http/2裡的請求和響應都被拆分成了幀,如果我們直接去抓取HTTP/2通訊包,那抓到的只能是一幀一幀地資料,像這樣:
可以看到,抓到的都是TCP型別的包(紅色方框);觀察前三個包的內容(綠色方框),分別是SYN、[SYN, ACK]和ACK,這就我們所熟知的TCP三次握手;右下角的黃色小方框是請求當前頁面後抓到的TCP包的總數,其實這個頁面只有七八個請求,但抓到的包的數量卻有334個,這也驗證了HTTP/2的請求和響應的確是被分成了一幀一幀的。
抓HTTP1.1的包,我們可以清楚地看到都有哪些請求和響應,它們的協議、大小等,而HTTP/2的資料包卻是一幀一幀地,那麼怎麼看HTTP/2都有哪些請求和響應呢?其實wireshark會自動幫我們重組擁有相同stream_ID的幀,重組後就可看到實際有哪些請求和響應了,但是因為我們用的是https,所有的資料都被加密了,wireshark就不知道該怎麼去重組了。
有兩個辦法可以在wireshark中解密 HTTPS 流量:第一如果你擁有 HTTPS 網站的加密私鑰,可以用加密私鑰來解密這個網站的加密流量;2)某些瀏覽器支援將 TLS 會話中使用的對稱金鑰儲存在外部檔案中,可供 Wireshark 解密使用。
但是HTTP/2為了前向安全性,不允許使用RAS祕鑰交換,所有我們無法使用第一個方法來解密HTTP/2流量。介紹第二種方法:當系統環境變數中存在SSLKEYFILELOG時,Chrome和firefox會將對稱祕鑰儲存在該環境變數指向的檔案中,然後把這個檔案匯入wireshark,就可以解密HTTP/2流量了,具體做法如下:
- 新建ssl.log檔案
- 新增系統環境變數
SSLKEYFILELOG
,指向第一步建立的檔案 - 在wireshark中開啟 preferences->Protocols,找到SSL,將配置皮膚的 「(Pre)-Master-Secret log filename」選中第一步建立的檔案
這時用Chrome或Firefox訪問任何一個https頁面,ssl.log中應該就有寫入的祕鑰資料了。
解密完成後,我們就可以看到HTTP/2的包了
下圖是在demo的主頁面抓取的包,可以清楚地看到有哪些HTTP/2請求。
HTTP/2協議中的流和可以在一個TCP連線中交錯傳輸,只需建立一個TCP連線就可以完成和伺服器的所有通訊,我們來看下在demo中的HTTP/2是不是這樣的:
wireshark下方還有一個皮膚,裡面有當前包的具體資訊,如大小、源IP、目的IP、埠、資料、協議等,在Transmission Control Protocol下有一個[Stream index],如下圖,它是TCP連線的編號,代表當前包是從哪個TCP連線中傳輸的。觀察demo頁面請求產生的包,可以發現它們的stream index 都相同,說明這些HTTP/2請求和響應是在一個TCP連線中被傳輸的,這麼多流的確複用了一個TCP連線。
除了多路複用外,我們還可以通過抓包來觀察HTTP/2的頭部壓縮。下圖是當前路由下的第一個請求,實際被傳輸的頭部資料有253bytes,解壓後的頭部資訊有482bytes。壓縮後的大小減少了幾乎一半
但這只是第一個請求,我們看看後來的請求,如第三個,實際傳輸的頭部大小隻有30bytes,而解壓後的大小有441byte,壓縮後的體積僅為原來的1/14!如今web應用單是一個頁面就動輒幾百的請求數,HPACK能節約的流量可想而知。
結語
在文章開篇,我們列舉了HTTP1.x時代的困境,引入並簡要說明了HTTP/2的起源;然後對比著HTTP1.x,介紹了HTTP/2的諸多優秀特性,來說明為什麼選擇HTTP/2;在文章的最後一部分,介紹瞭如何一步一步搭建一個HTTP/2例項,並抓包觀察,驗證了HTTP/2的多路複用,頭部壓縮等特性。最後,您是否也被這些高效特性吸引了呢?動手試試吧~
參考: