從Chrome原始碼看HTTP

人人網FED發表於2018-02-16

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

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

"GET /list HTTP/1.1\r\nHost: payment-admin.com\r\nConnection: keep-alive\r\nCache-Control: max-age=0\r\nUpgrade-Insecure-Requests: 1\r\nUser-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\r\nAccept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: en-US,en;q=0.9\r\nIf-None-Match: W/\"68104920-260-\"2018-02-13T14:16:35.000Z\"\"\r\nIf-Modified-Since: Tue, 13 Feb 2018 14:16:35 GMT\r\n\r\n"複製程式碼

這個是按照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 OK\0Server: nginx/1.8.0\0Date: Fri, 16 Feb 2018 03:31:51 GMT\0Content-Type: text/html; charset=UTF-8\0Transfer-Encoding: chunked\0Connection: keep-alive\0last-modified: Tue, 13 Feb 2018 14:16:35 GMT\0etag: W/\"68104920-260-\"2018-02-13T14:16:35.000Z\"\"\0cache-control: max-age=10\0Expires: Fri, 16 Feb 2018 03:32:01 GMT\0Content-Encoding: gzip\0\0"

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

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

(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其它感興趣的內容我們下回再分解。


相關文章