HTTP 協議詳解(二)


前面一篇已經說過了 HTTP 的基本特性,HTTP 的發展史,前情回顧。這一篇就更詳細的 HTTP 協議使用過程一些參數配置,緩存,Cookie設置相關的細節做一些梳理。

數據類型與編碼

在 TCP/IP 協議棧里,傳輸數據基本上都是 header + body 的格式。但 TCP、UDP 因為是傳輸層的協議,它們不會關心 body 數據是什么,只要把數據發送到對方就算是完成了任務。

而 HTTP 協議則不同,它是應用層的協議,數據到達之后工作只能說是完成了一半,還必須要告訴上層應用這是什么數據才行,否則上層應用就會 不知所措 。

你可以設想一下,假如 HTTP 沒有告知數據類型的功能,服務器把 一大坨 數據發給了瀏覽器,瀏覽器看到的是一個 黑盒子 ,這時候該怎么辦呢?

當然,它可以 猜 。因為很多數據都是有固定格式的,所以通過檢查數據的前幾個字節也許就能知道這是個 GIF 圖片、或者是個 MP3 音樂文件,但這種方式無疑十分低效,而且有很大幾率會檢查不出來文件類型。

幸運的是,早在 HTTP 協議誕生之前就已經有了針對這種問題的解決方案,不過它是用在電子郵件系統里的,讓電子郵件可以發送 ASCII 碼以外的任意數據,方案的名字叫做 多用途互聯網郵件擴展 (Multipurpose Internet Mail Extensions),簡稱為 MIME。

MIME 是一個很大的標准規范,但 HTTP 只 順手牽羊 取了其中的一部分,用來標記 body 的數據類型,這就是我們平常總能聽到的 MIME type

MIME 把數據分成了八大類,每個大類下再細分出多個子類,形式是 type/subtype 的字符串,巧得很,剛好也符合了 HTTP 明文的特點,所以能夠很容易地納入 HTTP 頭字段里。

這里簡單列舉一下在 HTTP 里經常遇到的幾個類別:

  1. text:即文本格式的可讀數據,我們最熟悉的應該就是 text/html 了,表示超文本文檔,此外還有純文本 text/plain、樣式表 text/css 等。
  2. image:即圖像文件,有 image/gifimage/jpegimage/png 等。
  3. audio/video:音頻和視頻數據,例如 audio/mpegvideo/mp4 等。
  4. application:數據格式不固定,可能是文本也可能是二進制,必須由上層應用程序來解釋。常見的有 application/jsonapplication/javascriptapplication/pdf 等,另外,如果實在是不知道數據是什么類型,像剛才說的 黑盒 ,就會是 application/octet-stream,即不透明的二進制數據。

但僅有 MIME type 還不夠,因為 HTTP 在傳輸時為了節約帶寬,有時候還會壓縮數據,為了不要讓瀏覽器繼續 猜 ,還需要有一個 Encoding type ,告訴數據是用的什么編碼格式,這樣對方才能正確解壓縮,還原出原始的數據。

比起 MIME type 來說,Encoding type 就少了很多,常用的只有下面三種:

  1. gzip:GNU zip 壓縮格式,也是互聯網上最流行的壓縮格式;
  2. deflate:zlib(deflate)壓縮格式,流行程度僅次於 gzip;
  3. br:一種專門為 HTTP 優化的新壓縮算法(Brotli)。

大文件傳輸問題

數據壓縮

通常瀏覽器在發送請求時都會帶着 Accept-Encoding 頭字段,里面是瀏覽器支持的壓縮格式列表,例如 gzip、deflate、br 等,這樣服務器就可以從中選擇一種壓縮算法,放進 Content-Encoding 響應頭里,再把原數據壓縮后發給瀏覽器。

如果壓縮率能有 50%,也就是說 100K 的數據能夠壓縮成 50K 的大小,那么就相當於在帶寬不變的情況下網速提升了一倍,加速的效果是非常明顯的。

