HTTP/2 協議詳解

HTTP/2 協議詳解

作者保留所有權利。All rights reserved.

目錄

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: UpgradeUpgrade: 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 的網絡請求示意圖:

HTTP/2 協議詳解

從圖中我們可以看出來,有大批的資源要下載,而瀏覽器通常不能新建大量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/2HTTP/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的長度,並不包括我們所說的幀的頭的長度。
  • 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 或者 MySQL 中設置一個自增列,但是顯然這種方案不適用於 HTTP/2 的情形;方案二是,每個子系統各自有一個發號器,例如1,2,3, 每次產生一個號碼之後,增加3。當全部產生完一輪號碼之後,三個子系統的號碼就變成了4,5,6,然後再進行下一輪。這種方案 很適合 HTTP/2 ,恰巧,它就是這樣做的。協議規定,客戶端使用奇數的ID,服務器使用偶數的ID,ID不可以重複使用,每次發起新的 請求的時候,都會使用一個更大的ID。

  • 狀態機以及狀態轉換 流的狀態機我們直接從RFC截圖過來:

    HTTP/2 協議詳解

    我們先來看一下這張圖裏,流的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等等。

    A                 A
      / \      ==>      /|\
     B   C             B D C

    此外,可以設置一個exclusive的flag,設置了這個flag之後,插入依賴樹的時候,會把所依賴的父節點的原有子節點下放,成爲 自身的字節點,而原來的父節點成爲自身的父節點,也就是說,自己獨佔原來的父節點。例如上面的情況,如果收到新的流D,它 依賴於A,而且同時設置了exclusive的flag,就會發生下圖的變化:

    A
       A                 |
      / \      ==>       D
     B   C              / \
                       B   C
  • 流的權重 我們還沒有看過HEADERS裏如果聲明流的依賴,二進制的內容會是什麼格式呢:

    +---------------+
    |Pad Length? (8)|
    +-+-------------+-----------------------------------------------+
    |E|                 Stream Dependency? (31)                     |
    +-+-------------+-----------------------------------------------+
    |  Weight? (8)  |
    +-+-------------+-----------------------------------------------+
    |                   Header Block Fragment (*)                 ...
    +---------------------------------------------------------------+
    |                           Padding (*)                       ...
    +---------------------------------------------------------------+

    我們可以發現,Stream Dependency下邊跟了個weight,接下來我們來看看上邊說的weight有什麼用。上邊說了,同一個節點下的 流是沒有先後順序的,但是,同一個節點下的字節點,是有權重的,而這個權重,就是weight所聲明的權重。舉個例子,如果是 這樣的依賴:

    A
      / \
     B   C

    其中B的權重是4,C的權重是8,那麼當處理完A之後,B和C的資源分配比例是1:2。

  • flow control 流程控制是指, HTTP/2 中,對流和其下方的TCP連接進行管理。進行管理的方式是發送類型爲 WINDOW_UPDATE 的幀。 流程控制是逐跳的,也就是說,如果有 A-B-C 三個參與方,流程控制只能是 A-BB-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之後,就可以正常開始發送各種幀了。

參考

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章