深入理解 web 協議:http 包體傳輸

開坑這個系列的原因,主要是在大前端學習的過程中遇到了不少跟 web 協議有關的問題,之前對這一塊的瞭解僅限於用 charles 抓個包,基本功欠缺。強迫症發作的我決定這一次徹底將 web 協議搞懂搞透,如果你遇到了和我一樣的問題,例如

  1. 對 http 的瞭解,僅限於 charles 抓個包。

  2. 對 https 的瞭解僅限於大概知道 tls,ssl,對稱加密,非對稱加密。真正一次完整的交互過程,無法做到心中有數。

  3. 對 h5 的各種緩存字段,瞭解的不夠。

  4. 移動端各種深度弱網優化的文章因爲基本功不紮實的原因各種看不懂。

  5. 有時作爲移動端開發在定技術方案的時候,因爲對前端或者服務器缺乏基本的瞭解,無法據理力爭制定出更完美的方案。

  6. 移動端中的網頁加載出了問題只能求助於前端工程師,無法第一時間自己定位問題。

  7. 每次想深度學習 web 協議的時候,因爲不會寫服務端程序導致只能泛泛而讀,隨意找幾篇網上的博客就得過且過了,並沒有真正解決心中的疑惑。沒有實際動手過。

  8. 沒有實際對照過瀏覽器和 Android 端使用 okhttp 在發送網絡請求的時候有什麼不同。

  9. 實話說現在 okhttp 的文章百分之 99 都忽略了真正實現 http 協議的部分,基本上都是簡要介紹了下 okhttp 的設計模式和上層的封裝,這其實對移動端工程師理解 web 協議本身是一個 debuff(我也是其中受害者。)

希望這個系列的文章可以幫助到和我一樣對 web 協議有困惑的工程師們。本系列文章中所有的服務端程序均使用 Go 語言開發完成。抓包工具使用的是 wireshark,沒有使用 charles 是因爲 charles 看不到傳輸層的東西,不利於我理解協議的本質。本系列文章沒有複製粘貼網上太多概念性的東西,以代碼和 wireshark 抓包爲主。概念性的東西需要讀者自行搜索。實戰有助於真正理解協議本身。

本文主要分爲四塊,如果覺得文章過長可以自行選擇感興趣的模塊閱讀:

  1. chrome network 面板的使用:主要以一個移動端工程師的視角來看 chrome 的 network 模塊,主要列舉了我認爲可能會對定位 h5 問題有幫助的幾個知識點。

  2. Connection-keeplive:主要闡述了現在客戶端 / 前端與服務端交互的方式,簡略介紹了服務端大概的樣子。

  3. http 隊頭擁塞: 主要以若干個實驗來理解 http 隊頭擁塞的本質,並給出 okhttp 與瀏覽器在策略上的不同。

  4. http 包體傳輸:以若干個實驗來理解 http 包體傳輸的過程。

一、chrome network 面板的使用

打開商城的頁面,打開 chrome 控制檯。

圖片

注意看紅色標註部分,左邊 disable cache 代表關閉瀏覽器緩存,打開這個選項之後,每次訪問頁面都會重新拉取而不是使用緩存,右邊的 online 可以下拉菜單選擇弱網環境下訪問此頁面。在模擬弱網環境的時候此方法通常非常有效。例如我們正常訪問的時候,耗時僅僅 2s。

圖片

打開弱網(fast 3g)

圖片

時間膨脹到了 26s.

圖片

這裏說一下這 2 個選項的作用,Preserve log 主要就是保存前一個頁面的請求日誌,比如我們在當前頁面 a 點擊了一個超鏈接訪問了頁面 b,那麼頁面 a 的請求在控制檯就看不到了,如果勾選此選項那麼就可以看到這個前面一個頁面的請求。另外這個 Hide data Urls,選項額外說明一下,有些 h5 頁面的某些資源會直接用 base64 轉碼以後嵌入到代碼裏面 (比如截圖中 data: 開頭的東西),這樣做的好處是可以省略一些 http 請求,當然壞處就是開啓此選項瀏覽器針對這個資源就沒有緩存了,且 base64 轉碼以後的體積大小要比原大小大 4/3。我們可以勾選此選項過濾掉這種我們不想看的東西。

再比如,我們只想看看同一個頁面下某一個域名的請求 (這在做競品分析時對競品使用域名數量的分析很有幫助),那我們也可以如下操作:

圖片

