從理論到實踐 全面理解HTTP/2

Claiyre發表於2019-02-18

前言

為了降低載入時間,相信大多數人都做過如下嘗試

  • 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年底支援了該標準(劃重點)。具體支援度如下:

HTTP/2支援度

資料來源

可以看到國內有58.55%的瀏覽器已經完全支援HTTP/2,而全球的支援度更是高達85.66%。這麼高的支援度,so,你心動了嗎

why HTTP/2

二進位制格式傳輸

我們知道HTTP/1.1的頭資訊肯定是文字(ASCII編碼),資料體可以是文字,也可以是二進位制(需要做自己做額外的轉換,協議本身並不會轉換)。而在HTTP/2中,新增了二進位制分幀層,將資料轉換成二進位制,也就是說HTTP/2中所有的內容都是採用二進位制傳輸。

使用二進位制有什麼好處嗎?當然!效率會更高,而且最主要的是可以定義額外的幀,如果用文字實現幀傳輸,解析起來將會十分麻煩。HTTP/2共定義了十種幀,較為常見的有資料幀、頭部幀、PING幀、SETTING幀、優先順序幀和PUSH_PROMISE幀等,為將來的高階應用打好了基礎。

HTTP/2

如上圖,Binary Framing就是新增的二進位制分幀層。

多路複用

二進位制分幀層把資料轉換為二進位制的同時,也把資料分成了一個一個的幀。幀是HTTP/2中資料傳輸的最小單位;每個幀都有stream_ID欄位,表示這個幀屬於哪個流,接收方把stream_ID相同的所有幀組合到一起就是被傳輸的內容了。而流是HTTP/2中的一個邏輯上的概念,它代表著HTTP/1.1中的一個請求或者一個響應,協議規定client發給server的流的stream_ID為奇數,server發給client的流ID是偶數。需要注意的是,流只是一個邏輯概念,便於理解和記憶的,實際並不存在。

理解了幀和流的概念,完整的HTTP/2的通訊就可以被形象地表示為這樣:

HTTP/2幀和流通訊示意圖

可以發現,在一個TCP連結中,可以同時雙向地傳送幀,而且不同流中的幀可以交錯傳送,不需要等某個流傳送完,才傳送下一個。也就是說在一個TCP連線中,可以同時傳輸多個流,即可以同時傳輸多個HTTP請求和響應,這種同時傳輸不需要遵循先入先出等規定,因此也不會產生阻塞,效率極高。

在這種傳輸模式下,HTTP請求變得十分廉價,我們不需要再時刻顧慮網站的http請求數是否太多、TCP連線數是否太多、是否會產生阻塞等問題了。

HPACK 首部壓縮

為什麼需要壓縮?

在 HTTP/1 中,HTTP 請求和響應都是由「狀態行、請求 / 響應頭部、訊息主體」三部分組成。一般而言,訊息主體都會經過 gzip 壓縮,或者本身傳輸的就是壓縮過後的二進位制檔案(例如圖片、音訊),但狀態行和頭部卻沒有經過任何壓縮,直接以純文字傳輸。

隨著 Web 功能越來越複雜,每個頁面產生的請求數也越來越多,根據 HTTP Archive 的統計,當前平均每個頁面都會產生上百個請求。越來越多的請求導致消耗在頭部的流量越來越多,尤其是每次都要傳輸 UserAgent、Cookie 這類不會頻繁變動的內容,完全是一種浪費。

為了減少冗餘的頭部資訊帶來的消耗,HTTP/2採用HPACK 演算法壓縮請求和響應的header。下面這張圖非常直觀地表達了HPACK頭部壓縮的原理:

HAPCK原理

圖片來源: Velocity 2015 • SC 會議分享

具體規則可以描述為:

  • 通訊雙方共同維護了一份靜態表,包含了常見的頭部名稱與值的組合
  • 根據先入先出的原則,維護一份可動態新增內容的動態表
  • 用基於該靜態哈夫曼碼錶的哈夫曼編碼資料