不過這個解決方法也有個缺點,gzip 等壓縮算法通常只對文本文件有較好的壓縮率,而圖片、音頻視頻等多媒體數據本身就已經是高度壓縮的,再用 gzip 處理也不會變小(甚至還有可能會增大一點),所以它就失效了。

分塊傳輸

壓縮是把大文件整體變小,我們可以反過來思考,如果大文件整體不能變小,那就把它 拆開 ,分解成多個小塊,把這些小塊分批發給瀏覽器,瀏覽器收到后再組裝復原。

這種 化整為零 的思路在 HTTP 協議里就是 chunked 分塊傳輸編碼,在響應報文里用頭字段 Transfer-Encoding: chunked 來表示,意思是報文里的 body 部分不是一次性發過來的,而是分成了許多的塊(chunk)逐個發送。

分塊傳輸也可以用於 流式數據 ,例如由數據庫動態生成的表單頁面,這種情況下 body 數據的長度是未知的,無法在頭字段 Content-Length 里給出確切的長度,所以也只能用 chunked 方式分塊發送。

Transfer-Encoding: chunkedContent-Length 這兩個字段是互斥的,也就是說響應報文里這兩個字段不能同時出現,一個響應報文的傳輸要么是長度已知,要么是長度未知(chunked),這一點你一定要記住。

下面我們來看一下分塊傳輸的編碼規則,其實也很簡單,同樣采用了明文的方式,很類似響應頭。

  1. 每個分塊包含兩個部分,長度頭和數據塊;
  2. 長度頭是以 CRLF(回車換行,即\r\n)結尾的一行明文,用 16 進制數字表示長度;
  3. 數據塊緊跟在長度頭后,最后也用 CRLF 結尾,但數據不包含 CRLF;
  4. 最后用一個長度為 0 的塊表示結束,即 0\r\n\r\n 。

1

范圍請求

有了分塊傳輸編碼,服務器就可以輕松地收發大文件了,但對於上 G 的超大文件,還有一些問題需要考慮。

比如,你在看當下正熱播的某穿越劇,想跳過片頭,直接看正片,或者有段劇情很無聊,想拖動進度條快進幾分鍾,這實際上是想獲取一個大文件其中的片段數據,而分塊傳輸並沒有這個能力。

HTTP 協議為了滿足這樣的需求,提出了 范圍請求 (range requests)的概念,允許客戶端在請求頭里使用專用字段來表示只獲取文件的一部分,相當於是**客戶端的 化整為零 **。

范圍請求不是 Web 服務器必備的功能,可以實現也可以不實現,所以服務器必須在響應頭里使用字段 Accept-Ranges: bytes 明確告知客戶端: 我是支持范圍請求的 。

如果不支持的話該怎么辦呢?服務器可以發送 Accept-Ranges: none,或者干脆不發送 Accept-Ranges 字段,這樣客戶端就認為服務器沒有實現范圍請求功能,只能老老實實地收發整塊文件了。

請求頭 Range 是 HTTP 范圍請求的專用字段,格式是 bytes=x - y ,其中的 x 和 y 是以字節為單位的數據范圍。

要注意 x、y 表示的是偏移量 ,范圍必須從 0 計數,例如前 10 個字節表示為 0-9 ,第二個 10 字節表示為 10-19 ,而 0-10 實際上是前 11 個字節。

Range 的格式也很靈活,起點 x 和終點 y 可以省略,能夠很方便地表示正數或者倒數的范圍。假設文件是 100 個字節,那么:

  • 0- 表示從文檔起點到文檔終點,相當於 0-99 ,即整個文件;
  • 10- 是從第 10 個字節開始到文檔末尾,相當於 10-99 ;
  • -1 是文檔的最后一個字節,相當於 99-99 ;
  • -10 是從文檔末尾倒數 10 個字節,相當於 90-99 。