再比如說我們只想看一下這個頁面的 post 請求,或者是 get 請求也可以。

圖片

再比如我們可以用 is:from-cache 查看我們當前頁面哪些資源使用了緩存。large-than:(單位是字節)這個也很有用,通常我們利用這個過濾出超出大小的請求,看看有多少超大的圖片資源(移動端排查 h5 頁面速度慢的一個手段)。

我們也可以點擊其中一個請求,按住 shift,注意看藍色的就是我們選定的請求,綠色的代表這個請求是藍色請求的上游,也就是說只有當綠色的請求執行完畢以後纔會發出藍色的請求,而紅色的請求就代表只有藍色的請求執行完畢以後纔會請求。這種看請求上下游關係的方法是很多時候 h5 優化的一個技巧。將用戶最關心的資源請求前移,可以極大優化用戶體驗,雖然在某種程度上這種行爲並不會在數據上有所提高(例如 activity 之間跳轉用動畫,application 啓動優化用特殊 theme 等等,本質上耗時都沒有減少,但給用戶的感覺就是頁面和 app 速度很快)。

圖片

這個 timing 可以顯示一個請求的詳細分段時間,比如排隊時間,發出請求到第一個請求響應的字節時間,以及整個 response 都傳輸完畢的時間等等。有興趣的可以自行搜索下相關資料。

圖片

二、關於 Connection:Keep-Alive

在現代服務器架構中,客戶端的長連接大部分時候並不是直接和源服務器打交道(所謂源服務器可以粗略理解爲服務端開發兄弟實際代碼部署的那臺服務器),而是會經過很多代理服務器,這些代理服務器有的負責防火牆,有的負責負載均衡,還有的負責對消息進行路由分發 (例如對客戶端的請求根據客戶端的版本號,ios 還是 Android 等等分別將請求映射到不同的節點上) 等等。

圖片

客戶端的長連接僅僅意味着客戶端發起的這條 tcp 連接是和第一層代理服務器保持連接關係。並不會直接命中到原始服務器。

再看一張圖:

圖片

通常來講,我們的請求客戶端發出以後會經過若干個代理服務器纔會到我們的源服務器。如果我們的源服務器想基於客戶端的請求的 ip 地址來做一些操作,理論上就需要額外的 http 頭部支持了。因爲基於上述的架構圖,我們的源服務器拿到的地址是跟源服務器建立 tcp 連接的代理服務器的地址,壓根拿不到我們真正發起請求的客戶端 ip 地址。

http RFC 規範中,規定了 X-Forwarded-For 用於傳遞真正的 ip 地址。當然了在實際應用中有些代理服務器並不遵循此規定,例如 Nginx 就是利用的 X-Real-IP 這個頭部來傳遞真正的 ip 地址 (Nginx 默認不開啓此配置,需要手動更改配置項)。

在實際生產環境中,我們是可以在 http response 中將上述經過的代理服務器信息一一返回給客戶端的。

圖片

看這個 reponse 的返回裏面的頭部信息有一個 X-via 裏面的信息就是代理服務器的信息了。

再比如說 我們打開淘寶的首頁,找個請求。

圖片

這裏的代理服務器信息就更多了,說明這條請求經過了多個代理服務器的轉發。另外有時我們在技術會議上會聽到正向代理和反向代理,其實這 2 種代理都是指的代理服務器,作用都差不多,只不過應用的場景有一些區別。

**正向代理:**比如我們科學上網的時候,這種是我們明確知道我們想訪問外網的網站比如 facebook、谷歌等等,我們可以主動將請求轉發到一個代理服務器上,由代理服務器來轉發請求給 facebook,然後 facebook 將請求返回給代理服務器,服務器再轉發給我們。這種就叫正向代理了。

**反向代理:**這個其實我們每天都在用,我們訪問的服務器,99% 都是反向代理而來的,現代計算機系統中指的服務器往往都是指的服務器集羣了,我們在使用一個功能的時候,根本不知道到底要請求到哪一臺服務器,通常這種情況都是由 Nginx 來完成,我們訪問一個網站,dns 返回給我們的地址,通常都是一臺 Nginx 的地址,然後由 Nginx 自己來負責將這個請求轉發給他覺得應該轉發的那臺服務器。

