咱們知道,HTTP/2 協議由兩個 RFC 組成:html
一個是 RFC 7540,描述了 HTTP/2 協議自己;一個是 RFC 7541,描述了 HTTP/2 協議中使用的頭部壓縮技術。node
本文將經過實際案例帶領你們詳細地認識 HTTP/2 頭部壓縮這門技術。python
爲何要壓縮
在 HTTP/1 中,HTTP 請求和響應都是由「狀態行、請求 / 響應頭部、消息主體」三部分組成。android
通常而言,消息主體都會通過 gzip 壓縮,或者自己傳輸的就是壓縮事後的二進制文件(例如圖片、音頻),git
但狀態行和頭部卻沒有通過任何壓縮,直接以純文本傳輸。github
隨着 Web 功能愈來愈複雜,每一個頁面產生的請求數也愈來愈多,根據 HTTP Archive 的統計,web
當前平均每一個頁面都會產生上百個請求。愈來愈多的請求致使消耗在頭部的流量愈來愈多,api
尤爲是每次都要傳輸 UserAgent、Cookie 這類不會頻繁變更的內容,徹底是一種浪費。瀏覽器
如下是我隨手打開的一個頁面的抓包結果。
能夠看到,傳輸頭部的網絡開銷超過 100kb,比 HTML 還多: 太誇張, 其實通常網站沒那麼大
下面是其中一個請求的明細。
能夠看到,爲了得到 58 字節的數據,在頭部傳輸上花費了好幾倍的流量: 太誇張
HTTP/1 時代,爲了減小頭部消耗的流量,有不少優化方案能夠嘗試,例如合併請求、啓用 Cookie-Free (就是靜態資源無cookie的意思)域名等等,
可是這些方案或多或少會引入一些新的問題,這裏不展開討論。(這句就是廢話)
壓縮後的效果
接下來我將使用訪問本博客的抓包記錄來講明 HTTP/2 頭部壓縮帶來的變化。
如何使用 Wireshark 對 HTTPS 網站進行抓包並解密,請看個人這篇文章。
首先直接上圖。下圖選中的 Stream 是首次訪問本站,瀏覽器發出的請求頭:
從圖片中能夠看到這個 HEADERS 流的長度是 206 個字節,而解碼後的頭部長度有 451 個字節。
因而可知,壓縮後的頭部大小減小了一半多。
然而這就是所有嗎?再上一張圖。下圖選中的 Stream 是點擊本站連接後,瀏覽器發出的請求頭:
能夠看到這一次,HEADERS 流的長度只有 49 個字節,可是解碼後的頭部長度卻有 470 個字節。
這一次,壓縮後的頭部大小几乎只有原始大小的 1/10。
爲何先後兩次差距這麼大呢? 咱們把兩次的頭部信息展開,查看同一個字段兩次傳輸所佔用的字節數:
對比後能夠發現,第二次的請求頭部之因此很是小,是由於大部分鍵值對只佔用了一個字節。
尤爲是 UserAgent、Cookie 這樣的頭部,首次請求中須要佔用不少字節,後續請求中都只須要一個字節。
技術原理
下面這張截圖,取自 Google 的性能專家 Ilya Grigorik 在 Velocity 2015 • SC 會議中分享的「HTTP/2 is here, let's optimize!」,
很是直觀地描述了 HTTP / 2 中頭部壓縮的原理:
我再用通俗的語言解釋下,頭部壓縮須要在支持 HTTP/2 的瀏覽器和服務端之間:
- 維護一份相同的靜態字典(Static Table), 包含常見的頭部名稱, 以及特別常見的頭部名稱與值的組合;
- 維護一份相同的動態字典(Dynamic Table), 能夠動態地添加內容;
- 支持基於 靜態哈夫曼碼錶的 哈夫曼編碼 (Huffman Coding);
靜態字典的做用有兩個:
1)對於徹底匹配的頭部鍵值對,例如 :method: GET
,能夠直接使用 一個字符 表示;
2)對於頭部名稱能夠 匹配的鍵值對,例如 cookie: xxxxxxx
,能夠將名稱使用一個字符表示。
HTTP/2 中的靜態字典以下(如下只截取了部分,完整表格在這裏):
Index | Header Name | Header Value |
---|---|---|
1 | :authority | |
2 | :method | GET |
3 | :method | POST |
4 | :path | / |
5 | :path | /index.html |
6 | :scheme | http |
7 | :scheme | https |
8 | :status | 200 |
... | ... | ... |
32 | cookie | |
... | ... | ... |
60 | via | |
61 | www-authenticate |
同時,瀏覽器能夠告知服務端,將 cookie: xxxxxxx
添加到動態字典中,這樣後續整個鍵值對就可使用 一個字符 表示了。
相似的,服務端也能夠 更新對方 的動態字典 。
須要注意的是,動態字典 上下文有關,須要爲 每一個 HTTP/2 鏈接 維護 不一樣的字典。
使用字典能夠極大地提高壓縮效果,其中靜態字典在首次請求中就可使用。
對於靜態、動態字典中不存在的內容,還可使用哈夫曼編碼來減少體積。
HTTP/2 使用了一份靜態哈夫曼碼錶(詳見),也須要內置在客戶端和服務端之中。
這裏順便說一下,HTTP/1 的狀態行信息(Method、Path、Status 等),
在 HTTP/2 中被拆成鍵值對放入頭部(冒號開頭的那些),一樣能夠享受到字典和哈夫曼壓縮。
另外,HTTP/2 中全部頭部名稱必須小寫。
實現細節
瞭解了 HTTP/2 頭部壓縮的基本原理,最後咱們來看一下具體的實現細節。
HTTP/2 的頭部鍵值對有如下這些狀況:
1)整個 頭部鍵值對都在字典中
0 1 2 3 4 5 6 7 +---+---+---+---+---+---+---+---+ | 1 | Index (7+) | +---+---------------------------+
這是最簡單的狀況,使用一個字節就能夠表示這個頭部了,
最左一位固定爲 1,以後七位存放鍵值對在 靜態 或 動態字典 中 的索引。
例以下圖中,頭部索引值爲 2(0000010),在靜態字典中查詢可得 :method: GET
。
2)頭部名稱在字典中,更新動態字典
0 1 2 3 4 5 6 7 +---+---+---+---+---+---+---+---+ | 0 | 1 | Index (6+) | +---+---+-----------------------+ | H | Value Length (7+) | +---+---------------------------+ | Value String (Length octets) | +-------------------------------+
對於這種狀況,首先須要使用一個字節表示頭部名稱:左兩位固定爲 01,以後六位存放頭部名稱在靜態或動態字典中的索引。
接下來的一個字節第一位 H 表示頭部值是否使用了哈夫曼編碼,剩餘七位表示頭部值的長度 L,後續 L 個字節就是頭部值的具體內容了。
例以下圖中索引值爲 32(100000),在靜態字典中查詢可得 cookie
;頭部值使用了哈夫曼編碼(1),長度是 28(0011100);
接下來的 28 個字節是 cookie
的值,將其進行哈夫曼解碼就能獲得具體內容。
客戶端或服務端看到這種格式的頭部鍵值對,會將其添加到本身的動態字典中。後續傳輸這樣的內容,就符合第 1 種狀況了。
3)頭部名稱不在字典中,更新動態字典
0 1 2 3 4 5 6 7 +---+---+---+---+---+---+---+---+ | 0 | 1 | 0 | +---+---+-----------------------+ | H | Name Length (7+) | +---+---------------------------+ | Name String (Length octets) | +---+---------------------------+ | H | Value Length (7+) | +---+---------------------------+ | Value String (Length octets) | +-------------------------------+
這種狀況與第 2 種狀況相似,只是因爲頭部名稱不在字典中,因此第一個字節固定爲 01000000;接着申明名稱是否使用哈夫曼編碼及長度,並放上名稱的具體內容;再申明值是否使用哈夫曼編碼及長度,最後放上值的具體內容。例以下圖中名稱的長度是 5(0000101),值的長度是 6(0000110)。對其具體內容進行哈夫曼解碼後,可得 pragma: no-cache
。
客戶端或服務端看到這種格式的頭部鍵值對,會將其添加到本身的動態字典中。後續傳輸這樣的內容,就符合第 1 種狀況了。
4)頭部名稱在字典中,不容許更新動態字典
0 1 2 3 4 5 6 7 +---+---+---+---+---+---+---+---+ | 0 | 0 | 0 | 1 | Index (4+) | +---+---+-----------------------+ | H | Value Length (7+) | +---+---------------------------+ | Value String (Length octets) | +-------------------------------+
這種狀況與第 2 種狀況很是相似,惟一不一樣之處是:第一個字節左四位固定爲 0001,只剩下四位來存放索引了,以下圖:
這裏須要介紹另一個知識點:對整數的解碼。上圖中第一個字節爲 00011111,並不表明頭部名稱的索引爲 15(1111)。第一個字節去掉固定的 0001,只剩四位可用,將位數用 N 表示,它只能用來表示小於「2 ^ N - 1 = 15」的整數 I。對於 I,須要按照如下規則求值(RFC 7541 中的僞代碼,via):
PYTHONif I < 2 ^ N - 1, return I # I 小於 2 ^ N - 1 時,直接返回 else M = 0 repeat B = next octet # 讓 B 等於下一個八位 I = I + (B & 127) * 2 ^ M # I = I + (B 低七位 * 2 ^ M) M = M + 7 while B & 128 == 128 # B 最高位 = 1 時繼續,不然返回 I return I
對於上圖中的數據,按照這個規則算出索引值爲 32(00011111 00010001,15 + 17),表明 cookie
。須要注意的是,協議中全部寫成(N+)的數字,例如 Index (4+)、Name Length (7+),都須要按照這個規則來編碼和解碼。
這種格式的頭部鍵值對,不容許被添加到動態字典中(但可使用哈夫曼編碼)。對於一些很是敏感的頭部,好比用來認證的 Cookie,這麼作能夠提升安全性。
5)頭部名稱不在字典中,不容許更新動態字典
0 1 2 3 4 5 6 7 +---+---+---+---+---+---+---+---+ | 0 | 0 | 0 | 1 | 0 | +---+---+-----------------------+ | H | Name Length (7+) | +---+---------------------------+ | Name String (Length octets) | +---+---------------------------+ | H | Value Length (7+) | +---+---------------------------+ | Value String (Length octets) | +-------------------------------+
這種狀況與第 3 種狀況很是相似,惟一不一樣之處是:第一個字節固定爲 00010000。這種狀況比較少見,沒有截圖,各位能夠腦補。一樣,這種格式的頭部鍵值對,也不容許被添加到動態字典中,只能使用哈夫曼編碼來減小體積。
實際上,協議中還規定了與 四、5 很是相似的另外兩種格式:將 四、5 格式中的第一個字節第四位由 1 改成 0 便可。它表示「本次不更新動態詞典」,而 四、5 表示「絕對不容許更新動態詞典」。區別不是很大,這裏略過。
明白了頭部壓縮的技術細節,理論上能夠很輕鬆寫出 HTTP/2 頭部解碼工具了。我比較懶,直接找來 node-http2 中的 compressor.js 驗證一下:
JSvar Decompressor = require('./compressor').Decompressor; var testLog = require('bunyan').createLogger({name: 'test'}); var decompressor = new Decompressor(testLog, 'REQUEST'); var buffer = new Buffer('820481634188353daded6ae43d3f877abdd07f66a281b0dae053fad0321aa49d13fda992a49685340c8a6adca7e28102e10fda9677b8d05707f6a62293a9d810020004015309ac2ca7f2c3415c1f53b0497ca589d34d1f43aeba0c41a4c7a98f33a69a3fdf9a68fa1d75d0620d263d4c79a68fbed00177febe58f9fbed00177b518b2d4b70ddf45abefb4005db901f1184ef034eff609cb60725034f48e1561c8469669f081678ae3eb3afba465f7cb234db9f4085aec1cd48ff86a8eb10649cbf', 'hex'); console.log(decompressor.decompress(buffer)); decompressor._table.forEach(function(row, index) { console.log(index + 1, row[0], row[1]); });
頭部原始數據來自於本文第三張截圖,運行結果以下(靜態字典只截取了一部分):
BASH{ ':method': 'GET', ':path': '/', ':authority': 'imququ.com', ':scheme': 'https', 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11; rv:41.0) Gecko/20100101 Firefox/41.0', accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'accept-language': 'en-US,en;q=0.5', 'accept-encoding': 'gzip, deflate', cookie: 'v=47; u=6f048d6e-adc4-4910-8e69-797c399ed456', pragma: 'no-cache' } 1 ':authority' '' 2 ':method' 'GET' 3 ':method' 'POST' 4 ':path' '/' 5 ':path' '/index.html' 6 ':scheme' 'http' 7 ':scheme' 'https' 8 ':status' '200' ... ... 32 'cookie' '' ... ... 60 'via' '' 61 'www-authenticate' '' 62 'pragma' 'no-cache' 63 'cookie' 'u=6f048d6e-adc4-4910-8e69-797c399ed456' 64 'accept-language' 'en-US,en;q=0.5' 65 'accept' 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' 66 'user-agent' 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11; rv:41.0) Gecko/20100101 Firefox/41.0' 67 ':authority' 'imququ.com'
能夠看到,這段從 Wireshark 拷出來的頭部數據能夠正常解碼,動態字典也獲得了更新(62 - 67)。
總結
在進行 HTTP/2 網站性能優化時很重要一點是「使用盡量少的鏈接數」,本文提到的頭部壓縮是其中一個很重要的緣由:同一個鏈接上產生的請求和響應越多,動態字典積累得越全,頭部壓縮效果也就越好。因此,針對 HTTP/2 網站,最佳實踐是不要合併資源,不要散列域名。
默認狀況下,瀏覽器會針對這些狀況使用同一個鏈接:
- 同一域名下的資源;
- 不一樣域名下的資源,可是知足兩個條件:1)解析到同一個 IP;2)使用同一個證書;
上面第一點容易理解,第二點則很容易被忽略。實際上 Google 已經這麼作了,Google 一系列網站都共用了同一個證書,能夠這樣驗證:
BASH$ openssl s_client -connect google.com:443 |openssl x509 -noout -text | grep DNS depth=2 C = US, O = GeoTrust Inc., CN = GeoTrust Global CA verify error:num=20:unable to get local issuer certificate verify return:0 DNS:*.google.com, DNS:*.android.com, DNS:*.appengine.google.com, DNS:*.cloud.google.com, DNS:*.google-analytics.com, DNS:*.google.ca, DNS:*.google.cl, DNS:*.google.co.in, DNS:*.google.co.jp, DNS:*.google.co.uk, DNS:*.google.com.ar, DNS:*.google.com.au, DNS:*.google.com.br, DNS:*.google.com.co, DNS:*.google.com.mx, DNS:*.google.com.tr, DNS:*.google.com.vn, DNS:*.google.de, DNS:*.google.es, DNS:*.google.fr, DNS:*.google.hu, DNS:*.google.it, DNS:*.google.nl, DNS:*.google.pl, DNS:*.google.pt, DNS:*.googleadapis.com, DNS:*.googleapis.cn, DNS:*.googlecommerce.com, DNS:*.googlevideo.com, DNS:*.gstatic.cn, DNS:*.gstatic.com, DNS:*.gvt1.com, DNS:*.gvt2.com, DNS:*.metric.gstatic.com, DNS:*.urchin.com, DNS:*.url.google.com, DNS:*.youtube-nocookie.com, DNS:*.youtube.com, DNS:*.youtubeeducation.com, DNS:*.ytimg.com, DNS:android.com, DNS:g.co, DNS:goo.gl, DNS:google-analytics.com, DNS:google.com, DNS:googlecommerce.com, DNS:urchin.com, DNS:youtu.be, DNS:youtube.com, DNS:youtubeeducation.com
使用多域名加上相同的 IP 和證書部署 Web 服務有特殊的意義:讓支持 HTTP/2 的終端只創建一個鏈接,用上 HTTP/2 協議帶來的各類好處;而只支持 HTTP/1.1 的終端則會創建多個鏈接,達到同時更多併發請求的目的。這在 HTTP/2 徹底普及前也是一個不錯的選擇。
本文就寫到這裏,但願能給對 HTTP/2 感興趣的同窗帶來幫助,也歡迎你們繼續關注本博客的「HTTP/2 專題」。