服務器收到 Range 字段后,需要做四件事。

  1. 它必須檢查范圍是否合法,比如文件只有 100 個字節,但請求 200-300 ,這就是范圍越界了。服務器就會返回狀態碼 416,意思是你的范圍請求有誤我無法處理,請再檢查一下 。
  2. 如果范圍正確,服務器就可以根據 Range 頭計算偏移量,讀取文件的片段了,返回狀態碼 206 Partial Content ,和 200 的意思差不多,但表示 body 只是原數據的一部分。
  3. 服務器要添加一個響應頭字段 Content-Range,告訴片段的實際偏移量和資源的總大小,格式是 bytes x-y/length ,與 Range 頭區別在沒有 = ,范圍后多了總長度。例如,對於 0-10 的范圍請求,值就是 bytes 0-10/100 。
  4. 最后剩下的就是發送數據,直接把片段用 TCP 發給客戶端,一個范圍請求就算是處理完了。

多段數據

剛才說的范圍請求一次只獲取一個片段,其實它還支持在 Range 頭里使用多個 x - y ,一次性獲取多個片段數據。

這種情況需要使用一種特殊的 MIME 類型: multipart/byteranges ,表示報文的 body 是由多段字節序列組成的,並且還要用一個參數 boundary=xxx 給出段之間的分隔標記。

多段數據的格式與分塊傳輸也比較類似,但它需要用分隔標記 boundary 來區分不同的片段,可以通過圖來對比一下。

2

每一個分段必須以 - -boundary 開始(前面加兩個 - ),之后要用 Content-TypeContent-Range 標記這段數據的類型和所在范圍,然后就像普通的響應頭一樣以回車換行結束,再加上分段數據,最后用一個 - -boundary- - (前后各有兩個 - )表示所有的分段結束。

要注意這四種方法不是互斥的,而是可以混合起來使用,例如壓縮后再分塊傳輸,或者分段后再分塊。

HTTP的重定向和跳轉

前面講過 HTTP 狀態碼的時候說過:3×× 狀態碼,301 是 永久重定向 ,302 是 臨時重定向 ,瀏覽器收到這兩個狀態碼就會跳轉到新的 URI。

那么,它們是怎么做到的呢?難道僅僅用這兩個代碼就能夠實現跳轉頁面嗎?

先在實驗環境里看一下重定向的過程吧,用 Chrome 訪問 URI /18-1 ,它會使用 302 立即跳轉到 /index.html 。

3

從這個實驗可以看到,這一次 重定向 實際上發送了兩次 HTTP 請求,第一個請求返回了 302,然后第二個請求就被重定向到了 /index.html 。但如果不用開發者工具的話,你是完全看不到這個跳轉過程的,也就是說,重定向是 用戶無感知 的。

我們再來看看第一個請求返回的響應報文:

4

這里出現了一個新的頭字段 Location: /index.html,它就是 301/302 重定向跳轉的秘密所在。

Location 字段屬於響應字段,必須出現在響應報文里。但只有配合 301/302 狀態碼才有意義,它標記了服務器要求重定向的 URI,這里就是要求瀏覽器跳轉到 index.html 。

瀏覽器收到 301/302 報文,會檢查響應頭里有沒有 Location 。如果有,就從字段值里提取出 URI,發出新的 HTTP 請求,相當於自動替我們點擊了這個鏈接。

在 Location 里的 URI 既可以使用絕對 URI,也可以使用相對 URI。所謂 絕對 URI ,就是完整形式的 URI,包括 scheme、host:port、path 等。所謂 相對 URI ,就是省略了 scheme 和 host:port,只有 path 和 query 部分,是不完整的,但可以從請求上下文里計算得到。

HTTP 是 無狀態 的,這既是優點也是缺點。優點是服務器沒有狀態差異,可以很容易地組成集群,而缺點就是無法支持需要記錄狀態的事務操作。

那該怎么樣讓原本無 記憶能力 的服務器擁有 記憶能力 呢?服務器記不住,那就在外部想辦法記住。相當於是服務器給每個客戶端都貼上一張小紙條,上面寫了一些只有服務器才能理解的數據,需要的時候客戶端把這些信息發給服務器,服務器看到 Cookie,就能夠認出對方是誰了。