這裏我們多次提到了 Nginx 服務器和代理服務器的概念,考慮到很多前端開發可能不太瞭解後端開發的工作,暫且在這裏簡單介紹一下。通常而言我們認爲的服務器開發工程師每天大部分的工作都是在應用服務器上開發,所謂 http 的應用服務器就是指可以動態生成內容的 http 服務器。比如 java 工程師寫完代碼以後打出包交給 Tomcat,Tomcat 本身就是一個應用服務器。再比如 Go 語言編譯生成好的可執行文件,也是一個 http 的應用服務器,還有 Python 的 simpleServer 等等。而 Nginx 或者 Apache 更像是一個單純的 http server,這個單純的 http server 幾乎沒有動態生成 http response 的能力,他們只能返回靜態的內容,或者做一次轉發,是一個很單純的 http server。嚴格意義上說,不管是 Tomcat 還是 Go 語言編譯出來的可執行文件還是 Python 等等,本質上他們也是 http server,也可以拿來做代理服務器的,只是通常情況下沒有人這麼幹,因爲術業有專攻,這種工作通常而言都是交給 Nginx 來做。

下圖是 Nginx 的簡要介紹: 用一個 Master 進程來管理 n 個 worker 進程,每個 worker 進程僅有一個線程在執行。

圖片

在 Nginx 之前,多數服務器都是開啓多線程或者多進程的工作模式,然而進程或者線程的切換是有成本的,如果訪問量過高,那麼 cpu 就會消耗大量的資源在創建進程或者創建線程,還有線程和進程之前的切換上,而 Nginx 則沒有使用類似的方案,而是採用了 “進程池單線程” 的工作模式,Nginx 服務器在啓動的時候會創建好固定數量的進程,然後在之後的運行中不會再額外創建進程,而且可以將這些進程和 cpu 綁定起來,完美的使用現代 cpu 中的多核心能力。

圖片

此外,web 服務器有 io 密集型的特點(注意是 io 密集不是 cpu 密集),大部分的耗時都在網絡開銷而非 cpu 計算上,所以 Nginx 使用了 io 多路複用的技術,Nginx 會將到來的 http 請求一一打散成一個個碎片,將這些碎片安排到單一的線程上,這樣只要發現這個線程上的某個碎片進入 io 等待了就立即切換出去處理其他請求,等確定可讀可寫以後再切回來。這樣就可以最大限度的將 cpu 的能力利用到極致。注意再次強調這裏的切換不是線程切換,你可以把他理解爲這個線程中要執行的程序裏面有很多 go to 的錨點,一旦發現某個執行碎片進入了 io 等待,就馬上利用 go to 能力跳轉到其他碎片(這裏的碎片就是指的 http 請求了)上繼續執行。

其實這個地方 Nginx 的工作模式有一點點類似於 Go 語言的協程機制,只不過 Go 語言中的若干個協程下面並不是只有一個線程,也可能有多個。但是思路都是一樣的,就是降低線程切換的開銷,儘量用少的線程來執行業務上的 “高併發” 需求。

然而 Nginx 再優秀,也抵不過歲月的侵蝕,說起來距離今天也有 15 年的時間了。還是有一些天生缺陷的,比如 Nginx 只要你修改了配置就必須手動將 Nginx 進程重啓 (master 進程),如果你的業務非常龐大,一旦遇到要修改配置的情況,幾百臺甚至幾千臺 Nginx 手動修改配置重啓不但容易出錯而且重複勞動意義也不大。此外 Nginx 可擴展性一般,因爲 Nginx 是 c 語言寫的,我們都知道 c 語言其實還是挺難掌握的,尤其是想要掌握的好更加難。不是每個人都有信心用 C 語言寫出良好可維護的代碼。尤其你的代碼還要跑在 Nginx 這種每天都要用的基礎服務上。

基於上述缺陷,阿里有一個綽號爲 “春哥” 的程序員章亦春,在 Nginx 的基礎上開發了更爲優秀的 OpenResty 開源項目,也是老羅錘子發佈會上說要贊助的那個開源項目。此項目可以對外暴露 Lua 腳本的接口,80 後玩過魔獸世界的同學一定對 Lua 語言不陌生,大名鼎鼎的魔獸世界的插件機制就是用 Lua 來完成的。OpenResty 出現以後終於可以用 Lua 腳本語言來操作我們的 Nginx 服務器了,這裏 Lua 也是用 “協程” 的概念來完成併發能力,與 Go 語言也是保持一致的。此外 OpenResty 對服務器配置的修改也可以及時生效,不需要再重啓服務器。大大提高運維的效率。等等等等。。。

