HTTP/2 協議詳解
HTTP/2 協議詳解
作者保留所有權利。All rights reserved.
- ofollow,noindex" target="_blank">關於作者
目錄
HTTP/1.x
簡介
要想深刻的瞭解 HTTP/2
,那麼我們必須對 HTTP/1.x
本身以及它的缺點有一定程度的熟悉,而這一節,我們對 HTTP/1.x
的請求 形式以及其缺點進行一個簡單的回顧。首先, HTTP/1.x
的一個非常明顯的特徵是它是明文協議,也就是說,所有的內容,人類可以閱讀, 例如這是一個簡單的請求的樣子:
GET / HTTP/1.1 Host: jiajunhuang.com
這個請求表明,此HTTP請求,請求獲取 jiajunhuang.com
這個網站的 /
的內容,請求方法是 GET
,使用的協議是 HTTP/1.1
。
而這個網站很可能會返回如下響應:
HTTP/1.1 101 Switching Protocols Connection: Upgrade Upgrade: h2c
響應中,首先表明響應是使用 HTTP/1.1
,狀態碼是 101
,狀態碼的含義是 Switching Protocols
,接下來就是 HTTP/1.1
中 的頭部,此響應包含兩個頭部: Connection
, Upgrade
。
通過上面的講解,我們瞭解到了一些專有名詞,為了方便理解後續的內容,我們需要在此作出解釋:
- 明文協議:與明文協議對應的名詞是二進位制協議,這麼來簡單的理解一下,我們知道ASCII編碼是把8個bit讀取位一個byte,而這個 byte的型別是char,例如
a
對應的二進位制是0110 0001
,給一串有意義的明文協議的二進位制流,我們可以按照8個bit一組,翻譯成 可以顯示的英文字元。但是二進位制協議則不可以,因為儘管我們也可以按照8個bit一組去讀取並且顯示,但是結果是,我們得到的是 一些看不懂的亂碼,例如各種奇奇怪怪的符號。當然,這只是舉個例子,實際上二進位制流可能不是ASCII編碼,可能是UTF-8,那就需要 另外的規則去解析了。 - 客戶端:例如使用瀏覽器瀏覽網頁的例子裡,瀏覽器就是客戶端。
- 伺服器:例如使用瀏覽器瀏覽網頁的例子裡,生成網頁內容的那一方就是伺服器。
- 請求:例如使用瀏覽器瀏覽網頁的例子裡,瀏覽器需要告訴伺服器自己想要看什麼內容,這個步驟就叫請求。
- 響應:例如使用瀏覽器瀏覽網頁的例子裡,伺服器返回給瀏覽器的網頁就是響應。
- 頭部:HTTP/1.x 中,請求或者響應分為兩個部分,一部分是頭部,一部分是payload。頭部是最開始的用冒號分隔的那些鍵值對,例如
Connection: Upgrade
和Upgrade: h2c
就是頭部。 - 狀態碼:HTTP/1.x 中規定了一系列數字,我們稱之為狀態碼,例如,200代表成功,400代表客戶端所給的請求有問題。
回顧 HTTP/1.x
的請求流程
如果我們使用瀏覽器開啟一個網站,那麼流程通常是這樣的,瀏覽器傳送請求:
GET / HTTP/1.1 Host: jxufe.cn
而響應則是 http://jxufe.cn/
首頁的內容,是一個網頁,其中包含許多圖片和CSS以及JS等靜態資源,為了展示出最終的結果,瀏覽器 還需要把這些資源下載到本地並且進行渲染。而由於我們的瀏覽器並沒有開啟 HTTP/1.0
及以上支援的 Keep-Alive
,所以對於每一個 資源,瀏覽器都要新建一個TCP連線去下載資源。例如下圖是訪問 http://jxufe.cn
的網路請求示意圖:
從圖中我們可以看出來,有大批的資源要下載,而瀏覽器通常不能新建大量TCP連線,通常的實現是同一個網站開啟6個連線。所以如果每個 資源整個流程需要1秒鐘,那麼下載32個資源,就要32秒鐘,這對於使用者來說,體驗是極差的。即便開啟了 Keep-Alive
,由於 Head-of-line Blocking 的問題,也無法充分利用底層的TCP連線。
此外,如果我們點開每一個請求細看,我們可以發現,頭部中有大量的重複內容,例如:
Host: jxufe.cn Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8 User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36
等等。當請求量一大,這些重複的頭部其實浪費了很多資源。
而 HTTP/2
就是為了解決上述問題而設計的。
HTTP/2
簡介
首先我們點開這個網站來看看 HTTP/2
和 HTTP/1.1
在效能,或者說使用者體驗上的區別: https://imagekit.io/demo/http2-vs-http1 。 可以看出來, HTTP/2
的載入速度會比 HTTP/1.x
快很多,尤其是當你所處的網路環境比較差的時候,差別尤其明顯。那麼 HTTP/2
是 怎麼做到的呢?接下來我們會講到 HTTP/2
的一些特性,等你瞭解完之後,就可以知道為什麼 HTTP/2
在效能上會有如此大的提升了。 不過在此之前,我們需要先講一些前置知識。
-
擴充套件:位元組序 進行系統程式設計或者網路程式設計,一定要了解的一個概念是位元組序,什麼叫位元組序呢?就是位元組的順序。在計算機中,有大端和小端兩種分類, 其定義是,從左往右讀一系列位元組的時候,如果決定性更大的那一部分在左邊,決定性更小的那一部分在右邊,那麼這就是大端,反之則 是小端。我們拿一個十進位制的數字來舉例子,1234567,1的決定性更大,為什麼呢?如果1變成了2,那麼整個數字的數值將會加大許多, 而如果是7變成了9,則對整個數字的改變不會有太大(才2而已)。人類習慣的表示法都是大端,網路序也通常是大端(沒有明文規定,但是IP 協議中是如此約定,現實實現中也是如此)。
-
所有數值都是網路序
HTTP/2
中規定,所有的數值都是網路序。
二進位制分幀
HTTP/2
中有一個明顯的特徵就是,不再採用明文協議,轉而使用二進位制協議, HTTP/2
中引入了一個新的概念,叫做幀,原本在 HTTP/1.x
中,一個請求中包含頭部和payload,頭部和payload 的劃分規則是 \r\n\r\n
,而 HTTP/2
中,把頭部和payload分開,放入到兩種不同的 幀裡。
-
為什麼使用二進位制協議? 二進位制協議在解析的時候更加高效。所謂高效,我們必須和
HTTP/1.x
對比一下才知道,在HTTP/1.x
中,對於如下請求:GET / HTTP/1.1 Host: jiajunhuang.com
我們的解析順序是,一個位元組一個位元組讀取,首先讀到第一個空格為止,然後判斷所讀到的位元組長度以及內容,是
GET
,POST
等等HTTP/1.x
中規定的哪一種方法,然後繼續讀取到下一個空格,我們得到/
,意思是請求的內容是/
這個位置的內容,繼續讀取 得到HTTP/1.1\r\n
,這裡是說明使用的是HTTP/1.1
版本的協議,接下來的內容是頭部。總而言之,HTTP/1.x
的解析流程就是這樣的。而在接下來的內容中,我們可以看到,
HTTP/2
中,解析的流程是,讀取TCP流中的前面9個位元組,根據第4個位元組的數值,判斷出這個 幀的型別,然後根據前面3個位元組得出這個幀的payload有多長,繼續在流中讀取內容,並且進行解析和處理。 -
為何分幀 對於這個問題,我們可以聯想一下TCP為何要把資料分成一小塊一小塊進行傳輸呢?試想,如果我們的請求中包含的是一個1G的檔案的 內容,而我們一次性把檔案寫入流中,由於要保證解析的時候的簡便性,我們約定,一次只寫入一個完整的請求的內容,如同
HTTP/1.x
中所做的那樣,那麼在寫完這整個 1G 的檔案內容之前,我們都不能寫入其他內容。這種時候就體現出分幀的作用了, 如果我們把 請求的資料分塊n個塊,每次寫入1M呢?那麼在這個時候,就可以插入其他請求或者響應的內容了。但是這個時候我們要怎麼區分哪個 內容是哪個請求的呢?這就需要提到 stream(流) 這個概念了,我們在此暫時按下不表。 -
幀的型別及其格式 直接從RFC中把對於幀的格式抄過來看一下:
+-----------------------------------------------+ |Length (24)| +---------------+---------------+---------------+ |Type (8)|Flags (8)| +-+-------------+---------------+-------------------------------+ |R|Stream Identifier (31)| +=+=============================================================+ |Frame Payload (0...)... +---------------------------------------------------------------+
上面我們說到解析的時候,我們先讀取9個位元組,為什麼是9個位元組呢?從上面幀的格式我們可以看出來,因為24+8+8+1+31 = 72, 而8個bit為一個byte(位元組),所以是9個位元組,也就是72bit。我們需要解釋一下幀的格式定義中,各個塊的意義。
- Length: 這裡說明了幀的頭的後邊,
Frame Payload
的長度,它是一個24bit長的unsigned int,單位是byte。因此,通常 情況下,payload最多能傳輸2^14 (16,384)個byte,那如果想要傳輸更長怎麼辦呢?可以通過SETTINGS
幀,傳輸一個叫做SETTINGS_MAX_FRAME_SIZE
的設定來改變。 - Type: 這8個bit表示幀的型別,例如
0000 0000
表示這個幀是DATA
,而0000 0001
表示這個幀是HEADERS
等等。 - Flags: 這8個bit是留給各個型別的幀使用的,每個幀可以設定一些標誌位來表示一些特殊的意義,例如
HEADERS
幀中,可以設定 一個叫做END_HEADERS
的位來表示這個幀裡就已經傳輸了所有需要的頭部內容,如果沒有這個標誌的話,我們還需要繼續讀取內容 以便獲取完整的頭部。 - R: 這個位是空著的,沒有使用。
- Stream Identifier: 這是我們上面提到的stream,也就是流的ID,就是一個編號,stream我們會在下一節進行介紹。
- Frame Payload: 這就是這個幀實際需要攜帶的資料,注意上面所說的 Length,指的就是payload的長度,並不包括我們所說的幀的頭的長度。
- Length: 這裡說明了幀的頭的後邊,
-
Go程式碼解析示例 我們簡單來看一下Go語言中,是怎麼讀取一個幀的:
func readFrameHeader(buf []byte, r io.Reader) (FrameHeader, error) { _, err := io.ReadFull(r, buf[:frameHeaderLen]) if err != nil { return FrameHeader{}, err } return FrameHeader{ Length:(uint32(buf[0])<<16 | uint32(buf[1])<<8 | uint32(buf[2])), Type:FrameType(buf[3]), Flags:Flags(buf[4]), StreamID: binary.BigEndian.Uint32(buf[5:]) & (1<<31 - 1), valid:true, }, nil }
可以看出來,我們讀取frameHeaderLen,也就是9個位元組到buf,然後把前三個位元組的內容讀取出來,設定為uint32型別的數值來儲存, (因為沒有uint24),然後第4個位元組和第五個位元組分別儲存為Type和Flags,其餘位元組按照大端序讀出來,作為StreamID,讀取完幀 的頭之後,我們就可以根據Length的數值來讀取payload了,類似於:
buf := make([]byte, Length) io.ReadFull(r, buf)
流
-
為什麼要有流 前面提到了
HTTP/2
把資料分成了幀,而HTTP/2
還有一個重大特性就是多路複用,這是怎麼做到的呢?如果我們可以想辦法 讓客戶端和伺服器之間同時傳輸多個請求或者響應的話,就達到了我們的目的,但是我們要想個辦法區分哪些幀串起來可以組成一個 請求或者一個響應。有一個辦法,就是抽象出一個概念,我們給這個概念一個唯一的ID,因為TCP會保證順序,也就是說,我們是以 何種順序寫入幀,在讀取幀的時候就是何種順序,所以,我們讀取資料的時候,把相同ID的幀拼在一起,就可以組成一個請求或者 響應。而我們所說的這個抽象概念,就是stream(流)。 -
流的ID 我們已經知道了,通過流,我們可以做到多路複用。但是
HTTP/2
中還有一個特性,叫做server push,就是說,在建立連線之後, 伺服器可以主動向客戶端傳送資料,那麼問題來了,既然每個請求或者響應都要有一個ID,而伺服器和客戶端都可以同時向對方傳送 資料,每個幀裡都會包含一個流的ID,他們必須是唯一的,如何保證伺服器和客戶端生成的ID不會衝突呢?本地生成一個,然後發給 對方讓對方確認對方沒有佔用然後再使用該ID?這樣顯然太低效了,我們需要一個更好的方案。這讓我想起了一個面試題,分散式 環境中,怎麼設計一個發號器(這個發號器產生的ID必須保證全域性唯一)?方案一是,採用一個集中發號器,例如一個Redis或者 SQL/">MySQL中設定一個自增列,但是顯然這種方案不適用於HTTP/2
的情形;方案二是,每個子系統各自有一個發號器,例如1,2,3, 每次產生一個號碼之後,增加3。當全部產生完一輪號碼之後,三個子系統的號碼就變成了4,5,6,然後再進行下一輪。這種方案 很適合HTTP/2
,恰巧,它就是這樣做的。協議規定,客戶端使用奇數的ID,伺服器使用偶數的ID,ID不可以重複使用,每次發起新的 請求的時候,都會使用一個更大的ID。 -
狀態機以及狀態轉換 流的狀態機我們直接從RFC截圖過來:
我們先來看一下這張圖裡,流的7種狀態:
- idle:流目前尚未啟用
- open:流目前正在使用
- closed:流已經使用完成
- reserved(local):本地保留,即將要使用但是尚未使用
- reserved(remote):遠端保留,即將要使用但是未使用
- half closed(local):本地半關閉,即將關閉但是尚未關閉
- half closed(remote):遠端半關閉,即將關閉但是尚未關閉
然後看一下里邊的縮寫:
- H:HEADERS這種幀
- PP:PUSH_PROMISE這種幀
- ES:幀設定了END_STREAM這個flag
- R:RST_STREAM這種幀
我們熟悉了這些之後,就可以很容易的讀懂這個狀態轉換圖了,例如:
- 收到或者傳送HEADERS這種型別的幀會使流進入open狀態,也就是說,HEADERS一定會建立一個新的流
- 傳送PUSH_PROMISE的那一方會把流儲存為reserved(本地)的狀態,當傳送完HEADERS之後會變成half closed(remote)狀態
當然了,這個狀態轉換圖要結合RFC中各個細節描述一起來理解會更好。
-
流的優先順序
HTTP/2
中,流是可以設定優先順序的,怎麼設定哪個流優先呢?簡單,宣告這個流依賴於哪個流即可,這樣,優先傳輸其依賴的流, 再傳輸其本身,就可以體現出優先順序了,在 HEADERS 幀的payload裡設定 它所在的流所依賴的流的ID即可。這是用來開啟一個新的流的時候宣告依賴,那怎麼在流處於開啟狀態之後改變依賴順序呢?傳送 型別為 PRIORITY 的幀即可。除了可以設定流的依賴,還可以設定權重。沒有宣告依賴的流有一個預設的依賴的流,ID是0。舉個例子,下邊,A沒有宣告依賴,B和C都依賴A,A的依賴預設是0,也就是不存在的 一個流。
當收到一個新的依賴的時候,它會被插入到原有的依賴樹裡,所有的子樹不區分先後順序,例如現在收到一個新的流D,它依賴於A, 則會發生下圖的變化,當然,BDC的順序不一定是BDC,也可能是BCD等等。
AA / \==>/|\ BCB D C
此外,可以設定一個exclusive的flag,設定了這個flag之後,插入依賴樹的時候,會把所依賴的父節點的原有子節點下放,成為 自身的位元組點,而原來的父節點成為自身的父節點,也就是說,自己獨佔原來的父節點。例如上面的情況,如果收到新的流D,它 依賴於A,而且同時設定了exclusive的flag,就會發生下圖的變化:
A A| / \==>D BC/ \ BC
-
流的權重 我們還沒有看過HEADERS裡如果宣告流的依賴,二進位制的內容會是什麼格式呢:
+---------------+ |Pad Length? (8)| +-+-------------+-----------------------------------------------+ |E|Stream Dependency? (31)| +-+-------------+-----------------------------------------------+ |Weight? (8)| +-+-------------+-----------------------------------------------+ |Header Block Fragment (*)... +---------------------------------------------------------------+ |Padding (*)... +---------------------------------------------------------------+
我們可以發現,Stream Dependency下邊跟了個weight,接下來我們來看看上邊說的weight有什麼用。上邊說了,同一個節點下的 流是沒有先後順序的,但是,同一個節點下的位元組點,是有權重的,而這個權重,就是weight所宣告的權重。舉個例子,如果是 這樣的依賴:
A / \ BC
其中B的權重是4,C的權重是8,那麼當處理完A之後,B和C的資源分配比例是1:2。
-
flow control 流程控制是指,
HTTP/2
中,對流和其下方的TCP連線進行管理。進行管理的方式是傳送型別為WINDOW_UPDATE
的幀。 流程控制是逐跳的,也就是說,如果有A-B-C
三個參與方,流程控制只能是A-B
,B-C
之間各自有控制,B不能把A傳送的WINDOW_UPDATE
幀轉發到C。WINDOW_UPDATE
幀可以是針對流也可以是針對連線的,如果幀的頭部裡,StreamID是0,則是針對 連線的,否則,則是針對具體的流的。那麼流程控制裡設定的Window Size設定的是啥呢?Flow control only applies to frames that are identified as being subject to flow control. Of the frame types defined in this document, this includes only DATA frames.
如上,
WINDOW_UPDATE
只能設定DATA這種幀的payload的大小。 -
錯誤處理
HTTP/2
中有兩種型別的錯誤,一種是針對流的錯誤,一種是針對連線的錯誤。針對流的錯誤終止那個流的使用,針對連線的錯誤 終止整個TCP連線。
頭部壓縮
TODO(還沒細讀 rfc7541)
- 擴充套件:哈夫曼編碼
約定的錯誤
參考: https://tools.ietf.org/html/rfc7540#section-7
SETTINGS
中可以設定的內容
參考: https://tools.ietf.org/html/rfc7540#section-6.5.2
如何與 HTTP/1.x
相容
參考: https://tools.ietf.org/html/rfc7540#section-3
首先, HTTP/2
共用 http://
和 https://
這兩個scheme,也就是說,伺服器和客戶端要想辦法從 HTTP/1.x
的連線升級到 HTTP/2
的連線,大概的流程如下:
客戶端傳送如下請求:
GET / HTTP/1.1 Host: server.example.com Connection: Upgrade, HTTP2-Settings Upgrade: h2c HTTP2-Settings: <base64url encoding of HTTP/2 SETTINGS payload>
如果伺服器不支援 HTTP/2
,則如同往常一樣返回,但是不會出現Upgrade這個頭部,例如:
HTTP/1.1 200 OK Content-Length: 243 Content-Type: text/html ...
而如果伺服器支援 HTTP/2
,則返回101,帶上Upgrade這個頭部並且隨即開始的內容就是 HTTP/2
的內容:
HTTP/1.1 101 Switching Protocols Connection: Upgrade Upgrade: h2c [ HTTP/2 connection ...
但是注意,上面所說的開始 HTTP/2
的內容,是這樣的:客戶端立即傳送 Preface,伺服器收到後,也傳送Preface,然後就開始 各自發送不同的幀。Preface的內容是固定的: PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n
,其中客戶端無需等待收到伺服器傳送的Preface, 也就是說,客戶端傳送完Preface之後,就可以正常開始傳送各種幀了。