從Chrome源碼看HTTP

本篇解讀基於Chromium 66。HTTP協議起很大作用的是http頭,它主要是由一個個鍵值對組成的,例如Content-Type: text/html表示發送的數據是html格式,而Content-Encoding: gzip指定了內容是使用gzip壓縮的,Transfer-Encoding: chunked又表示它使用分塊傳輸編碼,等等。

從Chrome發的請求複製一個原始的請求報文頭如下所示,如訪問payment-admin.com/list將會發送以下請求報文:

"GET /list HTTP/1.1
Host: payment-admin.com
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3345.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
If-None-Match: W/"68104920-260-"2018-02-13T14:16:35.000Z""
If-Modified-Since: Tue, 13 Feb 2018 14:16:35 GMT

"

這個是按照http報文格式拼接的字元串,如下圖所示:

對於每個請求,Chrome都會自動設置UA欄位:

User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3345.0 Safari/537.36

Chrome的UA欄位是這麼拼的:

Mozilla/5.0 ([os_info]) AppleWebKit/[webkit_major_version].[webkit_minor_version] (KHTML, like Gecko) [chrome_version] Safari/[webkit_major_version].[webkit_minor_version]

如下源碼所示:

並且我們看到源碼的注釋還說明了為什麼UA要帶上Safari——為了以最大限度地與Safari兼容的方式展示產品名稱。

當前請求收到了以下響應報文頭

"HTTP/1.1 200 OKServer: nginx/1.8.0Date: Fri, 16 Feb 2018 03:31:51 GMTContent-Type: text/html; charset=UTF-8Transfer-Encoding: chunkedConnection: keep-alivelast-modified: Tue, 13 Feb 2018 14:16:35 GMTetag: W/"68104920-260-"2018-02-13T14:16:35.000Z""cache-control: max-age=10Expires: Fri, 16 Feb 2018 03:32:01 GMTContent-Encoding: gzip"

這個請求報文頭和響應報文頭有個小區別,它的欄位間的分隔符是,而不是上面的
了。

對於請求報文頭欄位,我們重點討論以下兩個問題:

(1)緩存是以什麼做為鍵值的,即如何區分兩個不同的資源,緩存瀏覽器是如何組織管理的?

(2)gzip是如何壓縮和解壓的,為什麼通過gzip壓縮體積經常能小一半以上?

對於緩存,首先怎麼設定資源的緩存時間呢?如果使用nginx,可以這樣:

server { listen 80; server_name fed.renren.com; # .json不要緩存,時間為0 location ~* .sw.json$ { expires 0; } # 如果是圖片的話緩存30天 location ~* .(jpg|jpeg|png|gif|webp)$ { expires 30d; } # css/js緩存7天 location ~* .(css|js)$ { expires 7d; } }

上述代碼根據對不同的文件名後綴區分設置緩存時間,如圖片緩存30天,js/css緩存7天。

如果使用Node.js等在請求裡面單獨添加的,可以直接添加Cache-Control的頭:

// 設置30天=2592000s緩存response.setHeader("Cache-Control", "max-age=2592000");

這樣瀏覽器就能收到緩存的http頭了:

那麼瀏覽器是如何區分不同的資源進行緩存的?你可能已經猜到了,根據url,如下圖所示:

Chrome使用一個生成Cache Key的函數,這個函數是使用請求的url作為緩存的key值

如果這樣的話,POST等請求是不是也可以被緩存?實際上並不是的,因為它上面還有一個判斷,如下圖所示:

這個ShouldPassThrough會對請求方式進行判斷:

如果是普通的POST/PUT,是返回true的,也就是說,這種請求是直接返回true的,是需要pass的,不用取緩存。而對於DELETE和HEAD,在另外一個地方做的判斷:

如果mode為NONE的話,就會去發請求了。也就是說除了GET之外,Chrome基本上不會對其它請求方式進行緩存

請求完之後會對cache進行存儲,通過打斷點檢查可以發現是放在了這個路徑下

~/Library/Caches/Chromium/Default/Cache/

如下圖所示:

這個目錄下的緩存文件是以key值(即url)的SHA1哈希值做為文件名:

查看這個Cache目錄,可以發現文件名是以哈希值加上一個0或1的後綴組成,0/1是file index(具體不深入討論),如下圖所示:

緩存文件不是把文件內容寫到硬碟,而是把Chrome封裝的Entry實例內存內容序列化寫到硬碟,它是變數在內存的表示。如果用文本編輯器打開緩存文件是這樣的:

可直接讀取成相應的變數。

同時會把這個Entry放在entries_set_內存變數裡面,它是一個unordered_map,即普通的哈希Map,key值就是url的sha1值,value值是一個MetaData,它保存了文件大小等幾個信息,EntrySet的數據結構如下代碼所示:

using EntrySet = std::unordered_map<uint64_t, EntryMetadata>;

這個entries_set_最主要的作用還是記錄緩存的key值,所以它的命名是叫set而不是map。這個變數會保存它的序列化格式到硬碟,叫做索引文件index:

~/Library/Caches/Chromium/Default/Cache/index-dir/the-real-index

Chrome在啟動的時候就會去載入這個文件到entries_set_裡面,載入資源的時候就會先這個哈希Map裡面找:

如果找得到就直接去載入硬碟文件,不去發請求了。

數據取出來之後,就會對緩存是否過期進行驗證:

驗證是否過期需要先計算當前的緩存的有效期,如下源碼的注釋:

// From RFC 2616 section 13.2.4://// The max-age directive takes priority over Expires, so if max-age is present// in a response, the calculation is simply://// freshness_lifetime = max_age_value//// Otherwise, if Expires is present in the response, the calculation is://// freshness_lifetime = expires_value - date_value//// Note that neither of these calculations is vulnerable to clock skew, since// all of the information comes from the origin server.//// Also, if the response does have a Last-Modified time, the heuristic// expiration value SHOULD be no more than some fraction of the interval since// that time. A typical setting of this fraction might be 10%://// freshness_lifetime = (date_value - last_modified_value) * 0.10//

結合代碼實現邏輯,這個步驟是這樣的:

(1)如果給了max-age,那麼有效期就是max-age指定的時間:

cache-control: max-age=10

另外如果指定了no-cache或者no-store的話,那麼有效期就是0:

cache-control: no-cache

cache-control: no-store

(2)如果沒有給max-age,但是給了expires,那麼就使用expires指定的時間減去當前時間得到有效期:

Expires: Wed, 21 Feb 2018 07:28:00 GMT

這個日期是http-date格式,使用GMT時間。

(3)如果max-age和expires都沒有,並且沒有指定must-revalidate,就使用當前時間減掉last modified time乘以一個調整係數0.1做為有效期:

last-modified: Tue, 13 Feb 2018 08:16:27 GMT

如果指定了must-revalidate,如:

cache-control: max-age=10, must-revalidate

cache-control: must-revalidate

那麼就不能直接使用緩存,要發個請求,如果服務返回304那麼再使用緩存。

有了有效期之後再和當前的年齡進行比較,如果有效期比年齡還大則認為有效,否則無效。而這個年齡是用當前時間減掉資源響應時間,再加上一個調整時間得到:

// resident_time = now - response_time;// current_age = corrected_initial_age + resident_time;

因為考慮到請求還需要花費時間等因素,current_age需要做一個修正。

關於緩存就說到這裡,接下來討論gzip壓縮

gzip壓縮經常能把一個文件的體積壓到一半以下,如jquery-3.3.1.min.js有85kb,通過gzip壓縮就剩下35kb:

減小了58%的體積。所以gzip是怎麼壓的呢?這個是我一直很好奇的問題。

在linux/mac上經常可以看到.tar.gz後綴的文件名,.tar表示打成了一個tar包,而.gz表示把tar包用gzip壓縮了一下,可以用以下命令壓縮和解壓:

# 把html目錄打包成一個壓縮文件tar -zcvf html.tar.gz html/# 解壓到當前目錄tar -zxvf html.tar.gz

gzip已經被標準化成RFC1952,nginx開啟gzip可通過添加以下配置:

server { gzip on; gzip_min_length 1k; gzip_buffers 4 16k; # gzip_http_version 1.1; gzip_comp_level 2; gzip_types text/plain application/javascript application/x-javascript text/javascript text/xml text/css application/x-httpd-php image/jpeg image/gif image/png;}

Chrome是使用第三方的zlib庫做為壓縮和解壓的庫,其解壓使用的庫文件是third_party/zlib/contrib/optimizations/inflate.c,這個代碼看起來比較晦澀,具體過程可以參考這個deflate的說明和這一個,gzip依賴於deflate,deflate是結合了霍夫曼編碼和LZ77壓縮。以壓縮以下文本做為說明:

"In the beginning God created the heaven and the earth. And the earth was without form, and void."

先對它進行LZ77壓縮變成:

In the beginning God created<25, 5>heaven an<14, 6>earth. A<23, 12> was without form,<55, 5>void.

其中<25, 5>代表<distance, length>,表示字元串" the ",25是距離distance,在當前位置往前25個位元組,再取長度length = 5,就是最開始那個" the "。同理,後面的<14, 6>表示"d the "。

一個位元組有8位可以表示的最大數字為255,假設用一個位元組表示distance,一個位元組表示length,那麼上述文本由沒有壓縮的96B變成76B,其壓縮率已達到80%,如果文本越長,那麼重複的概率越大,壓縮率越高。標準建議最大的塊長度為32kb,即超過32kb後重複字元重新開始算。

但是有個問題是:如何區分正常的內容和表示<distance, length>的長度對?標準是這麼解決的,值為0 ~ 255的為正常內容,而256表示塊結束,257 ~ 285表示長度對。

為了表示數字285最小需要9個位,也就是說可以每9位 9位地讀取值(同理以9位為單位進行壓縮),這樣可以解決問題,但是會大量地浪費空間,因為9位最大能表示511.所以引入了可變長度編碼霍夫曼編碼,數據的存儲不再是固定長度的(如每一個位元組表示一個內容),而是可變的,最短可能是1位表示一個字元,最長可能是9位。

但是這樣可能會區分不了,如A、B、C 3個字元分別表示為:

A:0

B:1

C:01

那麼當遇到01的時候就不知道是C還是AB了。

所以霍夫曼編碼就是為了解決保證前綴不衝突的問題,如下圖所示:

先統計每個字元出現的次數,然後每次選取兩個次數最小的字元形成左右子結點,它們的和做為父結點做為一個新的結點,直到所有結點形成一棵樹,左子樹代表0,右子樹代表1,從根結點到葉子結點的路徑就是當前字元的編碼,如z的編碼就是001,而e是1,這樣高頻率出現的符號的編碼會比較短,就達到了壓縮的目的。同時需要有一個表記錄編碼的對應關係,在解壓的時候進行查找。(標準還對這個演算法進行了優化)。

剛才提到長度對的範圍是257 ~ 285共29個,這樣是不夠用的,因為一個塊最大有32kb(取決於壓縮率),重複字元串如果最長只能有29個或者只能往前找29個,那麼不能進行充分地壓縮,因此標準還在後面添加了額外的位進行加大,如下所示:

例如如果length是266,那麼後面還要再讀1位,如果這1位是0,那麼length就為13,如果這1位是1,那麼length就是14,依次類推。length後面緊接著就是distance,distance也會類似地處理。我們看到length最大為258,而distance最大為32kb。

gzip的特點是壓縮比較費時,但是解壓比較容易。壓縮需要統計字元,查找重複字元串,而解壓只需要查下可變長度編碼表,然後讀取比較value大小看是否為內容還是長度對再進行輸出。gzip壓縮率好壞取決於內容的重複度,重複率越高,則壓縮率越高。

本篇對HTTP的解讀就到這裡,主要講述了三個內容:HTTP報文頭、HTTP緩存、Gzip壓縮。看完了本文應該會了解HTTP請求頭和響應頭分別是長什麼樣的,Chrome的UA是怎麼拼出來的,HTTP緩存瀏覽器是怎麼組織管理的、緩存時間又是怎麼計算的,Gzip壓縮的過程是怎麼樣的、為什麼Gzip的壓縮效果普遍較好等問題。對於HTTP其它感興趣的內容我們下回再分解。


推薦閱讀:

KaliRouter安裝與使用全指南
Aspera技術如何實現原來需要傳26小時的24GB文檔做到只需30秒?
雲計算的1024種玩法之零基礎入門
【RPU-A】官網 HTTP 指南基於新 HttpClient 重構

TAG:HTTP | 前端開發 | 源碼閱讀 |