三、http 協議中 “隊頭擁塞” 的真相

前文我們數次提到了服務器,高併發等關鍵字。我們印象中的服務器都是與高併發這 3 個字強關聯的。那麼所謂 http 中的 “隊頭擁塞” 到底指的是什麼呢?我們先來看一張圖:

圖片

這張互聯網中流傳許久的圖,到底應該怎麼理解?有的同學認爲 http 所謂的擁塞是因爲傳輸協議是 tcp 導致的,因爲 tcp 天生有擁塞的缺點。其實這句話並不全對。考慮如下場景:

  1. 網絡情況很好。

  2. 客戶端先用 socket 發送一組數據 a,2s 以後發送數據 b。

  3. 服務端收到數據 a 然後開始處理數據 a,然後收到數據 b,開始處理數據 b(這裏當然是開線程做)

  4. 此時服務端處理數據 b 的線程將數據 b 處理完畢以後開始將 b 的 responseb 發到客戶端,過了一段時間以後數據 a 的線程終於把數據處理完畢也將 responsea 發給客戶端。

  5. 在發送 responseb 的時候,客戶端甚至還可以同時發送數據 c,d,e。

上述的通信場景就是完美詮釋 tcp 作爲全雙工傳輸的能力了。相當於客戶端和服務端是有 2 條傳輸信道在工作。所以從這個角度上來看,tcp 不是導致 http 協議 “隊頭擁塞”的根本原因。因爲大家都知道 http 使用的傳輸層協議是 tcp. 只有在網絡環境不好的情況下,tcp 作爲可靠性協議,確實會出現不停重複發送數據包和等待數據包確認的情況。但是這不是 http “隊頭擁塞 “” 的根本原因。 

從這張圖上看,似乎 http 1.x 協議是隻有等前面的 http request 的 response 回來以後 後面的 http request 纔會發出去。但是這個角度上理解的話,服務器的效率是不是太低了一點?如果是這樣的話怎麼解釋我們每天打開網頁的速度都很快,打開 app 的速度也很快呢?經過一段時間的探索,我發現上述的圖是針對單 tcp 連接來說的,所謂的 http 隊頭擁塞 是指單條 tcp 連接上 纔會發生。而我們與服務器的一個域名交互的時候往往不止一條 tcp 連接。比如說 Chrome 瀏覽器就默認了最大限度可以和一個域名有 6 條 tcp 連接,這樣的話,即使有隊頭擁塞的現象,也可以保證我一臺服務器最多可以同時處理你這個 ip 發出來的 6 條 http 請求了。

爲什麼瀏覽器會限制 6 條?按照這個理論難道不是越多的 tcp 連接速度就越快嗎?但如果這樣做每個瀏覽器都針對單域名開多條 tcp 連接來加快訪問速度的話,服務器的 tcp 資源很快就會被耗盡,之後就是拒絕訪問了。然而道高一尺魔高一丈,既然瀏覽器限制了單一域名最多隻能使用 6 條 tcp 連接,那乾脆我們在一個頁面上訪問多個域名不就行了?實際上單一頁面訪問多域名也是前端優化中的一個點,瀏覽器只能限制你單一域名 6 條 tcp 連接,但是可沒限制你一個頁面可以有多個域名,我們多開幾個域名不就相當於多開了幾條 tcp 連接麼?這樣頁面的訪問速度就會大大增加了。

這裏我們有人可能會覺得好奇,瀏覽器限制了單一域名的 tcp 連接數量,那麼 Android 中我們每天使用的 okhttp 限制了嗎?限制了多少?來看下源碼:

圖片

okhttp 中默認對單一域名的 tcp 連接數量限制爲 5, 且對外暴露了設置這個值的方法。但是問題到這裏還沒完,單一 tcp 連接上,http 爲什麼要做成前一個消息的 response 回來以後,後面的 http request 才能發出去?這樣的設計是不是有問題?速度太慢了?還是說我們理解錯了?是不是還有一種可能是:

  1. http 消息可以在單一的 tcp 連接上 不停的發送,不需要等待前面一個 http 消息的返回以後再發送。

  2. 服務器接收了 http 消息以後先去處理這些消息,消息處理完畢準備發 response 的時候 再判斷一下,一定等到前面到達的 request 的 response 先發出去以後 再發,就好像一個先進先出的隊列那樣。這樣似乎也可以符合 “隊頭擁塞” 的設計?

帶着這個疑問,我做了一組實驗,首先我們寫一段服務端的代碼,提供 fast 和 slow2 個接口,其中 slow 接口 延遲 10 秒返回消息,fast 接口延遲 5 秒返回消息。

package main
import (
    "io"
    "net/http"
    "os"
    "time"
    "github.com/labstack/echo"
)
func main() {
    e := echo.New()
    e.GET("/slow", slowRes)
    e.GET("/fast", fastRes)
    e.Logger.Fatal(e.Start(":1329"))
}
func fastRes(c echo.Context) error {
    println("get fast request!!!!!")
    time.Sleep(time.Duration(5) * time.Second)
    return c.String(http.StatusOK, "fast reponse")
}
func slowRes(c echo.Context) error {
    println("get slow request!!!!!")
    time.Sleep(time.Duration(10) * time.Second)
    return c.String(http.StatusOK, "slow reponse")
}

(滑動可查看)

然後我們將這個服務器程序部署在雲上,另外再寫一段 Android 程序,我們讓這個程序發 http 請求的時候單一域名只能使用一條 tcp 連接,並且設置超時時間爲 20s(否則默認的 okhttp 響應超時時間太短 等不到服務器的返回就斷開連接了):

dispatcher = new Dispatcher();
dispatcher.setMaxRequestsPerHost(1);
client = new OkHttpClient.Builder().connectTimeout(20, TimeUnit.SECONDS).readTimeout(20, TimeUnit.SECONDS).dispatcher(dispatcher).build();
new Thread() {
    @Override
    public void run() {
        Request request = new Request.Builder().get().url("http://www.dailyreport.ltd:1329/slow").build();
        Call call = client.newCall(request);
        call.enqueue(new Callback() {
            @Override
            public void onFailure(Call call, IOException e) {
                Log.v("wuyue","slow e=="+e.getMessage());
            }
            @Override
            public void onResponse(Call call, Response response) throws IOException {
                Log.v("wuyue", "slow reponse==" + response.body().string());
            }
        });
    }
}.start();
new Thread() {
    @Override
    public void run() {
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        Request request = new Request.Builder().get().url("http://www.dailyreport.ltd:1329/fast").build();
        Call call = client.newCall(request);
        call.enqueue(new Callback() {
            @Override
            public void onFailure(Call call, IOException e) {
                Log.v("wuyue","fast e=="+e.getMessage());
            }
            @Override
            public void onResponse(Call call, Response response) throws IOException {
                Log.v("wuyue", "fast reponse==" + response.body().string());
            }
        });
    }
}.start();

(滑動可查看)

這裏要注意一定要使用 enqueue 也就是異步的方法來發送 http 請求,否則你設置的域名 tcp 連接數量限制是失效的。然後我們用 wireshark 來抓包看看:

圖片

這裏可以清晰的看出來,首先這 2 個 http request 都是使用的同一條 tcp 連接, 都是源端口號 60465 到服務器 1329.  然後看下 time 的時間,差不多 0s 開始發送了 slow 的請求,10s 左右收到了 slow 的 http response,然後馬上 fast 這個接口的 request 就發出去了,過了 5 秒 fast 的 http response 也返回了。

如果將這個域名 tcp 數量限制爲 1 改成 5 那麼再次抓包運行可以看到:

圖片

這個時候就可以清晰的看到,這一次 fast 大約在 slow 接口 2 秒以後就發出去了,並沒有等待 slow 回來以後再發,且注意看這 2 條 http 消息使用的源端口號是不同的,一個是 64683,一個是 64684。也就是說這裏使用了不同的 tcp 連接來傳輸我們的 http 消息。

綜上所述,我們可以對 http 1.x 中的 “隊頭擁塞” 來下結論了:

  1. 所謂 http1.x 中的 “隊頭擁塞”,除了本身傳輸層協議 tcp 的原因導致的 tcp 包擁塞機制以外,更多的是指的 http 應用層上的限制。這種限制具體表現在,對於 http 協議來說,單條 tcp 連接上客戶端要保證前面一條的 request 的 response 返回以後,才能發送後續的 request。

  2. http 1.x 中的 “隊頭擁塞” 本質上來說是由 http 的客戶端來保證實現的。

  3. 如果你訪問的網頁裏面的請求都指向着同一個域名,那麼不管服務器有多麼高的併發能力,他也最多隻能同時處理你的 6 條 http 請求,因爲大多數瀏覽器限制了針對單一域名只能開 6 條 tcp 連接。想翻過這個限制提高頁面加載速度只能依靠開啓多域名來實現。

  4. 雖然 okhttp 中對外暴露了這個單域名下的 tcp 連接數的設置,但是也無法通過將這個值調的特別高來增加你應用的請求響應速度,因爲大多數服務器都會限制單一 ip 的 tcp 連接數,比如 Nginx 的默認設置就是 10。所以你客戶端單一將這個數值調的特別大也沒用,除非你和服務器約定好。但是這樣還不如使用多域名方便了。

經過上面的分析,我們得知其實 http 1.x 協議並沒有完全發揮 tcp 全雙工通道的潛能,(也有可能是 http 協議出現的太早當時的設計者沒有考慮現在的場景)所以從 1.1 協議開始,又有了一個 Pipelining 也就是管道的約定。這個約定可以讓 http 的客戶端不用等前面一個 request 的 response 回來就可以繼續發後面的 request。但是各種原因下,現代瀏覽器都沒有開啓這個功能(相關資料感興趣的可以自行查詢 Pipelining 關鍵字,這裏就不復制粘貼了)。我帶着好奇搜索了一下 okhttp 的代碼,想看看他們有沒有類似的實現。最終我們在這個類中找到了線索:

圖片

看樣子貌似這個 tunnel 的命名和我們 http1.1 中所謂的 pipelining 好似一個意思?那麼 okhttp 中是可以使用這個瀏覽器默認關閉的技術了嗎?繼續看代碼:

圖片

我們看到這個值使用的地方是來自於 connectTunnel 這個方法,我們看看這個方法是在 connect 方法裏調用的:

圖片

我們看下這個方法的實現:

/**
 * Returns true if this route tunnels HTTPS through an HTTP proxy. See <a
 * href="http://www.ietf.org/rfc/rfc2817.txt">RFC 2817, Section 5.2</a>.
 */
public boolean requiresTunnel() {
  return address.sslSocketFactory != null && proxy.type() == Proxy.Type.HTTP;
}

(滑動可查看)

從註釋和 rfc 文檔中可以看出來,要開啓這個所謂的 tunnel 的功能,需要你的目標地址是 https 的,講白了是 tls 來做報文的傳輸,此外還需要一個 http 代理服務器。同時滿足這 2 個條件以後纔會觸發這部分代碼。這部分由於涉及到 tls 協議的相關知識,我們將這一塊的內容放到後續的第三個章節中再來解釋。這裏大家只需要大概清楚 tunnel 主要用來直接轉發傳輸層的 tcp 報文到目標服務器,而不需要經過 http 的代理服務器額外進行應用層報文的轉發即可。

四、http 包體傳輸的本質

比如說 Referer(我在谷歌中搜索 github,然後點擊 github 的鏈接,然後看請求信息)

圖片

這個字段通常通常被利用做防盜鏈,頁面來源統計分析,緩存優化等等。但是要注意的是,這個 Referer 字段瀏覽器在自動幫我們添加的時候有一個策略:要麼來源是 http 目標也是 http,要麼來源是 https 目標也是 https,一旦出現來源是 http 目標是 https 或者反着來的情況,瀏覽器就不會幫我們添加這個字段了。

此外,在 http 包體傳輸的時候,定長包體與不定長包體使用的單位是不一樣的。

圖片

比如 Content-Length 這個字段後面的單位就是 10 進制。傳輸的就是這個 “Hello, World!”。但是對於 Chunk 非定長包體來說 這個單位卻是 16 進制的,且對於 Chunk 傳輸方式來說,有一些 response 的 header 是等待 body 傳輸完畢以後才繼續傳的。我們來簡單寫個 server 端的例子,返回一個叫 hellowuyue 的 response,但是使用 chunk 的傳輸方式。這裏我簡單使用 Go 語言來完成對應的代碼。

package main
import (
    "net/http"
    "github.com/labstack/echo"
)
func main() {
    e := echo.New()
    //採用chunk傳輸 不使用默認的定長包體
    e.GET("/", func(c echo.Context) error {
        c.Response().WriteHeader(http.StatusOK)
        c.Response().Write([]byte("hello"))
        c.Response().Flush()
        c.Response().Write([]byte("wuyue"))
        c.Response().Flush()
        return nil
    })
    e.Logger.Fatal(e.Start(":1323"))
}

(滑動可查看)