當要傳送一個請求時,會先將其頭部和靜態表對照,對於完全匹配的鍵值對,可以直接使用一個數字表示,如上圖中的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>
複製程式碼
  1. 瀏覽器向伺服器請求/user.html
  2. 伺服器處理請求,把/user.html發給瀏覽器
  3. 瀏覽器解析收到的/user.html,發現還需要請求/user.jsstyle.css靜態資源
  4. 分別傳送兩個請求,獲取/user.jsstyle.css
  5. 伺服器分別響應兩個請求,傳送資源
  6. 瀏覽器收到資源,渲染頁面

至此,這個頁面才載入完畢,可以被使用者看到。可以發現在步驟3和4中,伺服器一直處於空閒等待狀態,而瀏覽器到第6步才能得到資源渲染頁面,這使頁面的首次載入變得緩慢。

而HTTP/2的server push允許伺服器在未收到請求時就向瀏覽器推送資源。即伺服器傳送/user.html時,就可以主動把/user.jsstyle.csspush給瀏覽器,使資源提前達到瀏覽器;除了靜態檔案,還可以推送比較耗時的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/,開啟控制檯可以看所有的請求和它們的瀑布圖:

沒有push的瀑布圖

可以清楚地看到,當第一個請求,也就是對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的瀑布圖:

server push 瀑布圖

很明顯,被push的靜態資源可以很快地被使用,而沒有被push的資源,如log1.jslog2.js則需要經過較長的時間才能被使用。

瀏覽器控制檯看到的東西畢竟很有限,我們來玩點更有意思的~

wireshark 抓包驗證

wireshark是一款可以識別HTTP/2的抓包工具,它的原理是直接讀取並分析網路卡資料,我們用它來驗證是否真正實現了HTTP/2以及其底層通訊原理。

首先去官網下載安裝包並安裝wireshark,這一步沒啥好說的。

我們知道,http/2裡的請求和響應都被拆分成了幀,如果我們直接去抓取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流量了,具體做法如下:

  1. 新建ssl.log檔案
  2. 新增系統環境變數SSLKEYFILELOG,指向第一步建立的檔案
  3. 在wireshark中開啟 preferences->Protocols,找到SSL,將配置皮膚的 「(Pre)-Master-Secret log filename」選中第一步建立的檔案

SSL配置皮膚圖

這時用Chrome或Firefox訪問任何一個https頁面,ssl.log中應該就有寫入的祕鑰資料了。

解密完成後,我們就可以看到HTTP/2的包了

下圖是在demo的主頁面抓取的包,可以清楚地看到有哪些HTTP/2請求。

demo https圖

HTTP/2協議中的流和可以在一個TCP連線中交錯傳輸,只需建立一個TCP連線就可以完成和伺服器的所有通訊,我們來看下在demo中的HTTP/2是不是這樣的:

wireshark下方還有一個皮膚,裡面有當前包的具體資訊,如大小、源IP、目的IP、埠、資料、協議等,在Transmission Control Protocol下有一個[Stream index],如下圖,它是TCP連線的編號,代表當前包是從哪個TCP連線中傳輸的。觀察demo頁面請求產生的包,可以發現它們的stream index 都相同,說明這些HTTP/2請求和響應是在一個TCP連線中被傳輸的,這麼多流的確複用了一個TCP連線。

TCP id圖

除了多路複用外,我們還可以通過抓包來觀察HTTP/2的頭部壓縮。下圖是當前路由下的第一個請求,實際被傳輸的頭部資料有253bytes,解壓後的頭部資訊有482bytes。壓縮後的大小減少了幾乎一半

HTTP/2 HPACK

但這只是第一個請求,我們看看後來的請求,如第三個,實際傳輸的頭部大小隻有30bytes,而解壓後的大小有441byte,壓縮後的體積僅為原來的1/14!如今web應用單是一個頁面就動輒幾百的請求數,HPACK能節約的流量可想而知。

HTTP/2 HPACK

結語

在文章開篇,我們列舉了HTTP1.x時代的困境,引入並簡要說明了HTTP/2的起源;然後對比著HTTP1.x,介紹了HTTP/2的諸多優秀特性,來說明為什麼選擇HTTP/2;在文章的最後一部分,介紹瞭如何一步一步搭建一個HTTP/2例項,並抓包觀察,驗證了HTTP/2的多路複用,頭部壓縮等特性。最後,您是否也被這些高效特性吸引了呢?動手試試吧~

參考:

相關文章