Cookie 的工作過程

那么,Cookie 這張小紙條是怎么傳遞的呢?

這要用到兩個字段:響應頭字段 Set-Cookie 和請求頭字段 Cookie

當用戶通過瀏覽器第一次訪問服務器的時候,服務器肯定是不知道他的身份的。所以,就要創建一個獨特的身份標識數據,格式是 key=value ,然后放進 Set-Cookie 字段里,隨着響應報文一同發給瀏覽器。

瀏覽器收到響應報文,看到里面有 Set-Cookie,知道這是服務器給的身份標識,於是就保存起來,下次再請求的時候就自動把這個值放進 Cookie 字段里發給服務器。

因為第二次請求里面有了 Cookie 字段,服務器就知道這個用戶不是新人,之前來過,就可以拿出 Cookie 里的值,識別出用戶的身份,然后提供個性化的服務。

不過因為服務器的 記憶能力 實在是太差,一張小紙條經常不夠用。所以,服務器有時會在響應頭里添加多個 Set-Cookie,存儲多個 key=value 。但瀏覽器這邊發送時不需要用多個 Cookie 字段,只要在一行里用 ; 隔開就行。

Cookie 的屬性

首先,我們應該設置 Cookie 的生存周期,也就是它的有效期,讓它只能在一段時間內可用,就像是食品的 保鮮期 ,一旦超過這個期限瀏覽器就認為是 Cookie 失效,在存儲里刪除,也不會發送給服務器。

Cookie 的有效期可以使用 ExpiresMax-Age 兩個屬性來設置。

Expires 俗稱 過期時間 ,用的是絕對時間點,可以理解為 截止日期 (deadline)。 Max-Age 用的是相對時間,單位是秒,瀏覽器用收到報文的時間點再加上 Max-Age,就可以得到失效的絕對時間。

ExpiresMax-Age 可以同時出現,兩者的失效時間可以一致,也可以不一致,但瀏覽器會優先采用 Max-Age 計算失效期。

比如在這個例子里,Expires 標記的過期時間是 GMT 2019 年 6 月 7 號 8 點 19 分 ,而 Max-Age 則只有 10 秒,如果現在是 6 月 6 號零點,那么 Cookie 的實際有效期就是 6 月 6 號零點過 10 秒 。

其次,我們需要設置 Cookie 的作用域,讓瀏覽器僅發送給特定的服務器和 URI,避免被其他網站盜用。

作用域的設置比較簡單, DomainPath 指定了 Cookie 所屬的域名和路徑,瀏覽器在發送 Cookie 前會從 URI 中提取出 host 和 path 部分,對比 Cookie 的屬性。如果不滿足條件,就不會在請求頭里發送 Cookie。

使用這兩個屬性可以為不同的域名和路徑分別設置各自的 Cookie,比如 /19-1 用一個 Cookie, /19-2 再用另外一個 Cookie,兩者互不干擾。不過現實中為了省事,通常 Path 就用一個 / 或者直接省略,表示域名下的任意路徑都允許使用 Cookie,讓服務器自己去挑。

最后要考慮的就是Cookie 的安全性了,盡量不要讓服務器以外的人看到。

寫過前端的同學一定知道,在 JS 腳本里可以用 document.cookie 來讀寫 Cookie 數據,這就帶來了安全隱患,有可能會導致 跨站腳本 (XSS)攻擊竊取數據。

屬性 HttpOnly 會告訴瀏覽器,此 Cookie 只能通過瀏覽器 HTTP 協議傳輸,禁止其他方式訪問,瀏覽器的 JS 引擎就會禁用 document.cookie 等一切相關的 API,腳本攻擊也就無從談起了。

另一個屬性 SameSite 可以防范 跨站請求偽造 (XSRF)攻擊,設置成SameSite=Strict 可以嚴格限定 Cookie 不能隨着跳轉鏈接跨站發送,而 SameSite=Lax 則略寬松一點,允許 GET/HEAD 等安全方法,但禁止 POST 跨站發送。

