深入理解 web 協議(一)- http 包體傳輸

本文首發於 vivo互聯網技術 微信公衆號 
鏈接:https://mp.weixin.qq.com/s/WlT8070LlrnSODFRDwZsUQ
作者:吳越

開坑這個系列的原因,主要是在大前端學習的過程中遇到了不少跟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控制檯。

深入理解 web 協議(一)- http 包體傳輸

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

深入理解 web 協議(一)- http 包體傳輸

打開弱網(fast 3g)

深入理解 web 協議(一)- http 包體傳輸

時間膨脹到了26s.

深入理解 web 協議(一)- http 包體傳輸

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

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

深入理解 web 協議(一)- http 包體傳輸

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

深入理解 web 協議(一)- http 包體傳輸

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

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

深入理解 web 協議(一)- http 包體傳輸

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

深入理解 web 協議(一)- http 包體傳輸

二、關於Connection:Keep-Alive

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

深入理解 web 協議(一)- http 包體傳輸

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

再看一張圖:

深入理解 web 協議(一)- http 包體傳輸

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

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

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

深入理解 web 協議(一)- http 包體傳輸

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

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

深入理解 web 協議(一)- http 包體傳輸

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

正向代理:比如我們kexue上網的時候,這種是我們明確知道我們想訪問外網的網站比如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進程僅有一個線程在執行。

深入理解 web 協議(一)- http 包體傳輸

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

深入理解 web 協議(一)- http 包體傳輸

此外,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中的“隊頭擁塞”到底指的是什麼呢?我們先來看一張圖:

深入理解 web 協議(一)- 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限制了嗎?限制了多少?來看下源碼:

深入理解 web 協議(一)- http 包體傳輸

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來抓包看看:

深入理解 web 協議(一)- http 包體傳輸

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

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

深入理解 web 協議(一)- http 包體傳輸

這個時候就可以清晰的看到,這一次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的代碼,想看看他們有沒有類似的實現。最終我們在這個類中找到了線索:

深入理解 web 協議(一)- http 包體傳輸

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

深入理解 web 協議(一)- http 包體傳輸

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

深入理解 web 協議(一)- http 包體傳輸

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

/**
 * 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的鏈接,然後看請求信息)

深入理解 web 協議(一)- http 包體傳輸

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

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

深入理解 web 協議(一)- 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的展示信息:

深入理解 web 協議(一)- http 包體傳輸

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

深入理解 web 協議(一)- http 包體傳輸

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

深入理解 web 協議(一)- http 包體傳輸

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

深入理解 web 協議(一)- http 包體傳輸

深入理解 web 協議(一)- http 包體傳輸

最後我們看一下,瀏覽器和服務端在利用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" name="name"><br>
    Files: <input type="file" name="file"><br><br>
    Files2: <input type="file" name="file2"><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==" + 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抓個包來體會一下瀏覽器在背後幫我們做的事情。

深入理解 web 協議(一)- http 包體傳輸

深入理解 web 協議(一)- http 包體傳輸

關於這個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();

深入理解 web 協議(一)- http 包體傳輸

五、總結

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

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