我們在瀏覽器上訪問一下,看看 network 的展示信息:

圖片

然後我們用 wireshark 詳細的看一下 chunk 的傳輸機制:這裏要注意的是,我沒有選擇將我們服務端的代碼部署在外網服務器上,只是簡單的在本地,所以我們要選擇環回地址,不要選擇本地連接。同時監聽 1323 端口. 並且做 port 1323 的過濾器。

圖片

然後我們來看下 wireshark 完整的還原過程:

圖片

可以看一下這個 chunk 的結構,每一個 chunk 的結束都會伴隨着一個 0d0a 的 16 進制,這個我們可以把他理解成就是 / r/n 也就是 crlf 換行符。然後看一下 當 chunk 全部結束以後 還會有一個 end chunked 這裏面 也是包含了一個 0d0a 。(這裏篇幅所限就不放 ABNF 範式對 chunk 使用的規範了。有興趣的同學可以自行對照 ABNF 的規範語法和 wireshark 實際抓包的內容進行對比加深理解)

圖片

圖片

最後我們看一下,瀏覽器和服務端在利用 form 表單上傳文件時的交互過程以及 okhttp 完成類似功能時候的異同,加深對包體傳輸的理解。首先我們定義一個非常簡單的 html,提供一個表單。

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>上傳文件</title>
</head>
<body>
<h1>上傳文件</h1>
<form action="/uploadresult" method="post" enctype="multipart/form-data">
    Name: <input type="text" ><br>
    Files: <input type="file" ><br><br>
    Files2: <input type="file" ><br><br>
    <input type="submit" value="Submit">
</form>
</body>
</html>

(滑動可查看)

然後定義一下我們的服務端:

package main
import (
    "io"
    "net/http"
    "os"
    "time"
    "github.com/labstack/echo"
)
func main() {
    e := echo.New()
    //直接返回一個預先定義好的html
    e.GET("/uploadtest", func(c echo.Context) error {
        return c.File("html/upload.html")
    })
    //html裏預先定義好點擊上傳以後就跳轉到這個uri
    e.POST("/uploadresult", getFile)
    e.Logger.Fatal(e.Start(":1329"))
}
func getFile(c echo.Context) error {
    name := c.FormValue("name")
    println(" + name)
    file, _ := c.FormFile("file")
    file2, _ := c.FormFile("file2")
    src, _ := file.Open()
    src2, _ := file2.Open()
    dst, _ := os.Create(file.Filename)
    dst2, _ := os.Create(file2.Filename)
    io.Copy(dst, src)
    io.Copy(dst2, src2)
    return c.String(http.StatusOK, "上傳成功")
}

(滑動可查看)

然後我們訪問這個表單,上傳一下文件以後用 wireshark 抓個包來體會一下瀏覽器在背後幫我們做的事情。

圖片

圖片

關於這個 Content-Disposition 有興趣的可以自行搜索其含義。

最後我們用 okhttp 來完成這個操作,看看 okhttp 做這個操作的時候,wireshark 顯示的結果又是什麼樣子:

//注意看 contentType 是需要你手動去設置的,我們這裏故意將這個contentType值寫錯 看看能不能上傳文件成功
RequestBody requestBody1 = RequestBody.create(MediaType.parse("image/gifccc"), new File("/mnt/sdcard/ccc.txt"));
RequestBody requestBody2 = RequestBody.create(MediaType.parse("text/plain111"), new File("/mnt/sdcard/Gif.gif"));
RequestBody requestBody = new MultipartBody.Builder()
        .setType(MultipartBody.FORM).addFormDataPart("file2", "Gif.gif", requestBody1)
        .addFormDataPart("file", "ccc.txt", requestBody2)
        .addFormDataPart("name","吳越")
        .build();
Request request = new Request.Builder().get().url("http://47.100.237.180:1329/uploadresult").post(requestBody).build();

(滑動可查看)

圖片

五、總結

本章節初步介紹瞭如何使用 chrome 的 network 面板和 wireshark 抓包工具進行 http 協議的分析,重點介紹了 http1.x 協議中的 “隊頭擁塞” 的概念,以及該問題的應對方式和瀏覽器的限制策略。在後續的第二個章節中,將會詳細介紹 http 協議中緩存, dns 以及 websocket 的相關知識。在第三個章節中,將會詳細分析 http2 以及 tls 協議的每一個細節。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/WlT8070LlrnSODFRDwZsUQ