還有一個屬性叫 Secure ,表示這個 Cookie 僅能用 HTTPS 協議加密傳輸,明文的 HTTP 協議會禁止發送。但 Cookie 本身不是加密的,瀏覽器里還是以明文的形式存在。

HTTP的緩存控制

由於鏈路漫長,網絡時延不可控,瀏覽器使用 HTTP 獲取資源的成本較高。所以,非常有必要把 來之不易 的數據緩存起來,下次再請求的時候盡可能地復用。這樣,就可以避免多次請求 - 應答的通信成本,節約網絡帶寬,也可以加快響應速度。

服務器的緩存控制

  1. 瀏覽器發現緩存無數據,於是發送請求,向服務器獲取資源;
  2. 服務器響應請求,返回資源,同時標記資源的有效期;
  3. 瀏覽器緩存資源,等待下次重用。

服務器標記資源有效期使用的頭字段是 Cache-Control ,里面的值 max-age=30 就是資源的有效時間,相當於告訴瀏覽器, 這個頁面只能緩存 30 秒,之后就算是過期,不能用。

你可能要問了,讓瀏覽器直接緩存數據就好了,為什么要加個有效期呢?

這是因為網絡上的數據隨時都在變化,不能保證它稍后的一段時間還是原來的樣子。就像生鮮超市給你快遞的西瓜,只有 5 天的保鮮期,過了這個期限最好還是別吃,不然可能會鬧肚子。

Cache-Control 字段里的 max-age 和上一講里 Cookie 有點像,都是標記資源的有效期。

這里的 max-age生存時間 (又叫 新鮮度 緩存壽命 ,類似 TTL,Time-To-Live),時間的計算起點是響應報文的創建時刻(即 Date 字段,也就是離開服務器的時刻),而不是客戶端收到報文的時刻,也就是說包含了在鏈路傳輸過程中所有節點所停留的時間。

比如,服務器設定 max-age=5,但因為網絡質量很糟糕,等瀏覽器收到響應報文已經過去了 4 秒,那么這個資源在客戶端就最多能夠再存 1 秒鍾,之后就會失效。

max-age 是 HTTP 緩存控制最常用的屬性,此外在響應報文里還可以用其他的屬性來更精確地指示瀏覽器應該如何使用緩存:

  • no_store:不允許緩存,用於某些變化非常頻繁的數據,例如秒殺頁面;
  • no_cache:它的字面含義容易與 no_store 搞混,實際的意思並不是不允許緩存,而是可以緩存,但在使用之前必須要去服務器驗證是否過期,是否有最新的版本;
  • must-revalidate:又是一個和 no_cache 相似的詞,它的意思是如果緩存不過期就可以繼續使用,但過期了如果還想用就必須去服務器驗證。

聽的有點糊塗吧。沒關系,我拿生鮮速遞來舉例說明一下:

  • no_store:買來的西瓜不允許放進冰箱,要么立刻吃,要么立刻扔掉;
  • no_cache:可以放進冰箱,但吃之前必須問超市有沒有更新鮮的,有就吃超市里的;
  • must-revalidate:可以放進冰箱,保鮮期內可以吃,過期了就要問超市讓不讓吃。

我把服務器的緩存控制策略畫了一個流程圖,對照着它你就可以在今后的后台開發里明確 Cache-Control 的用法了。

5

客戶端的緩存控制

現在冰箱里已經有了 緩存 的西瓜,是不是就可以直接開吃了呢?

你可以在 Chrome 里點幾次 刷新 按鈕,估計你會失望,頁面上的 ID 一直在變,根本不是緩存的結果,明明說緩存 30 秒,怎么就不起作用呢?

其實不止服務器可以發 Cache-Control 頭,瀏覽器也可以發 Cache-Control ,也就是說 請求 - 應答 的雙方都可以用這個字段進行緩存控制,互相協商緩存的使用策略。

當你點 刷新 按鈕的時候,瀏覽器會在請求頭里加一個 Cache-Control: max-age=0 。因為 max-age 是 生存時間 ,max-age=0 的意思就是 我要一個最最新鮮的西瓜 ,而本地緩存里的數據至少保存了幾秒鍾,所以瀏覽器就不會使用緩存,而是向服務器發請求。服務器看到 max-age=0,也就會用一個最新生成的報文回應瀏覽器。

Ctrl+F5 的 強制刷新 又是什么樣的呢?

它其實是發了一個 Cache-Control: no-cache ,含義和 max-age=0 基本一樣,就看后台的服務器怎么理解,通常兩者的效果是相同的。

條件請求

瀏覽器用 Cache-Control 做緩存控制只能是刷新數據,不能很好地利用緩存數據,又因為緩存會失效,使用前還必須要去服務器驗證是否是最新版。

那么該怎么做呢?

瀏覽器可以用兩個連續的請求組成 驗證動作 :先是一個 HEAD,獲取資源的修改時間等元信息,然后與緩存數據比較,如果沒有改動就使用緩存,節省網絡流量,否則就再發一個 GET 請求,獲取最新的版本。

但這樣的兩個請求網絡成本太高了,所以 HTTP 協議就定義了一系列 If 開頭的 條件請求 字段,專門用來檢查驗證資源是否過期,把兩個請求才能完成的工作合並在一個請求里做。而且,驗證的責任也交給服務器,瀏覽器只需 坐享其成 。

條件請求一共有 5 個頭字段,我們最常用的是 if-Modified-SinceIf-None-Match 這兩個。需要第一次的響應報文預先提供 Last-modifiedETag ,然后第二次請求時就可以帶上緩存里的原值,驗證資源是否是最新的。

如果資源沒有變,服務器就回應一個 304 Not Modified ,表示緩存依然有效,瀏覽器就可以更新一下有效期,然后放心大膽地使用緩存了。

Last-modified 很好理解,就是文件的最后修改時間。ETag 是什么呢?

ETag 是 實體標簽 (Entity Tag)的縮寫,是資源的一個唯一標識,主要是用來解決修改時間無法准確區分文件變化的問題。

比如,一個文件在一秒內修改了多次,但因為修改時間是秒級,所以這一秒內的新版本無法區分。

再比如,一個文件定期更新,但有時會是同樣的內容,實際上沒有變化,用修改時間就會誤以為發生了變化,傳送給瀏覽器就會浪費帶寬。

使用 ETag 就可以精確地識別資源的變動情況,讓瀏覽器能夠更有效地利用緩存。

ETag 還有 之分。

強 ETag 要求資源在字節級別必須完全相符,弱 ETag 在值前有個 W/ 標記,只要求資源在語義上沒有變化,但內部可能會有部分發生了改變(例如 HTML 里的標簽順序調整,或者多了幾個空格)。

還是拿生鮮速遞做比喻最容易理解:

你打電話給超市, 我這個西瓜是 3 天前買的,還有最新的嗎? 。超市看了一下庫存,說: 沒有啊,我這里都是 3 天前的。 於是你就知道了,再讓超市送貨也沒用,還是吃冰箱里的西瓜吧。這就是 if-Modified-SinceLast-modified

但你還是想要最新的,就又打電話: 有不是沙瓤的西瓜嗎? ,超市告訴你都是沙瓤的(Match),於是你還是只能吃冰箱里的沙瓤西瓜。這就是 If-None-Match弱 ETag

第三次打電話,你說 有不是 8 斤的沙瓤西瓜嗎? ,這回超市給了你滿意的答復: 有個 10 斤的沙瓤西瓜 。於是,你就扔掉了冰箱里的存貨,讓超市重新送了一個新的大西瓜。這就是 If-None-Match強 ETag

條件請求里其他的三個頭字段是 If-Unmodified-Since If-MatchIf-Range ,其實只要你掌握了 if-Modified-SinceIf-None-Match ,可以輕易地舉一反三 。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM